Repository navigation
Releases: PolinniZhong/dsh-knit
Release list
v0.23.1 — 工具栏收敛 + 产品信息修订
⚠️ 本版不是纯 patch:类型档 四档 → 三档(「全部」档连同它的混合浏览视图从 UI 删除)、检索方式整块搬进第三行状态摘要做成可点状态格 —— 都是用户可见的工具栏收敛。
但 宿主src/host/*一行未改(kind=all的包能力、knit_docs、统一 Context Pack 全保留)⇒ 升级后只需硬刷新浏览器,不需要重启 DSH。
⚠️ 语义价值证据仍是pending:本机没有可用的 embedding provider ⇒ 四路径量尺的 H1(真模型)没跑。本版没有任何「检索更准 / 召回更高」的声明 —— 它只把文案改成如实区分:「默认词法(完全本地)」与「可选混合(配了远程 provider 时,发出去的是标题 + 摘要,不是正文)」。
🔧 本版的首要理由是包装修复:已发布的
0.23.0包内README.md还写着 v0.22 的「728 项」,而 npm 页面渲染的正是 tarball 里那份 README ⇒ 只能发一个版本才换得掉。本版已核:包内README.md的7280 次、7752 次(= 本版实测用例数)。
[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=alle2e 仍在跑)。 -
检索方式从工具栏搬进状态行(当日第二次裁决,推翻本版早先「挪到搜索框左侧」的中间版):
工具栏第二行只剩[相关|最新] [文档|代码|媒体] … [ ……搜索框…… ](搜索框仍是唯一可伸缩项,
窄面板先缩它 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.0tarball 里那份写的还是 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),本版只改信息准确性与工具栏形状。
v0.23.0 — Optional Hybrid Retrieval
默认词法、语义可选、默认关闭:不碰它时这一版与 v0.22 逐字一致;语义只补候选(只进「相关」层、只追加在最后),失败一律明着回退。零新依赖、零构建步骤、默认路径零网络出口。
⚠️ 价值证据 pending,不是「已证明」:本机没有可用的 embedding provider ⇒ 四路径量尺里的 H1(真模型)没跑,脚本如实打印SKIPPED: no provider。下面 H0 那组1.000是确定性替身(加权同义词表)造出来的机制证据,不能读成「语义检索提升了召回」。
[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 > 会话原话 > 关键词(兜底)(本版修掉的缺陷之二);
· 配置存 DSHctx.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 test728 → 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 test778 / 778(0 fail,33 个测试文件);
node tools/agents-budget.mjs --claimsexit 0(版本0.23.0/ 33 个测试文件 / 778 项);
node tools/clean-room-test.mjs打包dsh-knit-0.23.0.tgz· 65 个文件(tools/里只有白名单那两个),
解包目录里npm test778 pass / 0 fail ⇒ exit 0。
⚠️ 这组读数取自「界面第二条决定(「全部」档)落地过程中」的时刻;提交 / 打包前必须重跑
(knit/里任何一个在包内的字节被改,文件数与这一行都要重测)。
v0.22.0 — Incremental Context Index
这一版是内部能力,不是用户可见功能 —— UI 一行未改(没有索引面板 / 命中率 / Epoch)。
升级后必须重启 DSH:只改了宿主半边src/host/*,客户端半边一个字节没动。
[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、关系投影)
一行未改,见 01_ Knit PRD/Knit-v0.22-Phase0-审计.md。
变化
- 新增
src/host/index-store.js:索引的持久层,走 DSH 自己的ctx.storageKV 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 + readJSON.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 vsnostorage(等价 v0.21)242.6 ms,而计数逐项相同
(readFiles600 / 读入 13453 KiB /readdir21 / 复用 0)—— 因为这条路上索引根本没被查
(index-skipped)。差距在噪声内,且路径就是同一条。
· 时间序 / 无关键词(不吃正文)—— 这才是这一版真正省下来的地方:同形状 warm 106.0 ms
vsnostorage216.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× 那条旧结论来自这个形状,修法 ② 之后它已经不成立。
取舍、复现命令与逐条读数见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 test697 → 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= npmgitHead;
OIDC 可信发布 run38100521035(24 s,success);registrylatest = 0.22.0(共 19 个版本,
publishedAt2026-10-11T01:05:48Z);registry 上的真包 = 59 文件 / 668 701 B tarball / 1 986 907 B 解包、
shasum3813b5d85e94368eed3e85fe179fb424a53b0530。
⚠️ 如实记一笔:**包内CHANGELOG.md末尾那条「未发布」**是发布前写的(改它的提交必须在 publish 之后,
而同一版本号不能再发一次)—— 仓库里已经改掉,已发布的 tarball 里留着那一句(v0.16 / v0.17 同一个坑,第 4 次)。
v0.21.0 — Relations & Context Control
⚠️ 更新后必须重启 DSH。 这一版改了宿主半边(新增src/host/relations.js与src/host/control.js,并在src/host/index.js里注册)—— DSH 不热加载已安装的宿主插件,只刷新浏览器不够。重启后可用这条命令判断新代码是否在跑:curl -s "http://127.0.0.1:3080/knit/api/raw?rel=nope.png"→ 回{"ok":false,"code":"knit/not-found"}即是新版。🔒 这一版新增了一个写动作:
POST /knit/api/control(固定 / 排除)。它同样只认回环地址,只接受pin/unpin/exclude/restore四个动作 + 一条工作区内的相对路径;非 POST 打它回405、坏 body 回400。写的是 DSH 给的存储单元knit_control(~/.dsh/storages/knit_control.json,按工作区隔离),不碰工作区里的任何文件。没有 storage 时降级成「本会话有效」,界面会写明。
[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:240pxmin-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的跨面守卫,把工具里的截断拿掉即变红)。
v0.20.0 — Deep Context Retrieval
⚠️ 更新后必须重启 DSH这一版改了宿主半边(
src/host/),DSH 不热加载宿主插件。装完/更新完请重启 DSH,
否则面板与knit_docs还是旧代码。只刷新浏览器不够(那只能换掉客户端半边)。
已知问题(已在 v0.20.1 修复)
随包发布的测试套件少了一个文件:
test/deep-scale.test.mjs从../tools/deep-benchmark.mjs
取语料工厂,而tools/按设计不进 npm 包。因此在解包出来的 tarball 目录里跑npm test
会得到 638 pass / 1 fail(ERR_MODULE_NOT_FOUND: .../tools/deep-benchmark.mjs),
受影响的是 3 条 R1 规模门槛用例。
插件本体不受影响 ——src/不引用tools/,检索、面板、knit_docs全部照常。
仓库里的完整测试套件是 641/641 全绿。
[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 / 段落 / 代码块,代码按functionclassconstletvar
interfacetypeexportimport声明起点与大括号块;行号与字符偏移量由构造过程保证
(不是事后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。文案从「命中片段」缩到**「命中段」(ENPassage:),并且与
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);正文走显式bodyprop 而不是 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%:hover7.84%×0.36、active14.12%×0.54、chip14.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),是替换断言而不是加用例。
- 「不得亮过官方 active 14.12%」+「不许出现
-
代码预览:语法高亮(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」的替换提交。
渲染形状:正文仍是一个<pre>,行间只隔一个' '文本节点、不包逐行元素 ——
行锚点(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/recent59–104 ms、/api/context58 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存在、命中段...
v0.19.1 — 定位重构:从「最近文档」到 Workspace Context
[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 项全绿。
v0.19.0 — Code Context:让代码进入同一条上下文链路
[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 摆在最前挡脚本 /
外部资源 / 连接 / 表单 / 嵌套框架,内联<style>与data:图片保留),而 Knit 双击一行
本来就走actions.openResource→ 官方文档预览 —— 通路早就在,缺的只是一个入口。
所以预览头给.html/.htm加一个「网页预览」按钮,走的就是双击那条同一个
openResource通道:没有新开第二条打开路径、没有 iframe、没有把 DSH 的渲染器搬进面板,
Knit 的零网络出口承诺一个字不用改。
这不算「把删掉的功能加回来」:v0.10 撤掉「新标签页」按钮的理由是「就地预览已经能看到内容,
重复度高」—— 那条前提对 HTML 不成立(就地给源码、官方给页面),是纠正而不是回退;
其余代码类型一个都不给这个按钮。 - 修掉「工作区是符号链接时,点路径一律报
Path has no verified Host path」(发布前发现的真实 bug):
在DSH_Skill_Trace(→10_DSH_Skill_Trace的符号链接)这类工作区里,点预览头路径 /
「本地打开」/ 点顶部工作区路径全部失败,而在真实目录(08_Knit)里没事。
根因不在 Knit,在 DSH 的校验方式:dsh-api-session-controller的verifyDesktopPath()
把路径送进ctx.fs,要求
processPath(await resolve(processPathFromHostPath(p))) === p,而dsh-fs-local的
targetKey 落的是realpath()—— 也就是说含未展开符号链接的路径一定过不了。
⇒ Knit 这一侧自己先展开:宿主新增hostPathOf(),下发的hostRoot(工作区根)与
每条记录的path(文件本身)都已是 canonical 形态;客户端打开 / 定位一律用它们,
root只留给界面显示(用户看到的是自己选的那条路径)。
scan()内部也改成从 canonical 根走一遍,所以rel一字不变 ——
检索 / Context Pack / 生命周期 / Usage Lens 的输入完全没动。 - 文档同步:
knit/README.md补上代码档与「网页预览」两处说明、测试数刷成 588;
knit/README.en.md这次也补了一节 v0.19(它同样进 npm 包 —— 英文 README 停在 v0.18,
包内文档就自相矛盾了);knit/SECURITY.md属性表加一行「/knit/api/doc只放行文本类产物」;
package.json的 keywords 去掉了重复的code-context。
一处刻意的收窄(引用图)
引用图(md 文件之间互相指路的那些边)的源永远是 Markdown:代码文件不再进图的
list。理由是不能给代码开一条靠「注释里恰好写了一句 docs/X.md」就进 Supporting 的
后门 —— 那条路不打分。反过来,文档点名一个代码文件(设计文档里写了 src/x.js)仍然
和文档指文档一样会被尊重:那不是特例,是同一条规则。
(代码作为「被指向的一方」能进 Supporting,正是需求 §18 要的混合竞争。)
为什么不需要改 DSH 核心 / 新增 model tool / 新增 Store
- 不改 DSH:DSH 的文件访问走宿主已有的
ctx.fs缝;预览走面板里已有的那块预览区。
代码与文档走的是同一个资源身份、同一个读取接口 —— 没有自造 OS 路径、没有
window.open(local file)、没有 fork DSH 的 preview 实现(代码预览一共就 4 条 CSS 规则)。 - 不新增 model tool:
knit_docs工具本身没变,只是它现在能把代码算进同一个 Context Pack。
工具描述从「Markdown 文档」改成「文档与代码文件」,理由很直接:语料里已经有.ts了,
再说「Markdown documents」就是对模型说假话。 - 不新增 Store:没有
codeStore/codeContextStore/codeRetrievalStore/
workspaceArtifactStore。代码只是一个多出来的artifact.kind,通过既有状态投影出 UI。 - 不新建 benchmark framework:V0.19 的前后对比直接扩在
test/eval/fixture.mjs里 ——
同一个runEval,同一个rankByRelevance,只把语料换成「Markdown only」与
「Markdown + Code」两臂。
测试
npm test:588 / 588 通过(v0.18 是 532)。v0.18 的 532 条一条没删、一条没放宽;
中间有 12 条 v0.18 断言因为契约变化而同步(不是降级):
readDocument的「拒绝非 Markdown」拆成「拒绝不可预览的类型」+「v0.19 起代码可读并带分类结论」;
类型档从 3 个按钮改成 4 个;knit_docs的文案与两处总数(样本工作区里的package.json
现在是一个合法代码候选 —— 那个3 → 4本身就是「代码真的进了同一个检索池」的证据)。- 新增
test/classification.test.mjs(12 条):17 个支持后缀的 language 映射、
13 个不支持后缀、.map/min/generated/lock、锁文件与生成产物的反例
(minify.js/bundle.js/tsconfig.json仍是代码)、判定顺序是契约、
以及「后缀知识只许活在分类层」的架构守卫。 - 新增
test/code-context.test.mjs(25 条):代码穿过整条链路之后还对不对 ——
字段权重的三档可观测、代码进 Primary 的理由码是filenameMatch、
只命中目录走pathMatch、混合包里文档与代码公平竞争、条目投影与 v0.18 逐字相同、
引用图代码只能当靶不能当源、代码 read evidence 与包外读取、历史归因冻结、
代码四态生命周期、Epoch 的进入 / 离开 / 换层 / delta、七条噪声路径一条不进候选。 test/eval.test.mjs新增 6 条 v0.19 基准守卫(见下)。- 发布前补的 3 条符号链接守卫(
585 → 588):test/host.test.mjs造一条指向样本工作区的
符号链接当root,断言「下发的hostRoot/ 每条path都满足realpath(p) === p」
(这就是 DSH 的判据)+「换成符号链接后rel集合一字不差」;
test/path.test.mjs两条客户端守卫,分别覆盖「宿主给了 canonicalpath」与
「老宿主只有hostRoot」时,reveal / 本地打开 / 打开工作区目录都用 canonical 路径,
而界面显示的仍是会话原本那条。写完全部做过证伪(把修复回滚,3 条如实变红)。 test/client.test.mjs新增 9 条 v0.19 UI 守卫:代码档请求kind=code且代码行带语言徽章、
文档行一个徽章都不加(整列都是 MD,挂 MD 是纯噪声)、点代码行就地出行号 + 正文且不落到
Markdown 渲染(夹具正文故意以#开头 —— 走错分支它就会变成标题)、同名.map才给
「Source map」入口且列表里始终没有.map那一行、「全部」档三区(文档 / 代码 / 媒体)
各自限流、Usage Lens 里的代码路径照常显示(仍是只有事实、没有分数)、
全屏隐藏名单必须覆盖列表上方每一块(含.knit-usage)、「网页预览」只有 HTML 出场
且走的是双击那条openResource通道(正文读到之前按钮就已在场 —— 否则头会长高一下),
以及一条样式守卫(横向滚动只归正文列,语言徽章无底色无边框无圆角,DSH 令牌必须带回落值)。
基准(V0.19 前后对比)
同一个排序器、同一批用例,唯一变量是语料里有没有代码:
| 指标 | Markdown only | Markdown + Code |
|---|---|---|
| Document Recall (top-1) | 100% | 100% |
| Document MRR | 1.000 | 1.000 |
| Code Recall (top-1) | 0% | 100% |
| Code MRR | 0.000 | 1.000 |
- Overall MRR(文档 + 代码一起量:21 条文档用例 + 3 条代码用例)= 1.000,top-1 100%。
语料规模:Markdown only 16 条 → Markdown + Code 24 条(只多 8 条代码)。 - Noise Rate = 0% —— 7 条生成 / 噪声路径(
node_modules/foo.js、dist/app.js、
coverage/report.js、app.min.js、bundle.generated.js、app.js.map、package-lock.json)
在真实扫描与评测语料里都是 0 条进入。 - 对照臂:同样三条代码用例放在只有文档的语料里 top-1 是 0% —— 所以「加了代码之后答对了」
不是碰巧。混合场景(Case C)里那篇讲分层的设计文档仍排在第 3 名,没有被代码挤没。 ⚠️ 说清楚这份基准不是难度竞赛:三条代码用例的检索意图都很清晰,两臂都接近满分。
它的作用是回归钉 —— 谁把代码候选、噪声过滤、字段权重或 corpus cap 改坏了,这里会红。
node knit/tools/scale-benchmark.mjs会把这张表连同原有 20/60/180/540 规模基准一起打印。- 真机工作区量到的数(
08_Knit,扫描 4 ms):59 文档 + 54 代码 + 6 媒体,
0 generated / 10 ignored,语料 59 → 113。一个 1300+ 目录的大树(55 ms):
400 文档截断 + 300 代码(243 条被 cap 挡下)+ 400 媒体,语料 400 → 700 ——
MAX_CODE = 300在这里正好起到「别让代码把原有 Markdown 上下文挤出去」的作用。
v0.18.0 — Context Usage Lens:把「使用情况」读成结构
Context Usage Lens(依据 2026-10-05 用户给的 V0.18 规格 + 02_方案与 Demo/dsh-knit V0.18 UI 重构开发提示词.md):
v0.17 的「使用情况」是一行统计文字 + 三块折叠列表 —— 信息都在,但读不出结构:
哪一层读得多、最近读的是哪一篇、包外那二十篇里哪几篇被反复读、这次变化是进是出。
这一版把它重做成文档列表上方的一块轻量观察层 —— 不是弹窗、不是新页面、不是 Dashboard。
检索侧一行没改(BM25 / IDF / 关键词抽取 / 长度归一化 / freshness / 分层 / Context Pack 装配规则一个字没动),
宿主 src/host/* 一行未改(纯客户端,硬刷新即见效);
界面里没有分数、没有百分比、没有进度条、没有图表、没有第二份存储、没有新采集。
使用情况 Epoch 5 ˄
335 次读取 · 29 篇包外 · 上下文变化 1 次
当前上下文
主要 1 / 1 辅助 2 / 3 相关 0 / 5
● ● ○ ○ ○ ○ ○ ○
最近读取 docs/eval.md 12分钟前 ›
上下文外读取 20 ›
上下文变化 最近一次 ›
新增:Context Coverage(三层状态点)
主要 / 辅助 / 相关三层各给「状态点 + 已读数 / 本层总数」:
- 点某一层 → 平滑滚到那一层并短暂高亮(1200ms 自己收掉);
prefers-reduced-motion下改为瞬时。 - Primary 不存在时整列不显示(不画
0 / 0的假层)。 - 没有进度条、没有百分比、没有评分。
- Coverage 是派生出来的(
coverageOf),不新增记账:数据全部来自 v0.17 已有的
usage.lifecycle / recentRead / outsideDocs / latestDelta与context的三层 rels。
新增:行内生命周期 glyph(6px 真圆点)
文档行左侧给一枚状态点,四态靠 data-mark 区分:
| 状态 | 形态 | 界面文案 |
|---|---|---|
unread |
○ 空心(透明 + 1px 边框) | 未读 |
read |
● 实心 | 已读 / ×N |
updated_after_read |
△ 实心 + 内圈 | 读后已更新 |
reread_after_update |
↻ 字符 | 修改后已重新读取 |
- 真圆点与 Coverage 的点同一套 token、同一个直径(没有任何绿 / 红 / 黄 / 蓝)。
- glyph 与原来那行文字同时存在:glyph 说状态,文字说次数(
×3)。 - glyph 是真
button,带title/aria-label,点它=打开这一篇(已经打开则保持原预览)。 - 统计关掉时这一格退回 v0.17 的 Primary 黑点 —— 与 v0.17 逐字一致。
最近读取 / 上下文外读取 / 上下文变化
- 最近读取:从一行文字改成一行可点的事实(
文件名 · 相对时间 ›),点它直接打开该篇,
点的那一下不敲宿主(走已有的requestPreview)。语义仍是「最近一次成功read」,
没有改成「正在阅读 / Agent 正在查看」。 - 上下文外读取:改成可浏览集合,默认最多 10 篇 + 「还有 N 篇」,每行是可点对象并带生命周期;
排序改成count DESC(同次数再按lastReadAt/seq)—— 事实排序,不是相关度评分。
只表达「这篇不在当前 Context Pack,但 Agent 确实成功读过它」,不写「Knit 漏掉了 / 推荐错了」。 - 上下文变化:默认只给一条菜单行,展开后按 进入 / 离开 / 换层 分三组,每组最多 5 条;
按钮上不再写条数(账目改由「展开后的行 + 尾巴」承担,这样「说 N 条」与「列了几行」不可能再不一致)。 - 两处折叠集合都是菜单行:label 左、数量 / 说明右、
›展开变⌃,行高 38px,hover 只改背景、无位移。 - Lens 自己可以展开 / 收起(
aria-expanded)。收起 ≠ 关掉统计:收起只收起细节;
关掉才一个统计 DOM 都不产生。滚动仍然只交给.knit-list,没有第二个滚动容器。
UI 迭代(2026-10-06)
用户看真机后提的三处,同版一起发:
| 项 | 原来 | 现在 |
|---|---|---|
使用情况 / 刷新、Segmented、过滤框高度 |
22 / 24 / 36px | 28px(「太高了,不需要这么高」) |
| 状态点直径(Coverage 点 + 行内 glyph) | 9px | 6px 真圆点 |
| Coverage 定位高亮时长 | 900 → 650ms | 1200ms(FLASH_MS 与 .knit-tier{transition:background 1.2s} 必须同数) |
| 类型页签文案 | 「图片与视频」/ Images & video |
「媒体」/ Media(同一档计数本来就是 {n} 个媒体) |
字体尺度收成一套(15 / 14 / 13 / 12 / 11px),不再有 10.5px、11.5px 这类半档。
描边色改用 --dsw-alias-border-l3、粗细 0.5px,与官方页签下面那条横线同档。
没做:没有卡片化、没有阴影、没有上浮 / 缩放 / 连续脉冲、没有新增数据、没有新模型或网络请求。
顺手修掉的两处「同一份事实讲两遍 / 讲不齐」
moved的真实载荷是{tier, rank}(v0.17 的宿主形状),v0.17 的客户端直接把它当层名用 ——
真机上会渲染出空层名(↔ x.md · →)。现在两种载荷都认。- 包外读取的行里次数说了两遍(
.knit-out-count已是×71,生命周期又用短次数说一遍 →
真机渲染成client.js ×71 ×71)。新增lifeWord():次数已在同一行时只用状态词。
测试
npm test:532 / 532 通过(v0.17 是 517)。新增 15 条 v0.18 守卫:
Coverage 三层数值与「点它定位到那一层」、Primary 缺席不画假层、Lens 收起、空使用情况、
小高度面板的降级判据、点状态点 / 包外行不直接请求列表、轮询不重播动画、
样式(语义 token / 自己不滚动 /prefers-reduced-motion)、纯函数层,
以及英文 Lens(打开态 / 空态)里没有汉字(文字 + 属性)。- 一条账目守卫:载荷算出的总数必须等于「展开后列出的行数 + 每条组内尾巴里写的数字」。
- 宿主侧一行未改:v0.17 的 517 条全部照旧通过。
升级
npm i dsh-knit@0.18.0纯客户端重构 ⇒ 装好硬刷新(macOS ⌘⇧R)即见效;src/host/* 一行未改,不需要重启宿主。
v0.17.0 — Document Lifecycle:文档状态 + 列表进出动效
Document Lifecycle(依据 01_ Knit PRD/Knit-PRD-SDD-v0.17-Document-Lifecycle.md):
前面几版回答的是「Knit 推荐了什么 → Agent 有没有读 → 读落在哪一层」;
这一版接着回答一篇文档读完之后,现在处于什么状态。
只做四件事:文档状态、最近读取、包外读取的明细、最近一次 Context Delta。
检索侧一行没改(BM25 / IDF / 关键词抽取 / 长度归一化 / freshness / 分层 / Context Pack 装配规则一个字没动),
没有分数、没有百分比、没有进度条、没有时间线、没有新面板、没有第二份存储。
新增:四个事实状态
| 状态(数据层) | 界面文案 | 判据 |
|---|---|---|
unread |
未读 | 本会话没有一次成功 read |
read |
已读 / 已读 ×N | 有过成功 read,且最后一次成功读取之后文件版本标记没变 |
updated_after_read |
读后已更新 | 最后一次成功读取之后,文件的 mtimeMs 变过 |
reread_after_update |
修改后已重新读取 | 变过之后又成功读过一次;再变一次就退回上一档 |
判据复用现有工作区扫描拿到的 mtimeMs:src/host/index.js 里的 mtimeOf(root, rel) 做一次 statSync,
在 tool/call 那一刻把结果交给 ingestEvents(..., { stat }),feedback.js 自己不碰 I/O。
不引入内容哈希、不扫描全文、不新增索引、不落盘。
事实就是事实,不做推断:状态只说「文件在上次成功读取之后变过 / 变过之后又读过」,
不写「Agent 还不知道最新版」「Agent 没看到你的修改」这类话(测试里钉死了这些词不出现)。
grep / glob / bash 不算 read;失败的 read 不算;拿不到当前 mtimeMs 或没记过
lastReadMtimeMs 时不下结论(宁可停在 read);文件只是被重新扫描过,不等于被重新读过。
新增:最近读取 / 包外明细 / 最近一次变化
- 最近读取:
<rel>· <相对时间> —— 最近一次成功read对应的文件,按事件seq判定「谁最近」,
墙钟只负责显示相对时间;它落在 Context Pack 之外也照实显示。
不叫「正在阅读」:Knit 无法证明 Agent 此刻还在读这篇。 - 上下文外读取 · N 篇(可展开)—— 原来只有一个数字,现在点得开、看得到是哪几篇,
每行给rel和该篇的状态。只表达「这篇不在当前 Context Pack,但 Agent 确实成功读过它」;
不写「Knit 漏掉了 / 推荐错了」,也不做漏召回率、命中率、Context 质量。 - 上下文刚刚变化(只展示最近一次 Delta)——
+进入 /-离开 /↔换层(层名用短名),
折叠时最多三行,展开按「进入 / 离开 / 换层」分组;taskChanged只说一句「任务上下文已更新」。
不产出时间线,也不做事件浏览器。
数据层:扩现有 store,不加第二套
src/host/feedback.js 新增三个纯函数(lifecycleOf(entry, mtimeMs) / latestReadOf(reads) /
outsideDocsOf(reads, items)),recordRead() 每篇多冻三个标量
(lastReadMtimeMs / prevReadMtimeMs / rereadAfterChange);
usageFrom(state, { mtimes }) 的返回值顶层多四个字段
(lifecycle / recentRead / outsideDocs / latestDelta),v0.16 的 stats 15 个键一个没动。
/api/recent?usage=1 用工作区扫描的全量列表(未截断)算 mtimeMs,
只回传当前文档列表 + 包外文档的状态行,每篇三字段(status / count / lastReadAt)。
历史归因继续冻结:tier / rank / inside / epochId 仍是读发生时那一份 Context 的结算值,
不会因为「现在」换了包而改口(v0.16 的行为原样保留)。
knit_docs 工具的输出没有变:它仍然只负责「找上下文」,不输出生命周期报告
(工具描述与工具结果都要吃模型上下文,这一版的主要消费者是人)。
修订(真机验收):闸门开晚也要说真话
验收时暴露一个来回:先让 Agent 读了一篇文档、之后才打开使用情况开关 —— 那两次读发生在订阅
回调开始按会话早退之前,事件根本没进过内存,于是面板把一篇确实已经读过的文档显示成「未读」。
那不是解析错误,是没有证据:闸门打开之前,Knit 一条读都没看见,却对「未读」下了断言。
修订:闸门打开的那一刻,宿主回填一次本会话已有的事件(src/host/index.js 的
backfillFeedback(),由 activateFeedback() 在面板与工具两条开闸路径上调用;一个会话只回填一次、
只在当前这一 tick,零 I/O、零网络、零模型)。顺序是这个机制的组成部分:先把手上那份 Context Pack
noteSnapshot() 记下、再喂事件 —— recordRead() 在事件入库那一刻就把归因冻结,先喂事件的话,
补记的读会在「一份快照都还没有」的状态下被判成包外,而「这篇不在当时的上下文里」是一句我们并不知道
真假的话(§6)。回填那次不传 at,于是这份「会话开头」快照的 at 仍是 0,第一次 usage.at 的语义
(还没有上一份包)不变。session.snapshotEvents() 的 deprecated 提示仍然有效:这是闸门打开那一刻的
唯一例外。
已知不精确,写在明面上:回填的读按我们已知最早的那份包归因,更早的包 Knit 无从得知。
界面
- 文档行在标题与时间之间多一个弱化状态文字,只在使用情况打开 + 分层视图里出现;
标题仍是视觉主体,状态不用红绿、不加重、不给分。 - 使用情况那一行下面多三块极轻的东西:最近读取、
上下文外读取 · N 篇(可展开)、上下文刚刚变化(可展开);
折叠状态存在组件本地,不写进偏好。既有的「使用情况」行一个字没改。 - 使用情况默认关:关着时
usage=1不发、宿主不初始化生命周期、DOM 里零新增节点(测试钉死)。
新增:列表进出动效(2026-10-03 用户拍板)
验收时用户提的:「新读取跟挤掉旧的未读的,这里的交互可能要优化,因为现在是一闪一闪的,就非常的快…
从当前的、如辅助上下文中新增,它应该是从低向上的…消失的时候从上到下,渐隐掉」。做的还是同一件事,
只是把它看得见:一行文档被读到、换了层、被挤出列表时,不再是「瞬间落位」。
- 进来:从下方 26px 处淡入上浮(高度 0 → 量出来的自然高度、
blur(3px)收敛到 0);
同一拍里多篇按屏幕顺序错开 45ms,最多错 6 档(一次涌进十几篇时不排长队)。 - 出去:先把自然高度量下来,再把高度收到 0、自上而下擦除
(clip-path: inset(T 0 0 0),T 从 0 到 100%)并渐隐;这一行留在列表里把动画播完才移除,
否则看起来像「跳到末尾才消失」。它插回的位置由上一帧的index决定(mergeGhostRows())。 - 换层 / 重排:走 FLIP(同一篇
rel在新旧两帧都在 ⇒ 既不算进入也不算离开,
交给位置补间)。这一行的节点在跨分区时会被 React 重建,所以位置按rel记,
老节点量到的矩形照样能用在新节点上:先把它从老位置拉回来(transform: translate(dx, dy))
再补到归位(340ms),于是「从包外升进 Primary」看起来是飞上去的,不是瞬移。
它跑在提交之后、绘制之前的 layout effect 里,且在DocRow的进 / 出补间之后
(React 是子先父后):那一刻新行已经是 0 高、幽灵行还占着原高度,所以量到的位移只剩
「真的挪了位置」的行,不会和进 / 出补间算两遍;正在进 / 出的行直接跳过。
位移小于 0.5px(含浏览器缩放 / 滚动的亚像素抖动)算没动过,否则整屏会一直在微微地飘。 - 什么时候不播:首屏、换类型 / 换排序 / 搜索词变了(那是「换屏」、不是「来了新的」),
以及系统开了「减弱动态效果」(prefers-reduced-motion: reduce,连底色过渡一起去掉)。
两条硬约束:补间在 JS 里走 WAAPI(Element.animate),CSS 里只有两笔静态规则
(.knit-doc.leaving 与减弱动态效果那条媒体查询)—— 高度必须按量出来的自然高度补间,
写几个 @keyframes 会在动画收尾的瞬间跳一行间距。motionAllowed() 在没有 document、
没有 Element.prototype.animate、或系统要求减弱动态效果时返回假,于是宿主进程与测试进程里
一行补间都不排、也不造幽灵行:渲染树与没有这段动效时逐字一致
(planRowMotion() / mergeGhostRows() 都是纯函数,测试直接喂数据)。
测试
- 新增
test/document-lifecycle.test.mjs(24 例):四档状态、缺 mtime 不下结论、失败read不计、
grep/glob/bash不计、按seq判最近读取、包外明细与排序、历史归因不漂移、
同一事件重复派发幂等(count只 +1,且不重采样 mtime);另有 4 例钉住开闸回填
(补记的读归「会话开头那份包」⇒ 不再被误判成包外、只影响第一份快照、重复派发仍幂等)。 test/host-http.test.mjs多 3 例端到端:usage顶层新字段与stats形状不变;
真改盘上 mtime ⇒ 读后已更新,再读一次 ⇒ 修改后已重新读取;
面板开晚的会话在开闸那一刻回填已有的成功读(reads=1、firstReadTier='primary'、outside=0),
且一个会话只回填一次(再请求一次仍是 1)。test/client.test.mjs多 19 例:四档渲染、标题 / 状态 / 时间顺序、最近读取的相对时间、
包外折叠与展开、Delta 摘要与分组、溢出与taskChanged、只用语义 token、英文环境无汉字;
关着时零新增节点;另有 7 例钉住进出动效只在真机上播 —— 首帧与「换屏」都不播、
交错按屏幕顺序且封顶、离开的行带着原有的区与位次、同一篇换层既不算进也不算出、
幽灵行插回原位(含「还活着的不造幽灵」与「别的区不插」)、Node 里恒被motionAllowed()挡掉、
CSS 里没有@keyframes(只有.knit-doc.leaving与减弱动态效果那条媒体查询);
另有 3 例钉住换层 / 重排的 FLIP —— 位移的正负与阈值(亚像素抖动不算动)、
给挪过位置的幸存行排一段translate(dx,dy) → 0(进 / 出的行跳过、位置表跨帧传递、
节点不认 WAAPI 时只记位置不抛错),以及那行applyRowFlips(...)确实接在提交之后。test/i18n.test.mjs多 1 例:16 条新文案中英逐条对齐,且状态文案里不出现推断词。npm test:517/517 通过(v0.16 是 468,新增用例 49 条)。
修好:交互审的两处「列表自己跳」(2026-10-04)
用户报了两件事:①「我选中一个文档查看全文,特别是在最下方的文档,相关联文档,我一点它就跳,
我一点它就很跳,闪的很快」;②「我刚刚重启 DeepseekHarness,我没有输入任何的对话……
我们的文档列表就开始跳动,它怎么能够切换呢」。先按数据查:同一会话连续打 5 次
/knit/api/recent 返回逐字一致 ⇒ 服务端不抖,四个根因全在客户端几何 / 时序:
| 症状 | 根因 | 修法 |
|---|---|---|
| 点一下就跳 | 预览面板用 maxHeight: 46% ⇒ 面板高度由内容决定(「加载中」→正文、预览里换一篇都变高矮) |
打开期间高度固定成拖出来的比例;长文在正文区里滚,短文档下方留白是代价 |
| 整屏乱飘 | FLIP 拿 getBoundingClientRect()(视口坐标)当位置:容器滚动 / 容器变高矮 / 行上 :hover{transform:translateX(-2px)} 都凭空造出位移 |
改用布局坐标 offsetTop / offsetLeft(rowLayoutPoint()) |
| 越补越飘 | 换屏与进 / 出补间在飞的那几百毫秒里也照量位置,量到中间态,落地后又被当成「挪了位置」 | 这两种时候一个都不量,并把位置表清空 |
| 空会话自己跳 | 「光标跟手滚进视口」的依赖里有 state.docs(每 5 秒一个新数组)⇒ 把「刷新到数据」当成「用户按了方向键」,光标默认在第一行,用户往下滚过之后每 5 秒被拽回去一次 |
依赖只剩 [cursor, query],且只在列表自己拿着键盘焦点时才滚 |
四条都在源码注释里逐条写着「为什么」;两条硬规则进了 AGENTS.md §6.13,教训进了 §8.12。
测试 515 → 517(test/client.test.mjs 多 2 例:rowLayoutPoint 的布局坐标优先与兜底分支、
「刷新不许动列表」= 那条 effect 的依赖里没有 state.docs;test/tier1.test.mjs 那条预览高度的断言
从 maxHeight 改成 height + 断言 maxHeight === undefined)。
v0.16.0 — Context Effectiveness:读时归因冻结 + Context Epoch
✅ 2026-10-03 已发布:
v0.16.0=8c3ca2c· npmlatest = 0.16.0(42 文件 / 341.8 kB / shasum771705d6…)。
这一版把上一节那批「未发布」的界面收敛一起带上。
Context Effectiveness(依据 01_ Knit PRD/SDD-v0.16-Context-Effectiveness.md):
一次读落在哪一层,在读发生那一刻结算并冻结;并正式引入 Context Epoch。
检索侧一行没改(BM25 / IDF / 分层 / Context Pack 装配规则一个字没动),
没有分数、没有百分比、没有新面板。
修好:历史归因会被「现在」改口(split-brain)
v0.15 里「这次读落在哪一层」被算了两遍:读入库那一刻算一遍(recordRead() → snapshotAt(),
冻进 firstTier),出报告那一刻又拿最新那份快照重算一遍(usageFrom())——
byTier[].read / outside / supportingCoverage / firstReadTier 全吃重算值。
于是同一条读可以同时是 firstTier='primary' 与 tier='related'。真实场景就是:
10:00 交 Context A(primary = A.md)→ 10:01 读 A.md → 10:03 换 Context B → 10:04 看 usage
⇒ A.md 被算成「包外」—— 历史被现在改口了。
现在只有一条规则,由 src/host/feedback.js 的两个纯函数说了算:
| 函数 | 语义 |
|---|---|
snapshotAt(snapshots, readSeq) |
取 seq <= readSeq 的最新快照 = 读发生时生效的那一份 |
attributeRead(snapshot, rel) |
输出 { snapshotSeq, epochId, tierAtRead, rankAtRead, insideAtRead },读时冻结 |
recordRead() 在读那一刻调它并冻进 firstRead / lastRead;usageFrom() 只读冻结值。
reads[] 行上的 tier / rank / outside 也一律是首读的归因(不再是「它现在在哪一层」),
旧字段名(firstTier / firstReadAt / outsideCount …)与旧键 churn.snapshots 全部保留为兼容别名。
修好:右栏收起之后,Knit 还在每 5 秒敲一次宿主
现象(2026-10-01 用户报):一旦点开过文件、再把右栏那一栏关掉或收起来,过一会儿它会变成
一片空白 —— 没有芯片、没有文字,也没有「这类内容还没有可用的查看方式」。
查到的:官方右侧栏收起时不卸载标签身体(dockkit 的 keepMounted),而 KnitBody 的轮询
过去只看「挂载了没有」——于是收起来之后照旧每 5 秒请求一次 /knit/api/recent(一次全工作区扫描)
并 setState,等于往宿主正在收起 / 展开的那棵子树上又插了一帧重渲染。
实测(headless Chrome 里的真客户端):收起状态 12 秒里发了 2 次请求;那一栏确实还在
visibility:hidden 里挂着(knitVisible.visibility === 'hidden'、knitRoot === true)。
修法:用座位契约里现成的 tab.visible 做闸门。官方在
@deepseek-ai/dsh-client-ui-sidebar-right 里算的是
visible: active && (pane.host === 'float' || layout.expanded && (title || pane.activeTabId === tabId))也就是「收起」与「不是当前标签」两种情况都是 false。座位适配层把它传给 KnitBody:看不见时
连请求都不发,在途的那次回来也不落状态;visible 变回 true 时那个 effect 会重跑 ⇒
立刻补一次,不必等下一个 5 秒。
- 实测(同一台真客户端):收起 13 秒 0 次请求(修前 2 次);重新展开 7 秒内 2 次(立刻 + 5 秒那次)。
- 拿不到
tab.visible时(别的宿主 / 降级路径)当作看得见,不会把面板冻住。
修好:右侧栏「开始」页出现两行一模一样的入口
现象(2026-10-01 用户报):右侧栏「开始」页上有两行同名胶囊(都是「Knit 最近文档」),
点哪一行都进 Knit。
查到的:dsh-better-sidebar v0.24.1 起有个「原生表面」——把它自己每个 tab descriptor
镜像成官方右侧栏的一个类型(id = dsh-better-sidebar:<descriptor.id>、kind = descriptor.id),
并且在 descriptor 没写 hidden 时连带注册一条 guide 条目:
...descriptor.hidden === true || isEditor ? {} : { guide: [{ id: descriptor.id, order: descriptor.order ?? 100, … }] }于是「开始」页同时挂着 ① 我们自己的类型(kind: 'knit')与镜像(kind: 'knit:recent'),
标题都取 guide.title ⇒ 两行同名。(并不是 Knit 注册了两次。)
修法:给 ctx.betterSidebar.registerTab 的 descriptor 加 hidden: true。它只掐掉那一条镜像
guide 条目(连带 + 菜单项),镜像的类型 / 座位 / ctx.betterSidebar.openTab 都还在;入口保留 ①
那条 —— 不依赖宿主装没装 better-sidebar。
- 实测(真客户端 + 刷新):修前 5 条胶囊里两条 Knit;修后只剩
data-sidebar-right-guide-entry="knit"一条,
点它照旧开tab8「Knit」。
新增:Context Epoch
- 一份快照 = 一个 Epoch:
epochId从 1 开始,内容真的换了才 +1
(签名 =task+ 三层tier:rank:rel,与 v0.15 同一套判据)——5 秒轮询不会灌进几十个 Epoch。 usage.epochs[]与快照栈同生共死:每项{ epochId, snapshotSeq, topic, task, primary/supporting/related: { rel|total, read }, outsideReads, continuedReadsAfterExit, reEntries }。
只供内部 / 离线回放 / 将来的评估 —— v0.16 不把它上任何工具输出。- 保留上限:
MAX_SNAPSHOTS_PER_SESSION2 → 20、MAX_DELTAS_PER_SESSION20 → 50。
归因在读时结算,保留数量只决定「离开 / 重新进入」能回溯多远(宁可漏记,也不猜)。 churn口径:新增epochs(换过几个不同的上下文 = 最后一份的编号),changes = epochs - 1;
客户端在用的snapshots/deltas保留为changes的别名(文案「上下文换过 N 次」不变)。
新增:两个事实字段(只记事实,不加解释)
| 字段 | 判据(只在保留窗口内推导) |
|---|---|
continuedReadAfterExit |
这次读在当时那份快照之外,且更早的某份快照里有这篇 |
reEntry |
这次读在当时那份快照之内,更早的某份快照里也有,但中间至少有一份没有它 |
firstReadTier 多一个取值 'outside'(读过、但读在包外);null 只留给「一次都还没读」。
改法:读证据不再拉会话事件,改成订阅 session/event
session.snapshotEvents() 已被 DSH 标为 deprecated(dsh-session/README.md:62:
"new production calls are prohibited")。v0.16 把读证据的来源换成宿主事件流:
apply(ctx)顶部ctx.on('session/event', onSessionEvent, { global: true });回调首行按session.id
早退 —— 没开审计的会话只花一次 Map 查找,不建快照、不统计、不进内存。ingestEvents(store, sessionId, [event], { root })单事件入库;游标幂等不变。- 两个行为变化:① 订阅之前发生的事不再回补(不碰废弃 API 就读不到历史)——
打开「使用情况」的那一刻起才开始计数;② fork 出来的会话不再把父会话继承的读算进自己账上
(seed 事件不派发;v0.15 从state.seq = -1拉全量时会算进去)。
界面:只多一句「当前上下文 · Epoch N」
不加 Dashboard、不加图表、不动布局;开关打开后那一行开头多一个累计编号,其余逐字不变。
量尺
tools/context-feedback-eval.mjs:日志文件名改成按形状认(session.v<N>.jsonl[.zstd],
v0-v4 三代并存都能读,以前写死 v4);metricsFor() 增 epochs / continuedReadsAfterExit / reentries、
contextChanges 改读 churn.changes;Control 自检的判据放宽成「不许有落在层里的读」
('outside' 在对照组是正常的)。
feedback.js),
但交付时刻不同 —— 面板轮询 / 工具调用是采样,量尺是「每次读前合成一份包」。
逐字段一致是规则级;数字级对齐做不到(SDD §3 G4 已据此改名并写明原因)。
测试
- 新增
test/context-effectiveness.test.mjs(12 条):历史归因(§24.3)、离开后仍被读(§24.4)、
重新进入(§24.5)、规则一致(§24.6:runtime 记下的归因逐字段等于snapshotAt()+
attributeRead()现算的结果)、Epoch 边界与上限。 test/context-delta.test.mjs里那条钉着 split-brain 的双轨断言(firstTier='primary'与
tier='related'同时成立)改写成「tier也是首读的归因」。test/host-http.test.mjs的「使用情况」整段改成事件驱动(假 ctx 收session/event派发),
顺带覆盖:订阅前的事件不回补、同一 seq 派发 10 次仍只算一遍、畸形事件不影响接口。- 测试数:453 / 453 → 468 / 468。
test/client.test.mjs新增 2 条可见性用例:visible === false时一次都不取数、也不挂轮询;
座位契约的tab.visible一路传到KnitBody(拿不到useTabInfo时按「看得见」处理)。