Skip to content

Releases: PolinniZhong/dsh-knit

v0.23.1 — 工具栏收敛 + 产品信息修订

Choose a tag to compare

@PolinniZhong PolinniZhong released this 12 Oct 23:36

⚠️ 本版不是纯 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 的 728 0 次、775 2 次(= 本版实测用例数)。


[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),本版只改信息准确性与工具栏形状。

v0.23.0 — Optional Hybrid Retrieval

Choose a tag to compare

@PolinniZhong PolinniZhong released this 12 Oct 08:57

默认词法、语义可选、默认关闭:不碰它时这一版与 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 > 会话原话 > 关键词(兜底)(本版修掉的缺陷之二);
    · 配置存 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/ 里任何一个在包内的字节被改,文件数与这一行都要重测)。

v0.22.0 — Incremental Context Index

Choose a tag to compare

@PolinniZhong PolinniZhong released this 11 Oct 01:06

这一版是内部能力,不是用户可见功能 —— 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.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× 那条旧结论来自这个形状,修法 ② 之后它已经不成立。
    取舍、复现命令与逐条读数见 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,success);registry latest = 0.22.0(共 19 个版本,
publishedAt 2026-10-11T01:05:48Z);registry 上的真包 = 59 文件 / 668 701 B tarball / 1 986 907 B 解包、
shasum 3813b5d85e94368eed3e85fe179fb424a53b0530。
⚠️ 如实记一笔:**包内 CHANGELOG.md 末尾那条「未发布」**是发布前写的(改它的提交必须在 publish 之后,
而同一版本号不能再发一次)—— 仓库里已经改掉,已发布的 tarball 里留着那一句(v0.16 / v0.17 同一个坑,第 4 次)。

v0.21.0 — Relations & Context Control

Choose a tag to compare

@PolinniZhong PolinniZhong released this 09 Oct 02:39

⚠️ 更新后必须重启 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: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 的跨面守卫,把工具里的截断拿掉即变红)。

v0.20.0 — Deep Context Retrieval

Choose a tag to compare

@PolinniZhong PolinniZhong released this 08 Oct 05:08

⚠️ 更新后必须重启 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 / 段落 / 代码块,代码按 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」的替换提交。
    渲染形状:正文仍是一个 <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/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 存在、命中段...
Read more

v0.19.1 — 定位重构:从「最近文档」到 Workspace Context

Choose a tag to compare

@PolinniZhong PolinniZhong released this 07 Oct 04:49

[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:让代码进入同一条上下文链路

Choose a tag to compare

@PolinniZhong PolinniZhong released this 07 Oct 04:04

[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 两条客户端守卫,分别覆盖「宿主给了 canonical path」与
    「老宿主只有 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:把「使用情况」读成结构

Choose a tag to compare

@PolinniZhong PolinniZhong released this 05 Oct 15:10

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:文档状态 + 列表进出动效

Choose a tag to compare

@PolinniZhong PolinniZhong released this 05 Oct 00:45

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

Choose a tag to compare

@PolinniZhong PolinniZhong released this 03 Oct 11:58

✅ 2026-10-03 已发布:v0.16.0 = 8c3ca2c · npm latest = 0.16.0(42 文件 / 341.8 kB / shasum 771705d6…)。
这一版把上一节那批「未发布」的界面收敛一起带上。

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_SESSION 2 → 20、MAX_DELTAS_PER_SESSION 20 → 50。
    归因在读时结算,保留数量只决定「离开 / 重新进入」能回溯多远(宁可漏记,也不猜)。
  • churn 口径:新增 epochs(换过几个不同的上下文 = 最后一份的编号),changes = epochs - 1;
    客户端在用的 snapshots / deltas 保留为 changes 的别名(文案「上下文换过 N 次」不变)。

新增:两个事实字段(只记事实,不加解释)

字段 判据(只在保留窗口内推导)
continuedReadAfterExit 这次读在当时那份快照之外,且更早的某份快照里有这篇
reEntry 这次读在当时那份快照之内,更早的某份快照里也有,但中间至少有一份没有它

⚠️ 它们不许被读成「Agent 不认可新上下文」——那是推断,不是事实(SDD §5 Evidence Over Interpretation)。
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' 在对照组是正常的)。

⚠️ 对齐的是规则,不是数字:离线量尺与 runtime 共用同一份归因(都调 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 时按「看得见」处理)。