# Changelog 本项目的重要变更都记在这里。格式参考 [Keep a Changelog](https://keepachangelog.com/), 版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。 ## [0.25.0] - 2026-10-31 > **本版主题**:「看图」这件事从「点一下占半屏」变成**停一下看大图 / 双击看全屏**, > 并第一次把**对话里生成的图**接进媒体档(只显示、只去重,不往你的项目里写任何文件)。 > 按 semver 这是 minor:多了用户可见能力 + 两条宿主路由;媒体档的交互变了,但那是新特性区。 ### 新增 - **对话里生成的图出现在媒体档**(`src/host/attachments.js` 新增):DSH 把对话里的图存在 `/attachments/v1/objects/<前两位>/`,**在工作区之外** ⇒ Knit 之前完全看不到。 现在从**本会话的 `tool/result` 事件**取元数据(零 I/O),显示时按 **sha256 只读**那一个对象。 三条边界:① 只认 `sha256:<64 hex>`、**不接受任何路径**;② id 必须**在本会话工具产出里出现过** (跨会话 / 用户自己贴的图一律 404);③ 只回 `image/*` `video/*`、走既有 `sendMedia`。 **去重按内容**:工作区里已有同字节文件 ⇒ 它从列表消失(存成什么名字都行)。 两条新路由:`GET /knit/api/attachments`、`GET /knit/api/attachment`。⚠️ **改了宿主半边 ⇒ 升级后要重启 DSH**。 - **悬停看大图**:鼠标在图片卡片上停 350ms 弹出大图(上限 **420×420**,按可用空间收缩;优先摆在卡片左侧, 左边放不下就翻右侧;滚动即收)。只做图片,`pointer-events:none` 不挡点击。 - **双击全屏查看器**:双击图片进**整屏**查看器(portal 到 `document.body` + 模态级层级 + 毛玻璃遮罩,**随深色模式变**),左侧一列「当前这张附近 10 张」缩略图(**点缩略图直接切**,换图时当前那张自动滚进可视区),`↑↓`(左列是竖的)+ `←→` 翻页、**在图片区滚轮**也能翻(滚轮在左列上仍是滚列表,不被抢)、`Esc` / `✕` / 点图片以外关闭;工具条带**收藏 / 本地打开**。 - **媒体档排序固定按时间**:媒体档**不看相关性**(用户:「图片不需要有这种逻辑」)—— 请求直接发 `sort=time`, 网格合并「工作区文件 + 对话图」后按时间倒序,收藏仍前移;那一档的排序组**禁用并如实显示「最新」在生效**。 ### 变更 - **媒体档取消「单击就地预览」**(用户裁决):单击 / `↑↓` **只移光标**,看东西走悬停或双击 / `Enter`。 连带:**收藏 / 本地打开搬进全屏查看器**(对话图不显示这两个按钮 —— 它没有工作区路径); **视频仍走就地预览**(查看器只做图片),入口改成双击 / `Enter`。 - **图片的双击**从「开新标签页」改为「进全屏查看器」(文档 / 代码的双击仍是开标签页)。 ### 修复 / 内部 - **修掉连续四个版本各红一次 CI 的那条 flake**:`index-incremental.test.mjs` 的「同 mtime 同 size」场景 以前用 `utimesSync` 按浮点秒「还原」mtime,Linux 上还原不出逐位相同的 `mtimeMs` ⇒ 场景根本没造出来, 指纹抽验没被考到(`v0.23.0` / `v0.23.1` / `v0.24.0` / `v0.25.0` 各红一次)。现在两次 mtime 都钉成 **同一个显式整数秒**,断言从 `|Δmtime| ≤ 1` 收紧到 `===`。 - SECURITY.md 增加一行:**读工作区之外的唯一一处**,三条边界逐条指向真实检查。 - 指令文件(L0/L1)同步:媒体档例外写进 §7.2 / §7.4,测试数 883、测试文件 35。 ### 验收状态 - 自动化:`npm test` **883/883**(35 个文件)、解包净室 **883/0**、指令预算闸门 ✓、 每批新守卫都做过**突变证伪**(本版累计 30+ 条,全部如期变红)。 - ✅ **真机验收已过**(2026-10-31,用户逐条点过下面 4 条并判定通过): ① 双击图片 → **全屏查看器盖住整个屏幕**(连左侧栏与对话一起;这一条是本版**唯一**在 Node 替身里验不到的 机制 —— portal 到 `document.body`,那里只有形状守卫);② 查看器里 `↑↓` 翻页 / `Esc` 关闭 / 点图片以外关闭 / **点图片本身不关**;③ 媒体档**单击不再弹预览**;④ 对话里生成的图出现在媒体档,**重启 DSH 后仍在**。 ⚠️ 交接单里其余条目由自动化守卫覆盖,**没有**逐条人工复核 —— 这里只记人确实看过的那 4 条。 ### 发布记录 - ✅ **已发布(2026-10-31)**:轻量 tag `v0.25.0` = `3e95bc7`(与 registry 上的 `gitHead` **逐字相同**); OIDC 通道 run `41088869565`,**ref 是 `v0.25.0`** ⇒ workflow 里那条「tag 与 package.json 版本对齐」的守卫 **真的跑了**(上一版是跑在 `main` 上,那条守卫被跳过)。 - registry:`latest` = `0.25.0`,共 **23** 个版本;`dist.shasum` = `d6e9bca42da45ed8c1a200c90d3d55b3fdd32e5b`, `fileCount` **71**,`unpackedSize` **2525539 B** —— 与本地 `npm pack` 基线**逐项相同**。 - ⚠️ **发布中途红过两次,都不是重跑过去的**:attempt 1 红在 `:211`、attempt 2 红在 `:207`,同一条 flake 的两种表现; 修掉 mtime 还原之后才发成功。这一版是**改对了**才过的。 ## [0.24.0] - 2026-10-14 > **本版定位**:新增一个**默认关闭**的能力 —— **Git 变更关联**:按工作区里**未提交的改动** > 去找「还该一起看的文件」,依据是 Knit 已经有的那四类证据(引用 / 导入 / 测试配对 / 文档提及)。 > 按 semver 这是 minor:多了一条**用户可见**的能力、一条宿主路由,而且 `src/` 里**第一次**出现子进程。 > > ⚠️ **本版占用了 `v0.24` 这个号**。v0.20.0 节曾把「语义/向量检索、把片段当排序基本实体、第二套预览框架」 > 写成「留 v0.24」—— 那是一次**非正式**的候选登记,不是承诺:其中「语义/向量检索」已由 **v0.23.0 > 的可选混合检索**落地;另外两条仍未做,**不因此占号**。旧节保持原样(历史不改写),以本节为准。 > > **三条边界(本版的重点不是「找得更多」,而是「说得更准」)**: > ① **`knit_docs` 与 `/knit/api/recent` 的契约一个字节未改** —— 关联走**独立接口**,专用结构,不往既有 > 响应里塞字段(`src/host/context.js` / `src/host/tool.js` 回到 v0.23.1 **逐字一致**); > ② **只读** —— 固定 argv、不走 shell、关掉 fsmonitor 与可选索引刷新,且**子进程环境是白名单筛过的**; > ③ **默认关** —— 关着时客户端一个请求都不发、宿主一个 git 进程都不 spawn。 ### 新增 - **`GET /knit/api/changes`**:独立只读接口,返回**专用结构** (`git{available,reason,filesTotal,truncated,files[]}` + `links[{target, kind, source, title, summary, mtimeMs, evidence[{origin,type,dir,line}], evidenceTotal, types}]` + `linksTotal / unresolved / outOfScope`)。**起点 / 目标 / 关系类型 / 方向 / 行号**逐条给全; `tests` 那一类是文件名约定,`line` 如实为 `null`,不编行号。 - **面板状态行多一格二态开关「变更」**(默认关)。开态只把字色提亮(`aria-pressed`),不换底色、不用品牌色; 与「固定 / 关系」同属「这一份包里还装了什么」那一组,参与中点分隔、不参与编号。 - **变更关联区**:不编号的平行区,与三层互不影响;每条结果点开就是**既有那套行级证据** (类型 · 起点 · 行号),零第二条渲染路径。 - **降级可辨(三种状态互不混淆)**:读到了没有变更写「变更 0」;有变更但零关联时**出区头 + 提示句**; 读不到写「变更 —」并把原因**直接写在列表里**(不是只放悬停)。 - **两个开发量尺**(`tools/`,**不进包**):`changes-value-eval.mjs`(回放真实提交,量「当时漏改、 后来被补上」)、`changes-forward-check.mjs`(前向对账:先写预判,再看它多说了什么)。 ### 安全 / 只读边界 - **`src/` 里第一次起子进程**:只允许 `status --porcelain=v1 -z --untracked-files=all` 与 `rev-parse --show-prefix` 两个子命令,argv 是数组、**从不走 shell**;2.5 s 超时 / 1 MiB stdout / 200 条解析上限;五种失败各自收敛成一个 `reason`,**一条都不抛**。 - **每次都带 `--no-optional-locks --no-pager -c core.fsmonitor=false`** —— 实测(git 2.50.1):裸 `status` 会**重写 `.git/index`**(300 文件 + untrackedCache + stat 缓存过期时 3/3 次)、 会**执行 `core.fsmonitor` 指向的钩子**、会在 `core.fsmonitor=true` 时**拉起 builtin 守护进程**;带开关后三种全为 0。 - **子进程环境白名单**:只继承 `PATH` / locale / `HOME` / `XDG_CONFIG_HOME` / 临时目录等运行时必需品, **任何 `GIT_*` 一律不从父进程继承**。实测:`GIT_DIR`+`GIT_WORK_TREE` 会让它去读**另一个仓库** (本工作区的改动直接消失)、`GIT_INDEX_FILE` 会换掉索引、`GIT_TRACE` 会往任意路径**写日志**。 - **不读 diff、不读文件内容、不发网络请求**;载荷里**不含绝对路径**(只有工作区相对路径)。 ### 成本与降级 - 一轮轮询(两条请求)= **一次工作区扫描 + 一次 `git status`**:扫描备忘与 git 缓存**同窗 8 秒**、 并发去重;**关闭时两者都是 0**。真机用例正面证明过复用(窗口内删掉一个文件,两边都还看不见; 清掉缓存后同时反映)。 - 任何一步失败(不是仓库 / 没装 git / 超时 / 输出过大 / 读取失败 / 接口请求失败)都**只让这一区消失或说明原因**, 普通列表与 Context Pack **一个字节都不坏**。 ### 变化 - **README / README.en.md**:安装示例里的测试数 775 → **828**(两份同步)。 - **`SECURITY.md`**:补上「只读 git」与「子进程环境白名单」两条,含实测数字。 - **测试 828 项 / 34 个文件**(新增 `test/git-changes.test.mjs` 与 `test/http-harness.mjs`; `test/host-http.test.mjs` 的服务器替身抽到 `test/http-harness.mjs` 共用)。 ### 需要重启 DSH 本版**改了宿主半边**(`src/host/*`)—— 与 v0.23.x「只要硬刷新」不同:**升级后需要重启 DSH**, 否则 `/knit/api/changes` 不存在,状态行那一格会显示「变更 —」并写明原因。 > ✅ **2026-10-14 已发布**:轻量 tag `v0.24.0` = 发布提交 `cc51791` = npm `gitHead`;OIDC 可信发布 > run `38485942612`(**第 2 次尝试**才绿 —— 第 1 次又红在 `test/index-incremental.test.mjs:168` 那条 > **间歇性**用例上;这是**连续第三个版本**撞它 ⇒ 见发布 skill 台账 B27 / B32)、`conclusion=success`、 > **带 SLSA provenance**;registry `latest = 0.24.0`(共 **22 个**版本);GitHub Release > `published_at 2026-10-13T23:47:54Z`。 > registry 上的真包 = **69 文件 / 802 076 B tarball / 2 357 111 B 解包**、 > shasum `4a9d1ac1e4369039dceb804a5ebae9f8cb1b871b`(= registry `dist.shasum`),下载解包后 `npm test` **828 / 0**。 > 本地 pack 基线 = **791 091 B / `2208ae8e832f3f6d2f2cfb5d728ea0582043b937` / 69 条目 / 解包 2 357 111 B**(连打两次逐字节相同)。 > ⚠️ 本地 pack 的字节数与 shasum 和 registry **历来对不上**(差的是 tar/gzip 框架元数据)—— 能跨端比的是 > 「下载件 sha1 == `dist.shasum`」与「`fileCount` / `unpackedSize` == 本地 pack」这两条。 > ⚠️ **这一行不在已发布的 tarball 里** —— 改它的提交只能在 `npm publish` 之后(同一版本号不能再发一次)。 ## [0.23.1] - 2026-10-13 > **本版定位**:这一版**不是纯 patch** —— 它一边修包装/文案,一边带了一次**用户可见的工具栏收敛**。 > 之所以仍出 `0.23` 这条线而不走 `0.24.0`(按 semver,用户可见的行为变化该是 minor): > ① 它存在的**首要理由是包装修复**(已发布 `0.23.0` 的 tarball 内 `README.md` 写错测试数、`description` > 把「零网络」说成绝对)—— 与 `v0.20.1 包装热修` 同一档; > ② **`v0.24` 这个号已被文档预留给别的主题**(本文件 v0.20.0 节:「语义/向量检索、把片段当排序基本实体、 > 第二套预览框架 —— 留 v0.24」),拿它发一次工具栏整理会让「留 v0.24」的指向落空。 > > **工具栏收敛** —— > 类型档去掉「**全部**」(只剩 `文档 / 代码 / 媒体` 三档);**检索方式(词法 / 混合)从第二行整块搬进 > 第三行状态摘要**,做成一个**可点状态格**(`BM25 检索 ›`,点开菜单相对它弹出);`package.json` 描述与 > 两份 README 改成如实区分「**默认词法(完全本地)**」与「**可选混合(可能发往你自己配置的服务)**」。 > **这一版删的是一个 UI 视图,不是检索能力** —— 宿主检索 / 装配 / `knit_docs` / 统一 Context Pack / BM25 / 语义通道**一行未改**。 ### 变化 - **类型档:四档 → 三档** `文档 / 代码 / 媒体`(等距,下划线式页签不变,不留空位)。 老用户 `localStorage` 里存着的 `'all'` **回落到 `文档`** —— 不空白、不抛错(有守卫)。 - **删掉「全部」这个 UI 视图**:v0.23.0 它刚被改成「整份包的原样」(三层混排文档 + 代码 + 末尾媒体网格), 本版又从界面上整个删除 —— 视图、渲染分支、`kind.all` / `count.all` 文案与相关守卫一并清掉。 ⚠️ **内部跨类型检索能力保留**:宿主 `contextPayload()` 的 `kind === 'all'` 分支、`knit_docs` 与统一 Context Pack 都不动(`host-http.test.mjs` 里那条 `kind=all` e2e 仍在跑)。 - **检索方式从工具栏搬进状态行**(当日第二次裁决,推翻本版早先「挪到搜索框左侧」的中间版): 工具栏第二行只剩 `[相关|最新] [文档|代码|媒体] … [ ……搜索框…… ]`(搜索框仍是**唯一可伸缩项**, 窄面板先缩它 180–240px);状态摘要里原来那格静态文本 `BM25 词法检索` 变成**可点状态格** `BM25 检索 ›` —— 点开菜单**相对这一格**弹出(`left:0;top:22px`,不是工具栏那套 `right:0/top:32px`), 菜单内容逐字沿用原来的:两个选项(`词法检索` / `混合检索 · 实验`)+ 当前生效状态说明 + 「混合没生效」时的就地配置入口。 - **箭头改成横向翻转**(2026-10-13 用户裁决「修改成横向翻转……默认箭头向下,交互展开箭头向上」): 展开态 `rotate(90deg)` → `rotate(90deg) scaleX(-1)`。两个终态是「关=朝下、开=朝上」,变的是那 0.14s 的过程:对调角度会让箭头**原地转 180°**(像风车),横向翻转则是「同一个字形左右翻个面」。 ⚠️ **这里写错过一次**:先写成 `scaleY(-1)`(「垂直翻转」),文本上像有翻转、实际是**空操作** —— `›` 本来就沿水平轴对称,上下镜像后还是它自己,矩阵算出来仍朝下;用户当场抓到。 ⇒ 守卫从此是**几何**的:把 `transform` 真算成 2×2 矩阵、作用在「右箭头」的方向向量上, 断言关闭态 = 朝下 `(0,1)`、展开态 = 朝上 `(0,-1)`;只盯 CSS 文本的守卫**抓不到**这个空操作 (三条突变 —— 换回 `scaleY(-1)`、不翻转、对调角度 —— 都已确认会红)。 - **下划线只划文字层**(2026-10-13 用户裁决「› 悬停时不用带下划线」):下划线从按钮挪到包文字的那层 `.knit-pipe-method-label` —— CSS 里父级的 `text-decoration` 会传播到全部子节点、子级又取消不掉, 写在按钮上必然把箭头 `›` 一起划上。守卫有两面:CSS 面(按钮 hover 里**不许**出现 `text-decoration`) 与结构面(箭头必须是文字层的**兄弟**,不许塞进文字层里)。 - **状态格外观**(同日第三次、第四次裁决):文案从 `BM25 词法检索` 缩到 **`BM25 检索`**; 视觉**照命中段(`.knit-hit`)那套**逐条对齐 —— 底 `--knit-chip-bg`(三档灰底最深那档)、 **圆角 6px**、内边距 `1px 6px`、次级灰字、hover / 键盘焦点出下划线、**描边一根都没有**; 字号 / 行高 / 字重也与命中段**逐字同值**(`font:inherit` + `font-size:11px` + `line-height:15px`、不自己定字重) —— 2026-10-13 用户裁决「字号与背景胶囊修改成命中段一样」;这一格因此比它所在的状态行(10.5px)大 0.5px, 那是有意的:它是这一行里唯一的可点控件,与命中段同一档才认得出「这是同一类东西」。 **菜单里那两个选项与「就地配置」入口同样沿用这套**(圆角 6px、选中态底 `--knit-chip-bg`); 菜单**面板本身保持浮层**(`bg-base` + `l3` 描边 + 阴影 + 10px 圆角)—— 浮层要读起来像浮层, 压成灰胶囊就没有层级了。守卫除钉数值外,还**逐值比对 `.knit-hit`**(圆角、底色两处都是取出来比, 不是写死常量):谁改了命中段而没改这一族控件,测试就红。行为照旧:默认词法;**「最新」档整条状态行不渲染** (时间序没有 Context Pack)⇒ 格子不在,但**不重置**已存偏好 —— 切回「相关」仍是你上次选的模式。 - **`package.json` 描述重写**:原句「… Zero model calls, zero network, nothing leaves the machine.」**不准确**。 新描述说清三件事:① 默认是**纯词法**、完全本地、零模型调用、零网络出口;② **混合模式可选且默认关闭**, 需要你自己配置 provider;③ 配了**远程** provider 时,发出去的是工作区文件的**标题与摘要(不是正文)**, 指向**本机服务(loopback)**时内容不出这台机器。**不含**任何「更准 / 召回更高」的说法。 - **两份 README 同步**:加上「**默认词法模式下**」的限定、写明混合模式的发送范围(标题 + 摘要,正文不发送; 向量只在内存、配置存在 DSH 存储的独立单元 `knit_semantic`),并如实写上 「语义收益仍待真 provider 实测(`value-evidence: pending`)」;README.md 里「全部」视图的说明与截图段整段删除。 - **包内数字修正**:包内 `README.md` 的测试数改为 **775**(删掉 7 条「全部」用例、新增 4 条 → 778 − 3; 已发布的 `0.23.0` tarball 里那份写的还是 **728** —— 本版重新发一次把它换掉);`README.en.md` 里两处陈旧的 `641 tests` 也一并改成 775,并补上英文目录树缺的两个宿主文件。 ⚠️ 这一条**连 `CHANGELOG.md` 自己也算在内**(它随包一起发):发版前核一遍**包内 README 的测试数与 `npm test` 的实测数一致、且旧数字 `728` 为 0 次**(`CHANGELOG.md` 里出现旧数字属于叙事,允许存在 —— 所以别拿 CHANGELOG 做 grep 判据,那个会自相矛盾)。 - **工具栏两处微调**(同日裁决):① 排序组与类型组之间**那根竖线删掉了**(类名与 CSS 规则整体移除,靠间距分界, 不留空位、不用 `display:none` 糊);② 检索模式下拉的箭头**重画** —— 不再自造向下的文本字形,改用 **「最近读取」行最右端那个 `›`** 旋转 90°(展开时转回朝上),箭头是 `aria-hidden` 的装饰节点, 可访问名仍由按钮的 `title` 承担。两条都有守卫,并各自做了突变证伪(加回竖线 / 把旧字形放回词典 ⇒ 都变红)。 **验证(2026-10-13 实测)**:`npm test` → **775 / 775**(33 个测试文件); `node tools/clean-room-test.mjs` → `dsh-knit-0.23.1.tgz` · **65 个文件** · 解包后 **775 pass / 0 fail**; 包内核对:`version` = **0.23.1**、`README.md` 里 `728` 出现 **0** 次 / `775` 2 次、`screenshot-all` **0** 次。 ### 刻意不做 - **不动宿主检索 / 装配 / BM25 / 语义通道**;不新增仪表盘、图谱、设置页;不显示分数 / 百分比 / 置信度; 不做禁用态的死控件;不重排三层、编号、预览与键盘导航的既有纪律。 - **不承诺**语义收益:真 provider 的实测仍未跑(`value-evidence: pending`),本版只改**信息准确性**与**工具栏形状**。 > ✅ **2026-10-13 已发布**:轻量 tag `v0.23.1` = 发布提交 `2925ad3` = npm `gitHead`;OIDC 可信发布 > run `38322653330`(**第 2 次尝试**才绿 —— 第 1 次红在 `test/index-incremental.test.mjs` 那条 v0.22 带来的 > **间歇性**用例上;与 0.23.0 那次是**同一根因、不同断言**,见发布 skill 台账 **B26**)、 > `conclusion=success`、**带 SLSA provenance**;registry `latest = 0.23.1`(共 **21 个**版本); > GitHub Release `published_at 2026-10-12T23:36:24Z`。 > registry 上的真包 = **65 文件 / 748 583 B tarball / 2 190 275 B 解包**、 > shasum `1182685ac18cacd6ff337e1963f82a0528978af1`(= registry `dist.shasum`),下载解包后 `npm test` **775 / 0**。 > 🔑 **本版发布的首要判据已达成**:包内 `README.md` 的 `728` = **0**、`775` = **2**(0.23.0 那份是 728×2)。 > `check-published.mjs --online` **27/27 全过、零红** —— 0.23.0 那次唯一红掉的正是这条内容判据。 > ⚠️ **这一行不在已发布的 tarball 里** —— 改它的提交只能在 `npm publish` 之后(同一版本号不能再发一次)。 ## [0.23.0] - 2026-10-12 > **Optional Hybrid Retrieval(可选混合检索)** —— 在**保留「可解释的词法检索」为默认**的前提下, > 给 Knit 加一条**可选的**语义候选召回通道,并且**先把能证伪它的实验准备好**,而不是先相信它。 > **默认 = 纯词法**(BM25 + IDF;纯本地、零模型调用、零网络出口):**不碰它时这一版与 v0.22 逐字一致** > (有守卫钉住)。只有用户在**「相关」档**把检索模式切到「混合」、**并且配置了 provider**, > 语义那条路才会跑。 > 语义**只补候选、不参与排名**:它只可能落在「相关」层、**只追加在最后**(`raw === 0 && semantic` 才准入), > 主要 / 辅助两层与既有条目的顺序**一个字不动**;`capRelated` 满时**先牺牲语义条目**。 > 来源多一值 `provenance: 'semantic'`,理由码 `semantic`(`terms` 恒为空 —— **不冒充关键词命中**)。 > **任何失败一律明着回退**成词法:`no-provider` / `probe-failed` / `timeout` / `bad-response` / > `no-query` /(时间序)`not-applicable`;回退时结果与词法**逐字一致**,面板与工具输出都如实写原因。 > 🔴 **诚实边界**:本机没有 provider ⇒ 真模型那条路(H1)**没跑**,`value-evidence: pending`; > 下面那组「H0」数字是**确定性替身**造出来的**机制证据**,**不能**读成「语义提升召回」。 ### 一句话结论(这一版证明了机制,还没有证明价值) 三句话:① **默认路径是干净的** —— 词法模式下一次 provider 调用都不发(连探测都不发), 载荷与 v0.22 逐字相同,这条有守卫、可证伪;② **语义的准入被写成了结构性保证** —— 不是「约定只往相关层放」,而是装配层**单独收一份** `semanticOnly`、在既有条目收完之后才追加, 所以「只追加、不插队、满员先牺牲它」都能被断言;③ **价值证据没拿到** —— 本机没有可用的 embedding provider,真模型路径如实 skip,因此**「默认关闭」就是这一版的正式交付结果**, 而不是没做完。 ### 变化 - **新增 `src/host/semantic.js`(provider 接口 + 有界语义候选 + 回退叙事)**: · 两种协议形状:Ollama(探测 `/api/tags`、嵌入 `/api/embed`)与 OpenAI 兼容 (`/v1/models` + `/v1/embeddings`);配置一律**收敛**后才使用(不可信输入丢弃,不抛); · **`fetchImpl` 参数注入**:宿主自身**零 `fetch` 调用**,「谁在联网」这件事因此在签名上可见、可测; · **有界**:每轮最多嵌 `SEM_EMBED_PER_PASS = 8` 篇;进程内向量缓存,键 = `headHash + provider` (换模型即作废,不混用不同向量空间);嵌入失败 / 超时 / 形状不对一律收敛成一条 `reason`; · **嵌入输入只有标题 + 摘要**(不是正文)—— 这条是刻意的:正文一旦进嵌入输入,复用路径上 (`body` 为空)就会算出不同的向量,语义结果将取决于「这一轮有没有命中索引缓存」。 - **`src/host/index.js`:把语义那一轮接进取数路径(默认短路)**: · 新选项 `retrieval: 'lexical' | 'hybrid'`(默认 `lexical`);语义那一轮插在**排除之后、 `rankByRelevance()` 之前** —— 被排除的文件因此**不可能**从语义路径回来; · 语义候选只把命中的记录**标一个 `semantic` 标记**(不改名次、不抬层);`context.js` **只在 `raw === 0` 时**才认这个标记 ⇒「有词法证据的仍按词法分层」是结构性保证,不靠纪律; · **时间序整层短路**:时间序不产出 Context Pack(`contextPayload()` 要求 `sort === 'relevance'`), 语义候选没有消费者 ⇒ 直接回 `not-applicable`,**不探测、不嵌入**(原来会白花 provider 调用,这是本版修掉的缺陷之一); · **查询文本用原话**:显式 `query` 路径原来拿去做嵌入的是 `extractKeywords()` 去重叠后的 **3-gram 碎片**(实测 `失效策略怎么定` → `["失效策","效策略"]`)⇒ 改成 **显式 query > 会话原话 > 关键词(兜底)**(本版修掉的缺陷之二); · 配置存 DSH `ctx.storage` 的**独立单元 `knit_semantic`**(与 `knit_control` / `knit_index` 各开各的), **只存配置,向量只在内存**;新写路由 `POST /knit/api/semantic`(第二条也是最后一条写通道: 只回环、只 POST、坏 body 400、空配置即清空)。 - **`src/host/context.js`:准入、来源与理由(三分支)**: · 准入门槛 = `raw === 0 && semantic === true` 的记录**单独收一份**(`semanticOnly`), 在词法条目与「引用了 Primary」的邻居都收完之后**才追加**,且只吃剩下的 `capRelated` 名额; · `explainContext()` 多一个 `semantic` 分支,判定次序写死为 **词法 > 关系 > 语义 > 兜底**, `terms` 恒为空数组(语义**不冒充**任何关键词命中); · `provenance` 从两分支变**三分支**:`semantic` 单独判 —— 否则它会掉进默认分支伪装成 `retrieval`, 或被 `related` 兜底码误标成 `relation`。 - **`src/host/tool.js`:工具面如实说**:`knit_docs` 多一个参数 `retrieval`(默认 `lexical`, 不传它时入参与返回值与 v0.22 逐字一致);`reason.code` 与 `provenance` 两个 enum 各加一个值; **新增一行 `Retrieval: …`**,只在调用方要了 `hybrid` 时出现,回退时写明原因 (如 `Retrieval: lexical — fell back from hybrid (no-provider)`);`whyText()` 给 `semantic` 一句**可复核**的说法,不落进「与当前工作区相关」那句不可验证的兜底话。 - **界面(两条决定)**: · **检索模式 = 搜索框右端的小下拉**(`词法⌄` / `混合⌄`),**不再**是左端排序 / 类型之间的第三个分段控件 (左端因此回到两组,那根多余的竖线删掉);**「最新」档整块不渲染**(不是置灰、不是 `display:none`); **未配 provider 也出现** —— 菜单里如实写状态(未配置 / 已生效 / 已回退 + 原因),并给 「配置语义 provider…」入口,就地展开那行最小配置区(附披露:回环地址写「内容不离开这台机器」, 其余地址写「内容会发送到你配置的服务 <地址>」); · **「全部」档 = 整份包的原样** —— 三层(主要 / 辅助 / 相关)**混排文档 + 代码**, 媒体作为**末尾一个不截断的网格**(⚠️ 媒体**靠文件名也能命中**并拿到 `raw > 0` —— 实测 `docs/clip.mp4` 凭 “clip” 进过 Primary ⇒ 剔除发生在**装配之前**,层里永远不会出现媒体,有 e2e 钉住); **删掉**了原来「按类型分三区 + 文档 / 代码各 4 条 + 查看全部」整套特例。 - **量尺(Phase C,随包发布的部分在 `test/`)**:固定语料 `test/retrieval-corpus.mjs` = **19 篇 / 13 条查询**(每条人工标注 `expected`;含 **4 条刻意造 「词法必漏」的答案** 与 **2 个 hard negative**:相邻话题、不同意图);机器 `test/retrieval-eval.mjs`(指标 / 跑一条路径 / provider 探测)随包发布, CLI 外壳 `tools/hybrid-retrieval-eval.mjs` **不进包**。四路径: **L** 词法基线 / **H0** hybrid + 确定性替身(只证机制)/ **H1** hybrid + 真 provider(探测不到即 skip)/ **H2** hybrid + 探测失败(验回退)。 **实测(2026-10-12,K=5,按包内顺序)**: | 路径 | R@5 | MRR | 无关@5 | 墙钟 | provider 调用 | |---|---|---|---|---|---| | L 词法 | 0.808 | 0.808 | 0.167 | 48 ms | **0** | | H0 混合 + 替身 | 1.000 | 0.962 | 0.205 | 65 ms | 56(≈1.08/轮) | | H2 混合 + 探测失败 | 0.808 | 0.808 | 0.167 | 62 ms | 0 | H0 共**追加 5 条**,其中 **4 条命中标注答案**;**H2 与 L 的面板载荷逐字一致** (剥掉 `semantic` 状态行后 JSON 相等)。**H1 本机 skip**:`SKIPPED: no provider at http://127.0.0.1:11434` ⇒ `value-evidence: pending (no provider)`。 ⚠️ **H0 的 1.000 是替身(加权同义词表)造出来的**,它证明的是「候选算得出来、进得了相关层、 有界、可复现」,**不是**「语义检索提升了召回」——替身的召回率不可外推到任何真模型。 - **测试**:`npm test` **728 → 778 项**(33 个测试文件;v0.22 的 728 条**一条未删、未放宽、未 skip**)。 新增 `test/semantic.test.mjs`(provider 两协议 / 配置收敛 / 有界嵌入与缓存 / 五条回退 reason)、 `test/hybrid-retrieval.test.mjs`(判据 1/2/2c/3/3b/5/6/7,含**证伪用例**:拿掉准入标记,语义条目必然消失)、 `test/hybrid-eval.test.mjs`(把量尺钉住:回退逐字一致、只追加相关层、标注救回、有界、指标手算)。 ⚠️ **量尺放在 `test/` 而不是 `tools/`**:`tools/` 不随包发布,而测试要引它 —— v0.20.0 的 `test/deep-scale.test.mjs` 就是这么让发出去的包「自测 638/1」的,这次提前避开了。 ### 刻意不做 - **不引入任何 SDK / 向量库 / 数据库 / 常驻服务**(仍然零新依赖、无构建步骤); - **不建云服务、不替用户挑付费 API、不在没有证据时把语义默认打开**; - **不用 chat 模型生成「为什么」**(理由必须来自确定性事实:命中在哪个字段、哪条引用、或是语义候选); - **不替换 BM25**、不让相似度直接驱动最终名次(这一版语义**只补候选**); - **不做语义 Dashboard / 相似度百分比 / 维度 / confidence**;界面上只多两个字和一个下拉; - **不把向量写进盘**(进程内缓存;`knit_semantic` 只存配置),也不把正文送进嵌入输入。 > **验证(2026-10-12 15:21 实测)**:`npm test` **778 / 778**(0 fail,33 个测试文件); > `node tools/agents-budget.mjs --claims` **exit 0**(版本 `0.23.0` / 33 个测试文件 / 778 项); > `node tools/clean-room-test.mjs` 打包 **`dsh-knit-0.23.0.tgz` · 65 个文件**(`tools/` 里只有白名单那两个), > 解包目录里 `npm test` **778 pass / 0 fail** ⇒ exit 0。 > ⚠️ 这组读数取自「界面第二条决定(「全部」档)落地过程中」的时刻;**提交 / 打包前必须重跑** > (`knit/` 里任何一个在包内的字节被改,文件数与这一行都要重测)。 > ✅ **2026-10-12 已发布**:轻量 tag `v0.23.0` = 发布提交 `e4f5507` = npm `gitHead`;OIDC 可信发布 > run `38226035554`(**第 2 次尝试**才绿 —— 第 1 次红在 `test/index-incremental.test.mjs` 的抽验用例上, > 那是 v0.22 带来的**间歇性**用例、不是本版回归,见该用例自己的注释)job **23 s**、 > `conclusion=success`、**带 SLSA provenance**;registry `latest = 0.23.0`(共 **20 个**版本); > GitHub Release `published_at 2026-10-12T08:57:26Z`。 > registry 上的真包 = **65 文件 / 737 841 B tarball / 2 166 006 B 解包**、 > shasum `11efcf4ff2521c60e98c4d57c7eca77ac3a7ab81`(= registry `dist.shasum`),下载解包后 `npm test` **778 / 0**。 > ⚠️ **这一行不在已发布的 tarball 里** —— 改它的提交只能在 `npm publish` 之后(同一版本号不能再发一次)。 > ⚠️ **已发布的 tarball 里 `README.md` 仍写着「728 项」(两处)**:那是 v0.22 的数,本版漏更, > 发版前没人跑内容判据、是 `check-published.mjs --online` 事后抓到的。修它的提交同样只能在发布之后 > ⇒ **要换掉 npm 上渲染的那份 README,必须再发一个版本。** ## [0.22.0] - 2026-10-10 > **Incremental Context Index** —— 让**未变化的文件在进程重启后复用已有索引**,变化的文件 > **只重建它自己**,索引坏了 / 存储不在 / 版本不对时**明着退回全量扫描**,而不是悄悄交付 > 一份陈旧的 Context Pack。索引**只在那条检索真的用不着正文时才被查**(修法 ②)—— > 要正文的路径(相关检索)走的就是 v0.21 那条路,一个字都不多付。 > 旧格式的**遗留记录会被有界地清掉**(清理补丁),不会永远留在盘上白付装载成本。 > 这是**内部能力,不是用户可见功能**:UI 一行没改,没有索引面板、没有命中率、没有 Epoch。 > 硬约束照旧:**零模型 / 零网络 / 零新依赖 / 无构建步骤**(`node:crypto` 是内置模块)。 ### 一句话结论(先审计,再动手) Phase 0 审计推翻了这个版本号原本的卖点:**「未变化的文件不重读」v0.20/v0.21 早就做到了** (进程内 `cache` 按 `mtime + size` 命中,暖扫的 `readFile` 恒为 0)。真正的缺口只有四个: **跨进程重启要全量重读**、**`mtime + size` 是唯一的一致性证据**、**没有对账**、**没有降级叙事**。 所以这一版只补这四件事 —— 稳定模块(`relevance.js` 的 BM25 语义、`control.js`、关系投影) 一行未改,见 `99_暂存文档/01_ Knit PRD-历史版本/Knit-v0.22-Phase0-审计.md`。 ### 变化 - **新增 `src/host/index-store.js`**:索引的持久层,走 DSH 自己的 `ctx.storage` KV facet (与 `control.js` 同一个后端、**各开各的单元** `knit_index`)。 · 布局 `per-record`(**不是** `single`):改一个文件只碰一个键,而不是重写整份索引; · 键 = `rootHashOf(root) + base64url(rel)`(`per-record` 的键必须是 `[a-zA-Z0-9_-]+`); **root 必须进键** —— 只用 `rel` 的话两个项目里的同名文件会抢同一个键互相覆盖(写测试时 抓到的真实故障);值里也带 `root`,读的一侧再过滤一遍; · 记录 = `{root, rel, mtimeMs, size, headHash, title, summary, refs}`, **`body` 永远不进索引**(正文一旦持久化,「同 mtime 同 size 的编辑」就会被静默交付成旧内容); `refs` 是**抽取后的**引用 / 导入列表(`{md, any, imports}`,`md` 与 `any` 分开存 —— `extractRefsWithLines()` 有每篇 500 条的截断,用一套近似另一套会在超限文件上改变目标集合, 破坏第 7 条验收)。**原文首部不进索引**:它只为关系投影服务,而关系投影要的只是这份列表; · `INDEX_VERSION = 3` + `compatibleVersions: [1, 2]`:v1 记录缺 `headHash`、v2 记录存的是 `head` 而不是 `refs`,两者都读得进来、都过不了 `refsShapeOk()` 这一关, 于是当「没有可用记录」处理、下一轮重建 —— 迁移不需要任何专用代码; · `canonicalRoot()` 在**进出两侧**都做 `realpathSync`:`/var/...` 与 `/private/var/...` 是同一个目录的两个字符串,只靠调用方守纪律就会「索引时灵时不灵且不报错」(也真的踩到了); · 任何失败(没有存储 / unit 开不起来 / `malformed-medium` / 版本不符)⇒ 空 Map + 一个 `reason`,**绝不抛进检索路径**;`persisted` 只在读成功或写成功后才是 true。 - **`src/host/index.js` 的取数路径重写**:`readDoc()` / `readCode()` 这对「先查缓存再决定读不读」 的包装函数被删掉(留两份判定只会漂移),唯一读取路径是 `readDocFromDisk()` / `readCodeFromDisk()`。 每条候选现在走三选一:① 进程内 `cache` 命中(**单独计数**,这是 v0.20 就有的能力,不许算进 这一版的成绩)→ ② 索引记录可复用(`mtime + size` 当快速筛子,`headHash` 是可复核的第二证据) → ③ 整读 + 重解析 + 重算指纹。复用出来的条目 `body === ''`、`bodyReady: false`。 - **有界对账(没有 watcher)**:每轮抽验 `INDEX_VERIFY_PER_PASS = 8` 条复用项,按**游标轮转** 取样(不是每轮都核同一批),逐条读**头部**算指纹比对;对不上就重读该文件、就地改正并写回。 这是「`mtime`/`size` 不得作为可靠内容一致性的唯一证据」的落实方式 —— 不是每次扫都重读一遍 (那等于没省),而是**读到的那一份永远能被指纹复核**。删除项也按同一个游标分批清理, 否则「1000 个文件全删掉」会变成一次 O(N) 写风暴。 - **修法 ②(2026-10-10):索引只在那条路真的用不着正文时才查。** `collectDocs()` 现在接受 `{index: false}`;相关检索那条路(`scan()` 里 `mode === 'relevance'`)显式传它,于是 **不装载、不对账、不写回**索引 —— 因为 BM25 的 `df` / `avgdl` 都是正文的函数,那些记录 反正会被立刻整读补回正文,唯一净效果是**多付一次索引装载**(每条记录一个文件 open + read + `JSON.parse`,实测 600 条约 60–75 ms,且这个成本与记录里的载荷大小**几乎无关**)。 低层 API 不替调用方做这个决定:不传 `options.index` 仍然查索引(工具、测试、基准的语料阶段 就是这样)。这一轮没查索引时读数记 `skipped: true` / `reason: 'index-skipped'`, 与「这个进程没有存储」的 `no-storage` **分开报**,否则两种 0 混成一个数就没法排查了。 - **目录级 memo**:同一轮扫描里目录的 `readdir` 结果按**目录自身 mtime** 复用; 新增/删除/重命名会更新父目录 mtime,而**改动已有文件不会**(APFS 实测), 所以每个候选文件仍然**逐条重新 `stat`** —— memo 只省 `readdir`,不省变更检测。 - **`hydrateBodies()` 必须跑在 `rankByRelevance()` 之前**:复用路径上没有正文,而 BM25 的 `avgdl` 与 `df` 都是正文的函数;顺序错了,「索引开 / 关」两份 Context Pack 就对不上。 这是正确性问题,不是优化顺序。 - **实测(`node tools/index-benchmark.mjs`,合成语料、mtime 固定)**:语料阶段(`collectDocs`) 的整读次数在**重启后**从 `100 / 500 / 600 / 700 / 700`(N=100…10000)降到 **8** 条 (= 抽验预算,与规模无关),读入量从 `25 / 126 / 154 / 183 / 183 KiB` 降到 **1.9 KiB**; 改一个文件只重读 **1** 篇;新增/删除/重命名后索引项同步增删且不返回过期结果; 「索引开 / 关」两份 Context Pack 的指纹逐字相同(`parity-on = parity-off`)。 三次重复的中位数与计数型读数逐次一致(`c.read` 冷 `100/500/600/700/700`、暖恒 `8`、 `touch` 恒 `9`;parity 指纹 **12/12 次**完全相同),不是「碰巧一致」。 - **最终同口径实测(12 次重复取中位数;N=1000、每篇另加 8 KiB 填充 ⇒ 600 个文件 / 13.45 MiB,平均 22.4 KiB/篇)**: 两条检索序要分开说,因为它们**走的路不一样**。 · **相关序(面板默认,要正文)—— 生产形状下没有回归**:`--shape scan`(一次检索 = 新进程 首次取数)warm **259.4 ms** vs `nostorage`(等价 v0.21)**242.6 ms**,而计数**逐项相同** (`readFiles` 600 / 读入 13453 KiB / `readdir` 21 / 复用 0)—— 因为这条路上索引**根本没被查** (`index-skipped`)。差距在噪声内,且路径就是同一条。 · **时间序 / 无关键词(不吃正文)—— 这才是这一版真正省下来的地方**:同形状 warm **106.0 ms** vs `nostorage` **216.2 ms**(**2.04×**),整读 **8** 次 vs **600** 次、读入 **179 KiB** vs **13453 KiB**(**约 75×** 更少),复用 600 条、其中 8 条按指纹抽验过。 ⚠️ 报告里必须写明的一件事:**旧的历史形状(`--shape collect`,先单独跑一次 `collectDocs` 再跑检索)会给出误导性的读数** —— 那个形状下同一份语料被取两遍,于是 warm 的账会拆成 「语料阶段 93.2ms(省了)+ 检索 219.3ms(补正文)」而 `nostorage` 是「214.7 + 47.6」, 两列相加看起来仍慢 1.19×。那**不是生产行为**:真实的一轮相关检索没有那个预热的 `collectDocs`。1.31× 那条旧结论来自这个形状,修法 ② 之后它已经不成立。 取舍、复现命令与逐条读数见 `99_暂存文档/01_ Knit PRD-历史版本/Knit-v0.22-实现与基准报告.md`, - **清理补丁(2026-10-10,真机复验之后;裁决 F)**:真机 `~/.dsh/storages/knit_index/files` 里查出 **164 条记录,键全部是旧格式**(`base64url(rel)`,没有 root 指纹),其中 **152 条是存 16 KiB 原文首部的 v2 记录**(共 **1.7 MiB**,平均 10.7 KiB/条)。它们对**任何**工作区都不可用 (`refsShapeOk()` 判不过 ⇒ 每轮重建),但本版的写入路径只写 `rootHashOf(root) + base64url(rel)` ⇒ **永远覆盖不到这些键**。`compatibleVersions: [1, 2]` 本意是「读得进来、下一轮重建」,代价却是**永久残留**:`loadAll()` 每次都要为它们付钱, 而装载成本正比于**记录条数**(600 条小记录 60 ms ≈ 600 条肥记录 75 ms)。修法: `read()` 用 `keyFitsRecord(key, row)`(键必须是 `recordKey()` 的形状**且** 16 位之后解回来 **逐字等于这条记录自己的 `rel`** —— 只查 hex 前缀不够,`relToKey()` 的字符集本身就含 `a-f`/`0-9`) 把不合规的键收进清理队列,`write()` 在**本轮本来就要写盘时**顺手删,上界 **`INDEX_CLEAN_PER_WRITE = 32` 条/次**(与对账的 `INDEX_VERIFY_PER_PASS = 8` 同一条纪律: 不许为了维护索引把一次检索拖长)。删除幂等 ⇒ 不需要事务,中途失败下一轮接着删;真实工作区 164 条 ⇒ **6 轮写盘内清完**。同源的键一律放行(包括**别的 root 的合法记录**),实测真机 164 条里 同源 **0** 条 ⇒ 清理对象与活记录零重叠。**证伪**:把 `unit.deleteRecord()` 那行换成 `void key` ⇒ 新增的 2 项恰好变红(`pass 14 / fail 2`),恢复后 16/16。 ⚠️ 一处如实记录的小浪费:其中 12 条键不合规而内容仍可用(`refs` 在、mtime+size+headHash 相符), 它们在**同一轮里先被复用、再被删掉**,下一轮多付 12 次读盘重建 —— 正确性不受影响(内容可重新推导), 一次性、只发生在迁移期。 决策见 `Knit-决策记录.md` 的裁决 A / A′ / D / E / F。 - **测试**:`npm test` **697 → 728 项**,新增 `test/index-store.test.mjs`(持久层 16 项:键/指纹/ 隔离/降级/**清理与上界**)与 `test/index-incremental.test.mjs`(端到端:§6 七条验收逐条可证伪, 含两条**证伪用例** —— ① 把抽验预算设成 0,同 `mtime` 同 `size` 的改写必然漏掉; ② 相关序必须 `skipped: true` 且不装载索引,时间序必须真的复用)。 两个文件都已挂进 `package.json` 的 `scripts.test` 显式清单。 ### 刻意不做 - **不做索引状态 Dashboard**、不新增任何 UI 区块、不改列表与工具栏; - 不把内部索引 revision 暴露成第二种用户可见的 Epoch; - **不引入 `fs.watch`**:`recursive` 跨平台语义不一致、丢事件是必然(`git checkout` / 分支切换 / 批量操作 / 外部编辑),它只能加速不能当权威 —— 权威是每轮的有界对账。这条明确记为「未做」; - 不引入 SQLite / 常驻服务 / 新依赖,不建立云端索引,不改变 Context Pack 排名算法与关系语义; - 不为「让索引看起来有用」而在要正文的路径上查索引:那一条路按需查询,宁可这一次完全不碰它; > ✅ **2026-10-11 已发布**:轻量 tag `v0.22.0` = 发布提交 `ba17b23` = npm `gitHead`;OIDC 可信发布 > run `38100521035`(24 s,`conclusion=success`);registry `latest = 0.22.0`(共 **19 个**版本, > `publishedAt 2026-10-11T01:05:48Z`);GitHub Release `published_at 2026-10-11T01:06:17Z`。 > registry 上的真包 = **59 文件 / 677 704 B tarball / 1 986 907 B 解包**、 > shasum `3813b5d85e94368eed3e85fe179fb424a53b0530`(= registry `dist.shasum`),下载解包后 `npm test` **728 / 0**。 > ⚠️ **这一行不在已发布的 tarball 里** —— 那份里这个位置仍是发布前写的「未发布」, > 改它的提交只能在 `npm publish` 之后(同一版本号不能再发一次)。 ## [0.21.0] - 2026-10-08 > **Relations & Context Control** —— **原 v0.21 与 原 v0.22 合并成一个版本**(用户 2026-10-08 拍板: > 两件事在实现上是同一条链路上的前后两环,分两版只会让「关系」先当摆设)。这一版回答两个问题: > **「这一篇为什么在这里?」** 与 **「我自己想留住 / 挡掉的那几篇呢?」** > —— 前者给**有行号、能 grep 回原文**的关系证据(引用 / 导入 / 测试 / 文档),后者给**固定与排除**, > 而且真的活过重启。 > 硬约束一条没松:**零模型 / 零网络 / 零新依赖 / 无构建步骤**。 > 两条边界是刻意的:关系**是投影,不是第二个排序阶段**(`rankByRelevance()` 一行未改); > 不做调用图 / 依赖图 / 符号索引(没有 AST / LSP / Tree-sitter),Git provenance 推迟 > (每文件一次 `git log` 在 1000 文件规模上是秒级到十秒级,与 R1 门槛冲突)。 ### 变化 - **新增 `src/host/relations.js`(零 I/O 的纯逻辑)**。只做四类**有证据**的关系, 数组顺序即截断优先级:`references`(Markdown 里引用了另一篇 Markdown)、 `documents`(Markdown 里写到了某个代码路径)、`imports`(代码里的 `import … from` / `require(…)`, **逐行正则**,含行号)、`tests`(文件名约定上的测试与被测方,**唯一不带行号的一类** —— 证据是文件名这条可核验的路径事实,不是某一行文字,所以如实不给行号,而不是编一个)。 导出 `MAX_RELATIONS_PER_ITEM = 3` / `MAX_TOOL_RELATIONS_PER_ITEM = 2` / `RELATION_TYPES` / `importsOf` / `resolveSpec` / `testsOf` / `buildRelationFacts` / `relationsOf` / `resetRelationCache`。 每条边**两端各登记一次**,`line` 属于**提及所在的那个文件**(`dir: 'in'` 时行号读的是对方那一篇)。 同一 `(type, dir, other)` 去重取最小行号;对端**不在本批候选集**里就不产生边(不硬凑)。 - **`src/host/links.js` 三处修改**:① 新增 `extractRefsWithLines()`(与 `extractRefs` **同一套正则、 同一套顺序**,只多给行号;`extractRefs` 现在是它 `.map()` 的结果,一处真源);② 引用目标放开到 「已索引的文件」(`{anyExt: true}`),**源仍然只有 Markdown** —— `test/code-context.test.mjs` 里那条「代码只能被指向、不能当源」的守卫原意一个字没改; ③ **修掉一个既有缺陷**:引用图缓存的签名原本只是「篇数 + 最大 mtime + 是否截断」, **不含候选集身份**,同一进程里连跑两个候选数相同、最大 mtime 落在同一文件的 query 会**串图** (实测复现,第二个 query 的关系全落空)。现在签名是逐篇 `rel\u0000mtimeMs` 排序后的精确键, 不用哈希(碰撞会让关系静默错配),`test/links.test.mjs` 有回归用例。 - **`src/host/context.js`:`provenance` 三值**(`retrieval` / `relation` / `manual`)。 ⚠️ 字段名**不叫 `source`** —— `source` 从 v0.14 起指的是**文件角色** (`impl` / `test` / `config` / `design` / `doc`),两者同时出现在一条记录上,不能混。 三条既有断言(`test/context.test.mjs` / `test/context-eval.test.mjs` / `test/code-context.test.mjs`) 已按新键加强。包内条目还会带 `relations`(最多 3 条)与 `relationsTotal`(真实总数); **没有关系、也没被截断时这两个键根本不出现**(空数组是纯噪音,9 条要白花一百多字节)。 - **`src/host/index.js`**:`buildContextFor()` 组装完包后投影关系,并多一个 `withRelations: false` 开关 —— 它唯一的用途是让「关系不改变任何一层的成员与顺序」这条 可以被**同一次调用**直接证伪(`test/relations.test.mjs` 的端到端守卫就这么写的)。 新路由 **`POST /knit/api/control`**(`{action, rel}`,四个动作 `pin` / `unpin` / `exclude` / `restore`) 是**唯一的写通道**;排除在检索之前生效(`stats.controlSkipped`),固定是排名的下游 (`applyPinOverride()`:从三层移出、进 `pinned` 区、`provenance: 'manual'`,**零新 I/O**)。 `/api/recent` 另外回报 `control = {pinned, excluded, skipped, persisted}`, 其中每个被排除项带 `exists`(「这篇现在还在工作区里吗」——「恢复」按钮只在它真可能回来时给)。 - **新增 `src/host/control.js`(纯逻辑 + 可选持久化,不 import 任何官方包)**。 `ctx.storage` 拿得到就用 `kv` 落盘(`unit: knit_control`,表 `pinned` / `excluded`, `layout: 'single'`、键 = `` `${root}\u0000${rel}` ``,按工作区隔离),拿不到就退化成内存态。 写路径**只写差量**,且**串行**。 - 两条真机缺陷在这一版修掉(都是**先取证再改**):① 插件被重复加载时,每次加载都会新建一份 登记表,而存储单元「一个进程只有一个活句柄」,于是被模块级变量留下的**最后一个**实例恰好是 打不开存储的那个 ⇒ 共享那份登记表挂在**后端对象**上(`Symbol.for('dsh-knit.control')`), 同一进程里的后续加载直接复用;② `persisted` 原本在拿到 `kv` 的那一刻就报 `true` (报的是**能力**,不是**结果**),现在**证据到之前一律 `false`**:读成功、或这一次写真的成功, 才转 `true`;写失败在**同一次响应**里就说出来。面板因此可以诚实地写「本会话有效(未持久化)」。 - **`src/host/tool.js`:`knit_docs` 每个条目最多 2 条关系**(`relations` / `relationsTotal` 进 schema, `provenance` 进 `required`),渲染成一行 `relations: references ← docs/x.md:12; imports → src/y.js:3` (`←` = 对方提到本条目,`→` = 本条目提到对方;`tests` 不带行号)。**只给事实,不写理由文案** —— 理由由既有的 `Why:` 行承担。被固定的条目出现在 `pinned` 分区且 `provenance: manual`; 被排除的条目**完全不出现**。 - **面板(`src/client/client.js`)**:相关模式的每一行有一个**弱化的来源词**(`检索` / `关系` / `用户固定`, 只有这三个词,没有分数没有百分比);**2026-10-09 布局改造**:标题行只留「序号 · 标记 · 标题 · 时间」, 来源词与 `固定` / `排除` 一起搬到**证据行右端**,顺序 `固定 → 排除 → 检索`,**默认 `visibility:hidden`** (鼠标悬停该项 / 键盘光标 / 焦点进入才显形 —— 用 `visibility` 而不是 `opacity:0`,隐藏时**真的退出键盘焦点序**, 且不占额外高度、不引起横向跳动),证据文本留在左边、过长可截断而操作永不被挤掉; **没有命中段也没有理由的条目仍留一条只含右侧操作的空行**(不写占位文字);三个入口都是纯文字按钮、无图标; **同日按体验反馈**:三个入口的字号提到与命中段同级(10px → 11px;行高仍由证据行决定,不增高), 悬停热区左右各加 4px(用等量负外边距把宽度收回 ⇒ 行的宽度与词间距都不变); **同日工具栏重排(2026-10-09 第二条规格)**:`相关 / 最新`(排序)|一根浅灰竖线| `文档 / 代码 / 媒体 / 全部`(类型)|搜索框靠最右且宽度自适应 —— 类型组从独立一行搬进工具栏 (`.knit-types` 不再自带左右内边距),`.knit-filter` 用 `flex:1 1 180px` + `max-width:240px` + `min-width:0`(常规面板 180–240px、封顶后靠 `margin-left:auto` 贴右、面板窄了先缩它), 两组都是 `flex:none` ⇒ **前面的按钮永不被挤**;六个选项彼此独立(切类型不动排序与搜索词,反之亦然); 「为什么」展开**行级证据**(最多 3 条, 每条 = 类型标签 + 对方文件 + 第 N 行 + 打开,点开就地预览到那一行;超过 3 条如实写「共 N 条关系」); 被排除项消失并在上方留一行弱化说明 + 「恢复」;头部一条**检索状态摘要** —— **单行、三组、竖线分隔**:`收集 N · 排除 N · 候选 N | BM25 词法检索 · 关系 N 条 · 固定 N | 入包 N` (工作区候选 → 检索与调整 → 最终结果;组内中点、组间一根细竖线,**一个箭头都没有** —— 原来那串 `↓` / `→` 把一条**已经完成**的管线画成了流程图,2026-10-09 用户裁定删除;那个没有名字的光数字 「→ N」现在叫「候选 N」)。**每一格都是已经发生的事实**,口径逐格钉在守卫里; 没有图标 / 胶囊 / 色块 / 装饰边框,也**不承担任何交互**(它只负责展示信息)。 **同日第三条反馈(2026-10-09)**:这一行**紧贴工具栏**、**位置不随开关变** —— 观察层(「使用情况」) 从摘要上面挪到它的下面,渲染顺序固定为 工具栏 → 状态摘要 → 排除说明 → 通知 → 观察层 → 列表 (守卫钉着「开关前后摘要的位置一模一样」)。 **同日第四条反馈(2026-10-09)**:头部那格计数**只留阿拉伯数字**(`9` / 过滤时 `3 / 79`)—— 量词本来随档位变(文档「篇」/ 代码「个代码文件」/ 媒体「个媒体」/ 混排「项」), 同一位置换个档就换一种中文 ⇒ 整句降级到 `title`(悬停仍读得到「9 个代码文件」), `count` / `count.code…` 八条词条照旧在用。 **同日第五条反馈(2026-10-09)**:点开「检索」长出来的行级证据原来是**压在下一行上面**的 —— DocRow 的入场补间用 `fill:'both'`,播完之后**仍然**按入场那一刻量到的高度钉着这一行 (`el.style.height=''` 清不掉补间效果),于是证据不把下面的行挤下去、而是溢出到它上面; 现在收尾统一走 `settleEnterAnim(el, anim)`:先 `cancel()` 注销补间、再清那两行内联样式。 守卫 = `test/client.test.mjs` 的「行级证据是向下延展」那条(替身没有 DOM ⇒ 喂纯对象 + 钉住调用点)。 **固定不是排序干预**:固定区不编号,三层 `01…0N` 的编号一个都不变。 - **打包修复(即 v0.20.1 的内容,已在本版树里)**:语料工厂搬进 `test/deep-corpus.mjs`, `tools/deep-benchmark.mjs` 变成 CLI 外壳 —— 因为 `tools/` **不在 `package.json` 的 `files` 白名单**里, registry 上那份 tarball 里 `npm test` 是 638 pass / 1 fail(3 条 R1 规模门槛用例)。 **不变式保住了:`tools/` 仍然不进包**。新增 **`tools/clean-room-test.mjs`**:真打 `npm pack` → 解包到临时目录 → 断言 `package/tools/` 只有白名单那两个文件 → 在解包目录里跑 `npm test` ⇒ 非全绿就非零退出。它是**发版前**闸门(v0.20.0 的清单只在发完之后查,太晚)。 ⚠️ 它必须带 `--cache`,否则会撞 `~/.npm/_cacache/tmp` 的权限(那个目录属于 root)。 ### 实测 - `cd knit && npm test` → **697/697**(28 个测试文件;v0.20.0 的 641 条**一条未删、未放宽、未 skip**, v0.21 净增 56 条:关系 / 控制 / 跨面 + 布局与动效收尾守卫)。 - `node tools/clean-room-test.mjs` → `dsh-knit-0.21.0.tgz` · 56 个文件 · 解包目录 **697 pass / 0 fail** ⇒ exit 0 (版本号改成 `0.21.0` 当天复跑;改名之前那次是 `dsh-knit-0.20.0.tgz`,文件数与结论相同)。 - 关系投影的载荷(按 `{type, dir, other, line}` 四字段序列化):**≈252 B/项** (两组真实任务各 9 项、合计 4539 B 关系投影);面板总载荷实测 **7857 B / 9 项**(门槛 16000 B)。 - 真机(`curl` 打运行中的 DSH,工作区 `08_Knit`):三层逐条 `provenance: retrieval` 且 `relations` 恰好 3 条(`relationsTotal` 实测 11 / 7 / 24 / 22 / 33 / 66 / 12 / 18 / 8,截断说明里的数字是真的); 固定 `README.md` ⇒ 包里多出 `pinned` 分区、`provenance: manual`、原层里消失; 排除 `03_发布/真机验收清单.md` ⇒ `skipped: 1`、被排除项**不出现**、`excluded[].exists: true`; **重启 DSH 后读回**:`persisted: true`,介质 `~/.dsh/storages/knit_control.json`(475 B,按工作区隔离)。 - `src/host/tool.js` 与面板**同源**:同一条任务下,面板每项关系的前 2 条与工具侧逐字相同 (`test/tool.test.mjs` 的跨面守卫,把工具里的截断拿掉即变红)。 > ✅ **2026-10-09 已发布**:轻量 tag `v0.21.0` = 发布提交 `af7a9a39995d080381217e7f84121f17e15445d5`;npm `latest = 0.21.0`、`gitHead` = 同一个提交、共 **18 个**版本;registry 真包 **56 文件 / 637 155 B tarball / 1 870 085 B 解包 / shasum `a536984cfd908c8f1a3b117f1e4c0062e1d619a6`**;OIDC 可信发布 + provenance(`/slsa.dev/provenance/v1`,workflow run `37875191973`,19 s);GitHub Release 正文取自本节(前置「更新后必须重启 DSH」与「新增写动作 `POST /knit/api/control`」两条),`publishedAt 2026-10-09T02:39:05Z`。 > **解包净室自测 697/0**(v0.20.0 那条 `tools/` 包装缺陷不复现;判据 = 真 `npm pack` 产物解包后跑 `npm test`)。 > > ⚠️ **这一行不在已发布的 tarball 里** —— 它在 publish 成功之后才写进来(下次 `npm view` 看到的是这份仓库里的文件)。 ## [0.20.0] - 2026-10-07 > **Deep Context Retrieval**。核心体验从「任务 → 相关文件」升级为 > 「**任务 → 相关文件 → 相关内容片段**」—— Find the file. Then find the part that matters. > 在这之前,每一篇只有**正文前 2500 字**参与打分,所以「那一段在 5000 字之后」就等于 > 「搜不到」,而 `knit_docs` 只能诚实地告诉模型「用 grep 去更深处找」。 > 关键约束:**没有 embedding / 向量库 / LLM / 网络请求 / AST / LSP / Tree-sitter / > 代码知识图谱、没有第二个栏位、没有 Dashboard、没有新的 preview framework。** > 文件仍然是 Context Pack 的**基本实体** —— 这不是把 Knit 改成一个纯片段搜索工具。 ### 变化 - **取消「每篇只看前 2500 字」**(删掉 `HAYSTACK_CHARS`)。文档与代码的 `body` / `hayBody` 现在都是**整篇**转小写,上限仍是单文件 `MAX_BODY_BYTES = 256 KB`。 一个容易忽略的事实:宿主**本来就把整篇读进内存**(`readFile` 之后才 `subarray`), 所以这一版新增的磁盘 I/O 约等于 0,代价是幂等副本的内存(约 2× 正文,见「已知限制」)。 - **新增 `src/host/passage.js`(零依赖、纯字符串、无 AST)**。切分是**结构优先 + 长度兜底**: Markdown 按 H1/H2/H3 / 段落 / 代码块,代码按 `function` `class` `const` `let` `var` `interface` `type` `export` `import` 声明起点与大括号块;行号与字符偏移量由构造过程保证 (不是事后 `indexOf` 猜的)。导出 `chunkText()` / `snippetOf()` / `passageMatchesFor()` / `windowTf()` / `passageTfOf()` 与 `PASSAGE_WINDOW = 24` / `PASSAGE_MAX_PER_FILE = 2` / `SNIPPET_CHARS = 200` / `PASSAGE_MAX_CHARS = 1200`。 - **文件排序与片段排序分开**(需求 §8)。既有 `rankByRelevance()` 一行未改:先按文件排名, 再在每篇内部挑片段(命中词种数优先、其次窗口字频、最后行号升序),每篇最多 2 段、 段与段不重叠;「20 个低价值片段堆高分」这条被 `PASSAGE_MAX_PER_FILE` 与窗口内字频同时挡住。 - **长文件污染不再因为全文检索复活**(需求 §9)。`context.js` 的 `isDocHit` 闸门改用 **片段窗口内的字频**(`windowTf`,默认窗口 = `PASSAGE_MAX_CHARS`)而不是整篇字频 —— 否则 29000 字的 CHANGELOG 会对**每一个**任务都满足「这一篇在讲它」而挤进每条任务的 Supporting。 没有片段数据时(老宿主、`test/context*/fixture.mjs` 的合成记录)逐字退回 v0.19 的整篇口径, 因此 `test/eval.test.mjs` 与 `test/context-eval.test.mjs` 的基线**一条都没改**。 - **`knit_docs` 给出「文件 + 理由 + 最相关片段 + 行号」**(需求 §15/§11)。每项可选带 `matches: [{startLine, endLine, startOffset, endOffset, terms, snippet}]`;渲染成 `match: lines 147–163 — …`。**只渲染一段**:R2 实测两段 = v0.19 的 1.71×,超过实现说明 定的 1.4×,按那条规则砍到一段(同一语料 1.04×,最坏语料 1.36×)。诚实性文案改成 「Every document and code file was searched in full (files over 256 KB are indexed only up to that size); images and videos have no searchable text.」—— 不再提 `2500 characters` 和 `use grep`。顺带拿掉 v0.19 里「每返回一条就 `readDocument()` 读一遍整篇」的 I/O。 - **面板多一行 metadata:「命中段 147–163」**(需求 §12 + 2026-10-07 用户裁决)。它是一个 **长得像文字的 button**(无图标、**无描边** —— 选中 / 聚焦都不画环,键盘焦点靠与 hover 同款的下划线;**有一个灰底**:`--knit-chip-bg` + 6px 圆角(胶囊感)、1px 6px 内边距,两个主题各一套值, 比行选中那档深一点,免得它在被选中的行里消失),点它走**既有**的就地预览。**点整行也一样**:行点击与 「点那行字」都带上篇内第一段命中(用户原话「我在点击这个文档或者是代码块的时候,就进入一个 预览,那能不能直接定位到这几段呢?这样的交互非常丝滑」)。代码文件会定位到命中行,命中区域画 一条中性灰横带(`.knit-code-hit`,`position:absolute` + `pointer-events:none` + **`opacity:.5`** —— 用户要求「背景太深了,再减少一半」;不写死颜色、用 `--knit-active-bg` 回落),滚动用的仍是 既有那一个 `.knit-preview-body`。文案从「命中片段」缩到**「命中段」**(EN `Passage:`),并且与 v0.14 的「直接命中」**合成同一条 metadata 行**(`.knit-subrow`:命中段在左、中间一条 1px 中性 竖线、理由在右)—— 于是这一版**没有让每一行变高**。没有分数、没有百分比、没有置信度、 没有「AI 分析」—— 只展示可验证的行号区间。**2026-10-07 用户裁决(标点)**:中文去掉全角冒号 (`命中段 147–163 | 直接命中 knit · border`)—— 全角冒号占一个整字宽 + 强制停顿,而这一行是两个 标签并排;整族 6 个理由标签一起改,否则标点会随理由类型跳。**冒号只在引出列表时保留**,**英文保留 ASCII 冒号**(半个字宽,且 `direct match knit` 读起来像在说 match 这个词)。 - **Markdown 预览也跳到命中段**(2026-10-07 用户裁决:「文档这边也参考代码块一样,选中文档以后进入详情, 要指定到段落」)。`.md` 没有行号 ⇒ 不走行锚点,走**文本锚点**:从片段原文的 `[startOffset, endOffset)` 里按行取 1–4 根「针」(长度 ≥12、≤40;去掉行首 `#` / `>` / 列表符,`[文字](地址)` 只留文字, `*` / `~` / 反引号去掉,**下划线只在成对强调处删**以保住 `snake_case`,空白折叠),再把渲染后的 文本节点接成一根带下标映射的串去 `indexOf`,找到就滚 `.knit-preview-body` 并给那一块一次 1.2s 的淡底 (`.knit-md-body .knit-doc-hit`,与 Coverage 那层同一档灰、同一个 `FLASH_MS`);**找不到就什么都不做** (宁可不动,也不假装动了 —— 需求 §26)。两处都是真机实测后才对的:① DSH 的**行内 `code` 是 `inline-block`** —— 不把它算作行内,淡底会画在那一小片片上,所以 `DOC_INLINE_DISPLAY` 里补上 `inline-block / inline-flex / inline-grid / ruby`,落到所在的段落 / 列表项;② 同一根针在长文档里出现 几十次是常态,`docPickOffset()` 先**独特的那根针优先**、同档再按 `startOffset / 全文长度` 挑 **离锚点最近的那一次**(否则 `indexOf` 永远跳去全篇最早那次 —— 实测 CHANGELOG 就跳去了第 11 行的 同名行内 code 而不是第 36 行)。渲染仍是 DSH 的 `MarkdownText`(`DocPane` 只包一层,**不自己渲染 Markdown**);正文走**显式 `body` prop** 而不是 children(测试替身展开函数组件时只传 props)。 - **修一处在真机截图上发现的缺陷:Context Pack 的三层行不显示「命中片段」**(2026-10-07 验收时发现)。 宿主给三层条目的投影只有 `rel / title / summary / mtimeMs / kind / source / reason` 七个键 (`context.js`),**没有 `matches`** —— 于是 Primary / Supporting / Related 这九行(恰恰是最该给 片段的那几行)一条片段行都没渲染,只有「其他相关文档」区(取自 `docs`)有。修法是客户端按 `rel` 从 `visibleDocs` 取回 `matches`(`docs` 里本来就有这份数据 ⇒ **载荷零增长**;没有改成让宿主补键, 因为面板载荷已经吃掉 16 KB 预算的大部分)。新增守卫 `test/client.test.mjs`「v0.20 命中片段:三层(Primary/Supporting/Related)的行也要有片段行」—— 把修法拿掉即变红(`README 在三层里,也必须有片段行`)。 - **暗色模式:把列表里最高那一档的白降下来,并全部改回 DSH 官方令牌**(2026-10-07 用户: 「暗色模式下高亮那个色值是不是纯白色……太亮了,就是太白了,看得有点犯晕」)。属实:命中段的底 原本是**纯白 13%**(`--knit-chip-bg`),是三档里最高的一档,而且那个 13% 是我们自己拍的数 —— DSH 官方的 `--dsw-alias-interactive-bg-active` 在暗色下只有 `#ffffff24` = 14.12%(值取自 `dsh-client-ui-theme/lib/client.js`)。现在三档一律「官方透明度 × 固定比例」,且**暗色三个比例 比浅色各低 10%**:hover `7.84%×0.36`、active `14.12%×0.54`、chip `14.12%×0.81`(浅色比例是 `0.41` / `0.60` / `0.90`,暗色这组 = 浅色 ×0.9;**浅色的三个值一个字没动**)。顺手修掉两处「自己造一套」: ① 代码里有 6 处引用 `--dsw-alias-text-primary/secondary/tertiary` —— **DSH 全库零定义**(逐文件核实), 等于一直在静默失效:`color` 取到 unset ⇒ 命中段那一格的 hover 变色其实从来没生效过;已全部改回 官方 `--dsw-alias-label-primary/secondary/tertiary`,并补上官方浅色值作 fallback。 ② 图标原来写死 `#000` / `#fff`:暗色的 `#fff` 比 DSH 自己的主文本色 `#f9fafb` 还亮,现在走 `--dsw-alias-label-primary`(令牌自己随主题切,那条 `body[data-ds-dark-theme] .knit-icon` 覆盖规则 因此删掉)。守卫:`test/client.test.mjs` 的灰底用例改成断言三档 + 「命中段必须比行选中更深」 + 「不得亮过官方 active 14.12%」+「不许出现 `--dsw-alias-text-`」;`test/icon.test.mjs` 改成断言 官方令牌 + 不许再写死黑白。**测试数不变(635)**,是替换断言而不是加用例。 - **代码预览:语法高亮(2026-10-07 用户新增需求)**。用户原话:「我们现在在暗色模式下它是白色, 对于一个阅读代码的用户来说,它的可读性太低了;那在浅色模式下它也是一种黑色……你看这块要怎么治理一下?」 治理方式**不是自己配色**,而是**接 DSH 官方那一套**: ① 复用客户端**本来就在 require** 的 `@deepseek-ai/dsh-client-ui-primitives` 里的 `useCodeHighlighter(language)`(与 `MarkdownText` 同一个包 ⇒ **零新依赖、零新 specifier**); ② 语言标识**只认宿主分类层**给的 `language`(`classifyFile()` 的结论)—— 客户端自己不建第二张后缀表; ③ 颜色**只来自 `--shiki-*`**(定义在 `dsh-client-ui-theme`:`--shiki-token-keyword` 浅 `#d6336c` / 暗 `#faa2c1`、`comment` `#868e96` / `#adb5bd`、`string` `#2f9e44` / `#69db7c`……)⇒ 深浅两个主题 各自吃到官方调色板,不必为任何一个主题写死色值(这正是上面那条暗色治理的反面教材); ④ 拿不到高亮器(旧版 DSH / 包缺失)时**静默回落纯文本**,不报错、不空白。 **性能**:官方高亮器是同步的 shiki JS 引擎,真机实测约 170–560 ms / 千行 —— 4000 行的文件 (v0.19.1 基线 117 ms 打开)要 **1.3–2.2 s**,回车点开会白等一秒多。当天先收到「只上色前 800 行」,用户仍反馈「加载还是有点慢」,于是**再收一次**(用户原话:「要不代码模块我们换一个方案, 就是命中段才有颜色,不命中的段就不用上颜色,还是用原来的,浅色模式下用黑色,暗色模式下用白色 就可以了。我觉得这样可能就简单了。因为我刚刚去看了一下,加载还是有点慢,800 行也是很慢」): 上色范围 = **命中段前后各 `CODE_HL_PAD = 60` 行**,硬上限 `CODE_HL_MAX_LINES = 800` (命中段本身可能几百行);**没有命中段(载荷里没有 `matches`)就一个 token 都不上色**,其余行 保持默认正文色(浅色黑 / 暗色白,跟随主题令牌);窗口之上 / 之下的行各是**一个**纯文本节点 (截断的是颜色,不是内容)。真机实测(同一浏览器会话连开 8 篇):`client.js`(4000 行) **1496 → 424 ms**、`client.test.mjs`(4000 行)**2226 → 193 ms**,8 篇合计约 **5.9 s → 1.3 s**。 ⚠️ 已知代价:窗口从半截注释 / 字符串中间开始时缺分词上下文,那一段可能**错色**(命中横带仍精确 标出行)。⚠️ 试过并**否决**「用 `useEffect` + `setTimeout(0)` 让出一帧、颜色随后补」:真机反而更慢(首个文本 3605 ms、颜色 3614 ms, 基线是同步版 1458 ms)—— `setTimeout(0)` 的回调在下一次绘制**之前**就跑掉了,纯文本那一帧从未 真正画出来,还多付了一次「文本节点 → 19782 个 span」的替换提交。 **渲染形状**:正文仍是**一个** `
`,行间只隔一个 `'
'` 文本节点、**不包逐行元素** ——
  行锚点(v0.20 §13)靠 `pre.scrollHeight / 行数` 量行高,逐行元素会把这个几何改掉;
  窗口之外的头 / 尾也各是**一个**纯文本节点。高亮器返回的行数与正文对不上时一律退回纯文本
  (宁可没颜色,也不能让颜色落在错误的代码上)。`useMemo` 记忆化,滚动 / 悬停 / 5 s 轮询的重渲染不重算。
  测试 **+4(635 → 639)**:`test/client.test.mjs` 新增「逐 token 上色、颜色只来自 `--shiki-*`」
  「宿主没导出高亮器时回落纯文本」「只给命中段前后各 60 行上色、窗口被 `CODE_HL_MAX_LINES` 截住」
  「没有命中段就一个 token 都不上色」;`test/harness.mjs` 的假高亮器同时记录收到的**语言**与**正文**。
  真机实测(Playwright,浅/暗两主题):颜色集合 = `--shiki-token-keyword/-comment/-string/-constant/
  -function/-punctuation` + `--shiki-foreground`;命中横带几何、锚点滚动、横向滚动都没被 token 化破坏。

- **交互性能治理(2026-10-07,用户报「点击选中文档 / 代码都很卡、切换标签也卡顿」)**。先测后改:
  宿主**不慢**(`/api/recent` 59–104 ms、`/api/context` 58 ms;R1 规模基准 1000 文件 cold 157 ms /
  warm 21 ms,两条门槛都 PASS),慢在客户端四处:① **语法高亮每次重付**(`CodePane` 的 `useMemo`
  只活在挂载期,切换类型档会卸载预览)⇒ 加**按文件缓存**(`CODE_HL_CACHE`,LRU 8 篇,键 =
  `rel` + 窗口范围);② **FLIP 每次提交把整屏行全量量一遍**(`applyRowFlips` 的 `settled` 判据只看
  视图签名)⇒ 加**行签名短路**(`rowSig` = 窗口尺寸 + 每行 `rel` / `tierKey` / `matches` 数 / mtime,
  签名不变就复用上次的布局点、一行都不量);③ **每 5 s 轮询无条件 `setState` 一个新对象** ⇒
  载荷签名与上次相同就**返回同一个 state 对象**(真 React 靠 `Object.is` 跳过整棵子树重渲染),
  时间戳也改成渲染时刻取钟、不再为它重建 state;④ 行高 layout effect **先量后判** ⇒ 改成不补间就
  提前返回(「别白量」:一屏挂着上万个 token span 时白量一次 ≈ 0.25 s @4× 节流)。
  **改前 / 改后**(4× CPU 节流 + Retina,同一套脚本):切换类型档阻塞 **118 → 0–69 ms**、
  点文档行阻塞 **415 → 172 ms**、同一篇代码关掉再开阻塞 **1390 → 261 ms**(CDP Profiler 里
  `findNextMatchSync` = shiki 分词**从热点中消失**)、点代码行首开仍重(**2728 → 2217 ms**,剩下的是
  第一次冷分词,第二次起走缓存)。测试 **+2(639 → 641)**:「同一篇代码收起再打开不再重新分词
  (按文件缓存)」「载荷一字没变时,轮询不再落状态」—— 两条都**放过一次 bug**:把缓存 / 去重绕过
  立刻变红(`actual 17 vs 11` / `actual 2 vs 1`)。⚠️ `test/harness.mjs` 的 `reset()` 补了
  `timers.clear()` 与 `stateWrites()`:轮询链会自己续命、替身又从不跑 effect cleanup,上一个用例的
  链会污染下一个用例的 hook 槽位(`v0.18 Coverage flash` 那条用例因此补了一次 `await harness.flush()`,
  断言一个字没改)。

### 测试

- `npm test`:**641 / 641 通过**(v0.19.1 是 588;26 个测试文件)。
- **新增 `test/deep-context.test.mjs`(17 条)**:需求 §21 点名的 12 个用例逐条对应 + §17 的
  媒体反例 + `windowTf` 单元 + 长归档分层集成 + **R2 面板预算**(≤24 篇带片段、每篇 ≤2 段、
  片段 ≤`SNIPPET_CHARS`+8、载荷增量 ≤ 实测上界 16 KB / 每篇 700 B)。
- **新增 `test/deep-eval.test.mjs`(4 条)** 与 `test/eval/deep.mjs`(四指标的唯一定义处)。
- **新增 `test/deep-scale.test.mjs`(3 条)**:1000 文件规模的硬门槛与确定性。
- `test/client.test.mjs` **+17**(127 → 144):命中片段的渲染与点击、代码行锚点几何、样式纪律、i18n 键、
  「媒体卡片即使被塞上 `matches` 也不渲染」,以及两条 2026-10-07 的裁决守卫:
  **三层行也要有片段行**(`README 在三层里,也必须有片段行` —— 把客户端的 `withHits` 拿掉即红)、
  **两条 metadata 合成一条**(`.knit-subrow` 存在、命中段在「正文命中」之前、有且只有一条 1px 竖线)、
  **点整行带锚点**(行为断言 + `previewFor(doc, firstMatchOf(doc))` 源码守卫 —— 测试替身没有真实
  布局,横带量不到行高,所以这条按 `AGENTS.md` §8.2 走「守代码形状 + 人眼」)。样式守卫同时钉住
  `outline:none`(不再有白描边)、横带 `opacity:.5`、竖线是 1px 且不是虚线 / 渐变。
  其中 **6 条是 Markdown 文本锚点**:归一化口径(`snake_case` 不许被当成强调)、取针(跳太短的行 /
  去重 / 上限 / 越界不许闭眼切)、`docPickOffset`(重复的针按位置比例挑、独特的针优先、空正文返回 -1)、
  `DocPane` 只包一层且没有 DOM 时不抛、接线守卫(`ratio` 一路传下去、`inline-block` 必须算行内)、
  样式纪律(`transition` 在基础选择器上、时长 == `FLASH_MS`、不画边框)。
- `test/tool.test.mjs` +3:R2 预算守卫、镜像常量守卫(工具说的 256 KB 必须等于宿主真读的
  `MAX_BODY_BYTES`,工具的 `SNIPPET_CHARS` 必须等于评分侧的)、**跨面集成守卫**(需求 §15:
  同一条任务下 `knit_docs` 的 `matches` 行号必须与面板载荷 `docs[].matches` 逐条一致 ——
  两侧各有自己的补偿路径〔面板靠客户端按 `rel` 取回、工具靠 `tool.js` 的 `matchesByRel`〕,
  单面测试**各自都是绿的**,只有这条能抓住它们漂移)。
- **v0.19 的既有基线一条没改**(需求 §22):`test/eval.test.mjs` 与
  `test/context-eval.test.mjs` 的断言、夹具与阈值全部原样通过。

### 改动量

- **26 个功能文件 = 20 改 + 6 新增**(另有 2 个不进包的开发文件:`tools/agents-budget.mjs`、`knit/AGENTS.md`;
  发布提交合计 **33 处改动 = 25 改 + 8 新增** —— 多出来的 5 处是仓库侧的演示资产:`docs/README.md`
  与重拍的四张图 `docs/screenshot.png` / `screenshot-media.png` / `screenshot-all.png` / `demo-reorder.gif`)。
  仓库根就是 `knit/`;`git diff --shortstat` = `25 files changed, 2507 insertions(+), 218 deletions(-)`,
  不含 8 个新文件。
  > 2026-10-08 仓库侧又补了两轮**不进包**的改动:① **指令上下文治理**(`tools/agents-budget.mjs` 加
  > `--claims` / `--strict` / 每层 sha256 指纹,常驻层改成触发词路由并净减 302 B,裁决记在
  > `Knit-决策记录.md`);② 按 v0.20 界面**重拍四张演示图**(`docs/` 不在 `files` 白名单里,它们只出现在
  > GitHub README 与市场详情页上)。两轮都**没动插件代码**(tarball 只因本节这几行文档改动而略变,
  > 打包数字见下)。
- **新增 6 个**:`src/host/passage.js`、`test/deep-context.test.mjs`、`test/deep-eval.test.mjs`、
  `test/deep-scale.test.mjs`、`test/eval/deep.mjs`、`tools/deep-benchmark.mjs`。
- `npm pack --dry-run`:**51 文件 / 557.0 kB 打包 / 1.66 MB 解包**(v0.19.1 是 46 文件 / 483.4 kB)。
  ⚠️ **shasum 故意不写在这一节里**:这份 CHANGELOG 本身就在 tarball 里,写自己的 shasum 会自指
  (改一次就变一次)。**发布前那一刻的 shasum 记在 `03_发布/发布清单-v0.20.0.md`**(仓库外)。

### 基准

- **Deep Context Eval(需求 §23 的四个指标,`node -e "…runDeepEval()"`)**:
  `DeepHitRate = 1.000`(v0.19 口径 **0.167**)、`PassageHit@1 = 1.000`、
  `PassageRecall = 1.000`、`LongDocumentNoiseRate = 0.000`。5 条深度用例(词只在 2500 字之后、
  尾部、一处两命中、代码函数附近、heading 附近)全部 `✔ 第 1 名`,长归档在 6 条用例里
  **一次都没进 Primary/Supporting**。
- **R1 规模(`node tools/deep-benchmark.mjs`)**:100 文件 cold 63 ms;500 文件 164 ms;
  1000 文件(进池 400 文档 + 100 代码)**157 ms**;真实工作区 1100 文件
  (进池 400 + 300)**384 ms**。门槛 `1500 ms` / `1200 ms`,余量约 10× / 3×。
- **R2 预算(实测)**:面板载荷 24 篇带片段时 `+14760` 字节(每篇 615 B,片段最长 202 字符 ——
  需求 §16 要的是更精准而不是更大,所以这条钉了自动守卫);`knit_docs` 渲染文本
  1.04×(真机压力语料)~1.36×(最坏语料),均在 1.4× 以内。
- **§29 Step 8 真实验证**(真实工作区 `08_Knit`,非合成样本):`knit/CHANGELOG.md`
  第 907 行的唯一词(正是那句「Matches come from the first 2500 characters…」)与
  `knit/src/host/context.js` 第 133 行的唯一词,两者都**不在前 2500 字内** ——
  v0.20 都能命中并给出覆盖该行的片段(名次 5/126 与 10/126)。

### 已知限制

- **Markdown 预览靠文本锚点跳段,不是行号**:`.md` 的「命中段」点击(以及直接点整行)会滚到**那一块**
  (片段首行那根「针」落在哪个渲染块上)并闪一次底 —— 但它指的是一段**文本**,不是源文件第 N 行;
  针在渲染结果里找不到时只打开、不滚动(宁可不动,也不指错)。代码文件才是精确到行的锚点。
- **单文件 256 KB 上限**:超过的部分不索引(输出里如实说明)。
- **媒体没有可检索正文**,不做 OCR、不调视觉模型(需求 §17)。
- **内存**:`body`(原文)与 `hayBody`(小写副本)现在都是整篇,每条约占 2× 正文;
  缓存淘汰仍按 `bodyBytes` 计一份(已知偏差,未改)。
- **多词查询下,什么话题都提一句的长归档在文件级仍可能排在聚焦短文前面**:
  `isDocHit` 管的是**分层**,不管文件名次。

### 没有实现(刻意)

Context Provenance、Context Graph UI、Pin-Lock、Context Bundle、Multi-Harness、MCP Server、
Doubao Adapter、Semantic Search、Embedding、Session Memory、AI Summary、新 Dashboard、
embedding / 向量数据库 / LLM / 网络 / LSP / Tree-sitter / AST 依赖图 / 语义模型 / 知识图谱,
以及把 Knit 做成 IDE / Code Intelligence / Language Server。

### 三问(每版必答,需求 §16)

1. **用户现在多获得了什么能力**:点开任何一篇(无论多长)都能直接看到**命中在哪几行**,并且
   预览会**滚到那一段** —— 代码精确到行、Markdown 落到那一块。此前长文档里「第 5000 字之后」
   等于不存在:列表只给文件名,预览从头开始,用户得自己在整篇里找。
2. **Agent 的 Context Consumption 多获得了什么能力**:**知道「文件内部哪里相关」**。
   `knit_docs` 每条多一行 `match: lines 147–163 — …`(原文片段 + 行号),模型不必为定位再 `grep`
   或整篇读一遍;诚实性文案也随之改成「全文都搜过了」(不再说「只看前 2500 字,更深的用 grep」)。
3. **明确没有做**:见上一节「没有实现(刻意)」。这一版**刻意不做**的四条最容易被误加的:
   语义/向量检索(留 v0.24)、把片段当排序基本实体(文件仍是基本实体)、第二套预览框架、
   在任何界面上显示分数 / 百分比 / 命中计数。已知边界另见「已知限制」。

### 升级注意

- **宿主不热加载** ⇒ 升级后要重启 DSH 才会生效(客户端 bundle 随页面刷新)。

> ✅ **2026-10-08 已发布**:轻量 tag `v0.20.0` = 发布提交 `2be278486299c721daa21ee127fd26bbe21af284`;npm `latest = 0.20.0`、`gitHead` = 同一个提交、共 **17 个**版本;registry 真包 **51 文件 / 565 078 B tarball / 1 662 961 B 解包 / shasum `7be584a08529379dba7d255028d59a2853624094`**;OIDC 可信发布 + provenance(workflow run `37730146187`,22 s);GitHub Release 正文取自本节(前置「更新后必须重启 DSH」与「已知问题」两条),`publishedAt 2026-10-08T05:08:35Z`。
> ⚠️ 本地 `npm pack --dry-run` 报 51 文件 / 557.0 kB / shasum `deb8bfe6…` —— **本地字节与 registry 历来对不上**(v0.18 / v0.19.x 都这样),差的只是 tar/gzip 框架元数据,解包后清单与内容逐字一致。
> ⚠️ **发布后才发现的一条包装缺陷**:`test/deep-scale.test.mjs` 从 `../tools/deep-benchmark.mjs` 取语料工厂,而 `tools/` 不随包发布 ⇒ **解包目录里 `npm test` = 638 pass / 1 fail**(`ERR_MODULE_NOT_FOUND`)。**插件本体不受影响**(`src/` 不引用 `tools/`)。仓库完整套件仍是 641/641;已在 **v0.20.1** 修(把语料工厂搬进 `test/`)。
> ⚠️ **这一行不在已发布的 tarball 里** —— 它在 publish 成功之后才写进来(下次 `npm view` 看到的是这份仓库里的文件)。

## [0.19.1] - 2026-10-07

> **定位重构(只改对外叙事,一行代码没动)**。产品已经从 v0.12 时代的「更聪明的最近文件列表」
> 长成了 **Workspace Context Retrieval**:当前任务 → 工作区检索 → 主要 / 辅助 / 相关 →
> Context Pack → Agent 读取 → 读取证据 → 工作区变化 → 重新读取。v0.19.1 把 README、
> 仓库 About 与市场条目文案统一到这条主线上,并把「怎么排序」(BM25 / IDF)
> 从卖点降级成证据。

### 变化

- **`README.md` / `README.en.md` 第一屏整体换掉**。定位句升级为
  *Task-aware workspace context retrieval and lifecycle tracking for AI coding agents.*
  (面向 AI Coding Agent 的任务感知工作区上下文检索与生命周期追踪),并给出
  Find → Organize → Track 的主线图。原来开头的「Agent 一天产出 20 篇文档,你找不到刚才那篇」
  降级成 `## 为什么需要它` 里的一句引文。
- **「这不就是个最近文件列表吗?」不再是「是的,但…」**。改成
  `## 「最近文件列表」和「代码浏览器」都不是它`,用一张五行对照表(范围 / 排序 / 输出 /
  给谁 / 之后)说明区别,不再替读者接受「最近文件」这个框架。
- **BM25 降级为证据**。原「「相关」是怎么算出来的」改成
  `## 检索是怎么算的(实现细节,不是产品定义)`,正文前加一句「这一节是证据,不是卖点」。
- **章节重排**:两份 README 的章节顺序现在一一对应;英文版原先嵌在
  "Did it actually get used?" 里的 `### v0.19: code as context (Code Context)`
  提升成独立的 `## Code as context (v0.19)`。
- **`package.json`**:`description` 换成同时讲 retrieval 与 lifecycle tracking 的一版;
  keywords 24 → 27(新增 `workspace-context` / `context-retrieval` / `context-lifecycle`)。
- **无代码变更**:`src/`、`test/`、`tools/` 一行没动,测试仍是 588 项全绿。

> ✅ **2026-10-07 已发布**:轻量 tag `v0.19.1` = 发布提交 `8dbbdef80c0d4d691b2724225ee6c4ec93aa6c34`;npm `latest = 0.19.1`、`gitHead` = 同一个提交、共 **16 个**版本;registry 真包 **46 文件 / 490 417 B 打包 / 1 452 990 B 解包 / shasum `a69ed85eb065335fda6a3e6cf91dfb346b76b71a`**;OIDC 可信发布 + provenance(`/slsa.dev/provenance/v1`);GitHub Release 正文取自本节(27 行 / 2020 字节),`publishedAt 2026-10-07T04:49:46Z`。
> ⚠️ 本地 `npm pack --dry-run` 报 46 文件 / 483.4 kB / shasum `5bfd69b0…` —— **本地字节与 registry 历来对不上**(v0.18 / v0.19.0 都这样),差的只是 tar/gzip 框架元数据,解包后清单与内容逐字一致。
> ⚠️ **这一行不在已发布的 tarball 里** —— 它在 publish 成功之后才写进来(下次 `npm view` 看到的是这份仓库里的文件)。

## [0.19.0] - 2026-10-07

> **Code Context**。产品概念从「Markdown / 图片 / 视频」变成「**文档 / 代码 / 媒体**」——
> 这不是给后缀表加几行,而是让 Knit 把 AI Coding 任务**当前真正需要的那几个源码文件**
> 纳入同一条 Context Retrieval 链路。
> 关键约束:**没有第二条检索链路、没有第二套生命周期、没有第二个 Context Store、
> 没有 AST / LSP / embedding / 模型调用 / 网络请求、没有把 Knit 变成代码浏览器或 IDE。**
> 主线数据流一个字没改:当前任务 → Workspace Retrieval → Document + Code Candidates →
> Primary / Supporting / Related → Context Pack → Agent Read → Read Evidence →
> File Changed → Lifecycle → Context Epoch / Delta → Usage Lens。

### 变化

- **新增独立的文件分类层 `src/host/classification.js`**。在这之前,扩展名判定是
  `src/host/index.js` 里一个返回 `''|'md'|'image'|'video'` 的 `kindOfName()`,散在扫描、
  预览、UI 各处。现在只有一个入口:`classifyFile(name)` →
  `{kind, language, previewable, searchable, contextual, generated, sourceMap}`,
  扫描 / 检索 / 预览 / UI **全部消费它的产物**,没有一处再自己看扩展名
  (有一条架构守卫:`src/host/index.js` 与 `src/client/client.js` 里不许出现
  `'.ts'` `'.py'` `'.map'` `'.min.js'` 这类后缀字面量)。
- **第一批支持 17 个代码后缀**:`.js .mjs .cjs .ts .tsx .jsx .py .json .html .htm
  .css .scss .yaml .yml .sh .bash .zsh`。内部统一 `{kind:'code', language:'…'}`。
  **刻意不支持** `.go .rs .java .kt .c .cpp .h .hpp .cs .php .rb .swift .sql` ——
  范围越小,「代码上下文到底有没有用」这个结论越可信。
- **代码复用 Markdown 的 BM25,只换字段口径**。代码没有 Markdown 的 title/summary/body,
  所以宿主按 `filename`(**带扩展名**,`plugin-loader.js` 与 `plugin.json` 的差别就在这儿)
  → `title` 字段、`目录`(保留结尾斜杠)→ `summary` 字段、`正文前 8 KB` → `body` 字段填充,
  于是 `relevance.js` 里既有的 `FIELD_WEIGHTS = {title:4, summary:2, body:1}`
  **一行未改**就是需求要的 filename×4 / path×2 / content×1。排序器、语料过滤、
  门槛规则全是同一个。
- **`.map` / generated / noise 一律不进上下文**:`*.map`、`*.min.js`、`*.min.css`、
  `*.bundle.js`、`*.generated.js`、`*.gen.js`、`*.lock`(含 `package-lock.json`)→
  `{kind:'generated', searchable:false, contextual:false}`。它们不参与检索、不进 Context Pack、
  不进 Usage Lens、不进代码列表、不进 BM25 语料 —— 但**仍然可以预览**(用户主动从
  DSH 原生文件树点开时不拦):**Context eligibility ≠ File system visibility**。
- **有边界的扫描**:新增 `MAX_CODE = 300`(Markdown 仍是 400)、代码正文只读前 8 KB
  (`CODE_HEAD_BYTES = 8*1024`,没有无界读取)。目录元信息 → 扩展名分类 → 候选准入 →
  有界内容读取,顺序不变。数字是**量出来的**:真实项目根(`08_Knit`)59 文档 + 54 代码,
  上限根本碰不到;而一个 1300+ 目录的大树正好在这里截住(400 文档 + 300 代码 + 400 媒体)。
- **代码预览就地展开,不新增交互**:复用面板下方那块现有预览区。行号 gutter + 等宽正文、
  横向滚动只在正文列(纵向滚动仍归 `.knit-preview-body`,**没有第二个纵向滚动容器**)、
  复制、`打开文件`、同一个 `Source map` 轻量入口(只在同名 `.map` 存在时出现)。
  超过 4000 行只渲染前 4000 行并明说被截断。**没有**语法高亮 / 折叠 / 多标签 / mini-map /
  git diff / symbol outline —— 也没有引入 Monaco 或 CodeMirror。
- **UI 类型档从三档变四档**:`文档 / 代码 / 媒体 / 全部`。代码档**仍然受当前任务检索支配**,
  不是「项目代码文件树」:任务说「改 Skill loader 支持新 frontmatter」,这一档就该是
  `skill-loader.ts` 在前,而不是 `src/` 下全部代码。没有第二层语言分类 UI;
  代码行只多一个很弱的语言徽章(`TS` / `PY` …),文档行不挂(整列都是 MD,是纯噪声)。
  「全部」档变成三区:文档区(≤4)→ 代码区(≤4)→ 媒体网格。
- **代码的理由码**:`filenameMatch`(文件名命中,与 Markdown 的 `titleMatch` 同级)与
  `pathMatch`(目录命中,与 `summaryMatch` 同级)。`why.filenameMatch` / `why.pathMatch`
  中英各一条;**没有**新增第 8 个条目字段 —— `{rel,title,summary,mtimeMs,kind,source,reason}`
  与 v0.18 逐字相同,代码只是让 `kind` 多了一个取值。
- **生命周期 / Read Evidence / Epoch / Delta / Usage Lens 一行未改**。代码被读之后走的是
  完全同一套归因:读的时候在哪一层就冻结在哪一层,历史证据不许因后来的 BM25 变化而重算;
  四态(未读 / 已读 / 读后已更新 / 修改后已重新读取)也是同一套;Epoch 是同一份;
  UI 上多出来的叫法一个都没有(仍然只叫「上下文变化」)。
- **预览头那条路径改成「在文件管理器里定位到这个文件」**。原文案是「用系统默认应用打开这篇文档」,
  与页脚那个「本地打开」按钮**做的是同一件事** —— 一个东西两个入口,还偏要悬停才知道。
  现在两者分工清楚:**路径点开它所在的文件夹、并且已经选中这个文件**
  (`openWorkspacePath({ path, action: 'reveal' })`,macOS 走 `open -R`、Windows 走
  `explorer /select,`,正是 Chrome「在文件夹中显示」那个手感 —— 不用在文件夹里自己再找一遍),
  **「本地打开」点开文件本身**(不带 `action`,走默认应用)。文案随之换成
  `preview.reveal`(中「在文件管理器中显示并选中这个文件」/ 英 `Show this file in your file manager (and select it)`)。
  ⚠️ 送过去的是**文件本身的绝对路径**而不是所在目录 —— reveal 一个目录会变成
  「在上级目录里选中这个文件夹」,不是用户要的。Linux 的 `xdg-open` 没有「选中」这个概念,
  那边只有打开目录(平台差异,不是少做一步)。
- **修:开着「使用情况」点全屏,预览没铺满**。全屏的隐藏名单写于 v0.12,当时面板里只有
  「头部 / 排序条 / 类型档 / 列表 / 预览」这几块,所以只藏了前四块;v0.18 在列表**上方**
  加了 Context Usage Lens(`.knit-usage`),谁也没回头去补那份名单 —— 于是开着「使用情况」
  再点全屏,Lens 仍以 `flex:none` 的自然高度占掉面板上方约三分之一,预览被挤在下面,
  看起来就像「全屏没生效」。补 `.knit-root.fullscreen .knit-usage{display:none}` 一块即可,
  预览那条 `flex:1 1 auto` 本来就在等它让位。
  守卫放在 `test/client.test.mjs`:全屏隐藏名单必须覆盖 `.knit-head` / `.knit-bar` /
  `.knit-types` / `.knit-usage` / `.knit-list`,并跟真的渲染出来的全屏树对一次账 ——
  以后谁再往列表上方加块,这条会响。
- **HTML 多了「网页预览」**。加了代码支持之后,`.html` 点开看到的是**源码**;
  可 HTML 想看的恰恰是它渲染出来的样子。DSH 其实**自带**这个渲染器
  (`dsh-client-ui-sidebar-documentpreview` 注册了 `["html","htm"]`,默认静态档:
  DOMPurify 清洗过的文档塞进一个**不给任何 sandbox 权限**的 iframe,CSP 摆在最前挡脚本 /
  外部资源 / 连接 / 表单 / 嵌套框架,内联 `