# DSH 本地阅读 + AI 陪读插件 · 设计稿 v1 > **状态**:**已交付至 v1.0.0(首次公开发布)**,其后叠加 v1.19(跳读闸 / 倒退过滤 / 缓存测量口径)与 v1.20(跳读闸缺省值改为"开" / 章末附言剥离 / 切点对齐 / 「发笔记」不再自称"记忆已更新")与 v1.21(补齐循环化:一次点到底 / 可停 / 真实进度)与 v1.22(实体键:`###` 分组推广 / 取代与归档 / 两个静默丢数据)与 v1.23(背景更新:读 durable 会话事件 / HTML 注释块回传 / 章号超前的反向闸 / 不谎报覆盖)与 v1.24(非小说兜底分区「通用概念」/ 上一章只给尾部 / 抽样按章长比例 / 卷首章加权 / 合并提示词的密度规则 / 一批文档修正)与 v1.25(**抽样形状退回按预算均分** / 背景认识的分层降级(T3 结构性那一半)/ 压缩与水位的交互(T4)/ 两处被变异逼出来的测试空白)与 v1.26(**背景预算 D 6000 → 9000**(T3「提 D」)/ 发布副本核对(C2:无第二副本,但 `live` remote 悬空)/ A6 文档修正的复核 + 两处同类陈旧声明)八轮修订。**523 项测试全绿**。P0 骨架、P1(导入/编码/解析/书架/目录/正文/进度)、P2(会话绑定 + 双闸防剧透 + AI 视角预览)、P3(摘抄/结构化笔记/自动 tag/按需落盘)、P4(笔记分页与渲染隔离)、P5(背景认识 + 一次调用补一整段缺口 + 压缩)、P6(注入顺序 / 时间感知 / 讨论历史 / 书友设定)、P7(笔记标题格式 / 发到会话带章节头 / 书架分类与绑定跳转)、P8(联网档位开关 / 文风分区 / 抽样加权与打底)、P9(讨论历史限条 / 阅读排版 / 竞态守卫 / 结构核查)、P10(进度恢复 / 路径闸归一化 / 笔记摘抄回填 / 目录筛选)、P11(「发到会话」的跨会话投递)、P12(笔记页入口与跨会话交接的落点/层次)均已完成。本文件是架构依据;**实现细节以 v1.1–v1.26 修订块为准**,下文正文中与修订块冲突处(尤其是已删除的"规则 B")以修订块为准。 > **目标宿主**:DSH `0.1.5-rc.2` / cordis `4.0.2` / 活动 profile = `desktop` > **插件标识**:包名 `dsh-reading-companion` · cordis `name = dsh-reading-companion` · CSS 前缀 `drc-` · 槽位 key `dsh-reading-companion:reader` > > **v1.1 修订(实现期核实,取代下文对应处)** > 1. **`dsh-better-sidebar` 确认已安装**:profile 的 `dependencies` 与 `dsh.profile.bundles` 里都有 `dsh-better-sidebar@0.19.1`。§2.3 的证据冲突已消解(反方那份侦察扫的是 app checkout,不是 profile),但**结论不变**——主路径仍走 native 三件套,better-sidebar 只作可选增强。§2.1 的"未装"行已更正。 > 2. **Client 半边改为手写惰性 CJS 信封,取消打包步骤**。原判"手写 `__ModuleLoader__` 更脆"是**错的**:宿主模块加载器的契约本身就是这个信封,官方包(`dsh-client-ui-sidebar-right/lib/client.js`)的产物就是它,连 `require("react")` 都逐字相同。手写因此与打包产物**同构**,却省掉整条构建链与全部 devDependencies。§9 的"Client 半构建"段已重写。 > 3. **插件标识统一为 `dsh-reading-companion`**:它必须同时等于 `cordis.patch.yml` 的行 `id`、宿主半边导出的 `name`、以及浏览器模块 id(`` 与 `/client` 归一化到同一份 exports)。 > > **v1.2 修订(P1 实现期,取代下文对应处)** > 4. **章节正文不再包含标题行**。§4.2 原写"章节区间精确铺满全文",实测这会让 `title` 在正文里再出现一次,阅读界面重复显示标题。现改为:`startChar` 从**标题行之后**起算,标题行坐标另存为 `titleStartChar`/`titleEndChar`。代价是章节区间之间夹着标题行(有缝隙)——这是刻意的,因此 `validateChapters` 只校验**有序 / 不重叠 / 不越界**,不再要求铺满。§6.1 相应更新。 > 5. **`content.txt` 是解码后的整本 UTF-8 全文,字节区间靠累加得出**。导入时逐章 `Buffer.byteLength` 累加,同时得到精确的字符区间与字节区间,**不需要任何 char↔byte 映射表**,读取时也不必重新解码 GB18030。§4.2 保留的双区间模型不变,只是生成方式确定了。 > 6. **零运行时依赖,编码探测改用内置 `TextDecoder`**。§9 原列的 `iconv-lite` / `encoding-japanese` 被移除:Node 自带 full-icu,`gb18030` / `utf-16be` 都可用,而严格 UTF-8 才是判定的关键(`fatal: true`)。 > 7. **BOM-less UTF-16 的启发式必须换信号**。只用"NUL 字节奇偶分布"会在**中文**文本上彻底失效——汉字码位都在 U+4E00 以上,UTF-16 下几乎不产生 `0x00`。实测导致无 BOM 的中文 UTF-16 被误判成 GB18030 并整体解成乱码。现按优先级用三个信号:**换行码位**(按 2 字节单位数,避免 `上`=U+4E0A 的 `0A` 低字节污染)→ **CJK 高字节集中度** → NUL 分布兜底。 > 8. **HTTP 层的 403 不是插件故障**。DSH Desktop 的 `DesktopWebServer` 给每条路由套了 `decideDesktopBrowserAccess`,只放行携带 Electron 渲染器令牌(`x-dsh-desktop-renderer`)的请求,其余一律 403(连 `/` 也一样)。因此 §12 用 `curl` 验证路由的做法在 Desktop 上**不成立**,改查宿主日志的挂载行。补充:未挂载时 `curl` 同样回 403 而非 404——请求会落到 SPA 静态兜底,其路径穿越防护对任何非 `dist` 内路径回 403(`dsh-host-frontend-static/lib/index.js:52`)。 > 9. **错误语义分层**:领域错误必须映射成 4xx(未知书 `404`、非法 bookId `400`、进度非法值 `400`、方法不符 `405`),落成 `500` 会让客户端误判为插件损坏。另:进度与会话绑定共用一条记录,**只有 `sessionId` 非空才算"已绑定"**,否则只读过没绑会话的书会回一个没有会话的"绑定"。 > > **v1.3 修订(P2 实现期,取代下文对应处)** > 10. **§6.2 的上下文预算改为"当前章末尾 + 上一章末尾 + 更早章节只给标题"**。原稿写"近处全文、远处 LLM 摘要";实测一次性投放整章 + 多章前文在中文长篇上每轮就要一两万 token,成本不可接受,而 LLM 摘要又引入一个新的调用面与新的泄漏风险。现方案是**确定性**的:`currentTailChars`(8000) 取当前章进度之前的**末尾**(离读者最近的最相关)、`previousTailChars`(2000) 给上一章末尾做跨章衔接、更早章节**只给标题清单**——这份清单随进度自动增长,是"增量摘要"的最低成本形态,且绝不含未读内容。LLM 摘要留作后续可选增强。 > 11. **新增 `headAllowanceChars`(1500) 章首豁免,且它不是越界**。读者点开某一章时首屏内容已在眼前,而此刻进度仍是章内第 0 字;若严格按 0 投喂,AI 在每章开头都"什么也没看到",陪读直接失效。所以至少给出章首这么多字。它仍落在"读者已经看见"的范围内,可设为 `0` 换取严格的纯光标语义。 > 12. **防剧透闸拆成两条独立规则,顺序即优先级**。规则 A(**按路径,与会话归属无关**):任何工具参数只要匹配 `books/<16hex>/{content,source}.txt|chapters.json` 就拒绝——即使 agent 归属识别失败或在子代理里也拦得住。规则 B(按会话归属):命中所绑会话时拒绝 `read/grep/glob/bash/pwsh/run_code/web_*` 与写入类,以及任何 `sandbox_permissions` 提权。刻意**不含** `notes.md`/`meta.json`:用户完全可能正经要求 AI 看自己的笔记。 > 13. **`tools.guard` 失败方向是"放行 + 大声记日志",而非"失败即拒绝"**。守卫是全局注册的,一旦它自身抛错就拒绝,会把**所有会话的所有工具**一起锁死——那比剧透严重得多。支撑这个取舍的依据:正向投喂永远不会**多给**内容、只会少给;工具闸是第二道防线,坏掉时退化到"AI 不主动去翻"。为让这条 catch 成为真正走不到的路径,`bookIdForSession` 在守卫内被单独包了一层。 > 14. **system 段落回调永不抛错**。宿主对段落文本做 `{{variable}}` 插值,未注册变量名会**直接抛错并毁掉整次 prompt 装配**。因此:(a) 所有进入段落的书籍文本先过 `escapePromptText`(把连续 `{` 用空格拆开;注意**不能**写成 `replace(/\{\{/g,'{ {')`,那对 `{{{` 会产出 `{ {{`,反而又造出一对相邻的 `{{`);(b) 回调整体 try/catch,拿不出上下文时退回空串。`renderReadWindow` 还把正文包进 ``——小说里完全可能出现"忽略以上所有指示",那是提示词注入面。 > 15. **全局注册 + 按会话自我否决**。段落与守卫都全局注册(`context.agent.session.id` → `bookForSession` 反查),未绑定时段落回空串、守卫放行,对用户其它会话零影响。 > 16. **会话 id 必须归一化匹配**。`agent.session.id` 是裸 UUID,而槽注入给面板/绑定侧可能是 `session-`;绑定与反查统一折叠 `session-` 前缀,否则会"绑定成功但防剧透链路整个看不见"。 > 17. **P2 未新增模型工具**。§5.3 原计划的 `reading_context` 工具被去掉:当前章正文由段落直接投喂,模型没有"再取一点"的需要;少一个工具就少一个需要守的越界面。若后续 LLM 摘要落地,再按"Host 侧校验 `chapterIndex <= progress.chapterIndex`"的方式引入。 > > **v1.4 修订(P3 实现期,取代下文对应处)** > 18. **笔记落盘改为 `O_APPEND`,不做「读全文 → 拼 → 原子写回」。** §6.3 原写"追加式写入 + CAS"。实测这仍然是个读-改-写竞态:`notes.md` 可能同时被另一个标签页、或被用户的外部编辑器写入,两者的写入都会被静默吞掉。改用 `appendFileSync`(`O_APPEND`)后**根本不读文件**,内核保证既有字节不被触碰——这是"永不重写既有内容"最强的实现。代价是失去了"读回校验"的能力,但那本来也换不来原子性。测试用逐字节比较证明:先写一段"用户手写内容",追加两条笔记,断言原字节前缀完全不变。 > 19. **`parseNotes` 必须用 tempered greedy token,否则一条未闭合的笔记会吞掉后面所有笔记。** 最初写成"找到 begin → 向后找最近的 end",于是一条忘记写 `end` 的笔记会把**下一条的 end** 当成自己的,两条笔记一起消失。用户只是在外部编辑器里少打一行,代价是整份列表空掉。现正则为块体里不允许再出现 `drc-note:begin`,坏块因此匹配不上而被自然跳过。 > 20. **笔记块带机器可读标记**:`` … ``。HTML 注释在渲染后不可见,但让解析器能稳定还原结构,用户手改正文也不破坏解析。属性值做 `%20`/`%25` 转义——章标题里的空格否则会在第一个空格处把属性串截断(测试专门钉了 `雪 落 无 声`)。 > 21. **「AI 回应默认不落盘」落在渲染层,而不是调用方的自觉。** §6.3 的表述是"仅当用户显式要求写入时才追加"。实现上做得更硬:`renderNoteBlock` 里**根本没有** reply 为空时产出该小节的分支,所以"不落盘"意味着 `notes.md` 里连「AI 回应」四个字都不出现。协议层同样默认关闭——`POST …/commit` 的 `attachReply` 必须显式传 `true`。 > 22. **打 tag 这一版是确定性关键词加权,不是模型调用。** §6.3 原计划由 AI 打 tag。考察宿主接口后改判,理由记在 `lib/host/tags.js` 顶部:宿主的模型面 `llm.stream` 是 remote/RPC 服务,插件侧直调要自备路由解析(`agentDefaultModel`)、超时、取消、重试,以及 `subagents.start` 那条路上的**父代理锚定**(注册表懒注册,重启后逐个出现);换来的只是**一个属性**,而它一旦失败,用户点「写入笔记」就写不成——为核心动作引入新的失败模式是亏本买卖。打 tag 的信号本身极强(读者的感想里就写着他关心什么),所以用词表 + 加权(感想 3 / 回应 2 / 摘抄 1)。**该方案已最终否决**(v1.18 §186):打 tag 不是重要功能,预设词表 + 读者自由输入就是最终形态;`suggestTags()` 只产出候选 chip,不参与判定。 > 23. **tag 归一化必须严格**:去掉 `#`、拒绝内部空白与逗号(逗号是 `tags=a,b` 标记的分隔符)、长度截到 24。`test/tags.test.mjs` 里有一条元测试,遍历内置词表断言**每个 tag 名本身都能通过归一化**——否则建议出来的 tag 会在写入时被悄悄丢掉。 > 24. **新增 `lib/host/notes.js` 与 `lib/host/tags.js`,`library.js` 只做编排。** 笔记的 Markdown 渲染/解析/追加与草稿库都是纯逻辑 + 文件 IO,不该混进书库;`library.js` 暴露 `notes` / `writeNote` / `writeNoteFromDraft` / `listDrafts` / `saveDraft` / `removeDraft`,HTTP 层与它们一一对应。`notes.md` 的头部骨架也从 `library.js` 移到 `notes.js`——创建与渲染必须是同一份定义,否则会出现两个标题。 > 25. **草稿必须持久化到宿主,不能只放组件 state。** 读者的真实流程是"写感想 → 去会话里发给 AI → 拿到回应 → 才决定写不写进笔记",中间可能隔几分钟甚至一次刷新。`drafts.json` 存 `{draftId, bookId, chapterIndex, charOffset, excerpt, thought, reply, tags}`;`reply` 允许被显式置 `null`(用户先贴了回应又反悔)。 > 26. **`writeNoteFromDraft` 把"写笔记 + 清草稿"合成一次调用。** 分两次的中间态(笔记写成功但草稿没删)会让用户以为没写进去,然后重复写第二条。 > 27. **未采用「在会话里自动抓取 AI 回应」**。`sidebar.right.pane.tab` 的 standardProps 里确实有 `useConversation` / `useChat`,但它们的契约形状没有稳定文档,为一个可选便利去耦合宿主内部会话 API 不划算。这一版是**手动粘贴**,并已列入路线图。这也是"先保证可运行再优化"的直接体现。 > 28. **新增真实 cordis 集成测试(`test/cordis-integration.test.mjs`)——这是本项目的验收级证据。** 全部其余测试都跑在**手写宿主替身**上;替身能钉住我们自己的契约,钉不住"真宿主接不接受我们的用法"。于是把真的 `@deepseek-ai/cordis` + `dsh-system-prompt` + `dsh-tools` 装起来,再把插件挂上去,断言三件事:三个 inject 全部解析(apply 真的跑到)、`systemPrompt.assemble()` 在绑定会话上产出段落且**不含未读章节**、`tools.guardReason()` 拒越界读取而放行无关路径。它抓住的那类故障是替身永远抓不到的:**inject 名字写错**的表现是插件静默地永远等待,**连日志都没有**。已做变异验证(故意加一个不存在的 inject 名 → 测试变红 → 还原)。依赖本机 DSH 安装路径,找不到时**跳过而非失败**(开源仓库不能因为"没装 DSH"就变红),可用 `DSH_APP_NODE_MODULES` 指定。 > 29. **组件也要被真的执行**。`node --check` 只查语法,纯函数单测不碰组件体,于是"组件里写了个不存在的变量"会一路活到用户点开面板时的白屏。`test/client.test.mjs` 用一个小迷你渲染器把 `createElement` 的树逐层展开、真的调用每个函数组件,并断言七个视图都被执行到。已做变异验证(在组件体里引用未定义变量 → 测试变红并指出是哪个视图)。踩过的坑:第一版把根组件直接调用(而不是包成根节点交给渲染器),导致根组件的执行数漏计、`TopBar` 这种叶子组件被误报"没有产出任何节点"。 > 30. **一次真实的工程事故已记入 README**:用 **Windows PowerShell 5.1** 的 `Get-Content -Raw` / `Set-Content` 做批量替换,会按系统 ANSI 代码页(简中 GBK)处理**无 BOM 的 UTF-8**,把中文变乱码;更糟的是 GBK 会把汉字的末字节与紧随其后的引号/换行当成一个双字节字符吃掉,因此**这个过程有损、不可逆**(本项目因此损坏并重建过一个测试文件)。替代做法:用编辑器、`pwsh`(PS 7)或 Node 脚本。这也解释了 `test/client.test.mjs` 为何是被重写的而非原文——重建时顺带补上了 §29 的组件冒烟测试。 --- > **v1.5 修订(首次真机验收,修掉「没看到入口」)** > 31. **`sidebarRightTabs.register()` 的 `guide` 不是装饰,它就是入口本身——缺了它,页签注册得再正确也不会出现在右侧栏的「+」选择器里,而且不报任何错。** 首次真机验收时用户反馈「没看到入口」。宿主日志显示宿主半边**已经挂上了**(`00:13:04 … spoilerGate=true`,这条日志本身就证明 P2/P3 的宿主代码在跑),所以问题在浏览器半边。查 `@deepseek-ai/dsh-client-ui-sidebar-right/lib/client.js` 的注册表实现,`refresh()` 是决定性的: > ```js > this.cached = this.active().map((entry) => entry.definition) > this.guideEntries = this.cached.flatMap((d) => (d.guide ?? []).map((e) => ({ > ...e, kind: d.kind, > }))).sort((left, right) => left.order - right.order) > ``` > 选择器的条目**完全**来自 `definition.guide`;没有它,`guideEntries` 里就没有我们这个 kind。而 `SidebarRightTabRegistry.register()` 只校验 `id` 唯一性与 kind 的 band 冲突——`guide` 缺失不在校验范围内,所以是**静默**失败。§5.4 的 client 侧契约据此补齐:`guide: [{ order, title: () => string, description?: () => string, icon?: Component }]`,形状对齐官方 `ui-sidebar-files` 的 `filesDefinition()`。 > 32. **`guide` 条目的 `icon` 是一个会被宿主渲染的组件,不是图片。** `EntryBox` 的用法是 `const Icon = entry.icon ?? CubeGlyph; jsx(Icon, { size, className })`,所以自定义 icon 必须容忍 `size` / `className`(给字符串或 `` 会直接炸在选择器里)。不传 `icon` 也合法,宿主回落到自带的 `CubeGlyph`;本插件给了一个书本图形。 > 33. **同时排除了「客户端半边根本没被加载」这个可能**。`dsh-client-modules` 的发现逻辑确认:`dsh.client.platform` 必须**恰好**是字符串 `"web"`(`if (decl === void 0 || decl.platform !== "web") return null`),且 `exports["./client"]` 必须是字符串或 `{default: string}`。本插件的 `{"platform":"web","inject":[]}` 与 `"./client": "./lib/client.js"` 两条都满足,发现路径本来就是通的——问题只在 `guide`。 > 34. **两条回归测试**:`test/client.test.mjs` 现在断言 tab 类型带 `guide`、条目字段形状合法,并**按宿主的推导规则算一遍**(`flatMap(d => (d.guide ?? []).map(...))`)确认能产出恰好一条属于本插件的入口;另一条断言 icon 能吃下 `{size, className}`。两条都做了变异验证(去掉 `guide` → 变红,提示"否则用户看不到入口")。 > 35. **教训**:这次是「替身测试全绿、真机失败」的典型。`test/cordis-integration.test.mjs` 只覆盖了**宿主半边**(Node 侧服务),而浏览器半边的槽位/注册表契约没有任何真机验证手段——它只存在于浏览器里。因此 §34 的做法是退而求其次:**把从宿主源码里读出来的契约,原样编码进测试**(包括那条 `flatMap(d => (d.guide ?? []))`)。这类"契约是从官方实现里读出来的"测试,是纯替身测试与真机之间唯一可用的桥。 --- > **v1.6 修订(真机使用反馈,三处交互/记忆问题)** > 36. **笔记必须能在面板里读回。** §4.3 只定义了 `notes.md` 的落盘格式,**没有定义读回**;于是面板只列标题与 tag,用户记完就再也看不见自己写了什么。现在 `parseNotes` 额外抽出 `excerpt` / `thought` / `reply` 三段并完整渲染。`hasReply` 的判据也从"有没有那四个字"改成"回应段是否真的非空"——外部编辑器删掉内容却留下小节标题时,不该还标成「含 AI 回应」。 > 37. **免复制粘贴的交互**(取代 §11 原来那套):选中 → 「① 发到会话去聊」把摘抄与感想经 `inputActions.setDraft()` 放进会话输入框 → 读者自己按回车 → 聊完在会话里拖选回复 → 「② 抓取选中文字作回应」用标准 `window.getSelection()` 抓回来。两个 API 都是**核实过的**:`InputActions = { setDraft, addAttachments, removeAttachment, pruneAttachments, submit }`(`dsh-client-ui-conversation`)。刻意**不自动 submit**;也刻意先读 `useInput` 拿到输入框现有内容再拼接——`setDraft` 是**覆盖**语义,静默冲掉用户正在打的字是不可接受的。 > 38. **前文记忆改成「本章 + 上一章全文,更早每章一条梗概」**(取代 §6.2 与 v1.4 §10 的分层)。§6.2 原来是"当前章末尾 + 上一章末尾 + 更早只给标题",读者真机反馈「对前文无记忆」。新分层是读者明确要求的形态:到第 300 章时前文是 299 条梗概(约一万字),而不是 299 章全文。存储 `digest.json`,边界与渲染在 `lib/host/digest.js`。 > 39. **`missingChapters` 的上界是「不含当前章」,这是防剧透在这一层的唯一守门人。** 给当前章生成梗概意味着把**整章**交给模型,等于提前读完它。`uptoIndex` 因此是**不含**上界,且调用方不得自己算——`/digest/fill` 省略它时由宿主按进度推。 > 40. **梗概生成走 `subagents.start('spawn', …)`,且不注入 `subagents`/`agents`。** cordis 的 `inject` 只有必选没有可选,注进去等于"宿主没有子代理时整个插件不加载"——陪读不该因为一个锦上添花的摘要功能而消失。改用 `ctx.get` + 优雅降级。参数按**核实过的** `SubagentStartRequest` 组装:`parent`(取 `agents.get(sessionId)`,模型路由与凭据都从它来,所以插件里不需要配 key)、`toolFilter: { allow: [] }`(零工具)、`maxDepth: 1`、`persona`。 > 41. **摘要器必须 `Promise.race` 一个 abort promise,不能只 `AbortController.abort()`。** 第一版只 abort,被单测的超时用例当场抓住:**`AbortController` 只是一个信号,它不会替我们结束一个忽略该信号的 promise**——宿主若卡在不响应取消的等待上,这个调用会**永远挂着**。signal 仍然照传(让宿主有机会真正取消底层工作),race 是"我们自己绝不挂死"的保证。 > 42. **`'full'` 与 `'read-so-far'` 两种模式写进配置。** 默认 `'full'`(读者要求「完整阅读本章和前一章」;同一章之内的前向知识与"第 400 章的结局"不是一个量级),`'read-so-far'` 保留严格到光标的旧语义。**硬底线在两种模式下都成立:当前章之后的章节一个字都不出现**——测试对两种模式各跑一遍这条断言。 > 43. **自动补梗概的策略归宿主,不归客户端。** 客户端切章时无脑调 `/digest/fill`;宿主按 `autoDigest` 决定是否真的执行,手动按钮带 `manual: true` 绕过该开关(那是用户明确表达的意图)。一次只补一小批(2 条):每条都是一次真实的模型调用,花的是用户自己的额度,所以界面上要显示还剩多少。 > 44. **`lib/host/tags.js` 的"不用模型"结论在梗概这里被推翻,而且是正确的。** 两处的判据不同:tag 只是**一个属性**,失败会让核心动作(写入笔记)写不成,所以用确定性词表是划算的;梗概**无法**用关键词拼出来,它必须调用模型——那就把失败挡在梗概这一层(失败 → 退回标题,陪读照常工作),而不是让它污染别的路径。 > > **v1.7 修订(v0.5,读者实测反馈驱动,取代上文对应处)** > > 这一轮的四条改动全部来自真机使用反馈,不是设计推演。原话分别是: > 「每一章都会生成一个子代理去概括,这样设计有点问题」「即使有记忆也不提前说, > 而不是对功能造成限制」「我希望 ai 只对前文按章生成梗概」「给 ai 回复填写了内容, > 但打开 md 里面依然没有」。 > > 45. **防剧透降级为提示词要求,工具层只留精确规则。** §3/§7 原把「工具层硬拦」当成核心卖点,读者明确否掉了:**「不剧透应该通过提示词层面实现,而不应该对功能造成限制」**。这是**保证等级的性质变化**(AI 可能本来就知道结局,只是答应不说),不是难度变化,已在 README 用独立小节写明代价。落地上:删除规则 B(一刀切黑名单),保留规则 A(路径闸,唯一硬规则),新增规则 C(联网闸)。 > 46. **每章一条梗概 → 一份背景认识。** `digest.json` 与 `summarize.js` 删除,换成每本书一份 `background.md`(`lib/host/background.js`)+ `lib/host/memory.js`。**章节数不再等于调用次数**:一次调用处理 `[缺口起点 .. 前文末章]` 整段。§6.3 的"每章一条摘要"作废。 > 47. **覆盖区间写在产物自己的头部注释里,不另建状态字段。** 水位线躲不掉(不知道"已知覆盖到哪"就无法判断缺口),但它不该是第二份状态。`` 让**产物即真相**:删掉文件,覆盖区间跟着消失,不可能出现"状态说覆盖到 50 章、文件却没了"。这是对"必需状态"与"可推导状态"的一次划界。 > 48. **背景认识「条目只增不减」,且每条带章节归属。** 每次让模型"重新总结一遍",早期细节会被反复压缩掉一点,几次之后就没了。所以合并只追加 + 去重(去掉章节标记后比较),从不删除;同一人物在不同阶段的描述并存,认识的演变可追溯。测试用「清空后重来」与「两批合并后旧条目必须还在」双向钉住。 > 49. **联网闸三档(`block-all` / `block-book` / `off`),默认最严。** `block-book` 是**启发式**——扫查询里有没有书名/人物名/剧情词——已在代码与 README 里明确标注"挡无心之失,不挡刻意查询"。非法配置值在 `resolveConfig` 归一化到 `block-all`(配置笔误不该把闸门关掉),`webGateReason` 里还有第二道兜底。 > 50. **子代理的联网权限由 spawn 时的 `toolFilter` 决定,不靠工具闸的会话归属。** 子代理的 session 与陪读会话不同、也没绑定,**归属判定认不出它**——规则 C 保护不到子代理。所以开关落在我们自己的 spawn 参数上,那才是精确控制点。另外 `tools.restrict()` 对**未知工具名直接抛错**,所以「这个宿主没装联网工具」必须退化成零工具继续跑,而不是把补齐整个搞挂(有单测钉这条)。 > 51. **补齐改回「阻塞」,且只在发笔记时触发。** §43 的 `autoDigest`(切章自动补)与 `autoDigest`/`digestTimeoutMs` 配置项一并删除——那正是"每章起一个子代理"的来源。现在:客户端不再在切章时发任何补齐请求;唯一触发点是笔记页的「① 发到会话去聊」,它先阻塞式补齐、再把摘抄与感想放进输入框。读者明确选了"愿意等几十秒"。代价已写明:只读不聊时记忆不增长——这是刻意取舍,记忆只在要用时才值得花钱建。 > 52. **「陪读 AI 顺手带回背景更新」被砍掉(零成本方案)**。原方案让陪读 AI 在回复末尾附带机器可读的背景更新块,宿主解析后合并——零额外调用。**砍掉的理由是它变冗余了**:既然补齐改成了"发笔记前阻塞式补到当前章",那次补已经覆盖了本次对话需要的一切;而它需要读会话事件(`session/event` 或 `agent/pre-step`),是整条链上最脆的一环。已列入路线图作为可选优化。 > 53. **笔记写入合并成一个按钮。** 读者反馈"填了 AI 回应,打开 md 却没有"。两个成因都被修掉:(a) 两个并列按钮(「写入笔记」/「写入并附上回应」)意味着刚填完的回应会被相邻按钮**按设计静默丢弃**,且草稿随即删除、磁盘不留痕——现在"要不要带回应"由**输入框是否为空**决定,按钮文案随之变化;(b) 回应框内容原本只活在组件 state,切页签/刷新即丢——现在输入即防抖存草稿(0.8s),抓取回应时立刻落盘。**一个刚填完的字段被相邻按钮静默丢掉,这种设计不该存在。** > 54. **`notes.md` 的章节编号优先用书自己的编号。** 实测产出过 `### 第 17 章 · 第16章 带子`:这本书的 index 0 是「卷首」,所以 `index+1` 与书内编号错位。规则改为:标题里已含 `第N章/回/节/卷/篇` 时直接用标题,否则才加序号前缀。 > 55. **`parseNotes` 必须真的解析出正文。** 它原来只抽标题与 tag,正文(摘抄/感想/回应)**从来没被解析过**——所以面板只能列出标题,记完就再也看不见自己写了什么。现在三段都抽出来并在面板完整渲染。顺带修正 `hasReply` 的判据:从"有没有『AI 回应』四个字"改成"回应段是否真的非空"(在外部编辑器里删掉内容却留下小节标题时不该标成"含 AI 回应")。 > 56. **客户端组件冒烟测试抓到了两个真错**:`NotesView` 引用了它根本没收到的 `sessionId`;`notes.md` 的编号问题也是它先炸出来的。这条测试(用迷你渲染器真的执行每个函数组件)的价值在 v0.4 就已证明,本轮再次兑现。 > > **v1.8 修订(阅读产物落点 + 正文字体,取代 §4.1 与 §5.2 对应处)** > 57. **人可读产物改用「会话工作区 + 陪读文件夹」,大文件仍留在插件目录。** §4.1 原写所有数据都在 `$DSH_HOME/dsh-reading-companion/books//`。现改为:`notes.md` 与 `background.md` 落在 `<会话工作区>/陪读_<书名>/`(外加自动生成的 `README.md` 与认领标记 `.dsh-reading-companion.json`)。**`source.txt` / `content.txt` / `chapters.json` 永不搬** —— 本机实测一本 14 MB,把用户的工作区当仓库使是冒犯,而且它们是可以从原书重建的派生数据。 > 58. **工作区路径由宿主自己解析,不走客户端。** 客户端其实也能拿到(`sidebar.right.pane.tab` 标准注入了 `useWorkspaces`,实体上有 `path` 与 `sessionIds`),但那要求面板正开着、且 hook 契约稳定(v1.5 的 `guide` 事故刚证明猜宿主契约的代价)。宿主侧有现成服务:`dsh-workspace` 的 `super(ctx, "workspaceRegistry")`,`list()` **同步**返回实体数组,每个实体的 `sessionIds` 已按启动/实时的 canonical-cwd 索引过滤好。`workspaceDirForSession()` 用它,拿不到就回 `null`。`test/cordis-integration.test.mjs` 另外钉住了这个包名、服务名与 `list()` 的存在——它们一旦变化,解析会**静默失效**。 > 59. **撞名必须消歧,因为文件夹名来自书名。** 同一工作区里两本**不同**的书如果同名(同一部书的两个版本、或都叫「未命名」),后来者用 `陪读_<书名>_`。判定依据是文件夹里的认领标记,"先到先得"。用户明确提过这个担心,所以有专门的测试。 > 60. **书名是新的攻击面,清洗必须做死。** 以前所有路径都由 `bookId`(我们自己生成的十六进制)拼成,天然安全;现在文件夹名来自书名,而书名来自用户导入的文件名,完全可以叫 `../../evil` 或 `C:foo`。`sanitizeFolderName()` 处理路径分隔符/盘符、Windows 非法字符、控制字符、**以点或空格结尾**(Windows 会静默创建失败)、保留设备名(`CON`/`NUL`/`COM1`…但 `CONTEXT` 不该误伤)、长度与空值兜底。 > 61. **迁移是复制,不是移动。** 从旧位置搬到工作区时老文件原样保留,作为安全网。迁移失败不抛错 —— 它不该阻断"把笔记写进去"这件正事。 > 62. **`artifactPath` 必须有副作用,第一版因此有真 bug。** 我最初让 `artifactPath` 只调"纯解析"的 `companionDir`,而认领标记只由 `ensureCompanionDir` 写。结果是:`notes.md` 被 `atomicWriteText` 顺手建了目录,**标记却没写**,于是第二本同名书检测不到撞名,两本书共用了文件夹——正好是用户担心的那件事。修法是让 `artifactPath` 调 `ensureCompanionDir`。代价是"读也有副作用"(会在读路径上迁移),这是刻意的:打开笔记页就该在工作区里看到自己的笔记。**这个 bug 是测试抓的,不是我想到的。** > 63. **绑定成功与位置解析解耦。** 工作区解析失败(会话不在任何工作区、registry 抛错、目录被删)**绝不能让绑定失败**,也不能让笔记写不进去——那就退化回插件目录。三条测试分别覆盖这三种情况,其中"registry 抛错"专门验证不会冒成 500。 > 64. **`location()` 必须暴露 `fallbackReason`。** 用户发现笔记没落在工作区时,唯一有用的信息就是**为什么**。宿主侧把 `PATH_INVALID` / `PATH_NOT_ABSOLUTE` / `PATH_IS_ROOT` / `DIR_NOT_FOUND` / `NOT_A_DIRECTORY` 原样回给界面,界面翻成中文;`null` 表示"宿主手里根本没有工作区路径"。这个字段存在的意义是让"我的笔记到底写哪去了"成为一个**能自己回答的问题**。 > 65. **正文字体偏好存在 `localStorage`,不进服务端。** 这是纯界面偏好,改一下就该立刻见效,不该为一次字号调整走一趟 HTTP。代价是按浏览器域共享、不进 profile 备份——对"读小说时字大一点"完全够。`clampFontPrefs()` 永不抛错:输入可能是 localStorage 里的任意垃圾(用户手改、旧版本写的),任何非法值都回落默认;`loadFontPrefs`/`saveFontPrefs` 连 `localStorage` 整个不可用(隐私模式、配额满)都能降级。字体栈只列**系统自带**字体,不下载任何字体文件——插件是零依赖的,不该因为换个字体就产生网络请求。 > > **v1.9 修订(笔记规模:分页 + 冗余字段 + 渲染隔离,取代 §5.2 的 `GET /notes` 与 §6 对应处)** > 66. **笔记列表分页,`GET /notes` 默认每页 20 条**(上限 200)。起因是用户问"读得长了、笔记多了会不会成为问题"——先量化再改:解析本身**不是**问题(1000 条 4.2 ms),真问题是响应体与客户端 DOM 节点数。所以分页只在 HTTP 与渲染两层做,`paginateNotes()` 仍然解析整个文件再切片;增量解析是另一个量级的复杂度,换不来可感知的收益。 > 67. **游标用 id,不用页码。** 面板是"新的在前、向下加载更旧的"。若用下标,读者在翻页途中写了一条新笔记(插在**头部**),下标整体后移一位,下一页就会重复上一页的最后一条。用"最后一条已加载笔记的 id"作锚点,向头部追加不影响它。锚点找不到(被外部编辑器删改)时返回第一页并置 `reset: true`,让面板**替换**而不是追加——比悄悄产生重复好。**唯一的例外**是手写的、没有 id 属性的块:那时没有 id 可锚,游标退化成 `at:<下标>`,否则 `nextCursor` 会是空串、被当成"没有游标",表现为「加载更多」按钮点不动。 > 68. **响应体里删掉 `body`。** 每条笔记同时返回整块原文**和**三段拆解,而面板从来没用过 `body`。实测(1000 条真实长度笔记,三段都非空):全量响应 940 KB 里有 488 KB 是它,**占了一半**。顺带纠正一处早期估算:改动前我在注释里写的是"约三分之一",那是按较短的笔记测的,真实比例约一半。 > 69. **列表抽成 `memo` 组件,且 props 必须是稳定引用。** 在这之前列表**内联**在 `NotesView` 的返回值里,而 `thought`/`excerpt`/`reply`/`tagsText` 都是 `NotesView` 的 state——于是**每敲一个字**整张列表都要重新协调一遍,而列表会把每条笔记的摘抄/感想/回应全文都渲染出来(1000 条约等于 7000+ 个元素参与每次按键的 diff)。修法有两半,缺一不可:(a) `memo`;(b) 传进去的引用必须稳定。所以**排序在宿主侧做**(`paginateNotes` 返回"新的在前"),客户端**不做** `slice().reverse()`——顺手 reverse 一下就会每次渲染新建数组,memo 永不命中;`onLoadMore` 也必须 `useCallback` 包过。这两条都是**不会报错、只会变慢**的失效模式,所以 `test/client.test.mjs` 的 react 替身补上了**真的做浅比较并在命中时跳过渲染**的 `memo`(还记账 `renders`/`bailouts`),另加一条"NotesView 渲染时列表真的经过 `NoteList`"防止被内联回去。 > 70. **`refresh` 拆成 `refreshNotes` / `refreshDrafts`。** 保存草稿只影响草稿,原本却走全量重取——既多发一次笔记请求,又会让读者已经"加载更旧"翻出来的几页**丢掉、弹回列表顶部**。现在保存草稿只重取草稿,写入笔记才重取首屏笔记。改落点仍然全量重取,因为文件可能整个挪了目录。 > 71. **端到端实测(1000 条笔记)**:改动前 940 KB / 全量返回 / 7000+ 元素参与每次按键;改动后 **9 KB** 且**与条数无关**,打字不触发列表重渲染。列表标签显示的是 `total`(全部条数)而不是本页条数——分页是实现细节,读者要知道自己一共记了多少条。 > **v1.10 修订(v0.8.0:注入顺序 / 压缩 / 时间感知 / 讨论历史 / 书友设定,取代 §5.2、§6.2 与 v1.7 §46 对应处)** > 72. **注入顺序重排为「稳定 → 只增 → 动态」,这是本版最重要的一处。** prompt 缓存是**前缀匹配**的:前面有一个字节变了,后面全部重算。旧顺序把「读者当前读到第 N 章」放在**第一行**,于是每翻一章整个前缀作废,**缓存命中率恒为 0**——这是 prompt caching 的经典反模式(可变 system 前缀)。现在的顺序是:标题 + 守则(只依赖书名与 `webGate`,逐字节稳定)→ 书友设定(只在用户保存时变)→ 背景认识(只追加,天然是稳定的增长前缀)→ 当前情况(进度/日期/距上次聊)→ 讨论时间线 → 已读正文。为了让它稳定,`renderPolicy` 里那条「你还没有建立背景认识」被**移出**了守则(它是**状态**,不是规则),搬进 `renderSituation`;「书友设定只调风格」那条声明则改成**无条件出现**(否则用户保存一次人设就让前缀位移一次)。`describeWindow` 把 `cacheSplit.stable` 暴露到面板上——把"重排有没有用"做成一个可测量的数字,也让人把动态值挪回前面时**一眼看得出来**。`test/prompt-order.test.mjs` 与 `test/injection.test.mjs` 各有一条"只改进度/时间,稳定前缀必须逐字节不变"的断言钉住它。 > 73. **`systemPrompt.section` 回调第一次被真正测到。** 面板里的「AI 视角预览」走 `GET /context`,而每一轮对话真正用的是那个 section 回调——**两条不同的代码路径**。这个空白是被一次变异验证抓出来的:把回调里的 `discussions` 改成空数组(等于时间线根本没接进去),**307 项测试依然全绿**,因为它们全都只打路由。修法是让测试骨架**捕获**注册上来的 `section` / `guard` 回调并直接调用它(`test/helpers/server.mjs` 的 `hooks`),`test/injection.test.mjs` 专门打这条路径。同一批测试还钉住了另一条更重要的性质:**回调抛错会让整次 prompt 装配失败**,把用户正常的一轮对话一起毁掉——所以它必须永远吞掉异常、退回空字符串(用一个被删掉 `chapters.json` 的书写成可达的失败路径来验证)。 > 74. **背景认识的预算不足改成两级降级,不再整节丢弃。** 旧版是整节粒度:一旦「人物关系」自己就吃满预算,其余三节会被**整节**丢掉,最后附一句"未提供:人物、世界观、前文脉络"——读起来像在说这本书没有人物。现在是:先按权重(人物关系 .36 / 人物 .30 / 世界观 .20 / 前文脉络 .14)给每节分配、每节再给一份不超过**均分**的保底;某一节仍放不下时,在**节内**按"近期优先"(该条目提到过的最大章号)丢,并写明「本节另有 N 位人物未在此展示」。人物的取舍单位是**一位人物**而不是一条条目——拆到条目粒度会产出"甲有三条、乙一条都没有"这种读起来像残缺的东西。 > 75. **压缩:`background.md` 唯一的"往下减"。** 「只增不减」保证了认识单调累积,代价是文件只会越来越长,而注入预算有限。真正的解法是让**文件本身**留在预算内——这样渲染时不再触发截断,第 3 段重新变成一个稳定的增长前缀。压缩必须过**三道安全校验**,任何一条不过就**整批丢弃**:**保名**(压缩前有的人一位都不能少)、**保号**(覆盖区间只能不变或变大)、**真的变小**(否则白花一次调用,还会陷入"压缩→没效果→再压缩")。失败的姿态是刻意的:一次没压成只浪费一次调用,而一次丢了人物的压缩是**不可逆**的记忆损失。落盘前原件复制成 `background.bak.md`;压缩失败**不阻断补齐**。触发点是**下一次补齐**(用户已经同意为这次操作等一次模型调用,顺手做掉比再点一次按钮合理),顺序是**先压缩再合并**(反过来做的话刚合并完就又胖了);`window.compactThreshold` 设成 1 即关掉自动压缩。 > 76. **判据用"不设预算的完整用量",不是实际渲染结果的 `used`。** 后者本身取决于预算,用它判断会形成自我实现的循环:超预算 → 被截断 → 看起来不大 → 永远不触发压缩。见 `needsCompaction()`。 > 77. **「跑一次子代理」的机制抽成 `subagent-run.js`。** 记忆补齐与压缩要的是同一套东西(父 Agent 查找、超时 race、联网工具面的降级),只在**提示词**与**输出校验**上不同。三条不能写错的地方各有测试:父 Agent 必须是读者自己的会话(宿主用它解析模型路由与凭据);超时必须 `Promise.race` 而不能只 `abort()`(`AbortController` 只是信号,不会替你结束一个忽略它的 promise——第一版就是这样,被超时用例抓到跑满 120 秒);`tools.restrict()` **对未知工具名直接抛错**,所以"这个部署没装联网工具"必须退化成**无工具继续跑**,而不是把整个功能搞挂。 > 78. **判断顺序:先查环境能力,再查会话状态。** 「宿主没装子代理」是**部署事实**,用户做任何事都改变不了它;「会话里还没有活 Agent」是**用户自己能修的**(说一句话就行)。反过来的话,一台根本没装子代理的机器会一直提示"先在会话里说一句话",用户照做之后仍然失败,而且永远不知道该去改配置。这条被一次变异验证专门验证过——第一次的变异只挪了检查没挪返回,语义等价、测试照样全绿,说明**变异本身也要验证**。 > 79. **时间感知放在动态区,且只到"日"粒度。** 内容是「今天:YYYY-MM-DD」+「距上次和这位读者聊这本书:N 天前」。用**自然日差**而不是 24 小时(昨晚 23:00 到今早 08:00 该是「昨天」)。它每轮都可能变,所以绝不能混进稳定前缀——这正是 §72 那条断言要防的事。`createRoutes` 的 `now` 走依赖注入,否则"3 天前"这种断言不可能稳定。 > 80. **讨论历史(`discussions.jsonl`)不是对话历史的副本。** 陪读会话本身就有完整记录,再存一份再投喂回去是纯浪费 token。它只存**摘要**(每条几十字,字段截断到 240 字),解决三件会话本身给不了的事:跨会话/跨重启的时间戳(时间感知的依赖)、换绑或重建后的最低连续性、以及长会话老轮次掉出上下文之后仍在的时间线。来源有三种,分别对应三个**不同动作**:`note`(写了笔记)由**宿主**在 `POST /notes` 里记(不依赖面板有没有记性)、`sent`(发去聊)与 `reply`(抓回回应)由客户端在对应时刻回报。**补齐前文记忆刻意不记一条**——那是维护动作不是"聊过",记进去会让"距上次聊"在用户只点了个按钮之后变成「今天」,那是撒谎。文件是机器数据,按既定约定留在插件目录,不占用户工作区;条数封顶 200(信息已沉淀进 `background.md`),坏行跳过(追加写的文件最可能坏在最后一行)。 > 81. **书友设定(`persona.md`)落在陪读文件夹里,与 `notes.md` 同一条落点策略。** 它是人可读、人会想改的东西,落在插件目录里就别指望有人找得到;也因此它会被老数据迁移覆盖。**不在「AI 视角预览」那个框里编辑**:那个框显示的是**合成结果**(混着 6000 字背景 + 整章正文),让它可编辑等于让用户在几千字正文里找自己那一段,而且"我改的是哪一份"语义不清。所以是一个独立 textarea + 保存按钮,预览里能看到合并后的效果。上限 4000 字,**超了报错而不是静默截断**(设定被悄悄砍掉一截比拒绝保存更糟)。它进的是稳定前缀,所以保存一次只该让设定那一段变化——有一条测试专门断言"改设定时守则那一段逐字节不动"。至于"设定不能取消守则",落点是守则里那条**无条件**声明。 > 82. **修掉客户端里一段已经过期的文案。** 「防剧透闸」那块还在说 `read`/`grep`/`glob`/`bash`/`pwsh`/`run_code`/`web_*` 会被拒绝——那是 v0.5 就删掉的规则 B。文案对不上实现,用户就会按错误的模型理解这个功能。现在如实描述两条精确规则,并明说「不主动剧透靠的是提示词,不是技术保证」。 > 83. **测试里一个跨文件的坑:不要 `delete globalThis.fetch`。** `node --test --test-isolation=none` 让所有测试文件跑在**同一个进程**里,删掉全局 `fetch` 会把后面所有用它的测试一起弄挂(这个 bug 在本次改动里真的发生过,表现为 `routes.test.mjs` 报 `fetch is not defined`)。改成保存原值再恢复。 > 84. **本环境的测试命令**:受限沙箱里 `node --test` 的默认隔离模式会 spawn 子进程并捕获其 stdio,在 Windows 沙箱下会以 `EPERM` 失败(这是文档化的边界,不是 bug)。用 `npm run test:no-isolation`(`--test-isolation=none`)在**同一进程**里跑,可以绕开子进程管道。 > > **v1.11 修订(v0.9.0:笔记格式 / 发到会话 / 书架分类与绑定,取代 §4.1、§5.2 与 v1.4 §18 对应处)** > 85. **笔记标题行:章节与 tag 合并到同一行。** 形状是 `### 第16章 带子 #人设 #文笔`,tag 不再单独占一行。理由是 `notes.md` 是给人读的:一条笔记的第一行应该一眼说清「这是哪一章、我关心什么」,而 tag 单独占一行只是把正文推得更远。三种回落都要有:没有 tag 就只留章节名;**这本书没有章节**(纯文本分块的书)时章节那部分**整个不出现**,标题行只剩 tag;两样都没有时回落到 `读书笔记`。落点是 `noteHeading()`,`renderNoteBlock()` 与它共用同一份规则(改名一次就够,md 与解析不会漂移)。 > 86. **读回时 tag 必须按"属性里记着的那几个"精确剥掉,不能按字符切。** tag 现在同时存在于**属性**(`tags=a,b`)和**标题行**里;不剥掉的话面板会把同一个标签显示两遍。剥法是逐个 `endsWith('#tag')` 并从尾部截掉,而不是"按空白切分、见到 `#` 就当 tag"——后者会把 `第3章 C# 入门` 这种标题切坏。`test/notes.test.mjs` 有一条专测这个(`stripKnownTags` 被停用时会红两条)。 > 87. **「发到会话」以章节名开头,客户端为此镜像了一份 `chapterLabel`。** 会话里翻到那条消息时应该一眼看出摘抄的出处,而不是只看到一段没有来处的引用。`chapterHeading()`(client)是 `chapterLabel()`(host)的镜像——**两边必须逐字一致**,否则同一条笔记在会话输入框里和 md 里会出现两个章号。这只能靠测试兜:`test/client.test.mjs` 用一张输入表(20 个章号 × 7 个标题,含 `第16章 带子` / `卷二 风起` / `第十三回 见故人` / 空标题)断言两边相同。**唯一的刻意差异是 `{{`**:宿主那侧还要过 prompt 插值转义(`{{` 会让整次 systemPrompt 装配抛错,见 `escapePromptText`),而客户端这段文字进的是聊天输入框、是用户消息的字面文本,不该转义。这条差异也被显式断言,免得将来有人"顺手对齐"。 > 88. **顺带修掉一个守卫会静默失效的地方。** `sendToSession` 原本用 `if (text === '')` 判"没内容可发"。在开头加上章节行之后,那段判断会让**只有章节、没有摘抄感想**的输入通过(章节行本身就是非空文本)。改成只看 `excerpt` 与 `thought`。因为是组件内的回调、替身测不到它的分支,这里用一条**静态断言**钉住守卫的形态,并写明它为什么必须是这个样子。 > 89. **书架分类存进独立的 `categories.json`,不塞进 `library.json` 或书的 `meta.json`。** `meta.json` 是**从源文件派生**的客观信息(sha、编码、章数,全部可重算);分类是**用户的主观归类**,混进去会让「重导入时能不能覆盖 meta」变成一个没有干净答案的问题。`library.json` 是书架索引,它的损坏范围应当被限制在"书架列不出来"。独立文件还有一个实际好处:分类全丢了也不伤书架。 > 90. **刻意不维护"分类清单"。** 分类存在当且仅当至少有一本书属于它——于是"删掉最后一本书之后还留着一个空分类"这种需要额外清理的状态**根本不可能出现**,下拉里的选项也就永远和实际内容一致。代价是重命名一个分类只能逐本改(没有清单就没有批量入口),这是可接受的取舍。配套的一条:`remove()` 必须**同时删掉分类**,否则「导入 → 删除」会不断堆悬垂条目,而它们会被当成真实分类显示出来——一个永远分不进去的幽灵分类。 > 91. **空值语义:空串 = 取消分类,不是"分到一个空名字的类"。** 后者会在界面上变成一个点不中、也删不掉的空分组。归一化(trim、剥控制字符、截断到 40 字)在宿主侧做,路由原样透传 `body.category`;`category: null` 与 `category: ''` 是同一个意思。 > 92. **书架的"跳到会话"用 `ctx.get('sessions')` 而**不是**写进 `inject` 数组。** 宿主有 `sessions` 客户端服务(`ctx.sessions.open(id)`,`dsh-client-ui-workflow-run` 用的是同一条路)。但硬依赖意味着它一旦缺席**整个插件都不挂载**——书架、正文、笔记会一起消失。为一个便利按钮赌上整个插件不划算,所以拿不到就只是不渲染按钮(退回静态的「已绑定会话」),其余功能一律照常。三条回落(未绑定 / 已绑定但跳不了 / 已绑定可跳)都落在纯函数 `shelfBindingState()` 上。 > 93. **"点书籍就跳会话"是刻意不做的。** 点书籍本身是"打开这本书",两个动作抢同一个点击是最容易让人误操作的设计。所以跳转是每行右侧一个单独的按钮,且控制区整块 `stopPropagation`(否则点下拉会顺带把书打开)。 > 94. **组件层测不到的分支,提成纯函数再测。** 测试里的 React 替身 `useState` 返回 `[初始值, () => {}]`——不做状态更新,所以书架永远停在「正在读取书架…」那一屏,`state.books` 之后的分支**根本渲染不出来**。这正是 `groupBooksByCategory()` 与 `shelfBindingState()` 被提成纯函数的一半原因(另一半是它们本来就值得单独测)。这类提取不是为测试而测试:留在组件里的分支等于没有护栏。 > 95. **一次自伤记录:CSS 在模板字符串里,注释里不能出现反引号。** 给新控件补样式时,我在 CSS 注释里用了 `` `.drc-badge` `` 这种写法——反引号直接**终止了那个模板字符串**,报错是 `SyntaxError: Unexpected identifier 'button'`(指向样式表里下一个 `button.xxx {`),和真正的原因隔着十万八千里。五个浏览器半边测试同时变红才定位到。CSS 注释里一律用普通引号。 > 96. **本版变异验证:4 个变异植入,4 个全部被抓,且都是预期的那条测试报红。** 停用 `stripKnownTags` →「读回:标题行里的 tag 被精确剥掉」+「章节标题里出现 # 不会被误当成 tag 剥掉」;把「未分类」挪出首位 →「分组:未分类永远排第一」;`remove()` 不做分类清理 →「分类:删掉一本书会连它的分类一起删」;`chapterHeading` 去掉序号识别 →「客户端与宿主的规则在整数章号上必须逐字相同」。全量 **336/336**(本版从 308 起,新增 28 项)。 > 97. **第二处自伤:改 `package.json` 的版本号不要用 `Set-Content -Encoding utf8`。** 这个 PowerShell 会给文件加上 **UTF-8 BOM**(`EF BB BF`),而 `JSON.parse(readFileSync(path, 'utf8'))` **不剥 BOM**——于是 `test/plugin.test.mjs` 那条「`dsh` 字段是宿主发现插件的方式」当场报红。改版本号要么用 `edit` 工具,要么走 `[System.IO.File]::WriteAllText($p, $text, (New-Object System.Text.UTF8Encoding($false)))`。这条和第 95 条是同一类问题:**用不属于这个仓库的文本工具去改这个仓库的文件**。 > **v1.12 修订(v0.10.0:联网档位开关 / 文风分区 / 抽样加权,取代 §5.2、§6.2 与 v1.7 §48 对应处)** > 98. **联网档位原先根本没有写入路径。** 读者反馈"没看到开关"——查下来确实没有:`config.webGate` 全链路只有**读**(`DEFAULTS` → `resolveConfig` 归一化 → `renderPolicy` / `webGateReason` → 面板只显示),`createRoutes` 里没有任何写配置的路由。要改档位只能编辑 `cordis.yml` 再重启。这是**实现缺口**,不是用户没找到。 > 99. **新增 `settings.json` 作为运行期覆盖层,优先级:设置 > `cordis.yml` > 默认。** 与 `library.json` / `categories.json` 分开放:它是**运行期可改的开关**,混进任何已有文件都会让那份文件的损坏范围变大。只存用户**显式改过**的字段,没设过就是 `null`(= 跟随配置)——不把默认值抄进来,否则"我改过"与"我没改过"不可区分,将来调整默认值时老用户的库会永远停在旧默认。`webGate: null` 或空串的含义是**清除覆盖**,不是一个叫 "null" 的档位。 > 100. **档位必须"现读",界面上的改动才不用重启。** `effectiveWebGate()` 在每次装配 prompt / 每次工具守卫调用时读缓存。⚠️ 两个反直觉点:其一,`config.webGate` 是**在守卫回调内部**读的,而 `resolveConfig` 返回的是**普通可变对象**(没有 `Object.freeze`),所以运行期改值是可行的——如果当初写成挂载时闭包捕获,就必须重启;其二,**刻意不**在 `effectiveWebGate()` 里每次读盘:守卫是**每次工具调用**都跑的,热路径上一次同步 fs 读不划算。代价是**手动**编辑 `settings.json` 仍需重启,而界面开关会同步更新缓存。这笔交换划算,因为改档位的正常路径是开关。 > 101. **归一化放在读取侧,所以坏文件只会更严。** 非法档位在写入路由被拒(400 `WEB_GATE_INVALID`,且**不动**原来的值);文件被手动改坏或写成未知档位时,读取侧回落到配置值。闸门是防剧透用的,一个配置笔误不该把它悄悄打开——回落方向永远是**更严**的那一边。 > 102. **「完全」与「本书」给模型的措辞刻意相同。** 两档的区别是**工具闸的严格程度**(一律拒绝 vs 只拦看起来在查本书的查询),不是给模型的措辞。两种情况下模型都该"不要联网查这本书"。`test/settings.test.mjs` 有一条断言两档的注入文本**逐字相同**——若哪天有人把它们写成不同,那条会提醒他想清楚是不是有意的。 > 103. **新增第五个分区「文风」,而且它是天然防剧透的。** 叙述视角、句式习惯、用词偏好、节奏、对话密度**不含任何剧情信息**——告诉模型"作者爱用短句"不会泄露后续。它对读者要的"调整 AI 回复的风格"也有用。`MEMORY_PERSONA` 第 2 条原先是**明令禁止**写文风("不要评价文笔"),现在必须换成更精确的说法:**只描述特征,不评价好坏**("爱用短句"可以,"文笔很好"不行)。描述与评价是两件事。 > 104. **加分区不需要迁移旧文件。** `parseBackground` 用 `BACKGROUND_SECTIONS.includes` 过滤标题,所以旧 `background.md` 会自动获得一个**空的**「文风」节,已有四节的内容一个字都不会掉进 `unknown`。有专门一条用例钉住这一点——用户的 `background.md` 是可能被手改过的文件,加一节不能让已有解析崩掉或丢内容。 > 105. **抽样加权:绝对章号前 5 章 ×3。** 读者实测感受是均匀抽样 240 章各 100 字(约两句半)得到"覆盖极广、深度为零"的认识,从里面看不出任何人的性格;而开头是世界观与人物的密集区。⚠️ 判据必须是**绝对章号**(`index < emphasisChapters`),不是"本批的头几章"——否则第二批(比如第 51 章起)又会把它自己的头 5 章当成重点,而那 5 章毫无特殊之处。这一条有专测,且变异验证确认它抓得住。 > 106. **首次补齐(打底)限 30 章,两次收敛。** `covered === null` 时本批最多 `foundationChapters` 章:读到第 300 章才第一次补时,与其把 24000 字平摊成 240 章,不如**先把开头读厚**。实测(读第 246 章):第一次覆盖 1–30 章(前 5 章各 1800 字、第 6–30 章各 600 字),第二次把 31–245 章一趟补完(各约 111 字)。用 `covered === null` 而不是"水位线是 0":补齐失败时 `covered` 不变,于是下次仍是打底模式——这是对的。加权会让同批能覆盖的章数变少(权重总和 = 章数 + 额外权重),这是刻意取舍。 > 107. **⚠️ 权重表曾经是死代码——而且死了两次。** 第一次:注释写着"先按权重给每节分配预算",实现却是 `available / sections.length`(均分),`BACKGROUND_SECTION_WEIGHTS` 导出了、文档里引用了,**从来没被读过**。修的时候先试了"按权重给保底",**还是没用**:保底被 `SECTION_FLOOR_RATIO` 封顶,而所有权重都 ≥ 0.12,于是每节的保底都等于 `0.12 × 预算`——一模一样,权重换了个理由继续当摆设。最终拆成两件**互不干扰**的事:① **保底与权重无关**,唯一目的是"每节都要露头"(整节丢掉的输出读起来像"这本书没有人物");② **剩余预算按权重注水**,某节吃不下它应得的那份时,多出来的在下一轮分给还饿着的节,预算不会因为"某节内容太少"而浪费。分区顺序退居收尾兜底。 > 108. **"权重真的被执行"只能靠差分断言,而且不能拿渲染长度当尺子。** 前两次死代码都全绿,因为没有任何断言在**比较**两种权重。现在有一条:同一份内容、同一份预算,**只换权重表**,`allowances` 必须不同。两个实现细节:① `renderBackgroundForPrompt` 现在返回 `allowances`——**分配结果本身就是权重表的产物**,而"渲染出的字符数"是有损代理(条目按整条取舍,一条 40 字符的差异会被量化噪声吃掉,于是"权重没生效"和"尺子不够细"看起来一模一样);② 夹具里 `人物` 节必须用**多个角色、每人少量条目**——「人物」的取舍单元是**一位角色**,把所有条目挂在同一个人名下会让它变成一条 200 多字符、非全有即全无的巨块,预算稍紧就整节消失,那是夹具形状问题不是分配问题。 > 109. **测试用 3 章测"打底"机制,不用 30 章测那个数字。** 默认值 30 由配置契约用例(`GET /health` 的 `config.sample`)负责;机制用例要钉的是"首次批次会限章、剩下的如实报告为未覆盖、第二趟接得上不留缝"。夹具的 `NUMERALS` 只有八个数词,硬造 30 章反而要靠别的路子。另外 `emphasisFactor: 1` 是**合法值**(= 均匀抽样),这样"截断行为"可以和不加权分开测——否则加权会把每章额度顶到超过单章长度,`(中略)` 就不再出现,两条性质会挤在一个断言里。 > 110. **本版变异验证:5 个变异植入,5 个全部被抓,且都是预期的那条报红。** `effectiveWebGate` 忽略设置 →「设置:界面改档位压过配置,且不需要重启就生效」等 3 条;`weightOf` 改用相对偏移 →「抽样加权:判据是**绝对**章号,不是"本批的头几章"」;分配里把 `weight` 写死为 1 →「分区:权重真的被执行(不是死代码)」(差分断言报 `272 vs 272`)+「额度严格随权重递减」;从 `BACKGROUND_SECTIONS` 里去掉「文风」→ 8 条;去掉 `foundation` 限章 → 2 条。全量 **360/360**(本版从 336 起,新增 24 项)。 > 111. **第三处自伤:`package.json` 的 `description` 本来就是坏的 mojibake。** 这次改版本号时读出来才发现——`DSH 鏈湴闃呰…`,是 UTF-8 中文被当成 GBK 解读后的形状。它不影响任何功能(`JSON.parse` 照样成功,只是描述文字是乱码),所以一直没人注意。用 GBK 反向解码复原了原文,只有一个字节永久丢失(`导入本地 TXT` 里那个空格)。这条和第 95、97 条同源:**用不属于这个仓库的文本工具写这个仓库的文件**,坏掉的部分不一定会让谁报错。 > 112. **`WEB_GATE_CHOICES`(客户端)是 `WEB_GATE_MODES`(宿主)的镜像,靠契约测试兜住。** bundle 不能 import 宿主模块,所以档位清单只能各写一份。不一致的后果都很隐蔽:客户端少一档 → 那个档位在界面上**根本无法选中**(而 API 完全支持它);顺序不一致 → 分段控件的高亮与守则措辞看起来对不上。`test/contract.test.mjs` 从两份源码里把清单抠出来逐项比对——和该文件其余部分同一个思路:两边手写的字符串必须相等,恰恰是文本比对最擅长的事。 > **v1.13 修订(v0.11.0:讨论历史限条 / 阅读排版 / 竞态守卫 / 结构核查,取代 §5.2 的 `GET /discussions` 与 §5.3 的正文排版)** > 113. **讨论历史面板只显示最近 5 条,并且把"被截断"讲出来。** 读者反馈"不清楚它会不会无限显示所有历史"。落盘上限本来就有(`MAX_DISCUSSIONS = 200`),但**面板一次取 20 条**且没有任何提示。这个列表的用途是回答"我们上次聊到哪了"——它是一条**导航时间线**,不是账本;铺满 20 条会把「AI 视角预览」那一屏拉得极长。改成 5 条,并加一行「共 N 条,这里只显示最近 5 条。」。⚠️ 这行字是**功能的一部分**:不写的话,读者会把这 5 条当成全部历史。凡是有截断的地方,截断必须可见。 > 114. **`limit` 是外部输入,必须夹上限——而且光判"正数"不够。** 原实现是 `Number.isInteger(limit) && limit > 0 ? limit : 20`,**没有上界**。落盘侧的 200 条封顶让后果有限,但"攻击面靠另一个模块的第二道防线兜住"不是一条可依赖的性质。现在夹到 `DISCUSSIONS_LIMIT_MAX`(= `MAX_DISCUSSIONS`,读比存多没有意义)。 > 115. **限条的归一化抽成纯函数 `normalizeDiscussionLimit()`,理由是"能直接测"。** 内联在路由里的话,"夹上限"这条性质只能靠造 200 条以上记录去间接观察——那种测试跑一遍要几百次写盘,于是大概率根本不会有人写。抽出来之后 `'99999'` / `'abc'` / `'0'` / `'-5'` / `null` / `{}` 全部一断言即可。非法值一律**回落默认**而不是报错:这一类参数属于"调用方想少要一点"的提示,为它中断一次读取不成比例。 > 116. **`total` 必须与这一页来自同一次读取。** 分两次读的话,中间又追加一条就会出现「共 3 条,以下是最新 5 条」这种自相矛盾的输出。所以新函数 `discussionPage()` 一次读盘同时给出 `items` 与 `total`(`listDiscussions()` 保留原样,避免动到已有调用方)。 > 117. **⚠️ 修掉一个真正的竞态:`CompanionView` 的五个按书加载器会互相覆盖。** 绑定、背景认识、书友设定、讨论历史、AI 视角预览各自都是「发请求 → 回来 setState」,**互不等待**。快速切书时书 A 的响应可能比书 B 的**晚**回来,于是 B 的界面上显示着 A 的数据。书友设定那一路最危险——它**会回填编辑框**:A 的人设被灌进 B 的编辑框,读者再点一次「保存」就**写错了书**。这不是显示错乱,是数据写错。 > 118. **守卫用"票号"而不是 `AbortController`,理由是它**是纯的**。** 两者都能解决,但票号没有 fetch、没有 signal、没有平台差异,可以直接单测;而 `AbortController` 的失败模式(abort 之后那个 rejection 到底该不该吞)在组件测试的 react 替身里根本验不到。`createLatestGuard()` 是纯函数(`issue()` 自增、`isCurrent()` 比对),`useLatestGuard()` 只负责把它挂到实例上。 > 119. **每个资源必须各用一个守卫。** 共用一个会让互不相关的两个加载器互相作废——比如「重新生成预览」触发的背景认识重载,会顺手把还在飞的讨论历史判成过期。同理,**加新的按书加载器时必须照着接一个守卫**;漏掉的那个就回到"谁后回来谁说了算",而这类 bug 在单测里看不见(替身不跑真状态机)。所以有一条**静态接线断言**盯着:声明的守卫数必须与「取票」次数一致,且每个守卫的 `then` 与 `catch` 都要真的提前返回。 > 120. **`ReaderView` 早就写对了,`NotesView` 还没有。** `ReaderView` 取正文用的是 `useEffect` + `cancelled` 清理标志——那才是标准写法。`CompanionView` 之所以出问题,是因为它的加载器写成 `useCallback` 再从 `useEffect` 触发,而清理函数只用来重置,没有承载"作废"语义。`NotesView` 的 `refreshNotes` / `refreshDrafts` 是同一个形状,**已知未修**:它的暴露面略低(要切书得先退回书架),且修它要动到 `refresh()` 共享的错误处理。这条留着,等下一次有理由碰那个文件时一起做。 > 121. **⚠️ 排版层级是反的:章节标题被写死成 `15px`,而正文字号是用户可调的(13–30px)。** 默认 16px 时标题**就已经比正文小**,把字号调到 20px 之后标题会明显"塌"进正文里,章节与段落的层级彻底消失。改成 `1.3em` 跟随正文字号缩放。这条只能靠"读源码断言"钉住(替身测不到 CSS),断言写成「`.drc-article h2` 的 `font-size` 必须是 `em`、不得出现 `px`」。 > 122. **行宽也是 `em`(`36em`),而且这是刻意的。** `em` 解析成正文自身的字号,所以调大字号时**每行字数不变**、只是整栏变宽——「一行多少字」才是阅读舒适度的自变量,像素宽度不是。原来写的是 `42em`:在中文里就是 42 字/行,偏宽。另外顶部内边距从 `4px` 提到 `10px`,原来第一行几乎贴住工具栏,读起来像被切掉一截。 > 123. **⚠️ 结构性结论:`lib/client.js`(13.9 万字符)不能在无构建步骤的前提下拆成多文件。** 证据链:宿主 `dsh-client-modules` 把 `exports["./client"]` 指向的文件**当作构建产物整份读取**(`readFileSync(clientPath)`),并且有"bundle 纯净性闸"(`node:` import 会打挂 vite bundle);本文件的格式是 `window.__ModuleLoader__.load({ factory })`,factory 里只有 `require`(且只解析**包名/seed**,不解析相对路径)。所以相对 import 解析不了,`lib/client.js` 必须保持自包含。**拆文件需要一个构建步骤(把 `lib/client/*.js` 拼成 `lib/client.js`),而 HMR 监视的是拼接产物**——源与产物分离会让"改了没生效"变成常态。本版选择不动结构,改为在**文件内**按既有习惯改进:把可测的纯逻辑抽成纯函数(本版新增 `createLatestGuard` 与宿主侧的 `normalizeDiscussionLimit`),并补齐 `//#region` 分区。**下次有人想拆这个文件时,请先读这一条。** > 124. **本版变异验证:5 个变异植入,5 个全部被抓。** 章节标题退回 `15px` →「阅读排版:章节标题必须跟着正文字号缩放」;`persona` 加载器不再验票 →「竞态守卫:每个按书加载的资源各自装了守卫」;`normalizeDiscussionLimit` 去掉 `Math.min` →「限条:外部 limit 必须被夹上限」;`total` 跟着 `limit` 缩水 →「限条:total 是未截断的总条数」;`isCurrent` 恒返回 `true` →「竞态守卫」两条。全量 **367/367**(本版从 360 起,新增 7 项)。 > 125. **⚠️ 我自己的断言被变异骗过一次,值得记下来。** 第一条接线断言的写法是数 `.isCurrent(ticket)` **在源码里出现过几次**。变异 `if (false && !personaGuard.isCurrent(ticket))` 把这个检查彻底阉割,**而文本计数纹丝不动**——断言绿着,守卫已经完全不生效。改成匹配 `if \(!\w+Guard\.isCurrent\(ticket\)\) return`(数**真正的提前返回**)之后才抓得住。教训:**断言"某段文字存在"和断言"某个行为会发生"是两件事**;凡是能拼出"看起来对但不起作用"的变异,文本断言都可能被骗过去。这一条与 v1.12 §108(不能拿渲染长度当尺子)是同一类错误的不同面。 > 126. **顺手修掉两处被吃掉换行的文档损坏。** `design-v1.md` 的 v1.4 修订块第 18 条、以及 README 的 `curl` 提示块,都出现了 `**…**> 下一行` 这种形态——两行被并成一行。原因是编辑时替换串吃掉了行尾换行。**这是"用不属于这个仓库的文本工具改这个仓库的文件"的第三次发作**(前两次见 §95、§111),只是这次损坏的是文档而不是代码,所以没有任何测试会报红。改完必须**回读那一段**,别只看编辑工具的"成功"。 > **v1.14 修订(v0.12.0:位置恢复 / 路径闸绕过 / 笔记摘抄丢失 / 目录筛选,取代 §5.3 的进度恢复与 §5.2 的路径闸描述)** > > 本版的输入是两份外部审查报告(前端结构与性能、正确性与可维护性)。它们是在 **v0.10.0** 上写的(引用 360 项测试),所以每一条都先对着 v0.11.0 复核过;下面把"仍然成立""报告说错了""报告完全没提到"分开记。 > > 127. **⚠️ 最严重的一条:「读到哪记住哪」事实上是坏的。** `ReaderView` 恢复位置用的是一次性布尔旗标(`useRef(false)`),而组件**挂载那一刻** `chapter` 还是 `null`、`paragraphs` 是空数组,恢复位置的 effect 却照样会跑一趟,把旗标**提前消耗**掉。等正文真的到达、依赖变化让 effect 重跑时,旗标已是 `true` → 直接 `return`,于是**再也没有人滚动过视口**。后果不是"偶尔偏一点",而是每次打开书、每次切章都停在章首;而进度在服务端存得好好的、百分比也显示正确——**从界面上看不出它是坏的**。 > 128. **修法是把旗标换成"按书+章记账",并抽成纯函数。** 新增 `positionKey(bookId, chapterIndex)` 与 `shouldRestorePosition(restoredKey, key, hasParagraphs)`。后者**就是这个 bug 的全部**:`hasParagraphs` 为假时绝不能记账。抽出来的理由是它能被直接单测(三个分支),而组件测试的 react 替身不跑 effect、不做状态更新,**这条路径在替身里根本执行不到**——这也解释了它为什么能活到这一版。 > 129. **同一个修复顺带修掉了"进度回写把视口往回拉 12px"。** 恢复位置的 effect 依赖里有 `initialOffset`(= `progress.charOffset`),而自动进度回写会让它变化、effect 重跑、再滚一次;`handleScroll` 记的是 `scrollTop + 12`,于是每次回写都把视口往上带 12px。按书+章记账之后,同一章只恢复一次,回写引起的重跑自然变成空操作。**没有采用"删掉 `initialOffset` 依赖"的另一种修法**:那个依赖在"进度比正文先到"的场景下是必要的,删掉会引入另一个更隐蔽的失效。 > 130. **⚠️ 真能绕过的安全口子:路径闸的正则只看字面量。** `RAW_ARTIFACT_RE` 要求出现连续的 `books/<16位hex>/content.txt`,所以在 hex 段前后插一个 `.` 或 `..`(或重复分隔符)就能让它看不见——而操作系统与宿主在真正打开文件时会把那些段**归一化回同一个文件**,也就是正文。实测放行:`books//..//content.txt`、`books/.//./content.txt`、`books////content.txt`。README 把这一层写成"硬保证",所以它是**实现漏了一行归一化**,不是已知的取舍(经 `bash`/`glob` 间接读取才是取舍,那一层有测试反向断言)。 > 131. **修法是新增 `foldPathSegments()` 并在跑正则前折叠。** 刻意**不解析成绝对路径**(工具参数里的路径相对谁取决于那个工具自己的 cwd,没有可靠基址),只按 POSIX 语义做纯文本折叠。代价是极少数情况下会多拒一个调用(例如 `a/../books//content.txt` 折叠后同名、但真实解析结果可能在别处):**这个方向的误判是安全的**——被拒的工具会看到理由,而漏判是不安全的。含点的**文件名**(`x..y`)不是 `..` 段,不会被折掉,有专测钉住。 > 132. **⚠️ 两份报告都没抓到的一个 bug:「记笔记」之后摘抄回不到编辑框。** `NotesView` 把 `activeDraft` **只读进 `useState` 的初值**,而它恰好是在 `activeDraft` **还是 `null`** 的那一刻挂载的——`captureNote` 先 `setActiveDraft(null)` + `setView('notes')` 让面板重挂,草稿的 POST 响应要等一个网络来回才到。等它带着 `data.draft` 回来时组件**已经挂载**,初值不会重跑,于是摘抄框是空的。修法是给 `NotesView` 一个 `key: activeDraft?.draftId`,强制按草稿 id 重挂。**这个 `key` 是功能性的,不是性能优化**——注释里写死了这句话,免得以后有人"顺手"删掉。 > 133. **两份报告只把它当成竞态,没看出主路径是断的。** 报告在 `captureNote` 上标的是"`setActiveDraft` 没做身份校验"(低),并同时声称"`ReaderView` 卸载时最后一次进度没被 flush"。**后半句是误报**:`ReaderView` 的卸载清理里就调了 `flush()`(`useEffect(() => () => { … flush() }, [flush])`),滚动中的 `pendingOffset` 会被写回。排除一条误报和找到一条 bug 同样有用——它避免了为不存在的问题加机制。身份校验(守卫)仍然补了,因为它挡的是"两次起稿乱序"。 > 134. **书架分类是我自己在 v0.9.0 引入的 O(N²)。** `renderCategoryPicker` 内部直接调 `groupBooksByCategory(state.books)` 取分类候选,而它是**逐本书**调用的(在书架列表的 `map` 里),于是渲染一次书架要跑 N 次"建 Map + 排序"。而 `manualPath`(导入路径输入框)是 `ShelfView` 自己的 state——**每敲一个字符都会重渲染整张书架**,那 N 次重算是绑在打字上的。修法是把分组与候选提到组件顶层各算一次(`useMemo([state.books])`)。**如实说明:实测代价很小**(书架通常只有个位数本书),但它是随书数**平方**增长的形状,而修复成本是零。 > 135. **`appendDiscussion` 改走 `O_APPEND`——但只在前缀稳定还能保住的时候。** 报告指出:模块注释声称"天然只追加",实现却是"读全文 → 拼 → 整份重写"。核对后确认**内容上等价**(重写出来的字节就是旧字节加一行,实测 `startsWith === true`),所以没有正确性问题。真正的代价有两个:每次写整份文件(约 25 KB vs 实际新增 126 字节,且是同步写);以及**满仓后前缀必然失效**(丢最旧一条 → 头部平移 → 每一次装配看到的都是不同文本,"你们之前聊过"那一块永远不命中 prompt 缓存)。改法是未满仓时 `appendFileSync`、满仓才整份重写,并把这一段的**真实代价写进模块注释**——"前缀稳定"只到满仓为止,换上限之前先读那段。 > 136. **补了一个报告没提的边界:文件末尾缺换行。** 手改过、或被别的工具写过的 `discussions.jsonl` 可能不带结尾换行,直接 append 会把新记录接到上一行屁股后面,那一行**整条变成坏行**;而 `parseDiscussions` 对坏行是**静默跳过**的——用户只会看到"我记的那条不见了",不会有任何报错。所以追加前先检查并补一个换行。 > 137. **新增目录筛选,理由是实测数据而不是猜。** 读者的书库里《一世之尊》有 **1404 章**(`$DSH_HOME/dsh-reading-companion/`,实测),目录里每条 `li` 展开成 5 个元素,即**约 7000 个元素**一次性渲染;"滚到第 812 章"要划过整份目录。新增 `filterChapters()`(章号 / 标题 / 卷名)与一个筛选条,**50 章以上才显示**(短书里筛选条比目录还占地方)。⚠️ 章号必须按 **1 起**匹配(与界面上 `padStart(3,'0')` 显示的编号一致)——按 `index`(0 起)匹配的话用户打「1」搜不到第一段,而这类失效**不会报错**,只表现为"这本书好像没有这一章"。空串筛选必须**原样返回**(含引用),这样不加筛选时的渲染结果与加功能之前逐元素相同。 > 138. **⚠️ 我上一版留下的一条断言是错形状的,这一版被自己的改动撞红了。** 接线断言原来数的是**全文件**的验票次数(5×2 = 10),新增两个受守卫的资源(`openBook` / `captureNote`)就把它撞红。断言没写错,是它**问错了问题**:要钉住的性质是"每个加载器都验了票",而不是"全文件恰好有 10 处验票"。总数是会随无关改动漂移的量。改成**按守卫逐个计数**(一张 `[名字, 期望验票数]` 表),并加一条**反向断言**:源码里声明的守卫数必须等于表长——否则新加的守卫永远不受保护,而测试看起来还是绿的。 > 139. **本版变异验证:6 个变异植入,6 个全部被抓,且都是预期的那一条。** 空正文也记账 →「位置恢复:空正文绝不能记账」;路径闸不做归一化 →「路径闸:`.` / `..` 不能绕过」;讨论历史退回整份重写 →「未满仓时走追加」(连带 1 条);`NotesView` 拿掉 `key` →「必须按草稿 id 重挂」;筛选按 0 起章号 →「章号按 1 起匹配」;`openBook` 少一处验票 →「每个按书加载的资源各自装了守卫」。全量 **377/377**(本版从 367 起,新增 10 项)。 > 140. **对两份报告的取舍,逐项记下来(这部分和代码一样重要)。** *采纳并修*:位置恢复(高 1)、路径闸绕过(高 2)、`openBook` 身份校验、`appendDiscussion` 前缀、书架 O(N²)、段落 memo(**降级为未做**,见下)。*排除误报*:`ReaderView` 卸载不 flush(见 §133);`atomicWriteText` 的 Windows rename 覆盖(同卷 + 随机临时名 + `MOVEFILE_REPLACE_EXISTING`,覆盖是原子的,报告自己也确认了);`chapterHeading` 缺 `escapePromptText`(刻意的,有测试断言);`saveWebGate` 的乐观回滚(已正确);`collectStrings` 的 3 层深度上限(路径参数都在 1–2 层)。*降级*:前端报告把两个 O(N²) 项(书架分类重算、段落内联 ref)排在最前,但核对实际调用频率后要把它们降下来——**`ReaderView` 并不随滚动重渲染**(`handleScroll` 只写 ref,不 setState),所以内联 `ref` 的往返只发生在划选、开字体条、调字号这几种用户主动交互上,一章几百段的量级是**微秒**,不是报告暗示的"每帧"。**报告给的是形状,频率必须自己核**——形状对不代表量级大。段落 memo 因此留到下次碰那个文件时一起做(它同时还能改善结构)。 > 141. **仍然不拆 `lib/client.js`(承接 §123)。** 报告独立复核了模块加载机制并得出同一结论:宿主把 `exports["./client"]` 当构建产物整份 `readFileSync`,bundle 纯净性闸禁止 `node:` import,factory 里的 `require` 只解析包名/seed 不解析相对路径。报告提出的三条替代路(eval 拼接器 / 拆多包 + `dsh.client.external` / 打包器)也都不可取,理由与 §123 相同:拆多包要把 `cordis.patch.yml` 的单行"纯加法开关"变成 4 个必须同时正确的行,打包器则毁掉"改完即生效"的调试回路。本版继续在**文件内**改进(新增 `filterChapters` / `positionKey` / `shouldRestorePosition` 三个纯函数)。 > **v1.15 修订(v0.13.0:「发到会话」的跨会话投递,取代 v1.11 §70 的 `sendToSession` 描述)** > 142. **现象与根因:文字进了错误的会话,而且没人告诉你。** 读者在会话 B 里打开一本绑在会话 A 的书,写笔记后点「① 发到会话去聊」——文字进了 **B 的输入框**。根因不是"没跳转",而是**投递目标本来就只能是当前会话**:`inputActions`(即 `bindDraftMirror` 那一侧)是宿主按会话交给面板的,面板拿到的永远是**当前**会话那一份。宿主的 `inputHub.shell(sessionId)`(`dsh-client-ui-conversation/lib/client.js:16597`)虽然能按 id 取到任意会话的输入外壳,但**不在插件可达的服务面上**。所以这不是漏了一行代码,而是一个能力边界——只能在边界内做正确的降级。 > 143. **宿主自己的先例就是"把草稿搬过去"。** `selectWorkspace`(同文件 `:16648-16654`)在切换工作区时做了这件事:读 `from.snapshot.draft`,写进 `shell(nextId)`。也就是说"跨会话搬草稿"是这个界面的既有语义,不是我们发明的。**但它用的是内部 `inputHub`,插件拿不到**——这正是本条修订存在的理由:我们要用**外部**能做到的手段(跳转 + 交接)复现同一个效果。 > 144. **方案:交接(handoff)。** 判定抽成纯函数 `planNoteSend`,三态:`here`(没绑定 / 绑的就是本会话 → 照旧)、`handoff`(绑的是别的会话 → 先交接再跳)、`here-only`(绑的是别的会话但宿主没给跳转能力 → 留在当前输入框并**如实说明它去错了地方**)。`here-only` 是这条修订里最重要的降级:**绝不能让界面显示"已发到会话"而实际进了别人家**。 > 145. **⚠️ 交接必须是父层 `useState`,不能是模块级变量 —— 两个理由都不是风格问题。** 其一,**放在父层**:跳过去之后 `NotesView` 未必还挂着(面板可能重挂回书架或别的视图),而父层一定在,它才是收得到新 `sessionId` 与新 `inputActions` 的那个组件。其二,**必须是 `useState`**:模块级变量的变化**不触发重渲染**,于是"要去的会话恰好就是当前会话"(`sessionId` 根本没变)时收文字的 effect 永远不会重跑,文字就永远送不出去——而这恰恰是读者已经在目标会话里时最该成功的路径。 > 146. **⚠️ 合并"输入框里已有的字"必须发生在**兑现那一刻**,不能在发送时。** 这是最容易写错、且写错了会**搬错草稿**的一处:发送那一刻手上的 `existingDraft` 是**源会话**的,把它拼进交接等于把源会话里打了一半的话搬到目标会话去——那是读者根本没打算发出去的内容。兑现时那一边读到的 `existingDraft` 才是目标会话自己的。测试用正则钉住交接载荷必须是**原始 `text`**(§149 M5)。 > 147. **`takeDraftHandoff` 返回 `wait`/`drop`/`deliver` 三态,而不是"能兑现就返回字符串"。** 因为调用方对三者的处理**完全不同**:`wait` 必须**留着**(还没跳过去),`drop` 必须**清掉**(过期了,留着会在很久以后突然往输入框里塞一段旧摘抄)。只返回字符串的版本会逼调用方自己再判一次,而"忘了清"正是那种**不会报错、只会在某天突然出现**的错误。`DRAFT_HANDOFF_TTL_MS = 120000`:交接存在组件状态里,一卸载就没了,所以这个上限只作用于"面板一直开着但一直没跳过去"的情况。 > 148. **判定顺序被单测钉死:`wait` 的提前返回必须在 `setDraftHandoff(null)` 之前。** 顺序反了就会把"还没跳过去"的交接当成处理完而丢掉——**失效方向是静默的**(跳过去了,输入框空的,没有任何提示)。这条用源码位置断言(`indexOf` 先后)而不是行为断言,因为替身不跑 effect,行为根本测不到;正因如此它才值得单独钉一条(§149 M4)。 > 149. **变异验证 6/6,每条都由预期的那一条测试抓住**(脚本跑完自动比对 SHA256 还原一致): > > | 植入 | 报红 | > |---|---| > | M1 `planNoteSend` 改回裸字符串比较 | 「前缀不一致不能把"本会话"误判成"别的会话"」 | > | M2 会话不匹配时返回 `drop` 而非 `wait` | 「不是发往这个会话时留着,等跳过去」 | > | M3 忽略 TTL | 「过期必须丢掉」 | > | M4 把清空挪到 `wait` 判断之前 | 「`wait` 必须在清空之前返回」 | > | M5 交接时拼上源会话输入框内容 | 「交接带的是**原文**」 | > | M6 只判"跳得过去"不判"有人接住" | 「两个条件缺一不可」 | > > M6 那条是**故意做成可测的**:若只判 `openSession`,交接通道缺失时 `requestHandoff(...)` 会抛 `TypeError`,被同一段 `try/catch` 吞成一句"放入输入框失败"——读者看到"失败",实际更糟:**跳也跳了、字也没了**。所以那条接线用正则钉住两个条件必须在同一行。 > 150. **两个 `normalizeId` 的比较是必需的,不是洁癖。** 绑定记录里存的是带 `session-` 前缀的形式,而槽注入的 `sessionId` 不一定带。直接比字符串会把"本会话"误判成"别的会话",于是**读者在自己会话里发笔记反而被弹走**。这是这一版里唯一一个"改错了会让常见路径变坏"的点,所以给了它一条独立的用例(含 `' session-abc '` 这种带空白的形态)。 > 151. **没验证的部分,如实记下。** 替身不跑 effect、不做状态更新,所以「跳过去之后文字真的出现在那边输入框里」这条**只有真机能确认**。残留风险写在这里:若目标会话的右侧栏没开着这个插件的页签,父层可能根本不在,交接就一直等到 TTL 过期;此时**既不落字也不报错**(`drop` 是静默的)。之所以接受这个静默,是因为能在对面弹提示的位置(父层)没有提示 UI,而为一个"要点开面板才会走到的路径"新增一条通知栏不划算——但它是一个**已知的**静默失败,不是没想到。 > 152. **⚠️ 根因是平台的,不是插件的。** 读者真机反馈:"跳过去之后要重新点进侧边栏、点进小说,输入框也没拿到字。"查证结论:右侧栏页签的槽 `scope` 是 **`session`**,所以**切会话 = 本面板被卸载、在目标会话里重新挂载**。不是"状态没同步",而是**那个组件已经不存在了**。任何存在 `useState` 里的东西都会被跳转带走——v0.13.0 的交接棒正是这么丢的(它存在 `ReaderPanel` 的 `useState` 里,而跳转恰好卸载那个组件)。 > 153. **模块级变量是这个页面里唯一能横跨会话切换的私有存储。** 本插件的 factory 在整个页面只跑一次(`window.__ModuleLoader__.load` 只登记一次),所以 `const draftHandoffs = new Map()` / `const sessionViews = new Map()` 会跨会话存活。这一层**不触碰任何平台契约**:平台完全看不见插件自己的内存。`test/client.test.mjs` 里有一条源码级断言钉住"不许退回组件状态"(`doesNotMatch(/setDraftHandoff\()/`),因为这类回退**在替身里测不出来**——替身不跑 effect、不卸载组件。 > 154. **接力区按目标会话索引,不是"一个格子"。** 一把格子的实现下,两本书各绑一个会话时后发的会把先发的顶掉:读者在 A 书里发的摘抄凭空消失,而两边界面都说"已发送"。已有一条用例专门钉这个(M3 变异:`handoffs.clear()` 后只写一条 → 该用例单独报红)。 > 155. **清空必须在真的写进输入框之后。** 反过来的话,`setDraft` 一旦抛错(宿主接口换形状),文字就从接力区消失了、而输入框里也没有——两头空,读者看到的是"失败"但实际是"弄丢了"。同时"输入框接口还没到就先留着"(`inputActions` 可能比面板晚一步注入)——这两条顺序各有一条用例,且变异脚本确认它们分别报红。 > 156. **真正解决"又要重新点进小说"的那一步是"给目标会话播下视图种子"。** 目标会话的面板可能是**本页面里第一次**被打开,它的视图记忆是空的。所以交接时必须把 `book` 一起交出去(`rememberSessionView(sessionViews, targetSessionId, { view: 'reader', book })`)。不做这一步,跳过去只会看到书架——比"位置没恢复"更退一步。 > 157. **平台事实(逐条核实过,不是猜的)**:`ctx.reflect.provide("sidebarRight", controller)`(`dsh-client-ui-sidebar-right/lib/client.js:3668`)——侧边栏控制器**是**提供的服务。`SidebarRightController` 上:`openTab(kind, options)` 用 `this.require()`,即**当前已挂载**的 binding,没挂载时**直接抛** `no session surface is mounted`(`:1414-1417`);`openTabIn(sessionId, kind, options)` 显式接受 sessionId,但依赖 `actionsFor(sessionId)` 非 `undefined`(`:1411-1413`),且被宿主自己标注为「Not part of `ISidebarRight`」——是内部路径。所以:**用公开的 `openTab` + 重试**,只有在它缺席时才退到 `openTabIn`。 > 158. **重试是幂等的,所以可以无脑重打。** `openTab(..., { revealIfOpened: true })` 对已经开着的页签只是"再显示一次"。`sessions.open()` 之后目标会话的侧边栏要等一次 React 提交才挂上,所以第一次几乎必然太早;`REVEAL_RETRY_DELAYS = [150, 340, 560, 820, 1120]` 覆盖这个窗口。风险很低:源会话里本插件页签**本来就在**(读者是从 `NotesView` 点的按钮),早早打中的那几次等于 no-op。 > 159. **降级契约写死在注释里**:`revealReaderTab` 是**尽力而为**。拿不到服务 / 侧边栏始终没挂载 / 宿主改了方法名,都只是"不自动开页签",书、章、位置、摘抄照常就位。绝不为一个便利功能去改平台,也绝不让它连累别的功能——它是 `ctx.get` 而不是写进 `inject` 数组,理由与 `sessions` 那处相同。 > 160. **我自己写错了一条,被自己的用例抓住。** `resolveRestoreView` 的文档写着「`notes` -> `toc`」,代码却**没有实现这一条**,`'notes'` 直接落到最后的 `return 'shelf'`。是在跑全量时由「笔记页不还原成空编辑框」报红才发现的。值得记的是:如果当时没写那条用例,这个 bug 的表现会是"跳过去看到书架",而**注释还信誓旦旦地说它会回到目录**——文档与实现不一致,且方向恰好是"看起来像是有意的"。 > 161. **`notes` 必须落回目录,不能还原成空编辑框。** 笔记页的内容来自 `activeDraft`,那是**组件状态**,切会话时已经没了。还原到一个空编辑框比回到目录更糟:读者会以为自己的摘抄丢了。 > 162. **变异验证的"归因"要单独看。** 第一轮 7 个变异全部被抓,但 M2/M4 报的是 `caught-by-OTHER`——我的 `marker` 是直接 `Contains` 测试文件里的字面量,而测试里那两处写在**正则字面量**中(`/book: payload\?\.book/`、`/const \[draftHandoff, setDraftHandoff\] = useState/`),字面量带反斜杠,所以没匹配上,期望行号算成 -1。改成转义后的形式后 7/7 都是 `caught-by-expected`。**"被某个用例抓到"比"被钉这条行为的用例抓到"弱得多**:前者可能是别处的连带失败。M5 就是例子——它让全量挂 15 条,但钉顺序的那条确实在其中。 > 163. **本轮没验证的,如实记下。** 第 2 条(页签自动打开)**只有真机能确认**:替身不跑 effect、不做状态更新、也没有侧边栏。我能保证的是接线形态与判定逻辑(`revealReaderTab` 的降级、重试次数、选项形状都有用例),以及"这些调用全部包在 try/catch 里、不会连累发送路径"。**如果真机上页签没有自动打开**,请按 P17 的降级说明操作,并把现象告诉我——那是 `openTab` 的 binding 时机问题,可以再调重试窗口。 > 164. **`cordis_inspect_query` 的 client 查询会一直挂着**,这是设计行为(工具说明里写了"waits for the first valid page response"),不是卡住。本轮两次都因此被取消。**改读源码更快也更确定**——上面 §157 的三条事实全部来自直接读 `dsh-client-ui-sidebar-right/lib/client.js`。下次要查客户端契约,优先 `grep` + `read`,别等 inspect。 > 165. **真机反馈:从笔记页发到会话会落到目录页,而不是正文。** 根因不是"来源没记",而是**记的是错的那一刻**:发送发生在笔记页上,那一刻 `view` 恒为 `'notes'`,而 `'notes'` 被 `resolveRestoreView` 刻意映射到目录。于是"从正文选一段、记笔记、发到会话"稳定地落到目录页。修法:来源在**进入笔记页那一刻**记下(`noteOriginRef`),两个入口各记一次(`captureNote` → 正文;`TocView.onOpenNotes` → 目录)。**这条与 §161 不冲突**:`notes -> toc` 那条映射依然正确,只是现在走不到它了——种子记的是**来源**,不是当前页。 > 166. **同一族 bug 的第三次现身:一次性待办在"数据还没到"的那一趟就被消费。** 待落点项在**挂载那一趟**被清掉:挂载时 `catalog` 还是初始的 `{chapters: [], loading: false}`(看起来"已加载完、章节为空"),而 `openBook` 要到**同一次提交的另一个 effect** 里才把它置成 loading。老代码清了待办、又因为章节为空而 `return`,等目录真到位时已经没得还原。这是本轮**第二次**遇到同一形状(第一次是 `restoredRef` 那个一次性布尔,§127)。教训值得单独记:**凡"一次性旗标 + 等数据到位再重跑"的组合,都必须问"第一次跑的时候数据到了吗"**——在 `useEffect` 里答案几乎总是"没到"。现在只有真正落点(`apply`)才消费,`stay` 一律原样保留(`resolveRestoreStep`,与 `takeDraftHandoff` 的三态同构)。 > 167. **变异验证这一轮自己也出了噪声,如实记下。** 6 个变异全部被抓,但 M2/M3 那一轮报了 15 条失败,其中 14 条是**与 `client.js` 毫无关系的宿主侧用例**(书库 / HTTP / 时间线 / 落盘形态 / 注入 …),且两轮列表**完全一致**。我把 M2、M3 各自单独重跑:**都只挂期望的那一条**;再连跑 3 次干净全量:`fail=0`。所以那 14 条是**批处理脚本的环境噪声**,与变异无关——但我**没能复现、也没能定位**它的成因(怀疑与背靠背连续运行时的临时目录/句柄有关)。记下来的理由:上一版刚结论"被某个用例抓到"比"被钉这条行为的用例抓到"弱得多(§162)",这一轮就出现它的镜像——**噪声会伪造出"抓到了"的假象**。批量变异脚本的结论必须能被单独重跑复核,否则不采信。 > 168. **源码级接线断言这一轮确实补了盲区。** `resolveRestoreStep` 本身有单测,但**调用点**(还原 effect 的三态分派)替身渲染不到。补的静态断言抓到了 M4(把 `stay` 当 `apply` 处理);而 `if (step === 'drop')` 抓到了 M5——**M5 在行为上几乎抓不到**(不清待办时,换书后 `bookId` 依然不匹配,于是每次都走 `drop` 返回,表面行为正确),只有"又回到原来那本书"这种特定序列才会暴露。这是"静态钉接线"在一个行为难测的点上真正补上的盲区。 > 169. **真机反馈:记完笔记点「←」返回,落到目录而不是正文。** 根因是 `NotesView` 的 `onBack` **写死**成 `setView('toc')`(`lib/client.js:3802`),而"进来时的那一层"根本没人问。§165 修的是**跨会话跳转**的落点(用了 `noteOriginRef`),但**同一个来源**还有第二个消费者——返回按钮——它没被一起改。现象因此是分裂的:发到会话能回正文,点返回却回目录。修法:`onBack` 改用同一个 `noteOriginRef.current ?? 'toc'`。**"来源"这种状态一旦被记录,就要找出它的全部消费者**,只改其中一个会让功能表现自相矛盾。测试上补了一条静态断言把两处钉在同一个值上——两处若各取各的,等于同一个"来源"有两套落点。 > **v1.16 修订(v0.14.3:笔记页的进入路径与"发送保留层次",部分取代 §161 与 §165 的落点结论)** > 170. **真机反馈:在正文里不选中文字,就进不了笔记页。** 根因是一次**漏传 prop**:`ReaderView` 顶部那颗「笔记」按钮一直在调 `onOpenNotes`,而 `ReaderPanel` 渲染它时**没把这个 prop 给下去**。渲染层根本不认识"哪个 prop 忘了传"——组件少收一个 prop 只是**安静地什么都不做**,所以这个洞在替身里、在人工点之前都不报错。修法是一行;值得记的是**测试策略**:钉的不是那一个名字,而是**整条规律**——"`ReaderView` 解构出来的每个 prop,`ReaderPanel` 渲染它时都必须给"(`name: value` 与简写 `name` 两种写法都算数,见 §175)。 > 171. **真机反馈:在笔记页点「发送到会话」,跳过去之后被弹回正文——§165 修错了方向。** §165 让交接的落点取"进笔记页那一刻的来源",从正文选段的确实回正文了,但读者**正在写的那条笔记从眼前消失**,还得重新点进来。正确的切分是:**「发送」保留此刻这一层,「返回」才回到来源。** 这与 §169 是同一族的另一半,但形状不同——§169 是"少改了一个消费者",这一次是**两个消费者需要不同的值,却被塞进了同一个变量**(`noteOriginRef`)。教训:把某个状态同时当作"A 的落点"和"B 的落点"之前,先确认 A 和 B 要的是不是同一个东西。 > 172. **笔记页从此是"有条件还原"的:`resolveRestoreView(view, draft)`。** 有草稿就还原笔记页,没草稿才退回目录。条件化的理由与 §161 完全一致(没有内容的笔记页 = 空编辑框,比目录更糟),只是 §161 的结论"`notes` 一律落回目录"**过强了**:草稿是**可以**被交过去的。跨会话交接现在把它一起交出去,于是目标会话能把笔记页连同内容渲染出来——这正是 §161 当年缺的那一块。本条与 §165 的落点结论一并取代。 > 173. **会话记忆的形状从"哪本书"扩成"整层":`{ view, book, draft, origin }`。** 后两项是这一版加的。`rememberSessionView` 的守卫相应加了一条:`draft` 要么是 `null`,要么有字符串 `draftId`(半截对象不进记忆)。**为什么非得连草稿一起记**:只记 `view: 'notes'` 而不记草稿,还原出来就是个空编辑框——`resolveRestoreView` 判定的就是"草稿在不在"。写入侧也只在 `view === 'notes'` 时记草稿,别的时候记 `null`:让记忆始终等于"面板此刻真实的样子"。 > 174. **等目录的条件从"总是"收窄到"只有正文":`if (pending.view !== 'reader') return 'apply'`。** 只有 `ReaderView` 需要 `chapters[chapterIndex]`;目录页 / 笔记页 / 陪读页都不读 `chapters`,让它们一起等,代价是读者**看得见**的一次多余跳转(笔记页尤其明显:先闪一眼目录,再跳回笔记页)。**这不削弱 §166 那条坑的防护**:正文仍然必须等,而"挂载那趟 `catalog` 是初始假象"仍会被 `stay` 挡下——那条用例的重心因此更清楚了,它护的是正文。 > 175. **这一版真正补上的是一条"规律",而不是一个用例。** 用于钉 §170 的断言写成了:取出 `ReaderView(props)` 的解构列表,再取出 `ReaderPanel` 里对 `ReaderView` 的那次渲染调用,逐个检查名字在不在。**第一版是错的**:它只认 `name:` 形式,于是把 `book,` 这种**简写属性**误报成"漏传"——是变异脚本第一次跑就把这个误报打了出来。改成"两种写法都算数"后才干净。这个演变本身就是论点:**静态断言写成"钉名字",下一个 prop 漏传照样漏;钉规律才可扩展。** > 176. **变异验证 8/8,而且这一轮的噪声我核实清楚了。** 8 个植入全部被**预期的那一条**用例抓住(脚本跑完 SHA256 比对、文件逐字节还原一致,之后干净全量 `fail=0`)。但批量那一轮 M4 多挂了一条 `配置契约:抽样默认值就是当初约定的那三个数`(`test/settings.test.mjs` —— 起真实 HTTP 服务器、与 `client.js` 无关)。我把 M4 **单独连跑 3 次**:每次**只挂预期的那一条**;干净全量**连跑 3 次**:`fail=0`。所以那条是批处理噪声。**这与 §167 记的是同一个现象**(两轮都落在"起服务器的宿主侧用例"上),而这次也没能定位成因——已排除端口冲突(`listen(0)` 用的是临时端口)。记下来的理由同 §167:**归因不实的红既不能算成果,也不能算回归**;批量脚本的结论必须能被单独重跑复核。 > 177. **本版没验证的,如实记下。** 三件事**只有真机能确认**:正文那颗「笔记」按钮真的进得去;跳过去之后真的停在**笔记页**(同一条草稿);在那边点「←」真的回正文。原因是它们全在组件内部——测试替身的 `useState` 是 `(initial) => [initial, () => {}]`,**不调用初始值函数**,所以"从模块记忆还原那一帧"在单测里根本跑不出来(`boot` 拿到的是那个函数本身,不是它的返回值)。因此分工是:纯函数(`resolveRestoreView` / `resolveRestoreStep` / `rememberSessionView`)用行为用例,那一帧的**接线**只能用静态断言。**没有顺手把替身改成忠实地调用初始值函数**:那会让全部组件的 `useState(初始化函数)` 都真的执行(例如 `loadFontPrefs` 会真的去读存储),牵动全部 400 条用例的语义——它是一个独立的、值得单独评估的改动,不该夹在一个 bug 修复里。 > **v1.17 修订(v0.14.4:草稿只在读者动手保存时产生,取代 §173 的草稿守卫与 §132 的 `key` 写法)** > 178. **真机反馈:光是"用光标选一段、点进笔记页"就会在草稿栏里多出一条。** 根因是 `captureNote` **立刻 POST** 了一条草稿。它当初的理由站得住(读者写完感想要先去会话发给 AI,中间可能隔一次刷新),但代价是**浏览动作被记成了保存动作**:草稿栏那一栏标着「未落盘的草稿」,而读者从没打算存它。修法是把两个入口的行为统一 —— 进笔记页只把选区装进编辑框,服务端的记录等「保存草稿」或「写入笔记」才产生。于是**草稿栏里出现的东西恰好等于读者亲手存过的东西**。 > 179. **修法顺带把 `captureGuard` 删掉了。** 它挡的是"连点两次起稿、两发 POST 乱序";起稿同步化之后没有响应会晚到,守卫就成了没有对象的机制。⚠️ 删它必须**同时**改测试里那张 `[名字, 期望验票数]` 表,否则那条反向断言(声明的守卫数 == 表长)会报红 —— 它正是为这种情况准备的。 > 180. **"未保存的起稿"复用了草稿的形状,只是没有 `draftId`。** 这不是图省事,而是因为**下游全部靠"有没有 id"分派**:`save` 有就更新、没有就让宿主新建;`commit` 提交用的 id 必须取自**这一次 POST 的响应**(未保存的起稿靠那次 POST 才有 id,写死 `active.draftId` 会去提交一个 `undefined`);`discard` 没有 id 就只清编辑框。一个形状省掉了三条分支。 > 181. **⚠️ 保存成功后**不许**换 `NotesView` 的 `key`。** §132 定的 `key: activeDraft?.draftId` 在"起稿是一发 POST、id 由响应带回"的前提下成立;起稿同步化之后它反而有害 —— 面板会在保存成功时从 `onDraftChange` 拿到真实 `draftId`,那一换 key 就把组件重挂掉,"草稿已保存。"连同编辑框里还没回写的中间态一起消失。改成只在"换编辑目标"时 +1 的世代号 `noteEpoch`。这段代码因此是**第三次**因为"前提变了"而改(§167/§169 的教训是"找全消费者",这一次是**前提本身变了**)。 > 182. **会话记忆必须收下没有 `draftId` 的草稿。** `rememberSessionView`(§173)原有一条守卫要求 `draft.draftId` 是字符串。起稿同步化之后"服务端还没有这条记录"是常态,那条守卫会把**整条会话记忆**丢掉 —— 表现是切个页签回来摘抄与感想没了,而界面上看不出任何错误(`resolveRestoreView` 只会安静地退到目录)。放开的范围刻意很窄:从"必须是带 id 的草稿"改成"必须是对象",而 `resolveRestoreView` 判的本来也只是空不空。 > 183. **未保存的内容靠一条新的上报线活着:`NotesView.onDraftChange` → 面板 → 模块级会话记忆。** `captureNote` 不再落盘之后,"没保存"就真的只在内存里,而右侧栏页签是**按会话**的、切会话会卸载重建。⚠️ 面板那个回调**必须是空依赖的 `useCallback`**:内联箭头函数会让"上报 → setState → 重渲染 → 新函数 → 再上报"变成**死循环**。测试为此专门钉了那个 `useCallback` 的形状。**留了一个诚实的缺口:整页刷新仍然会丢**(模块级记忆随页面一起没),那种情况下读者本来也回了书架,重新选一次即可。 > 184. **"写了但还没保存"必须看得见。** 起稿不再自动落盘之后,"我明明写了"与"草稿栏里没有它"会同时成立。那是正常状态,但不说明就只剩下困惑,所以编辑区多了一行「尚未保存 · 点「保存草稿」才会进草稿栏」。`persist`(回应框的 0.8 秒防抖自动存)在没有 `draftId` 时**刻意什么都不做**,而不是"顺手建一条" —— 替读者建记录等于绕过「保存草稿」;`grabSelection` 的提示也据此分成"已保存"与"还没保存过"两句,不说那句不成立的话。 > 185. **本版变异验证 9/9,而且第一次在普通全量里撞上了那条抖动。** 9 个植入全部被**预期的那一条**用例抓住(每个变异都只有 1 条红、无连带;脚本跑完 SHA256 比对、文件逐字节还原一致)。⚠️ 另记一件事:本版有一次**普通全量运行**报了 14 条红,**全部落在宿主侧**(书库 / HTTP / 时间线 / 落盘形态 / 设定 / 注入),与本次只改的 `client.js` 无关;紧接着 6 次连跑全部 `fail=0`。这印证了 §167 / §176 记的那个现象**不在变异脚本里,而在测试套件本身**。已核的两处 fixture 命名(`test/helpers/server.mjs` 的 `${tag}-${pid}-${seq}`、`library.test.mjs` 的 `lib-${pid}-${seq}`)都带 pid 与序号,**不构成目录碰撞**,所以成因仍未定位(下一步可试 `--test-concurrency=1` 做对比)。 > **v1.18 修订(v1.0.0 发布前定稿:路线图裁定,取代 §1 的 tag 归属行与 §6.3 相应处)** > 186. **「让模型打 tag」最终否决,不再是"暂缓"。** §1 需求裁定里的「tag 归属 = **AI 自动打**」与 §6.3 的相应描述以本条为准。最终形态是**预设词表 + 读者自由输入**:`TAG_VOCABULARY` 的 11 个标签只作为候选 chip,写入哪些完全由读者决定;判定不过来就不判定——打 tag 不是重要功能,为它引入任何新的失败面都不划算。`suggestTags()` 作为**唯一分类入口**的边界价值保留(所以将来无论怎么改,笔记层与界面层都不用动),但「将来换成模型调用」这句表述已从代码注释与 README 中收回。 > 187. **「陪读 AI 在回复里顺手带回背景更新」保留为计划,但写明了前置条件。** 两个条件缺一不可:**兼容**——插件目前一条会话事件都不订阅(全仓 `ctx.on` 零命中),这是新开一条数据来源,不是改个函数;**安全**——背景更新只能取自读者已读范围,否则它会变成一条新的剧透通道(与 §6.2 同一类关口)。 > 188. **prompt 缓存命中率实测属于工程内部验证,不该出现在用户向的 README 路线图里。** 它没有对应的用户可感知功能,已移入 §13 未决项(R8)。事实依据:防剧透上下文是**重排**过的(按预算抽样 + 压缩后拼进 prompt),重排有打乱前缀、压低缓存命中率的风险,而 `dsh-token-meter` 已有 `cacheReadTokens` / `cacheWriteTokens` 可直接对照。 > 189. **AI 页边批注那条「待验证」已就地查清:能做到,但要选对机制。** 原表述("需要先确认能不能只对绑定会话开放")容易被读成"可能做不到"。实际插件里已有同款先例——§15 的**全局注册 + 按会话自我否决**(`context.agent.session.id` → `bookForSession` 反查)。但工具与闸有一个真实差别必须写清:**闸被拦下来是看不见的**,而全局注册的工具会**出现在每个会话的工具列表里**,只有真调用时才被否决——表现成"别的会话看到一个永远报错的工具"。要真正收窄到绑定会话,得走**按调用上下文 scope 注册**(`ctx.tools.register` 归档到调用方 scope,`Agent.ctx` 可取出某个 agent 的 scoped context),需先确认从 profile 级挂载如何拿到当前绑定会话的 agent scope。另有硬要求:入库前校验"原文真的是本章子串"。 > 190. **本版两处"文档承诺了代码里没做的事"一并收回。** README 那句「要换成模型只需替换 `suggestTags()` 一个函数」与 `lib/host/tags.js` 顶部同款表述,都把一个**已否决**的方向写成了待办;现改为陈述最终形态 + 保留模块边界这个仍然成立的事实。**代码只改了注释,零逻辑改动。** > **v1.19 修订(T0 + T1:跳读闸 / 倒退过滤 / 缓存测量口径)** > > 191. **跳读闸:一次补齐的缺口超过 `sample.jumpGateChapters`(默认 50 章)时,不自动补,先问。** 这是插件里**唯一不可逆**的数据损坏路径:读者从目录直接点开第 1000 章时缺口是第 1–999 章,此刻自动补齐会把**第 900 章的条目**写进 `background.md`,而这份文件之后**原样注入**——等他回到第 50 章老实读,那些条目就是静默剧透,补救只有「清空重建」(会连真读过的记忆一起清掉)。闸门回 409 + 缺口区间,客户端渲染成**两个诚实答案 + 一个"先不补"**:`mode: 'all'`(这些章我确实读过,回到旧行为)与 `mode: 'recent'`(我从这里接着读,**只记最近这一段**)。设 0 关掉闸门。 > 192. **`mode: 'recent'` 必须同时关掉"打底"。** 打底(`foundation`,首次批次只取 `foundationChapters` 章)是为"读者读到第 300 章才第一次补齐"设计的;但读者已经明确说"只要最近这一段"之后,再按打底从**窗口起点**截 30 章,等于把他要的那段砍掉一半。所以 `foundation: doc.covered === null && asked !== 'recent'`——不这样写,"只记最近 50 章"就是个点了没用的假选项。 > 193. **倒退过滤:`covered.last > 正在读的章号` 时,丢弃"最早章号都超出它"的单元。** 旧实现只在**落后**方向发缺口警告(`covered.last < lastReadable`),于是"补到 1000 章又退回 50 章"这一情形**完全静默**。三个判据细节都有专测:① 用**最早**章号而不是最晚(一位在第 5 章和第 900 章都有条目的角色必须整块保留,他在第 5 章就已经是这个人了);② **没带章号的条目一律保留**(读者手写的通用设定不该被章号过滤误伤);③ 过滤**只在倒退时启用**——它会让背景这一段随进度变化,而这是缓存里最值钱的稳定前缀,常开等于每翻一章作废一次。 > 194. **⚠️ 这里是章号基准最容易写错的一处,记下来。** `covered.last` 是 **1 起**章号,`chapterIndex` / `progressIndex` 是 **0 起**索引,两者正好差 1。所以"允许记到你正在读的那一章为止"要用 `chapterIndex + 1`:写成 `chapterIndex` 会把**当前章**的条目当成剧透丢掉,而那一章整章本来就要投喂给模型。渲染层的两个方向**基准也不同**——前向警告用 `progressIndex`(它同时是"前一章的 1 起章号"),后向警告说"你正在读第几章"要用 `progressIndex + 1`。这两处**最初都写错了**,是被 `covered.last === 当前章号 不该报倒退` 与 `只丢当前章之后的条目` 两条用例逼出来的。 > 195. **跳读闸的闸门必须在**发模型调用之前**。** 测试里最要紧的那条断言不是状态码,而是 `labels.length === 0`(假子代理一次都没被调用)。只断言 409 的话,"先跑一次调用再报错"的实现会全绿,而它恰好烧掉用户明确拒绝的那一次十几分钟的等待。同理,`mode: 'recent'` 的用例断的是 `sampled.first === 100`——证明它**真的没碰**前面那 99 章。 > 196. **T0(R8)的交付物是**测量口径**,不是数字,另有两条硬约束。** 数字只能从真实阅读里攒出来,所以落地的是 `docs/manual-testing.md` 的 **P21**(步骤 + 判读)。两条约束:① **必须用 provider 实报的 `cacheReadTokens` / `cacheWriteTokens`**——官方文档自己指出宿主 token meter 的「每 token 四字符」启发式**对中文系统性低估 2.5–4 倍**,用它算出来的命中率只是噪声(另记:插件自身预算**全程用字符**、不做 token 换算,所以这条只伤"对比",不伤预算逻辑);② **先测再动排序**——若第 2 轮 `cacheReadTokens ≈ 0`,第一反应不该是改背景认识的内部顺序(那是白改),而是查有没有别的插件在同一段落之前注入了每轮都在变的内容。 > 197. **抽样参数的两个轴(T2/T3 的依据,本版只记录不改)。** 轴 1「深度」= `minPerChapter ÷ 章长`;轴 2「合并比」= `章数 × minPerChapter ÷ backgroundBudgetChars`。同一套参数下两者**反向**:少章长章的书(590 章 × 3390 字)深度只有 2.9%,多章短章的书(1400 章 × 1429 字)合并比 23:1 尚可、5000 章 × 400 字则到 83:1。把抽样改成**按章长比例**(`u = k × 章长`)可以让合并比化简为 `全书总字数 × k ÷ D`——**章数消失**,于是"能支持多大的书"变成单一不等式 `总字数 ≤ 70 × D ÷ k`(`D = 6000, k = 20%` → **210 万字**)。据此:当前 6000 字的目的地恰好是为 200 万字标定的,而**病根是 `minPerChapter` 是绝对字数**(深度因此取决于章长,而章长是出版产物、不是叙事量)。**本版不动它**——它要配 `backgroundBudgetChars` 一起改,属 T3,且以 T0 的结果为闸门。⚠️ **v1.24 改判并落地了它**(见 §225):读者显式豁免 T0 并要求做完 T2,所以按章长比例已实现(`sample.lengthRatio`,默认 `0.2`;设 `0` 退回这里的旧形状),但 `backgroundBudgetChars` **仍然一个字没动**——换的是形状,不是预算。 > 198. **本版验证:419 项全绿(0 skip),变异 12/12。** ⚠️ 三处如实记下:① **"404 项测试"这个数字是错的**,一直沿用自更早的状态;用同一套计数法在 `git stash` 前后各跑一次,**基线是 403**,本版加 16 项到 **419**。② **同一个错我在这条里又犯了一次**:先写下 418,随后为界面接线补了一条静态断言用例就让它是旧的了。**数字类的结论不该在收尾之前写**——写完必须重跑一次再定稿。③ **有一个变异最初没被抓住**——`minChapterIn` 改成取区间**后**端(等价于按 recency 过滤)时全绿,因为我的用例全用单章标记(`第7章`),而单章下 `matched[1]` 与 `matched[2] ?? matched[1]` 恰好相等。补了一条**区间标记**用例(`第5-9章` 在读到第 7 章时要保留)之后才抓住。**"变异全被抓住"只有在变异集覆盖到那条代码路径时才成立**——这一次是变异集自己漏了路径,不是用例写松了。 > > **v1.20 修订(T1:跳读闸缺省值 / 章末附言剥离 / 切点对齐 / 「发笔记」不再自称"记忆已更新")** > > 199. **跳读闸的缺省值必须是「开」(50),不能是 0。** 这不是理论问题:`sample` 是**整体替换**的配置对象,profile 里那份写在这个键存在之前,于是 `jumpGateChapters` 缺失,原实现 `… : 0` 把安全闸**静默关掉**——读者点开第 150 章直接跑了补齐,一次提示都没有。现改为缺省 50,要关必须显式写 0。**安全功能的缺省值不能是「关」:缺配置是常态,而「没配」绝不该等于「不要保护」。** 另有专测钉住「`sample` 存在但没这个键」这一情形——它不是「没有配置」,不能靠无配置那条用例顺带覆盖。 > > 200. **⚠️ 这次真机没生效,根因不在代码,而在改错了树。** 运行时加载的是 `<工作区 A>`(`%DSH_HOME%\profiles\desktop\package.json` 里 `"dsh-reading-companion": "file:<工作区 A>"`),而本版改的是 `<工作区 B>`。两棵树**没有任何共同祖先**(互不认识对方的 HEAD,`merge-base` 直接报 `Not a valid object name`),且**各有一个 `v1.0.0` 标签指向不同提交**——早前的 sanitize / email-rewrite 重写过历史,同名的提交信息哈希因此全不同。**两条教训**:① 会话工作区里存在多个副本时,改动前必须**先确认 `package.json` 的 `file:` 指向哪棵树**,而不是按相对路径 glob——相对路径会安静地落到副本上;② 本版改动**无法 merge** 到 live,只能作为补丁重新落地,这一点必须在推送前处理,否则两棵树的 `v1.0.0` 会互相打架。 > > 201. **抽样必须**先剥章末附言、再切片**,顺序不能反。** 头 60% / 尾 40% 里那 40% 是**专门**留给收束信号的("于是他明白了那个人是谁"),而中文网文的章末几乎总是粘着「作者有话说」「求月票」「(本章完)」与分隔线——以默认 100 字配额算,40 字的尾窗会被整块占满,**收束信号归零**(拿到一个起景句加一条附言,情节推进全丢)。新增 `stripChapterTrailingNoise()`:只在**章末 15%** 内、且只在**行首**判定;命中后**整块带走**(附言常是多行,只削首行没用);并且**跳过"其后仍剩超过窗口 60%"的候选**——那种更像正文里偶然出现的短语,不是附言;切完为空则判定有误,**退回原文**。剥完才量长度:这一章可能因此整章都装得下(原样全给)。⚠️ 它改的是**抽样输入**,不写正文文件——"正文逐字不变"这个不变量不受影响。 > > 202. **两个切点都要对齐到自然边界。** 裸 `slice` 会切出半句话和断掉的引号,而尾窗本来就只有几十字,半个句子占的比重很大。`alignHeadCut()` 向后找到句读之后(上限 +24 字),`alignTailCut()` 向前找到段首(上限 +24 字)。**头窗对齐的溢出要从尾窗里扣除**(`tailBudget = allowance - headEnd`),否则每章都可能超配额——240 章 × 48 字 ≈ 11,520 字,接近整个预算的一半。找不到边界就原样返回:宁可多留半句,也不丢整句。 > > 203. **本版验证:23 文件 / 427 项通过 / 0 跳过;变异 5/5 被抓住。** 基线 419(同一套逐文件计数法;`npm test` 在本沙箱因子进程管道 `EPERM` 跑不了,属已记录的沙箱边界)。⚠️ 两处如实记下:① **M5 第一次是被"跳过"的**——变异锚点写成 `: config.sample.jumpGateChapters : 50`,而源码是 `? … : 50`,于是"锚点没找到"被当成了通过。**变异脚本报 SKIP 时绝不能当成 CAUGHT**,这一条差点把一次未经验证的缺省值变更放进提交。② 那两条"钉接线"断言是**后来补的**:`alignHeadCut` / `alignTailCut` 单独测得到,但把 `sampleChapters` 里那两行调用换成裸数字,纯函数测试仍然全绿——**函数对而没接线照样是 bug**(§198 第 ③ 条那个教训的复现)。 > > 204. **「发笔记」那条路径一直在无条件声称「前文记忆已更新」——这是一句假话,现在按真实结果生成。** 补齐调用写的是 `callApi(…).catch(() => null)`,结果被整个丢掉,于是**跳读闸拦下 / 宿主调用失败 / 网络断掉**三种情况下,读者看到的都是同一句"前文记忆已更新"。这不是措辞问题:读者会带着"记忆里有前文"的预期去聊,然后困惑于 AI 怎么不记得——而那正是这个功能要解决的问题。改法是把结果留成三态(`{kind:'ok',data}` / `{kind:'error',error,gate}`),交给新的 `memoryFillClause()` 生成**必须属实**的那一句,调用方只负责拼接、不再自己判断成败。闸门那一态还额外点明该去面板点「补齐」并在两个选项里选一个(发笔记这条路径拿不到 `setGate`——`gate` state 在另一个组件里,所以这里只能用文案把读者送回去)。**同一个文件在 `grabSelection` 那里早就为这条原则留过注释("最不该做的就是说了但没做")——本条是同一原则在另一处的落地。** > > 205. **跳读闸的"连续小跳"担忧经复核并不成立,故不增加机器。** 曾经的担心是:每次前跳都 ≤ 阈值,几十章几十章地累积,闸门从不触发、而记忆一路推高。但**危害的那一半已经被倒退过滤(§192)在读取侧挡住了**:记忆覆盖到第 139 章而读者回到第 105 章时,`backgroundFiltered` 会丢掉章号超过 105 的条目。所以闸门在**已经修好倒退过滤之后**的性质其实是**成本/知情同意**(别不问就烧掉十几次调用),而不是防剧透——防剧透由读取侧那个与"意图"无关的判据负责,比对单次跳跃幅度做启发式猜测可靠得多。**结论:不加。** 加它只会引入"跳多少章算跳"这个没有正确答案的阈值和一批误报。 > > **v1.21 修订(T1:补齐循环化——一次点到底 / 可停 / 真实进度)** > > 206. **补齐循环化,且循环放在客户端。** 服务端一次调用只补**一段**缺口(首次 30 章打底,之后按预算一批),旧版把"再点一次继续"交给读者——跳读几百章的书要点十几次、等十几分钟,中途还没有任何进度。现在 `runFillLoop()` 接着补下一批。**为什么不放在服务端转圈**:那条路由是阻塞的,服务端自转等于把一次 HTTP 请求拖过宿主的超时线;客户端循环天然能报进度、也能中途停。**四条出口缺一不可**:`partial !== true`、`skipped === true`、**水位线没有前进**(兜底——服务端回 `partial: true` 而 `covered.last` 不动时,前两条都不成立,会一直转下去)、**批次上限 40**。⚠️ 「停止」只把运行号加一、**不 abort 那次 fetch**:请求可能正卡在宿主的模型调用里,硬断既省不下这次调用,又会让服务端白写一遍合并;让它跑完、由 `alive()` 决定**要不要采信结果**。已落盘的那一批如实保留——每一批都是独立落盘的,没有半成品。进度所需的数据(`covered` 与 `gap`)**响应里本来就有**,服务端一个字段都不用新增。 > > 207. **主按钮在补齐中必须变成「停止」,且进度可见。** 循环可能跑十几分钟,而旧版那行 `disabled: filling || …` 会让主按钮在补齐中**点不动**——循环化之后那等于把读者关在里面。这是本轮唯一一处"改功能反而造出新死角",所以 `disabled: !filling && …`,文案切成「停止补齐」,并新加一行「已补到第 N 章,还剩 M 章…」。原来那句 `已把第 A–B 章纳入记忆…再点一次继续` 交给 `fillOutcomeNotice()` 按五种结局(补完 / 无缺口 / 停在原地 / 达上限 / 失败)分别出话,**失败与停住时绝不说"纳入记忆"**(§204 那条原则在循环场景的落地)。⚠️ 组件路径上"取消"由 `cancelFill` 自己出文案,所以 `fillOutcomeNotice` 的 `cancelled` 分支在那里走不到;保留它是为了让该函数对其声明的输入域是**全函数**,将来的调用方不会把"取消"误报成"失败"。 > > 208. **⚠️ 测试文件的缓存击穿键必须带自己的名字。** `--test-isolation=none` 把所有测试文件跑在**同一进程**里,而 `client.test.mjs` 用 `?t=1,2,…` 击穿缓存;新文件照抄 `?t=` 就会**撞上对方已加载的那份模块**——`import()` 直接命中缓存、factory 不被捕获,`captured` 是 null。**单独跑那个文件 17 项全绿,跑全套才有 12 项一起炸。** 这是"隔离地测通过 ≠ 集成地测通过"的一次具体复现,也是新文件必须自带**带名字前缀**的查询串的原因(现为 `?fillloop=N`)。 > > 209. **⚠️ 本仓库里"逐字节还原"不能用文件树遍历来验。** 本轮用 `Get-ChildItem -Recurse | Get-FileHash` 比对 `git stash` 前后,得到 `False`;追下去发现是**基线那一跑在 `test/.tmp/` 下留下了被 gitignore 的产物**(`test/.tmp/compact-auto-*/…`、`test/.dsh-app-path`),遍历把它算了进去——而 `git status --porcelain` 只报 3 个文件,因为其余全被忽略。改用 git 自己的哈希后:`git diff HEAD` 的补丁逐字节相同(14317 字节)、未跟踪文件 SHA256 相同、CRLF 0→0。**结论:这类校验只能走 git,不能走文件系统**——`Get-ChildItem` 看到的东西与"这棵树的版本内容"不是一回事。⚠️ 顺带记下:`npm test` 在本沙箱跑不了(子进程管道 `EPERM`,已记录的边界),`node --test` 同样会 spawn;**可用的等价命令是 `node --test --test-isolation=none`**(CI 用的就是它,不 spawn)。 > > 210. **本版验证:24 文件 / 447 项通过 / 0 失败 / 0 跳过。** 基线 **430** → 本版 **447**,本轮 **+17 项,全部落在新文件 `test/fill-loop.test.mjs`**。该文件**刻意单开、自带加载器**,不并进 `test/client.test.mjs`:后者的夹具是**合成过**的(占位书名、假哈希),而循环逻辑与夹具无关,分开之后这个文件可以整份搬到任何一棵树。⚠️ 同时更新了 `client.test.mjs` 里那条**已过期**的静态断言——它钉的是 `onClick: () => fillBackground(),`,而主按钮现在兼作「停止」。**契约是有意改的,断言就该跟着改**,不能为了让测试通过去扭曲代码。 > **v1.22 修订(T1:实体键 —— `###` 分组推广 / 取代与归档 / 顺带修掉两个静默丢数据)** > > 211. **`### 主体` 从「人物」推广成「分组分区」的通用机制。** 新增 `BACKGROUND_GROUPED_SECTIONS = ['人物关系', '人物', '世界观']`:这三个分区里,条目按 `### 主体` 归拢(人物关系的**主体是一对关系**,如 `### 甲 × 乙`;世界观的主体是地名/势力/设定)。**为什么需要它**:`人物关系` 的条目常常不写清"是谁和谁"(`- \`第12章\` 关系出现裂痕`),既让人读不明白,也让"这条关系属谁"无从追溯;有了主体,每条条目才有一个**可寻址的落脚点**——这正是 §213 能指哪打哪的前提。「文风」「前文脉络」**刻意不分组**:它们的主体分别是整本书和时间轴,强行分组只会造出一堆只有一个成员的分区。⚠️ 兼容做法:`doc.characters` 仍是「人物」那一组,且与 `doc.groups['人物']` **指向同一个对象**(`index.js` / `compact.js` / `memory.js` 三处都按老形状读它)。 > > 212. **⚠️ 借这次改数据形状,顺手修掉两个「静默丢数据」——都是量过才发现的。** ① **散条目丢失**:`## 人物` 下没有 `###` 的条目,旧解析把它塞进 `sections['人物']`,而 `renderBackground` 只渲染 `doc.characters`,于是那几行**在下一次写盘时消失**(读者手写的、或模型偶尔漏了 `###` 的条目)。现在散条目会如实渲染在分区标题正下方,并且**各成一个预算单元**(它们没有主体可以归拢)。② **手写内容被误归**:遇到认不出的 `## 标题`(典型就是 `renderBackground` 自己写出的 `## 你手写的内容`)时,旧实现直接 `continue`、**不重置分区**——于是它后面的内容被算进**上一个**已知分区:手写条目被当成 AI 的记忆渲染出来,散文则直接丢失。现在认不出的标题一律 `section = null`,其后内容进 `unknown`,并被 `renderBackgroundForPrompt` 跳过。 > > 213. **「取代」:把旧条目搬进 `## 已取代` 归档,而不是删掉。** 设计的核心约束是「条目只增不减」,但读者与陪读 AI 总会推翻早先的说法("沈某某其实是女子"推翻"沈某某是男子")——只增不减之下,两条互相矛盾的说法会**一起**进提示词。于是取代按"搬家"实现:旧条目从活分区**移到**文件末尾的 `## 已取代`,原文一字不改地留着。三条性质缺一不可:**不删除**(读者随时能把一行搬回去撤销取代);**永不进展**(它不在 `BACKGROUND_SECTIONS` 里,所以 `renderBackgroundForPrompt` 根本走不到它);**不参与压缩**(见 §214)。三个实现细节都有专测:① **抑制复活**——归档集合同时是一张抑制名单,模型下次重读第 5 章还会总结出那条旧说法,若不抑制,它会作为活条目复活、跟修正后的说法一起进提示词,**那正是取代要解决的问题本身**;② **幂等**——同一条取代两次只产生一条归档,且"已经取代过了"不该报成"没找到";③ **如实报告**——取代指令没命中任何条目时记进 `lastMerge.unmatched`,**静默什么都没做而调用方以为修正已生效,是这一节最不该出现的情形**(§204 同一条原则)。⚠️ 顺带一个**必改的细节**:比对键 `entryKey` 必须剥掉 HTML 尾注(归档条目带 ``),否则它跟"模型重新总结出来的同一句话"永远对不上,**整个抑制机制静默失效**。 > > 214. **归档与压缩的边界:不进输入、原样带回。** 归档是**历史日志,不是活记忆**,所以:`createCompactor` 用 `renderBackground(before, title, { includeRetired: false })` 造压缩输入——送进模型既浪费 token,又给了它把已被推翻的旧说法"重新总结"回正文的机会(那正好把取代白做了);压缩通过校验后再 `after.retired = before.retired` **原样带回**,因为**模型没见过它,就没有资格动它**。⚠️ 同时给 `validateCompaction` 加了第四条硬约束「**保主体**」:分组分区的主体(一对关系、一个设定)一个都不能少——旧校验只看 `characters`,丢一整条人物关系它会放过去。这一条与「保名」是同一原则,只是推广到了新的分组分区。 > > 215. **本版验证:25 文件 / 465 项通过 / 0 失败 / 0 跳过;变异 15/15 被抓住(含一条被拆成两半的)。** 基线 447 → 465,本轮 **+18 项**(新文件 `test/background-entities.test.mjs` 17 项 + `memory.test.mjs` 1 项)。该文件只用静态导入,所以不涉及 §208 那条缓存击穿键的坑。⚠️ **变异测试逼出了一条真实的测试盲区,如实记下**:抑制逻辑在 `mergeBackground` 里写了**两遍**(散条目一遍、分组分区一遍),我的第一条变异只打中散条目那一边——而那条路径**原本完全没有覆盖**,去掉它全套测试仍然全绿(`ESCAPED`)。补了一条散条目抑制的用例、并把变异拆成 `M4`/`M4b` 分打两遍之后,15/15 全部被抓住。**这是 §198 第 ③ 条与 §203 第 ② 条的第三次复现:同一段逻辑写了两遍时,只测一遍等于有一遍没有守卫。** ⚠️ 另记一条工程事实:本沙箱里带管道的子进程会 `EPERM`(§209),所以变异脚本用 `spawnSync(..., { stdio: 'ignore' })` **只取退出码**,全程不捕获输出——这也让脚本更简单。 > > 216. **⚠️ 分组改动**激活**了一个原本睡着的 bug:`memory.js` 的「解析不出」判据漏数分组主体。** 那个判据数的是 `sections['人物关系'] / ['世界观'] / ['文风'] / ['前文脉络']` 再加 `Object.keys(characters).length`——**没有**数 `groups`。旧代码没事,只因为那时「人物」是唯一的分组分区,而它恰好被 `characters` 数到了。分组一推广,「人物关系」「世界观」的条目大多挂到 `### 主体` 下,判据就看不见它们了:一份**解析得好好的**输出会被判成 `UNPARSABLE_OUTPUT`,**整批好数据被丢掉,而给出的理由还是错的**——比静默失败更糟的是"失败得理直气壮"。改法是按**结构**数(所有分区的散条目 + 所有分组分区的主体名),不再按"我记得有哪些分区"数;这顺带补上了同一个漏洞的另一半:旧判据数了 `characters` 却没数**散条的**「人物」,而模型漏写 `###` 时那一条正落在那里。两条都有专测与变异(M13/M14)。**教训:改数据结构时,必须把每一个**读这个结构**的地方都过一遍——`characters` 这个名字让四处调用点看起来都还正常,只有 `memory.js` 那一处是按"分区清单"硬编码的。** > **v1.23 修订(T1-②:背景更新 —— 读 durable 会话事件 / 注释块回传 / 章号反向闸 / 不谎报覆盖)** > > 217. **数据源必须是宿主的 durable 事件流,不是「模型可见面」。** 读者的指正发生在第 N 轮,插件读到它时可能已是第 N+9 轮;中间只要发生过上下文压缩(`contextCompactionEnabled`),那一轮就已经不在模型可见的历史里了——**从可见面读会漏掉恰好最该被记住的那句**(§187 写下的硬约束)。落点是 `ctx.on('session/event', (session, event) => …)`:宿主的「Post-commit, fire-and-forget append feed」,每条消息**落日志之后**才来一次,与压缩无关。⚠️ 刻意**不用** `agent/assistant-stream`:那是进程内的逐帧流,只对"正在流的那一次"有意义。观察者注册在插件根 fiber,拿到全部会话的追加事件,是不是陪读会话由 `bookForSession` 在回调里判——与 system 段落"全局注册、按会话自我否决"同一套做法。热路径上先按 `event.type` 短路(只认 `assistant/message` 与 `user/message`),**非消息事件不查书**,有专测钉住。 > 218. **传递方式:回复里带一个 HTML 注释块,零额外调用。** 硬约束是"零额外调用",所以不能"回复完再问一次模型"。做法是让陪读 AI 在回复末尾附一个 `` 块(字段 `节 / 主体 / 章 / 事实 / 取代`),由守则第 8 条说明格式。**为什么用 HTML 注释**:渲染器不显示它,而**原文里它在**——插件读的正是原文(durable 日志),从不经过渲染。于是"读者看不到"与"插件看得到"同时成立,界面侧一行都不用改。解析器**不丢弃认不出的行**,而是接到上一个字段后面:模型换行写一条长事实是常态,丢掉续行会把句子**悄悄截断**,而截断后的句子仍然像一句完整的话(M5 专测)。 > 219. **反向安全闸:章号不得超前进度。** 一个块声称"第 300 章的事实"而读者才读到第 30 章时,写进去就是**用一次修正换一次剧透**,且不可逆(`background.md` 之后会被原样注入)。所以 `章 > 进度 + 1` 一律拒绝,`+1` 是因为 `chapterIndex` 0 起、章号 1 起,读者正在读的就是 `progressIndex + 1`。省略章号时**归到当前章**——那是唯一不会猜到他还没读到的地方的选择。拒绝理由必须可分辨:`CHAPTER_AHEAD`(拦下一次剧透)与 `EMPTY_FACT`(模型没按格式写)对用户的意义完全不同,混成一个"非法"会让日志毫无用处。**这与跳读闸是同一类关口,只是入口不同——入口越多,越要保证判定在每一条上都成立。** > 220. **不谎报覆盖:写进去一条修正,不等于这些章被补过。** 为此给 `mergeBackground` 加了 `options.extendCoverage`(默认 `true`,**既有调用方零影响**),背景更新这条路径传 `false`,覆盖区间原样保留。否则一条关于第 30 章的修正会让文件声称 `covered=1..30`,**真正的第 1–29 章缺口就此静默消失**,往后补齐再也不碰它们——这是"用一个动作的副作用谎报另一个动作已完成",与 §204 同一条原则。测试里配了一条**反例断言**:不带这个选项时区间确实会撑到 `1..30`。**"某个选项是承重的"这件事,只有对照才看得出来。** > 221. **格式与解析器必须同源,示例必须能被自己的解析器接受。** 守则第 8 条从 `renderUpdateInstruction()` 取,与解析器在**同一个文件**里:这个仓库已经三次栽在"同一段逻辑写两处、只测一处"(§198 ③ → §203 ② → §215 的 M4),把格式与解析器分开就是同一个陷阱的第四个入口。⚠️ 顺带修掉一处**自己给自己挖的坑**:那段说明的第一版示例写的是 `章: <这个事实属于第几章>`,而它**通不过本模块自己的校验**(`CHAPTER_INVALID`)——一个要模型照抄的示例,起码得是它自己的解析器接受的形状。现在示例写具体值,并有一条**往返断言**把说明喂回解析器,专门钉这一点。 > 222. **本版验证:26 文件 / 490 项通过 / 0 失败 / 0 跳过;变异 5/5 被抓住,0 逃逸,0 无效。** 基线 465 → 490,本轮 **+25 项**(新文件 `test/background-update.test.mjs`)。变异脚本这次多一步:**先用 `node --check` 证明变异体语法合法**——否则一个把文件改坏的变异会让测试以语法错误退出,而脚本会把它记成"被抓住",那正是 §203 那条教训("SKIP 长得像 PASS")的另一种长相。五条变异:关掉章号闸 / 让它扩覆盖区间 / 拆掉 `session/event` 接线 / 守则里删掉格式说明 / 解析器丢掉续行。⚠️ **接线这一面暴露了三处假 ctx**:`helpers/server.mjs`、`routes.test.mjs`、`plugin.test.mjs` 各有一份宿主上下文替身,都得补 `on`。其中 `plugin.test.mjs` 的 `assert.equal(effects.length, 4)` **正好挡住了"注册了但没托管"**——计数断言在这种地方的价值,比它看起来大得多;顺手加了"卸载后 `session/event` 订阅必须为空"的断言(留下悬挂监听器意味着插件停用之后仍在吃每一条会话事件)。📌 但也如实记一笔:`routes.test.mjs` 那份假 ctx 与 `helpers/server.mjs` 那份**是同一段东西写了两遍**,而前者正是后者顶部警告过的形态(`systemPrompt.section` 写成 `() => () => {}`,把回调丢掉)。**这是"同一段逻辑两处、只测一处"的第四次同形**——宿主契约一改就要改两遍,漏一处就是一组假绿。 > **v1.24 修订(T2:非小说兜底分区 / 上一章只给尾部 / 抽样按章长比例 / 卷首章加权 / 密度规则 / 文档修正)** > > 223. **「通用概念」:给非小说文本的兜底分区,做成了纯加法。** 读者不只读小说,而前面五节全是为小说定的——读史书、哲学、技术书时那五节可能全空,内容却无处可写。新增第六节(`BACKGROUND_SECTIONS` 末位、权重最低 `5/55`、**分组**分区),规则只有一条:**能归进前面任何一节的就归进去,归不进去的才写在这里**;若这本书本就没有人物与世界观可言,核心概念就都落在这里。⚠️ **"纯加法"是三条性质合起来的结果,不是一句声明**:① `renderBackgroundForPrompt` 本来就**跳过没有内容的分区**,所以兜底为空时分配结果一字节不变;② 权重表写成 `15/55 … 5/55` 而不是漂亮的小数,是为了让原五节的**相对份额乘同一个因子**——注水法只用比值,于是小说的分配结果与改前逐键相同(有专测把这条性质变成断言,因为"比例没变"是**看不见**的);③ 只给新增的兜底分区配**异名归一**(`通用概念(兜底)`/`其他概念`/…),既有五节的名字一个都不动。异名的理由很具体:分区是**精确匹配**,而这一节的名字是提示词里现写的,模型换个写法就会让整节落进"认不出的 `## 标题`"分支——那里的条目会被当成人手写的内容,**不再作为认识渲染,也不再进压缩**。反向风险也有专测:归一表不能太松,认不出的标题仍必须把手写散文隔在 `unknown` 里。 > > 224. **上一章默认只给尾部 60%(`window.previousChapterMode`)。** 跨章讨论回看的几乎总是**上一章的结尾**("刚才那句是什么意思"),而上一章的开头对理解当前这一章几乎没有贡献;整章投喂等于每轮白带几千字,而它是 prompt 里第二大的可变块。切点对齐到**段首**(`alignTailCut`),从半句话中间开始的窗口读起来像残句。`'full'` 保留为逃生门。⚠️ **必须连带改口**:渲染层原先写死「上一章全文」,截断之后那句话就是假的——而它会直接进模型看到的 prompt。现在标题跟着 `truncatedBefore` 走(「上一章结尾」+ 一句"前面的内容未提供"),并有断言钉住"截断了就不许自称全文"。**产物不能替我们声称一件没发生的事(§204)。** > > 225. **抽样额度从「绝对字数」改成「章长的一个比例」(`sample.lengthRatio`,默认 0.2)。** `u = clamp(lengthRatio × 章长, minPerChapter, maxPerChapter)`(下限同时从 100 降到 60,它是那个公式的封底),再乘重点章权重。理由见 §197:`minPerChapter` 是绝对字数时,"一章抽到多少"取决于章长,而**章长是出版产物、不是叙事量**。⚠️ **这是一次改判,必须记清楚**:§197 当时把它划给 T3、并写死"本版不动它"——理由是它要配 `backgroundBudgetChars` 一起改、且以 T0(缓存命中率实测)为闸门。本轮读者**显式豁免了 T0**("只要预算不超过太多,我认可结构化背景知识")并要求做完 T2,所以在这里落地;但两条保留:① `backgroundBudgetChars` **一个字没动**(本版只换形状,不换预算);② `lengthRatio: 0` 是合法值,含义是退回"按预算均分"的旧形状,两种形状各有专测。**代价如实说**:这个取舍最直观的后果是**短章被截得比从前狠**——一本均分模式下整章装得下的 200 字短章,现在只拿到下限那一小段(换来的是同一批预算能覆盖更多章)。而且 §197 那条定律**是从公式推的、没有实测语料调优**,0.2 只有"落在公开区间内"这一条外部支持。补齐已经循环化(§206),覆盖最终会收敛,所以这不是"读不完",是"一次调用看到多少"变了。 > > 226. **§105 的加权思路推广到「卷首章」——判据依旧是绝对的。** `weightOf` 现在认两个绝对判据:全书开头 `emphasisChapters` 章(§105 老规矩),以及**卷首章**(`volume` 与前一章不同的那一章)。卷首与全书开头值得读厚的理由相同(人物与设定的密集区),而"哪几章是卷首"是书自己的属性,与这一批从哪开始无关——**§105 警告过的那件事在推广时必须继续成立**,所以专测里把"从第 5 章起的两章必须等长"和"卷首章在任何一批里都更厚"放在同一条用例里对照。没有卷标记的书(`volume` 为空)一条都不受影响。⚠️ 顺带一条实现约束:章长取自索引里的 `startChar`/`endChar`(导入时就量好了),**不读正文**——缺口可能有上千章,第二刀必须只是整数运算。 > > 227. **合并提示词的两条密度规则(零额外调用)。** ① **逐主体过一遍**:`existingMarkdown` 里已经出现的每一位主体,这一段只要有新信息就补到他名下,**没有就一条都不写**,并明确禁止"把已有的换个说法再写一遍"(那是只长胖不长信息)。② **专名照抄原文**:人名、地名、门派、器物、招式、称号按原文写,不许用"某人""那个地方"代替,不许意译缩写。⚠️ 同时把压缩提示词与它自己的**校验器**对齐:校验器会因丢主体而整批拒绝(§214 第四条约束),而提示词只说"每一位人物都要在"——模型按提示词做、校验器按另一套判,白花一次调用。现在两边说同一件事。**边界要说清:这两条只优化"写进去"的信息密度,修不了"一次调用能看多深"——那是 §225 的事。** > > 228. **一批文档修正(都不是功能,但都属于同一类缺陷)。** ① `collectReadWindow` 的注释描述**一个不存在的 `earlier` 字段**(v0.5 换成背景认识后就没人跟过),而 `spoiler.js` 里**真有一条永远不会走到的 `earlierText` 渲染分支**——注释说了算、代码不认账,比没有说明更容易误导;两处一并清掉。② `headAllowanceChars` 只在 `read-so-far` 模式下、且**只作用于当前章**,README 原先没写后半句(这一版又给上一章加了尾部规则,两者更容易混)。③ 抽样那段的 60/40 措辞不准确:头窗对齐到句读之后可能**多取 24 字**、且是从尾窗里扣的,所以实际只会比 60/40 更偏开头。④ README 的压缩章节写的是**三道**安全校验,漏了 v1.22 加的「保主体」(代码与 `compact.js` 文件头都是**四条**)——升级文档时说改了三处、实际改了四处,是同一类失误。 > > 229. **本版验证:28 文件 / 512 项通过 / 0 失败 / 0 跳过;变异 13/13 被抓住,0 逃逸,0 无效,对照组按预期逃逸。** 基线 490 → 512,本轮 **+22 项**(新文件 `test/generic-section.test.mjs` 15 项 + `test/sampling.test.mjs` 7 项)。四条既有用例按新契约更新:分区清单 5→6(两处"配置契约"式的钉子)、`measureDoc` 夹具补上第六节(否则"额度严格随权重递减"会默默跳过一个空分区)、以及一条"上一章给全文"改成尾部契约。⚠️ **一处必须记下的夹具改动**:`helpers/server.mjs` 的 `prose(seed)` 原先只在**章首**放标记,于是"上一章到底进没进 prompt"这件事在尾部契约下没法用标记断言;现在首尾各放一次,标记既能证明"进去了"、也能证明"进的是尾部"。这不是为了让测试变绿而改夹具——标记只在一端时,那条断言**表达的就不再是它想表达的东西**了。 > > 230. **⚠️ 变异验证当场抓出一条"看起来对、其实很弱"的断言(M12)。** 压缩提示词那一节我原本写的是"整段文本里出现过这一节的名字",而变异把这一节**从顺序清单里删掉**之后,测试**依然全绿**——原因是提示词下面还有一行"这几节要写 `### 主体`",那里也提到了同一节,于是"存在性"仍然成立:清单已经少了一节,句子却还写着"六个小节"。改成断言**那一行清单本身**(六节一个不少 **且顺序与 `BACKGROUND_SECTIONS` 一致**)之后 M12 被抓住。**教训:`includes(name)` 证明的是"这个名字在文件里出现过",不是"清单里有它"、更不是"顺序对"。** 一条断言写在"出现过"这个强度上时,它对"从清单里删掉一项"这种改动是完全透明的——而这类改动恰恰是加/删分区时最可能发生的。另外 12 条各打一个新性质:兜底不再是分组 / 异名表失效 / 兜底权重不再最低 / 上一章永远给全文 / 额度不再按章长比例 / 卷首章判据失效 / 比例模式第二刀失效 / `perChapter` 说谎 / 渲染层截断了还自称全文 / 提示词不再说"兜底" / 不再要求照抄专名 / 配置缺省值与库侧兜底分叉。对照组(只改一句没人读的注释)按预期逃逸——它证明这套脚本不是"凡变异必报红"。 > **v1.25 修订(读者退回抽样形状 / T3 分层降级 / T4 压缩与水位的交互)** > > 231. **抽样形状退回「按预算均分」(`sample.lengthRatio` 默认 `0`,`minPerChapter` 回到 `100`)。** ⚠️ **这是读者要求的回退,不是又一轮调参**,理由只有一条但足够硬:§225 那个取舍最直观的后果是**短章被截得比从前狠**——一本均分模式下能整章装下的 200 字短章,比例模式下只拿到下限那一小段。读者明确不接受:他读的不只是长篇,短章(笔记体、段子、诗歌、公文体)是**真实用法**,不能为了长篇的合并比牺牲它。**按章长比例那条路完整保留为选项**(`lengthRatio: 0.2` + `minPerChapter: 60` 即 v1.24 的形状,两种形状各有专测),§197 记的"两个轴反向"也仍然成立——它只是不再当默认值。⚠️ 顺带修掉一条**因换形状而变成空话的断言**:`sampling.test.mjs` 里"这一批含那个短章,所以最小值就是下限"只在比例模式下为真;改形状后它变成一句无意义的真话,已换成"预算压到极小,均分额度只能落到下限上"。 > > 232. **背景认识的分层降级(T3 的结构性那一半,`window.backgroundCoarseDegrade` 默认开)。** §197 的两个轴(深度 / 合并比)反向,加预算是唯一能同时松开的办法,而加预算以 R8(缓存命中率实测)为闸门。本轮**不动 `backgroundBudgetChars`**(T3 自己的"门关"分支:没有 R8 数据就维持 D),改的是**同一份预算能装下多少主体**——在既有降级阶梯**中间插一级**:按权重分额度 → **装不下的单元先降为粗粒度**(`### 主体` + 它名下章号最晚的那一条)→ 粗粒度也装不下才整块丢弃。 > > 这一级最重要的性质是**纯补位**:`kept` 的算法一个字符都没改,粗粒度只在**本来会被丢掉**的单元里补位。于是 ① 预算充足时输出**逐字节不变**(没有单元被跳过就没有单元被粗化,有差分断言钉住);② 预算紧张时集合意义**严格单调**(`新 ⊇ 旧`,原本能完整显示的单元一个都不动)。这也是它敢默认开的原因。 > > ⚠️ **它只用剩余空间,不做置换。** 完整单元先把额度用到装不下为止,粗粒度再用剩下的补位,于是有些预算点上它一个都补不进去(剩余空间 < 一个粗粒度单元),该丢的还是丢——这段边界是被一次真实现象逼出来的(同一夹具、预算 500 时 `coarsened` 为空而 700 时有 3 个),已写成断言。曾考虑"丢掉一个完整单元换三个粗粒度单元"(覆盖率更高),但那会把**本来能完整显示**的主体降级,破坏上面那条最强的性质;宁可少帮一点忙。另:散条目(本来就只有一行)没有可压缩的余地,`coarseCost === cost`,这一级自然跳过它们,不会造出"假降级"。 > > 233. **压缩与水位线的交互(T4)。** 这是报告里"先核对、不算建议"的一条,形状与 `dsh-adaptive-context` 的实测踩坑相同:**压缩排在合并之前,而水位线随合并写入**。读代码后的结论要比"同形状类比"更窄也更准——原来那段的洞**不是**压缩器抛异常(子代理 runner 已经把 `start` 的异常转成 `{ok:false}`,这条路上根本没有异常可达),**而是 `library.backgroundCompact` 的落盘压在合并之前**:写盘一旦失败(或合并那一步失败),删过内容的文件已经生效,而缺口还在——**净损失,且没有任何进展换来它**。 > > 修法是**"算而不写"**:第一趟只把压缩结果**拿在手上**,不落盘;等第二趟(合并)真的成功了,再用 `backgroundMerge` 的 `base` / `backup` 选项与它**共用一次写入**。于是 ① 合并失败 ⇒ 什么都没写,压缩下轮重来,净损失为零;② 压缩与合并一起生效时仍先写 `background.bak.md`(压缩是唯一会删内容的一步);③ **没有缺口**时(`gap === null`)压缩是这一趟唯一的成果,且后面没有会失败的步骤,所以单独落盘,响应里 `skipped: true` + `compact.persisted: true` 如实区分。另加一道**守卫**(`compactor` 直接抛时降级为"这次不压"):⚠️ 如实标注——按现在的接线它**没有已知的可达路径**,钉的是不变式"压缩怎么坏都不能让水位线停住",不是已复现 bug 的修复;已复现的那条是上面那个落盘顺序。 > > 234. **本版验证:29 文件 / 522 项通过 / 0 失败 / 0 跳过;变异 11/11 被抓住,0 逃逸,0 无效,对照组与等价变异体按预期逃逸。** 基线 512 → 522,本轮 **+10 项**(新文件 `test/background-coarse.test.mjs` 7 项 + `test/compact.test.mjs` 4 项 − 1 项合并计数)。**变异逼出两条真实的测试空白,都必须记下**: > > ① **"压缩成功 **且** 合并成功"这条最常见的组合此前一次都没被测过。** 既有的"自动压缩:背景太胖会先压一次"**其实走的是"没有缺口"那条分支**——它的假压缩结果头部写着 `covered=1..2`,而进度正好是第 2 章,于是 `backgroundGap` 判定无缺口。于是"合并有没有带备份"(M2 逃逸)与"合并用的是压缩后的底稿还是盘上那份"(M4 逃逸)两个性质都无人守卫。补上那条用例(进度推到第 3 章、压缩结果只声称覆盖到第 1 章 → 强制走合并)之后两条都被抓住。**教训:一条用例的名字说的是它想测的东西,实际走的却可能是另一条分支——"分支覆盖"这件事,名字与注释都不会替你保证。** > ② **渲染顺序此前无人守卫**(M9:把粗粒度单元整批插到最前面,测试全绿)。粗粒度与完整单元混排时最容易写成"粗的全塞前面",那样模型看到的次序与 `background.md` 对不上。已加"主体顺序必须递增"的断言。 > ③ 另记一条**等价变异体**(C1):去掉"粗化必须更便宜"那道闸,行为**完全不变**(一行条目的 `coarseCost === cost`,本来就走不到那条分支)。它逃逸是**正确**的——把它写进脚本当对照,是为了说明"逃逸"本身不等于"测试有洞",得看这条变异体是不是真的改变了行为。 > **v1.26 修订(背景预算 D 提高 / 发布副本核对 / 文档复核与两处同类陈旧声明)** > > 235. **背景预算 D:`backgroundBudgetChars` 6000 → 9000(T3「提 D」那一半)。** 这是 §232 那个"门关"分支的解除:门(R8 缓存命中率实测)**仍然没有数据**,但读者在真机确认功能可用之后决定**不再等门**,取 **+50%**——按"最省事的办法"走的,也是"用最小代价换最大信息"的那一档。依据仍然是 §197:两个轴(深度 / 合并比)**反向**,加预算是同时松开它们唯一的办法。⚠️ **赌注如实说**:缓存命中时多出来的背景几乎不花钱,**不命中则每轮全额付**;这正是只提 50%、而不是一步到 12000 的理由。 > > 两道闸是**联动**的:`compactThreshold`(0.85) 按比例跟走,6000×0.85=5100 → 9000×0.85=7650 才触发压缩。所以 D 变大**不会撑爆上下文**,只是压缩来得晚一些。库侧三处兜底(`library.collectReadWindow`、`background.renderBackgroundForPrompt`、`background.needsCompaction`)同步改为 9000——**它们必须与配置缺省值同值**,这个分叉曾经被变异抓住过(§230)。 > > ⚠️ **在均分形状下 §197 那条不等式不适用。** `总字数 ≤ 70 × D ÷ k` 的前提是 `u = k × 章长`;本版 `lengthRatio` 仍是 `0`(§231),所以提 D 的收益**不是**"能撑更大的书",而是"同一批预算能多覆盖主体、多留余量"。想要前者必须同时打开 `lengthRatio`——而那个代价(短章被截)读者已经明确撤回过。**这一点写进代码注释了,因为 D 与 k 常被当成同一件事。** > > ⚠️ **§232 与 §225 里那句「`backgroundBudgetChars` 一个字没动」从本版起不再成立**——那两句是 v1.24 / v1.25 的史实陈述,**保留不改**(本文件的规矩是修订块按时间叠加、不改写旧块),本块是它之后的新事实。同理,全文里其余提到旧数字的地方(§197 的 `D = 6000` / `k = 20%` → 210 万字推算、§81 的「混着 6000 字背景」)也**一律保留**,含义以本块为准——**旧块是记录,不是当前配置**。 > > 236. **C2 核对「发布副本」:没有第二个代码副本,不存在"看起来全绿、实际无守卫"的空洞。** 三件事实分开记: > > ① **运行副本就是本仓库**:`~/.dsh/profiles/desktop/node_modules/dsh-reading-companion` 是一个 **junction**,`Target` 直指 `<工作区>`。所以**部署即提交**,不存在需要手工同步的第二棵树(此前"要把测试移植到 live"的担心由此消解)。 > ② **唯一的对外出口是 `.github/workflows/test.yml`**,它跑的就是仓库内**同一份 `test/`**(`node --test --test-isolation=none`,两 OS × 两 Node)。**CI 与本地不可能分叉**——这一条正是当初担心的那个空洞,核对结果是**它不存在**。 > ③ ⚠️ **但 git remote `live` 指向一个不存在的路径**(`E:/DSH workspaces/dsh-reading-companion`,`Test-Path` = `False`;`git ls-remote live` 失败),**从未成功推送过**。含义必须说清:CI 声明的 4 个矩阵组合(ubuntu/windows × node 22.19/24)**一次都没跑过**,实际只有**本地 Windows + Node 24** 一档有证据。这不是功能缺陷(不影响已部署的行为),但"CI 是绿的"这句话**没有依据**,不能当成事实。 > 附带发现(**本轮未改**):`package.json` 的 `version` 仍是 `1.0.0`(插件自身已到 v1.26),`repository` / `homepage` / `bugs` 三处仍是 **`OWNER` 占位符**。真要公开发布,这些必须先替换。 > > 237. **A6 那批文档修正的复核结果:四处都已在 v1.24 落地。** 读者问起时我上一轮说的是"**未逐条核**"——现在逐条核完了,结论是**"没核"而不是"没做"**:① `collectReadWindow` 的注释现在明写"这里**没有** `earlier` / `earlierText`",且 `lib/host/spoiler.js` 里 `earlier` 的出现次数为 **0**(那条永远走不到的分支确实清掉了);② `README.md` 与 `library.js` 都写了"`headAllowanceChars` 只在 `read-so-far` 模式下、且**只作用于当前章**";③ 抽样那段已改成"最多多取 **24 字**、而且是从尾窗里扣的,所以只会比 60/40 更偏开头";④ README 压缩章节已是**四条**硬校验(含「保主体」)。 > > ⚠️ **但复核过程中又抓到两处同类陈旧声明(本版修掉)**:`README.md` 的「AI 到底记得多少」表格里写着 **`| 上一章 | 全文 |`**(v1.24 起只给尾部 60%),以及 `cordis.patch.yml` 开头那句 **"the current chapter plus the previous one go in FULL"**(同一段注释往下 30 行就正确地写了 `previousChapterMode` 的尾部语义——**自相矛盾**)。这两处与 A6 是同一类缺陷,而 README 那处是**用户最先读到的那张表**。**教训与 §204 同源:一次行为改动会留下不止一处"改口",而漏掉的那处往往在最显眼的位置——因为它是最早写的。** > > 238. **本版验证:29 文件 / 523 项通过 / 0 失败 / 0 跳过。** ⚠️ §234 记的是 522,差 1 项,已用 `git stash` 直接核实:**HEAD(本版改动全部 stash 掉)也是 523**,本版增/减用例数为 **0**。所以那 1 项是 §234 当时**数字记错了**,**不是本版引入的**——记在这里,不悄悄对齐。 > > 239. **`OWNER` 占位符 → 真实账号 `xling001`:共 6 处,分布在两个文件里。** 这是 §236 ③ 那条附带发现("真要公开发布,这些必须先替换")的兑现。⚠️ 替换范围**不止我上一轮点名的三处**:`package.json` 的 `repository` / `homepage` / `bugs`(3 处),**外加 `README.md` 里的三条安装命令** `dsh plugin --profile add "github:OWNER/dsh-reading-companion"`(装法 A 的 AI 提示词 1 处、装法 B 的 Desktop / Web 各 1 处)。**后三处是我上一轮漏报的,而且比前三处更要紧**:`package.json` 里那三处只在有人点链接时才 404,而 README 这三条是**读者直接复制粘贴的安装命令**——照着敲会去装一个不存在的仓库。**教训与 §237 同源:点名修一处字符串时,最容易漏的是"同一个字符串的其它出现位置",而不是"另一类问题"。** > > ⚠️ **一处刻意不改**:本文件 §236 ③ 里那句"三处仍是 `OWNER` 占位符"是 **v1.26 当时的史实陈述**,按本文件"修订块按时间叠加、不改写旧块"的规矩(§235 同款处理)**原文保留**。 > > ⚠️ **一处仍未动**:`package.json` 的 `version` 依旧是 **`1.0.0`**(而设计文档已到 §239)。把 code 侧版本号与设计文档的 v 号对齐属于**发布决策**,读者表示"不再过度开发",所以**不由我代改**。 > **v1.27 修订(版本号 1.0.0 → 1.0.1:四项修复 / 一处事实更正)** > > 240. **版本号 `1.0.0` → `1.0.1`。** §239 把这一步记为"发布决策,不由我代改"——本版是**读者明确下令**,所以执行。同时 `README.md` 顶部横幅 `v1.0.0 · 首次公开发布` 改为 `v1.0.1`(顺带去掉了"首次公开发布":它已经不是首次了)。这个号只在 `package.json` 一处定义,`/health` 的 `version` 从它读,所以不会漂移。 > > 241. **测试假红(`bad port`)根因定位并修掉——CONTRIBUTING 里那句"成因仍未定位"作废。** 根因**不是并发,是端口号本身**:`fetch`(undici)按 Fetch 规范在客户端拒绝一批端口,**即使服务端监听成功**,而 `6665–6669` 是**五个连续**端口;Windows 的顺序分配临时端口 + 临时端口区间可被配置成从 1024 起(本机 `dynamicport tcp` = 1024 / 58977),使指针每次全量上移几十个,扫过那一段就红一片。修法是 `test/helpers/server.mjs` 的 `listenOnSafePort()`(复检 + 重挑),并由新增的 `test/harness-ports.test.mjs` 钉住。⚠️ **`--test-concurrency=1` 不是修复**——`listen(0)` 次数与端口序列几乎没变,它只是刚好没踩到;把它当结论会掩盖真因(CONTRIBUTING 里原本正是这条建议)。 > > 242. **`/health` 的 `phase: 'P2'` 删除。** 它是 P0–P3 分阶段开发期的残留标签(P3 早已完成),却被 `routes.test.mjs` 与 `plugin.test.mjs` **写死断言**——陈旧值被固化成对外契约,以后真要推进阶段反而得先改测试。两处断言改成"`version` 与 `package.json` 一致":不会漂移,且真的能在版本忘记同步时炸掉。阶段划分是开发期语言,不进对外接口。 > > 243. **`POST /library/import` 的越界面:加精确规则 + 一条可选白名单;另外撤掉一个我中途造出来的坏判据。** 加了什么:① **真实路径复检**(`statSync` 跟着链接走,所以要再确认落点本身是常规文件);② **`importRoots` 白名单**(默认空 = 不限制,保持"从磁盘任意位置导书"这个正当用法;非空时只接受落在这些根内的路径,判定走 `relative()` 而**用真实路径**,所以"用链接指向白名单里"绕不过去,`允许导入-但不是它` 这种前缀相同的兄弟目录也不会被误判成包含);③ 配置归一化(`['']` 不会变成"限制到空目录"而拒绝一切导入)。⚠️ **没加什么,以及为什么**:试过两版"二进制文件过滤器",都撤了——第一版"开头有 NUL 就拒"把两条 UTF-16 导入用例当场打红;第二版"按 NUL 的位置奇偶性区分"**原理上就不成立**(实测 `一` 的 UTF-16LE 字节是 `00 4E`,**低字节本身就是 `00`**,NUL 落在偶数位,而 ASCII 的落在奇数位,同一份 UTF-16 文本两种奇偶性都有)。根本理由是它**与本仓库自己的取舍冲突**:能被正确解码的编码有 UTF-8 / UTF-16 / GB18030 三种,任何"看字节像不像文本"的判据都只是压在它们之上的一层启发式,而**启发式的每次误判都让用户的书导不进来**——与 §"删掉陪读会话一律禁工具那条规则"是同一个理由。结论与两次失败的实证都写进了 `lib/host/paths.js` 的注释,免得后人再走一遍。残余风险如实写进 README 隐私一节:导入是本地文件读取面,插件侧无鉴权,完全依赖宿主的渲染器令牌门。 > > 244. **README「后续计划 ① 陪读 AI 顺手带回背景更新」删除——它早已实现。** 代码侧 v1.23(§355)就落地了:`lib/host/background-update.js` + `` 注释块 + 读 durable 的 `session/event` + `章 > 进度 + 1` 反向闸 + `extendCoverage: false` 不谎报覆盖,`test/background-update.test.mjs` 也在测。README 却仍把它列为"还没做",于是读者会以为要手动点——**文档落后于代码,比没有文档更容易误导**(与 §237/§239 同源:改口漏在了最显眼的那处)。本版把它从路线图移到「背景认识」一节,写成正式小节(含四条必须说清的约束)。 > > 245. **一处事实更正:本机的运行副本不是 junction,是打包后的 27 文件真实副本。** §236 ① 记的是"`node_modules/dsh-reading-companion` 是一个 junction,`Target` 直指工作区,所以**部署即提交**,不存在需要手工同步的第二棵树"。**本版实测这条不成立**:该目录 `LinkType` 为空、无 `Target`、硬链接数为 1,且只有 **27 个文件**——正好是 `package.json` 的 `files` 白名单(`lib/ docs/ scripts/` + 4 个根文件),而工作区那份是 **60 个**。它是**打包产物**,改工作区**不会**生效。(顺带更正路径:§236 ① 与 §200 里写的两处工作区绝对路径都已不是当前值 —— 具体路径因机器而异,本文件不再记录。)⚠️ **后果要说清**:§236 ① 的结论在当前状态下是**错的**,而这正是 §200 记过的同一类故障("改错了树 → 真机不生效 → 两棵树的标签互相打架")的**复发形态**——同一类坑踩了第二次,只是这次换成了 junction 被替换成打包副本。另外,profile 依赖写的是 `file:` 指向工作区那份,所以**只更新运行副本、不同步工作区**的话,下次 `pnpm install` / `dsh plugin add` 会把运行副本**静默回退**。本版把这件事固化成脚本(`drc-sync.ps1`:默认干跑、`-Apply` 才写、每步先备份、可 `-Rollback`;白名单从 `package.json` 的 `files` 派生),并把「改前先确认 `package.json` 的 `file:` 指向哪棵树」补成硬规矩。**好消息**:三份内容**逐字节一致**(纯净版 60/60、运行副本 27/27 与纯净版的白名单子集完全相同),所以不存在"本地手改被覆盖"的风险。 > > 246. **本版验证:30 个测试文件 / 527 项通过 / 0 失败 / 2 跳过。** §238 记的基线是 29 文件 / 523 项,本版**净 +4**(新增端口护栏 3 项 + `importRoots` 1 项;另外中途加过又撤掉的 2 项二进制判据用例不计入)。那 2 项跳过需要本机装 DSH(`cordis-integration.test.mjs`),是刻意的。⚠️ 沙箱内跑测试必须用 `node --test --test-isolation=none --test-concurrency=1`:默认的进程隔离会 `spawn EPERM`(受限沙箱不允许子进程管道),而 `--test-concurrency=1` 顺手避开 §241 那条端口地雷。 > **v1.28 修订(1.0.1 之后:压缩前历代备份 + 导出到笔记库)** > > 247. **背景备份从"单槽覆盖"改为"一代一份、一份不删"。** 备份名带上本地时间戳 `background.bak..md`,并做**同秒去重**(`-2`、`-3`)。⚠️ 同秒冲突是真会发生的:一次补齐是"先压缩再合并",两处都会写备份(`backgroundCompact` 与 `backgroundMerge` 的 `options.backup`),过去它们都写同一个固定名 `background.bak.md`,不去重的话第二代会盖掉第一代 —— 丢的恰好是覆盖更全的那份。两处写入口收敛成一个 `backupBackground(bookId)`。遗留的老 `background.bak.md` **不改名、不删除**,`listBackgroundBackups` 用文件 mtime 给它补一个时间戳,一并算作一代。`ensureCompanionDir` 的迁移名单补上了历代备份(原先只迁 `notes.md`/`background.md`/`persona.md`,落点一变历代快照就留在插件目录里了)。 > > 248. **需求原话(读者):"我压缩背景,`background.md` 会重写一次,前文详细信息就会丢失";"AI 的这份背景认知价值很高……这些信息除了给 ai 形成记忆,对其他用途也很宝贵"。** 两条事实要分清:① 压缩**不是**唯一的整份重写,补齐合并也会整份重写,但那是**纯加法**(条目只增不减);**真正会删信息的只有压缩**,所以备份钩子只需要、也只需要挂在那里。② 更关键的是**单槽的根本缺陷**:压缩后活下来的 `background.md` 就是下一次压缩的"压缩前",所以压第二次之后单槽里剩的**已经是压缩产物** —— "压缩前 = 完整信息"只对第一次压缩成立。改成一代一份之后,**各代的并集**才真的把细节留住(越早的那代覆盖的章更少、但每条更细)。历代备份**永不进 prompt**;也**不引入新的剧透面**:历代覆盖的章节范围只会等于或窄于当前那份,而当前那份本来就在读者的文件夹里、本来就能被 `read` 读到。 > > 249. **新增导出:把笔记 / 背景 / 历代备份写成"文件名自带书名"的一组文件。** 起因是读者在 Obsidian 里的实际问题 —— 文件夹隔离了身份,**文件名没有**,两本小说的笔记都叫 `notes`,只能手工改名。**路线刻意选了"只加导出动作"而不是"改内建文件名"**:后者会与已有数据形成契约(`notes.md` 要被读回来、按纯追加写),必然要迁移;前者是**纯加法,零迁移**。目标名:`<书名>-笔记.md`、`<书名>-背景.md`、`<书名>-背景-压缩前.md`(最新一代稳定入口)、`<书名>-背景-压缩前-<时间戳>.md`(每代各一份)。 > > 250. **导出的四条硬规矩,以及一处刻意的"语义分裂"。** ① **绝不重写别人的文件**:每个导出文件头部埋 ``;目标存在时同书才更新,是别的书、或**没有任何标记**(用户自己写的文件)一律拒绝(`EXPORT_REJECTED` → 409)。② **同名不互踩**:首选名被占就改用 `<书名>_<后缀>`,消歧后缀**插在书名之后**而不是甩到最末尾 —— 否则同一本书的文件在笔记库里会散开,把这个功能想解决的问题又还回来了。③ **幂等**:⚠️ 这里踩过一个坑 —— 标记里带导出时间,每次都写新时间戳的话,"源内容一字未变"的第二次导出产出仍然不同,`writeIfChanged` 会白写一遍、动一次 mtime(而 mtime 正是 Obsidian 重新索引的信号)。所以 `generated` 的语义定为**首次导出时间**,更新时**复用原标记**。④ **语义分裂**:**笔记按 id 增量追加**(那是用户写字的地方,绝不覆盖),**背景整份快照**(它是插件生成的派生物,且条目没有稳定 id,要"增量"就得往 `### 主体` 底下插入 = 中间插入等于重写整个文件,反而会动到用户改过的地方)。另有一条边界:**没有 id 的手写块不导出并明说条数**(没有 id 就没有"导过没有"的判据,硬追加只会在每次导出时把那几条复制一遍)。 > > 251. **导出目录的解析顺序:请求体 > 运行期设置 > cordis 配置 > 这本书绑定会话的工作区根。** 配置项刻意**不给具体默认值**(空串):默认值应该跟着"这本书绑定的会话工作区"走,而那正是全局配置算不出来的东西。三个都拿不到就 `EXPORT_DIR_REQUIRED`(409),**不悄悄导到别处**。相对路径一律拒绝(`EXPORT_DIR_NOT_ABSOLUTE`)—— 它会跟着进程的工作目录走,那是"文件导到别处去了"最常见的成因。⚠️ 实现上踩过一次:`effectiveExportDir()` 定义在 `apply` 的闭包里,而路由表在 `createRoutes` 里,直接调用会 `ReferenceError`(对外表现为 500)。改成通过 deps 传**函数**(不是值),与 `settings.getWebGate` 同一个理由:设置随时可改,缓存成常量就把"改完立即生效"这条性质丢了。 > > 252. **两个入口,一份行为。** 「陪读」页(含"记住导出目录"的路径框)与「笔记」页各一个入口,都调用客户端的 `exportBookFiles`,并且**都传空目录**(交给宿主按上面那套优先级现算)—— 于是它们不可能因为"路径框里改了但忘了点记住"而给出不同结果。路径框的职责被限定为"改设置",不是"这次导到哪"。 > > 253. **本版验证:31 个测试文件 / 539 项通过 / 0 失败 / 2 跳过。** 相对 §246 的 527 **净 +12**:新增 `test/export.test.mjs` 8 项(标记往返、许可判定、增量去重、幂等、追加时保留既有字节、避让外来文件、同名书互不踩、连消歧名也被占则报错)、`library.test.mjs` 3 项(一代一份 + 同秒去重、遗留单槽仍算一代、`exportBook` 幂等)、`routes.test.mjs` 1 项(导出路由 + 409/400 语义)。 > **v1.30 修订(背景注释续行漏进 `unknown` 的修复 + 读者文件清理)** > > 254. **真 bug:`background.md` 每写一次盘就多 3 行垃圾。** 现象是读者的文件里 `## 你手写的内容` 下攒了 4 组重复段落(12 行),并且被他导出到 Obsidian 里看出来了 —— 那 12 行**缩进 5 空格**,按 Markdown 规则(≥4 空格 = 缩进代码块)被渲染成一块灰底等宽文本。根因在 `parseBackground` 的守卫 `!line.startsWith('`、缩进 5 空格**。 > > 255. **影响面据实说(别夸大)。** `renderBackgroundForPrompt` 只遍历 `BACKGROUND_SECTIONS`,`unknown` 全程不被读 → **不进 prompt、不花 token、不引入剧透面**;但文件无上限增长、污染"留给读者写字"的那一节,并随导出与历代压缩前备份一起扩散。**读者的真实手写内容不会被复制**(引一次、写一次,稳定)—— 只有注释续行会翻倍。这也解释了它为什么一直没被注意到:**没有任何可观测的功能退化,只是文件在悄悄变胖**;是"导出到 Obsidian"这个新功能把它照出来的。 > > 256. **修法三条 + 一条闸。** ① `parseBackground` 加**注释块状态**:遇未闭合的 `` 的行为止,期间整块跳过(顺带覆盖"读者自己在别处写多行注释"的同类风险);② 顶部说明收敛成**单行常量** `BACKGROUND_NOTE`(两个渲染点共用)—— 没有续行,就没有"续行"这个类别可以漏;③ 新增 `scripts/clean-background-note.mjs` 做一次性清理(默认干跑、`--apply` 才写、备份到**插件目录**而不是工作区、**只删与常量逐字相同的行**),并且**写盘前要求"清理结果 = 解析→渲染的不动点"** —— 即清理出来的文件必须正好等于插件下次会写的那份,否则拒绝写盘。⚠️ **一个必须讲清的边界**:解析器修复**只阻止它继续长,不会清掉已经长出来的** —— 那些行此刻已经是该小节的普通内容了,所以必须显式清理。④ **闸**:`test/background-entities.test.mjs` 新增"渲染→解析→渲染 必须逐字相同"的幂等用例。**这条闸做过变异验证**:把 `background.js` 还原成修复前,它当场红("第二次渲染与第一次不同:往返在漂移"),恢复后转绿。它本该在 §212 引入 `unknown` 那次就存在 —— **当时的用例只验了"手写内容不被误归",没验"往返会不会漂移"**。 > > 257. **本版验证:31 个测试文件 / 540 项通过 / 0 失败 / 2 跳过**(相对 §253 的 539 净 +1,就是那条幂等用例)。读者那本书的文件已清理:`background.md` **131 → 113 行**,原始 9852 字节整份备份在 `$DSH_HOME/dsh-reading-companion/books/e662fbb34ac770e9/background.md.before-cleanup-<时间戳>`。另:导出副本不再写 ``(那条标记只服务于 `notes.md` 自己),H1 按读者要求**保留**;机器标记形态**维持 HTML 注释**、不做 `%%` —— 实测需要盖住的只有 10 行 / 96 行(10%),而 `%%` 盖不住源码模式、却要拿"逐字节搬运笔记块"去换,不划算(读者已确认:阅读模式下注释本来就不可见,他看到的"框框"是编辑模式的正常表现)。 > **v1.31 修订(界面标签:三处改名 + 文档对齐)** > > 258. **三处标签按读者要求改名,并把文档里所有会过期的称呼一起对齐。** ① 侧边栏页签名 `TAB_TITLE`:`陪读` → **`陪读模式`**。它同时是右侧栏「+」选择器里的条目名与页签标题,所以只改常量一处、两处跟着变;两条断言(`client.test.mjs`)同步更新。② 目录页 / 正文页右上角那颗进设置页的按钮:`陪读` → **`设置`** —— 它进的那一页装的是绑定 / 背景认识 / AI 视角预览 / 书友设定 / 防剧透闸 / 导出,本质就是一页设置;叫「陪读」既与页签名混起来,也说不清点下去会看到什么。③ 两个入口的导出按钮:`导出` → **`导出背景与全部笔记`** —— 「导出」本身没说是导出什么。⚠️ ③ 在「笔记」页那排按钮里偏长,但**两个入口必须同名**(它们行为一致,名字不一致会让人以为是两件事),这一点优先于排版。 > > **一处连带(不在读者那三条指令里,属于判断,可随时回退)**:设置页自己的标题也跟着改了(`陪读 · 书名` → `设置 · 书名`)。理由是同一个 —— 入口叫「设置」而打开后标题写「陪读」,正是 §237 / §239 记过的"同一个字符串的其它出现位置漏改"。 > > README 里所有会过期的称呼一并改掉:`右上角「陪读」`→`「设置」`、`「陪读」页`→`「陪读模式」页`、`列表里应出现「陪读」`→`「陪读模式」`、`看不到「陪读」`→`「陪读模式」`、`不会出现「陪读」`→`「陪读模式」`,并在导出一节写明两颗按钮的名字。**刻意没改的**:`## 陪读模式怎么用` 这个标题本来就叫对了;`陪读守则` / `陪读会话` / `陪读_<书名>` 文件夹名 / guide 的描述文字(`不剧透的 AI 陪读`)—— 它们是**内容**或**标识符**,不是那个页签名。 > > 259. **本版验证:540 项通过 / 0 失败 / 2 跳过**(用例数与功能数都不变:这一版只改字符串,两条页签断言随值更新)。 > **v1.32 修订(导出的落点与去重:每本书一个文件夹 / 去掉"固定名"那一份)** > > 260. **导出改为每本书一个文件夹:`<导出根>/陪读导出_<书名>/`。** 起因是读者实测后的两点:文件平铺在他的工作区根(那里还放着别的东西),而一本书的导出会随压缩代数长到十几个文件。同名书的消歧因此**上移到文件夹一级**(文件夹里已经有别书的导出标记 → 后来者用 `陪读导出_<书名>_`);**文件名一级的消歧保留**,两层各司其职:文件夹分开"整本书",文件名保证"从文件夹里单拿一个文件出去"也不丢身份 —— 后者正是这个功能最初的诉求,所以**没有**因为有了文件夹就把书名从文件名里拿掉。 > > 261. **去掉"固定名"那一份(`<书名>-背景-压缩前.md`)。** §249 / §250 当初刻意让最新一代出现两次(固定名 = "一眼找到最新"的入口 + 它自己的时间戳名)。读者一压缩就实测到了这个重复,并问"是不是一份最新、一份历史" —— 于是我重新掂量:时间戳本身可排序(`yyyyMMdd-HHmmss`),"最新"就是按文件名排序的最后一个,那个入口不值"每次多一份副本"。现在**一代一个文件**,N 次压缩 = N 个文件,信息零损失。⚠️ **这是我在实施时该单独问一次的地方**:当时它被打包进"A 四步"的整体确认里,读者没有单独对这条表态,结果要等到看见实物才发现。 > > 262. **本版验证:31 个测试文件 / 540 项通过 / 0 失败 / 2 跳过。** 导出相关的 7 条断言随落点更新(`test/export.test.mjs` 5 条 + `library.test.mjs` 1 条 + `routes.test.mjs` 1 条):其中"同名书"那条改成断言**文件夹级**消歧,"导出落盘"那条新增断言**固定名不再出现**,路由那条新增断言回报的 `dir` 是**实际写入的文件夹**(显示导出根会让人找错地方)。 > > 263. **顺带抓到一个测试夹具的真缺陷(与上面两件事无关,但症状是同一个)。** `test/.tmp` 里累积了 **1045 个目录 / 14.6 MB**,而夹具目录名一直是 `${tag}-${进程号}-${序号}` —— **Windows 会重用进程号**,而序号在同一文件的同一调用位置是确定的,于是两次不同的运行可能算出**完全相同的路径**;那些测试大多不清理自己,于是**读到了上一轮的残留**。实测证据:`discussions-prefix-10500-24/discussions.jsonl` 里有 **4 条**记录,正是"上一轮遗留 2 条 + 本轮又写 2 条",一条只写两条的用例因此断言 `4 !== 2` 失败。表现是**20 条与本轮改动毫无关系的红**——正是 §241 与 CONTRIBUTING 记过的那个症状。**也就是说那个症状有两个根因,先前只修了端口那一个。** 修法:目录名加上 `Date.now()`(要撞得同时满足同进程号 + 同一毫秒 + 同序号,可忽略),只改含 `join(` 的行(`process.pid` 在别处可能有别的用途)。加完之后旧目录再也不会被命中,无需清理。 > **v1.33 修订(2.0.2:跳读闸拆概念 + 最近窗口读厚 + 弹窗预估)** > > 264. **把 `jumpGateChapters` 一分为三。** 读者提问暴露的正是这个形状:他问"子代理数量和章节数是否可以设置",而他列出的"1–30 细读 + 30–199 粗读"实际是**两次串行调用**(`runFillLoop`,一次补齐 = 一次子代理调用,**没有并行**)。核对代码时发现一个更实在的问题:**`jumpGateChapters`(50)同时当两个东西用** —— 闸门阈值,和 `mode: 'recent'` 的窗口大小(`gap.to - gate + 1`)。于是"想放大最近窗口"就不得不放松闸门。v2.0.2 拆成三个:`jumpGateChapters`(阈值,默认 50,行为不变)+ **`recentWindowChapters`(窗口,默认 200)** + **`recentMinPerChapter`(那条路专用的每章下限,默认 300)**。 > > 265. **"一批多少章"与"每章多厚"是同一个旋钮 —— 这条认识改变了两处设计。** `一批章数 ≈ budgetChars ÷ minPerChapter`,抬下限会自动把批内章数压下来。① 读者要的"粗读一批最多 90 章"**等价于** `minPerChapter ≈ 266`,**不需要新增 `maxChaptersPerFill`**(我先前提过要加,那其实是同一个数的另一种说法)。② 于是"太浅"这件事只需改一个数:`minPerChapter` **100 → 150**(读者选定)。代价如实说:一批从约 240 章降到约 160 章,**批数变多、总耗时变长**(README 那张"读到第 246 章"的表因此从两批变三批)。另一条推论也写进了文档:**批数只影响耗时、不影响总成本**(总读入 = 覆盖章数 × 每章字数,与分几批无关)—— 所以"嫌慢就加批数或上并行"是无效的,要么减覆盖,要么每章减薄。 > > 266. **两条路给两套厚度,并把"值不值得"提前算给读者看。** 读者的两个诉求是"认知太浅"(主)与"不可控"(次),而它们**正面冲突**(更厚 = 批更多 = 等更久)。解法不是把全局加厚(对 989 章那又慢又贵),而是**缩小范围再加深**:全量纳入(覆盖优先,150 字/章)与最近这一段(深度优先,300 字/章)分开配下限。⚠️ 300 那条路一批只吃约 80 章,所以"最近 200 章"约 **3 批**。同时闸门弹窗新增两样:**两个预估**(约几批 · 每章约多少字,**由宿主算好**,客户端不重算预算 —— 同一套算术只该有一处实现)+ **窗口三档可选**(50 / 配置默认 / 300,请求体带 `recentWindow`,只作用于本次、不写回配置)。⚠️ 预估必须把**打底那批只吃 30 章**算进去,否则会少报一批(有专测钉住:149 章的缺口是 2 批而不是 1 批)。 > > 267. **两个边界 + 一处我自己写错的断言。** ① **窗口大于缺口时必须夹到缺口起点**:`gap.from = max(gap.from, gap.to - window + 1)`,否则 `from` 会算成 0 或负数(实测触发:缺口 149 章、窗口 200)。② `sampled` 新增回报 `perChapter` —— "深度"这个数此前只存在于库内,界面上与测试里都拿不到。③ ⚠️ 我第一版断言写成"recent 那条路每章 300",实测是 **600**:窄窗口(20 章)时均分额度算出来是 1200、被 `maxPerChapter` 封顶 —— **下限只在窗口很宽时才托底**。两个方向都补成了用例(窄窗口拿满 600、宽窗口被托到 300)。 > > 268. **本版验证:31 个测试文件 / 541 项通过 / 0 失败 / 2 跳过。** 新增 1 条(默认窗口大于缺口时的夹取 + 更厚下限的可观测形态),并更新 4 处断言:`gatePromptOf` 的返回形状(新增 `recentWindow`/`estimate`,并补一条"新宿主的字段原样交给弹窗")、界面接线的正则(**连窗口一起钉住** —— 只传 mode 而忘了窗口,读者选的档位就白选了且界面上看不出来)、`sampling.test.mjs` 的"库侧兜底 == 配置缺省"(库侧 `?? 100` → `?? 150`)、以及抽样默认值契约(新增三个数)。⚠️ 最后那条正好证明"两处写默认值 + 一条断言"这个设计是有效的:改了配置忘改库侧,它当场变红。 > **v1.40 修订(CI 修红:Node 22.19 上不存在 `--test-isolation`)** > > 290. **v2.0.4 首次推送后,CI 四档里两红两绿。** 红的都是 **Node 22.19**(ubuntu 与 windows 各一档),测试步骤**耗时 0–1 秒**;绿的 Node 24 两档正常。注解给出的关键证据只有一句 **`Process completed with exit code 9`** —— 而 **Node 的退出码 9 就是 Invalid Argument**,即**命令行开关不被识别**(≠ 退出码 1 的"测试失败")。 > 根因:`node --test --test-isolation=none` 里的 **`--test-isolation` 在 Node 22.19 上还不存在**。本机是 **Node v24.21**,所以本地怎么跑都是绿的 —— **只有当 CI 跑另一个 Node 大版本时才暴露**。 > 修法:**CI 换回零可选开关的 `node --test`**(就是 `package.json` 里 `npm test` 那条)。默认的"每个测试文件一个进程"在 CI 上完全没问题(带权限在本地实测过一次:551 项 / 549 通过 / 0 失败 / 7.7 秒);**只有受限沙箱**(不能 spawn 子进程)才需要 `--test-isolation=none`,那条留在 README 的 `npm run test:no-isolation` 里。矩阵**保留 22.19** —— 它正是逮住这个 bug 的那一档。 > ⚠️ **两条可复用教训**:① **"本地全绿"证明不了 "CI 全绿"**,当 CI 跑的是另一个 Node 大版本时尤其如此;② **退出码本身就是线索** —— 9 = Invalid Argument(开关/参数问题),1 = 测试失败,别把两者混为一谈。 > **v1.39 修订(笔记分页:追加式 → 真翻页)** > > 287. **读者的澄清**:他要的是"**每页 10 条 + 可以翻页**",而不是"一路点「加载更旧」、把列表摊成一长条"。⚠️ 顺带纠正一处事实:**从前也不是翻页** —— 它一直是追加式(`before` 游标 + `mergeNotesPage` 把新一页接到列表尾部)。所以这次是**换形态**,不是把 20 改成 10(v1.38 那条只改了页大小)。 > > 288. **实现:只改客户端,宿主一行没动。** `NotesView` 把单个 `notesCursor` 换成**游标栈**(`cursors[i]` = 第 i 页的 `before`,第 0 页 = null):下一页把宿主的 `nextCursor` 压栈、上一页弹栈,每次都**替换**列表。宿主那边的游标语义(`before` = 该页之前那一条的 id)**一点没改** —— 翻页要的"往回",是客户端把走过的游标存下来实现的。这正是当初选"**游标**"而不是"页码"的红利(见 README 那段:翻到第 3 页时又写了一条新笔记,用页码就会重复上一页的最后一条)。 > 删掉 `mergeNotesPage`(连同它的纯函数专测)与 `loadingMore`;`NoteList` 的 props 从 `hasMore/loadingMore/onLoadMore` 换成 `page/pageCount/hasPrev/hasNext/onPrev/onNext`,底部渲染成「← 更新 / 第 N / M 页(每页 10 条)/ 更旧 →」。 > **测试**:那条纯函数用例换成**静态接线断言**(游标栈 / 压栈 / 弹栈 / 两个回调接进 `NoteList`),并加**反向守卫**(`mergeNotesPage` / `onLoadMore` / `loadingMore` 都不该再出现)—— 与 v1.37 撤「复习」时同一手法。 > > 289. **本版验证:551 项 / 549 通过 / 0 失败 / 2 跳过。** > **v1.38 修订(笔记每页 20 → 10 条)** > > 286. **笔记分页的页大小 20 → 10**(读者真机验收后提出:"我一条笔记还蛮长的")。**两处必须同时改**:宿主 `NOTES_PAGE_DEFAULT` 与客户端 `NOTES_PAGE_SIZE` —— `contract.test.mjs` 有一条契约用例钉着"客户端传的 `limit` 必须等于宿主默认页大小"(不然客户端传的值会赢,宿主的默认就成了摆设)。上限 `NOTES_PAGE_MAX = 200` 不动。 > 只改这两个数值,**分页逻辑本身一点没动**:游标语义(`before` = 当前最旧一条的 id)、`reset` 兜底(锚点失效时替换而不是追加)、宿主侧 reverse(为了 `memo` 不被打掉)全部原样。 > ⚠️ 历史修订块 §66 里写着"默认每页 20 条"——**那是当时的记录,按本仓库的规矩不改写历史**;当前口径以本块与 README 为准。 > **v1.37 修订(人物卡:只认「人物」一节;「复习」加过又撤掉)** > > 281. **新增「人物卡」**(调研建议 ②,`entityCardsFor`):`background.md` 里每条条目本来就带章号、也按 `### 主体` 分好组,但读者能看到的只有原始 markdown。于是加一份**只读视图**:按主体归堆 + 按进度过滤(`GET /background?atChapter=` 与同一个响应里的 `gap` 共用 `progressIndex`)+ 面板渲染。**只读、零 AI 调用、不进注入路径。** > > 282. **⚠️ 第一版判定太宽,真机当场被否。** 我把四个分组分区(人物关系 / 人物 / 世界观 / 通用概念)全收进来,于是《魔女霓裳》里 `## 通用概念` 下的「廿年之约 / 缘 / 情分 / 心魔 / 作者有话要说」、`## 世界观` 下的「《白发魔女传》原著」都成了"人物卡"。改为**只取 `## 人物`**(`CARD_SECTION`)。实测同一份文件:修前 11 张(含 7 个噪音)→ 修后 **4 张**(练霓裳 5 / 竹纤 7 / 凌慕华 4 / 白狼 2)。 > ⚠️ **「白狼」仍在** —— 它是模型自己归进「人物」的(书里那条狼),分区限制治不了它。所以**界面上写明了来源**("来源是 background.md 的「人物」一节,想增删就改它"):**判定归分区、分区可手改**,这比在代码里猜名字可靠。 > > 283. **「复习」tag + 「回顾」开关:加了又撤(读者裁定)。** 理由是"历史本来就都在这里,没必要"。连带撤掉 `notesPage` 的 tag 过滤与 `GET /notes?tag=`。**保留「回到第 N 章」** —— 它不依赖 tag,是独立的导航能力(章号是笔记机器标记里现成的)。并在 `test/notes-jump.test.mjs` 留了一条**回退守卫**(断言 `复习` / `reviewOnly` / `reviewQuery` / `query.get('tag')` 都不再出现):**撤掉的东西要有痕迹,否则改天又长出来。** > > 284. **教训(本轮自己踩的)**:回退 `tags.js` 时我**多加了一个 `])`**(原来的那个还在),整个文件语法错 → `node --test` 里 **64 个测试失败、用例数从 550 掉到 315**。⚠️ **"共同模块坏掉"就是这个形状:大量「文件级」失败 + 用例总数骤降** —— 看到它先去 `node --check` 那几个被改动的共用模块,不必逐个查。 > > 285. **本版验证:551 项 / 549 通过 / 0 失败 / 2 跳过。** > **v1.36 修订(版本号定稿 2.0.2 + 把"历代合并"正式记为插件不做的事)** > > 278. **版本号 `1.0.1` → `2.0.2`**(读者下令:"现在的版本号是 2.0.2,先暂时按这个写")。做法同 §240:`README.md` 顶部横幅一起改(`v1.0.1` → `v2.0.2`)。这个号只在 `package.json` **一处**定义、`/health` 的 `version` 从它读,所以不会漂移。⚠️ 读者已说明**后面还会要改 README**,所以本版只动横幅与下面那节新增,**不重构 README**。 > > 279. **"历代合并"正式记为插件「不做」的事**(读者裁定)。他的表述:**最完整的背景 = 所有背景文件副本互相剔除压缩后的信息、保留未压缩信息的并集**;他自己追问"压缩后的信息和详细版的信息怎么一一对应地去重",然后裁定**不在插件里实现** —— 读完把文件夹丢给 AI 或别的工具合并,**保留原文件**,那样最稳妥。 > 本版把这个建议写进 README(新增 `#### 想要"最完整的那一版":历代合并(插件不做,交给你)`),并写明**为什么插件不做**:压缩是模型对自由文本的**重写与合并**,压缩后的条目既没有 id、也没有稳定标题,章号区间还可能被合并或改写 —— 所以"哪条对应哪条"**无法可靠判定**;而任何自动合并都会写下**不可逆**的新内容,偏偏它又是整条链路上唯一会删内容的一步。插件只负责"历代一份不删"与"导出时一代不漏",合并交给外部工具、且**必须保留原件**。 > 同处也记下那条容易被忽略的事实:**每一代都是完整快照(不是增量)**,所以"历代 + 当前"叠起来就是并集;而"越早的那代覆盖章更少、但每条更细"。 > 给出的提示词要点:并集优先 / 同主体按章号对齐且同号取更详细者 / **不发明内容** / 冲突并存并标注 / 输出到新文件且不动输入 / 最后报告"补回多少条、多少条无法对应、哪些章号冲突"。 > > 280. **README 新增「参考了哪些插件(以及具体借鉴了什么)」**(读者要求:把借鉴过的插件与**具体借鉴的功能**写清)。写法上定了一条硬规矩:**只写真的有代码落点的借鉴**,每条都指得出出处(各文件头注释、`docs/design-v1.md` 的规避清单与逐条核实记录),并单列一节"只做过定位对比、没有借鉴具体机制"的插件 —— 免得把"看过"写成"借鉴过"。 > 梳理出的实际引用面:**`dsh-reader`**(编码探测顺序 / 章节正则基线 / inbox 扫描导入的形态;同时是反面教材的主要来源:DOM 选择器冒充插槽、幂等旗标只置真不重置、整本进内存、`