dsh-reading-companion
No description
- Stars
- 0
- Language
- JavaScript
- Created
- Sep 23, 2026
- Updated
- Oct 6, 2026
Introduction
dsh-reading-companion
给小说读者的本地阅读器 + 不剧透的 AI 陪读。 把一本书装进 DSH 右侧栏,让「读」和「聊」在同一屏发生:你在正文里划一段、写下想法,它接着聊 —— 而它只读到你读到的地方。
它跟"把书丢给 AI 聊"最大的区别是:它记住的是这本书,不是这段对话。 读一本 1400 章的书,它不会忘、不会乱、也不会提前把后面的情节说给你。
- 读什么:本地 TXT(自动探测编码、自动切章;无标题时降级为固定块),原书、笔记、AI 的理解全在你自己磁盘上 —— 无账号、无云、无书源
- 留下什么:摘抄 → 我的感想 → tag → AI 回应,写成结构化 Markdown,可直接进 Obsidian 之类的笔记库
- 怎么装:DSH 插件(Cordis bundle),零运行时依赖、零构建步骤 ——
lib/就是源码
本地 TXT → 自动目录 → 正文阅读 → 进度持久化 → 绑定会话陪读
→ 三层防剧透 → 摘抄笔记 + 自动 tag → 背景认识增量补齐 → 导出到笔记库
缘起 · 五个特色 · 怎么用 · 防剧透 · 安装 · 配置参考 · 数据目录 · 开发
每个版本改了什么 → 见 Releases(只写读者能感知的结论)。这份 README 只讲它现在是什么、能做什么、怎么用 —— 更新说明不放在这里。
缘起:因为某本书,才想做这样一个插件
它始于一个很私人的念头:在读一本自己很喜欢的书时,我想让 AI 陪我读,又不想被它剧透 —— 于是有了这个插件。
五个特色
按「别人最做不到的」排。每条后面都写着它做不到什么 —— 这个插件不靠把话说满来卖。
① 不剧透是结构保证的,不是提示词承诺的
绝大多数"AI 陪你读书"靠一句"请不要剧透",那只是请求。这里是三层,而且第一层是硬的:
- 路径闸:任何指向本书
content.txt/source.txt/chapters.json的工具调用一律拒绝,与会话归属无关(../之类的绕过也挡,有专测) - 每轮只投喂三样:本章全文 + 上一章结尾 + 那份背景认识 —— 你贴过去的摘抄另算
- 倒退阅读会过滤:从目录直接跳到第 1000 章、补完记忆又回到第 50 章时,第 900 章的条目不会原样注入(否则就是静默剧透)
- 想亲眼复核它到底收到了什么:
GET …/books/<bookId>/context原样给出注入内容
⚠️ 它保证的是路径级,不是文件级:Windows 的 8.3 短名与硬链接仍能绕过(详见「安全与隐私」的边界表)。
② 它记住的是这本书,不是这段对话
不是"每章一条梗概",而是一份随进度增量丰富的理解,写在书目录的 background.md 里,只增不减:
- 分区:文本类型(元判断)/ 人物状态 / 人物关系 / 人物 / 世界观 / 文风(只写一次) / 通用概念(兜底)+ 只给你看、不进提示词的「时间与分线」与「冷档案」
- 每条带章号、按时间排;AI 记错了,你打开文件改一行就是纠正
- 缺口大时先问再补 —— 把没读到的章节写进记忆是不可逆的
- 压缩是唯一会减内容的一步,要过五条硬校验,任何一条不过就整批丢弃、文件一字不动;压缩前留一代带时间戳的备份,一份不删
- ⇒ 这就是"百万字也读得下去"的原因:上下文不随书长而长
③ AI 一起读,不打断阅读
正文里选中一段就弹出「记笔记」:原文摘抄 → 我的感想 → tag(按关键词确定性打分,零模型调用)→ AI 回应(留空则不落盘)。 「发到会话去聊」把这段和你的想法送进对话,它接着聊;正文页还会显示「本章你记过 N 条」,点一条能跳回原文那一段。
④ 笔记是你的文件,不是数据库
结构化 Markdown,只追加(绝不覆盖你在笔记库里写的批注与双链)、绝不往陌生文件里写(每个导出文件带 <!-- drc-export book=… --> 标记,没有标记的一律拒绝)。
可以直接在 Obsidian 里编辑,也可以提交到版本管理。笔记列表分页浏览、一键跳回对应章节;重切章节会自动重映射笔记坐标,不会丢。
⑤ 为小说而生
- 章号锚 + 章内偏移:全插件只有一个坐标"你读到第几章" —— 投喂窗口、记忆缺口、跳读闸、倒退过滤、讨论注入全部由它派生,所以它绝不会"顺手"知道更多
- 长章自动切分(阈值 5000 字 / 片长 3500);1400 章的书目录按卷折叠,不必一次铺出七千个元素
- 编码探测:BOM → 严格 UTF-8 → GB18030 依次试;没标题就降级为固定块并给出解析告警
- 文本分析 / 总结:背景认识本身就是这本书的结构化摘要;另有只读的「人物卡」(只含你读到的部分)与这本书的「讨论时间线」
怎么用
四个地方,各管一件事。
① 书架:导入、绑定、分类
把 TXT 丢进 $DSH_HOME/dsh-reading-companion/inbox/ 点「扫描导入目录」,或直接粘一个绝对路径。
每本书显示章数、体积、编码与阅读进度;点「跳过去」进正文,也可以先选个分类、绑定一个会话。
② 正文:选中一段,点「记笔记」
顶部是上一章 / 下一章 / 设置 / 笔记 / 字体(Aa)。在正文里选中一段,浮动条会自动弹出 「已选 N 字」与「记笔记」——点它会带着这段原文与该章节号进入笔记页。
③ 笔记页:摘抄 → 感想 → AI 回应
四段式:原文摘抄(自动填)、我的感想、tag(按感想里的词确定性打分,可自己加)、
AI 回应(可选,留空就不落盘)。按钮分两行:① 发到会话去聊 / ② 抓取选中文字作回应,
然后是落盘用的「写入笔记」(主按钮)与暂存用的「保存草稿」;导出在「设置」页
(那一页还能记住导出目录)。换笔记存放位置也在这一页。
④ 设置(「本地书架」页):绑定、人设、记忆
右侧栏「+」里选「本地书架」:绑定会话、写「书友设定」(你想要的口吻与关注点)、 看背景认识记住到第几章、翻人物卡(只含你读到的部分), 以及这本书的讨论时间线。
防剧透:三层
| 层 | 强度 | 管什么 |
|---|---|---|
| 提示词守则 | 常驻,不受任何开关影响 | 不主动说后续 ——包括"制造期待"式的元剧透("后面有反转"、"熬过这段就好"、"以后看到 X 留意"、"我先不说");引文只能来自原文(不许凭记忆引,那可能把后文引出来);分清事实 / 引语 / 推断;用了二手来源(书评 / 百科 / 它自己的记忆)就第一句声明,且读者永远优先于二手来源 |
路径闸(spoilerGate) | 硬保证(唯一例外见下) | 参数指向本书 content.txt / source.txt / chapters.json 的调用一律拒绝,与会话归属无关。⚠️ 唯一例外:你在面板里声明「这本书已读完」之后,这一本的原始文本对你放开(界面常驻显示,可一键收回) |
联网闸(webGate) | 启发式 / 可关 | 见下 |
它每轮实际拿到的只有三样:本章全文、上一章结尾、那份背景认识——外加你贴过去的摘抄。
你还没读到的地方,它字面上拿不到:路径闸连"模型自己想办法去读文件"这条路都堵了(../ 之类的绕过也挡,有专测)。
⚠️ 但它是"路径闸"不是"文件闸":Windows 的 8.3 短名与硬链接能指向同一个文件而路径不同,这两种仍能绕过(见下方「安全与隐私」的边界表)。
读完一本书之后,你可以在面板里标记「已读完」解锁它:那只放开这一本的原文("你问,它才读得到"),不影响每轮自动投喂的内容,而且可以一键收回。
想亲眼复核它到底收到了什么:注入的内容由插件的只读接口原样给出 ——
GET /dsh-reading-companion/api/books/<bookId>/context(面板里不再放这个入口,
因为它只是「别处状态的视图」,摆一节在那里会让人以为它可以单独重建)。
联网闸是启发式:扫工具参数里有没有书名、人物名、"结局/剧透"这类词,能挡住无心之失,
挡不住刻意查询——这一点写在守则里,也写在 docs/design.md 里,不装成"绝对防得住"。
导出到笔记库
在「设置」页点「导出背景与全部笔记」之后,默认落到这本书所绑会话的工作区根下的
陪读导出_<书名>/,文件名是 <书名>-笔记.md;在设置页里填过一次导出目录就落到那里
(还没绑定会话、又没填目录时它会让你先指定一个 —— 不会乱猜一个位置写进去)。
它就是普通的 Markdown:用任何笔记库工具打开、编辑、提交到版本管理都行。
导出的结果不再弹提示框:它常驻在这一节的说明里(成功绿 / 失败红),并记着上次导出是什么时候、 新建了几个、更新了几个 —— 它属于"这一节的状态",看一眼就知道,不用去追一条会消失的提示。
- 只追加,绝不覆盖。 你在笔记库里写的批注、加的双链,重复导出一个字都不会被碰。
- 绝不往陌生文件里写。 每个导出文件头部有一条
<!-- drc-export book=… -->标记;目标属于别的书、 或者压根没有标记(那是你自己写的文件),一律拒绝并报错。 - 手写的、没有 id 的笔记块不导出,并会明说几条。
背景认识(记忆)
它是陪读 AI 对这本书的理解,一份随进度只增不减的 Markdown,写在书目录的 background.md 里。
分区:文本类型(元判断)/ 人物状态 / 人物关系 / 人物 / 世界观 / 文风(只写一次) / 通用概念(兜底)+ 只给你看、不进提示词的「时间与分线」与「冷档案」。
- 你随时可以直接打开读、也可以改 —— AI 记错了,改一行就是纠正
- 压缩是唯一会删内容的一步:过五条硬校验,任何一条不过就整批丢弃、文件一字不动;每次压缩前留一代带时间戳的备份,一份不删
- 缺口大时先问再补(把没读到的章节写进记忆是不可逆的);发笔记那一路会自动把开头 30 章跑完
- 面板里另有只读的「人物卡」(只含你读到的部分)与这本书的「讨论时间线」
📖 格式细节、哪几节喂给 AI、立卡门槛、怎么合并两个同名人物、怎么把历代备份并成最详细的那一版 → 见 docs/background-format.md。
安装
[!NOTE] 没有插件前置:本插件不依赖任何第三方插件(
dependencies/peerDependencies都是空的)。 它自己不画侧边栏——只是往 DSH 本体提供的右侧栏里注册一个页签。那个接口(sidebarRightTabs) 是 DSH 的客户端模块@deepseek-ai/dsh-client-ui-sidebar-right发布的,由本插件的dsh.client.inject声明,不需要额外装任何插件。 若右侧栏「+」里看不到「本地书架」、而控制台没有任何报错,那是 DSH 太老 (sidebarRightTabs是0.1.5起才有的服务)—— 按下面的版本要求升级。
前置要求:DSH ≥ 0.1.5-rc.2、Node ≥ 22.19(engines: ^22.19.0 || >=24.0.0)。
一键装(推荐让 DSH 自己装)
把下面整段复制到 DSH 对话框里发出去,它会自己找 profile、检查并补齐前置、装好、核对 manifest:
请帮我把 DSH 插件 dsh-reading-companion 装进我当前的 profile。
1. 先确定 profile 目录:我用的是 DSH Desktop,profile 名应该是 desktop;如果我的环境实际属于别的面,
请告诉我正确的 profile 名再继续。目录 = $DSH_HOME/profiles/<profile 名>,$DSH_HOME 默认 ~/.dsh。
确认该目录下确实有 package.json 和 cordis.yml。
2. 装本插件。它**没有插件前置**——不要顺手装别的插件:
dsh plugin --profile <profile 名> add "github:xling001/dsh-reading-companion"
3. 装完核对 profile 的 package.json 这两处:dependencies 里有 "dsh-reading-companion"、
dsh.profile.bundles 里有 "dsh-reading-companion"。缺哪条补哪条。
4. 最后告诉我需要重启 DSH Desktop,以及重启后怎么验证装好了。
或者:命令行 / 手工 / 本地开发
# 本插件没有插件前置,直接装(DSH Desktop / DSH Web 各一行)
dsh plugin --profile desktop add "github:xling001/dsh-reading-companion"
dsh plugin --profile web add "github:xling001/dsh-reading-companion"
| 你用的面 | profile 名 | profile 目录 |
|---|---|---|
| DSH Desktop | desktop | $DSH_HOME/profiles/desktop |
DSH Web(dsh web) | web | $DSH_HOME/profiles/web |
$DSH_HOME 默认是 ~/.dsh(Windows:C:\Users\<你>\.dsh)。别把 --profile desktop 抄给用 Web 的人:
内置模板只有 acp / web / headless / sdk / sdk-minimal,desktop 是 DSH Desktop 自建的。
dsh plugin 只做一件事:把剩余参数转发给 profile 目录里的 pnpm。所以你不用手动改 bundles
——pnpm 结束后,DSH 会把「声明了 dsh.bundle 的依赖」自动补进去。从 GitHub 装时若 pnpm 提示构建脚本
被拦下,把它打印的 key 加到 $DSH_HOME/profiles/<profile>/pnpm-workspace.yaml 的 allowBuilds 下重跑一次
(本插件没有构建步骤,正常不会遇到)。
本地开发(改完即生效)用仓库自带脚本——它只碰自己那一个键,并在 profile 的 node_modules
里建一个目录联接指向本仓库,所以不需要跑 pnpm install,也不会打扰 profile 里已有的其它插件:
node scripts/link-into-profile.mjs --profile desktop --dry-run # 先看将要做什么
node scripts/link-into-profile.mjs --profile desktop # 实际写入
node scripts/link-into-profile.mjs --profile desktop --unlink # 完全回滚
⚠️ 装完必须重启 DSH Desktop
dsh.profile.bundles 只在启动时读取一次。patchReload: "live" 只覆盖 cordis.patch.yml 的改动,
覆盖不了"新增一个 bundle"。刷新页面不够,要重启应用(dsh web 同理:重启那个进程)。
验证
- 打开任意会话,点右侧栏的「+」;
- 列表里应出现「本地书架」(一本摊开的书的图标);
- 点开进入书架视图。
看不到时按顺序查:重启了没?(dsh.profile.bundles 只在启动时读一次;右侧栏选择器的条目
完全由插件注册的 guide 数组构建,看不到就是客户端半边没挂上)→ DSH 版本够不够?
(sidebarRightTabs 是 0.1.5 起才有的服务,缺了它 cordis 不执行 apply、也不报错)
→ 都没有看控制台报错,请开 issue。
接着导一本书、读一章、记一条笔记。最短全流程与发版前的真机回归清单在 docs/manual-testing.md(⚠️ 这份是开发用的,不随包发布)。
关闭与卸载
本插件是纯加法的:cordis.patch.yml 里只有一条 insert,不替换任何宿主自带行、不接管既有服务。
- 临时关闭:把
dsh-reading-companion从 profile 的dsh.profile.bundles里删掉,改完重启。 - 彻底卸载:
dsh plugin --profile desktop remove dsh-reading-companion(Web 换成--profile web), 或用node scripts/link-into-profile.mjs --unlink。 - 数据不会被卸载删除:书库与笔记都在独立目录里,删插件不删书。
配置参考(全部字段与默认值 —— 需要时展开)
配置参考
配置写在 cordis.patch.yml 的那条 insert 里,任何字段都可在 profile 的 cordis.patch.yml 覆盖。
标注「运行时」的项,读者也能在面板里改,且面板优先于配置文件。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
storageDir | string | '' | 书库与笔记根目录。留空是有意的:默认走宿主的 dshHomePath() 解析成 $DSH_HOME/dsh-reading-companion,这样 profile 迁移时书库跟着走 |
inboxDir | string | 'inbox' | 「扫描导入目录」扫的收件箱,相对 storageDir |
fallbackBlockChars | number | 4000 | TXT 没有可用章节标题、降级为固定块时的块大小(字符) |
importRoots | string[] | [] | POST /library/import 的白名单。空 = 不限制(导入本来就是"从磁盘任意处读书"这个功能本身)。填了就只接受落在这些根目录内的路径,判定走真实路径,用链接绕不过去 |
exportDir | string | '' | 默认导出目录。空 = 落到这本书所绑会话的工作区根;面板里改过一次就写进 settings.json,优先级 settings > 此值 > 工作区根 |
spoilerGate | boolean | true | 路径闸:指向本书原始文本(content.txt / source.txt / chapters.json)的工具调用一律拒绝,与会话归属无关 |
webGate | 'block-all' | 'block-book' | 'off' | 'block-all' | 联网闸强度(运行时可在面板改)。block-book 是启发式:放行联网,但拒绝看起来在问这本书的查询 |
window.currentChapterMode | 'full' | 'read-so-far' | 'full' | 当前章给全文,还是只给到光标处 |
window.headAllowanceChars | number | 1500 | 仅 read-so-far 用:至少给当前章开头这么多字符 |
window.previousChapterMode | 'tail' | 'full' | 'tail' | 上一章给多少:只给结尾(在段落处切)还是整章 |
window.backgroundBudgetChars | number | 9000 | 背景认识那段的上限;超了按优先级裁剪,面板会说明裁掉了什么 |
window.backgroundCoarseDegrade | boolean | true | 降级第二档:主体整体被丢之前,先降成"### 主体 + 最近一条" |
window.compactThreshold | number | 0.85 | 背景超过 backgroundBudgetChars × 此值 时,下一次补齐先压缩(设为 1 = 关闭自动压缩) |
window.archiveWindowChapters | number | 120 | 冷归档的活跃窗口(3.0):补齐前,纯代码把整条落在窗口之外的旧条目搬进 ## 冷档案(原文只搬运、零模型调用、不再进提示词,但仍在文件里可查)。先归档、后压缩;设 0 = 关掉 |
window.personOfflineChapters | number | 60 | 在线折叠(3.0 ②c):人物"最后被提及"距今超过这么多章 ⇒ 注入时折叠成锚(只留最新一条、状态行也不注入——文件不动,他再出场自动展开)。治"窗口只向前看 ⇒ 离场配角全卡一直占注入";设 0 = 关闭 |
window.discussionLimit | number | 8 | 注入多少条讨论时间线 |
sample.budgetChars | number | 18000 | 一次补齐调用的字符预算——它决定一次能闭合多大的缺口。3.0 从 24000 降到这里:批更小 ⇒ 单次回复更小 ⇒ 不撞模型 32768 输出上限、也不容易卡住 |
sample.foundationBudgetChars | number | 24000 | 打底批(首次补齐那批)专用预算(3.0):它只有一个、输出有界,所以保住旧预算 ⇒ "开头 30 章读厚、一次成型" |
sample.minPerChapter | number | 600 | 默认形态下这是"均分额度的下限",同时决定一批能吞多少章:一批章数 ≈ budgetChars / minPerChapter(600 → 约 30 章)。⚠️ 不要设得比 maxPerChapter 高,否则这个下限会被上限吞掉 |
sample.maxPerChapter | number | 1200 | 单章上限(重点章可拿到它的 emphasisFactor 倍)。批越窄,budgetChars ÷ 权重和 算出的额度越高,靠它放行 |
sample.foundationChapters | number | 30 | 只对第一次补齐生效的上限:第一次就厚读开头,而不是把预算摊到几百章 |
sample.emphasisChapters | number | 5 | 开头前 N 章(以及每卷的卷首章)按 emphasisFactor 加权 |
sample.emphasisFactor | number | 3 | 加权倍数 |
sample.jumpGateChapters | number | 50 | 跳读闸阈值:一次补齐要闭合的缺口超过它就先问(回 409 与缺口范围,面板给三个选项)。设 0 关闭 |
sample.recentWindowChapters | number | 200 | 选「只记最近这一段」时的窗口大小(调用时可临时改,这是默认值不是上限) |
sample.recentMinPerChapter | number | 1200 | 只给 recent 路径用的每章下限(比 minPerChapter 厚:那条路要的是"能聊这一章",不是"不致迷路")。⚠️ 必须 ≤ maxPerChapter,否则形同虚设 |
memoryTimeoutMs | number | 600000 | 一次补齐最多阻塞多久(插件内部另有中止定时器,不会永久挂住)。⚠️ 超时会把子代理 abort 掉,界面上看起来像"停止了",而且那一批整批白跑—— 真嫌慢请调小 sample.budgetChars(批更小、批数更多),别把它调得太短。2026-10-02 据真机会话记录从 5 分钟提到 10 分钟:慢模型在大批次上会出现"首 token 5 秒、之后 5 分钟不吐字" |
数据目录
人可读的东西跟着会话工作区走,大文件留在插件目录。
<会话工作区>/陪读_<书名>/ # ★ 你的笔记在这里
notes.md # 结构化读书笔记(只追加,永不重写)
background.md # 陪读 AI 的背景认识(条目只增不减)
persona.md # 你写给 AI 的「书友设定」
background.bak.<时间戳>.md # 每次压缩前留一代,一份不删
README.md / .dsh-reading-companion.json # 自动生成的说明 / 认领标记
$DSH_HOME/dsh-reading-companion/ # 默认;可用 storageDir 覆盖
inbox/ # 把 TXT 丢这里,点「扫描导入」
library.json / bindings.json / drafts.json / categories.json
books/<bookId>/
meta.json # 书名/编码/字数/章节数/解析告警
source.txt # 原书原始字节(只读,永不改写)—— MB 级
content.txt # 解码并归一化换行后的 UTF-8 全文 —— MB 级
chapters.json # 章节索引(标题 + 精确的字符/字节区间)
discussions.jsonl # 讨论时间线(每行一条摘要)
notes.md / background.md / persona.md # ← 迁移期间的安全网副本
拿不到工作区时(还没绑定、或路径失效)退回插件目录,笔记照样写得进去,「笔记」页会把实际路径
与回落原因摊给你看,并给一个「重新检测位置」。两本书绝不会写进同一份笔记:每本书一个文件夹,
同一工作区里两本不同的书同名时后来者变成 陪读_<书名>_<bookId 前 6 位>(有专测钉住)。
迁移是复制,不是移动——老文件原样保留作安全网,你确认没问题后可以自己删。
bookId = 源文件 sha256 的前 16 位,所以同一份文件重复导入是幂等的:命中已有记录、不重复落盘、
更不会覆盖你写过的笔记。
安全与隐私
| 要求 | 实现 |
|---|---|
| 数据本地化 | 原书 TXT、章节索引、笔记 md、背景认识全程留在本地,不上传任何服务器 |
| 只发该发的 | 只有你主动发感想时,被裁切过的那段正文才随对话进入模型请求——裁切范围是「前文 + 本章已读」,不含后续剧情 |
| 导出可控 | 只写到你指定的那个目录,也只在你点了按钮之后才写;不会改动陪读文件夹里的任何东西 |
| 不覆盖你的字 | 导出只追加,并靠文件头部的 <!-- drc-export book=… --> 标记拒绝写进陌生文件 |
| ⚠️ 导入面要说清 | POST /library/import 接受一个绝对路径并把它读进书库——插件自己没有鉴权,这一条完全依赖宿主的渲染器令牌门。想收窄范围就配 importRoots(判定走真实路径) |
| ⚠️ 路径闸的边界(未修) | 闸判的是路径,不是文件本身。Windows 的 8.3 短名(PROGRA~1 这类)与硬链接都能指向同一个文件而路径不同 ⇒ 它们仍能绕过。要挡住得做 inode / 文件 id 比对,当前没做 |
| ⚠️ 交接棒有 120 秒保质期(静默丢弃) | 把摘抄"发到会话"、而这本书绑的是另一个会话时,文字会先交接过去、等那边把面板挂起来接住。超过 120 秒没人接就静默丢掉 —— 你会看到"已放进输入框"但输入框里没有。遇到就重发一次 |
| ⚠️ 彻底删除存在 TOCTOU 缝隙(已知限制) | purgeNotes 的保险是"先备份 → 写前核对文件没被别人改过 → 才写"。核对与写入之间仍有极短窗口:若外部程序恰好在那一瞬改动 notes.md,可能覆盖掉那次改动。已裁定为已知限制,不修(代价是给每次清理加一把常驻文件锁) |
| 无遥测 | 本插件没有账号、没有云、没有书源,也不含任何遥测/行为分析代码 |
架构简介(代码结构与关键设计 —— 需要时展开)
架构简介
一切皆插件、零依赖、无构建。 宿主半边(Node)只用 node: 内置模块;浏览器半边是宿主模块加载器认的
手写惰性 CJS 信封(window.__ModuleLoader__.load({ id, factory })),唯一外部依赖是壳提供的
require('react')——所以 lib/ 就是源码,省掉了整条构建链与全部 devDependencies。
lib/
├── index.js # 宿主入口:cordis 插件名、prefix 路由、服务发布、prompt 段落回调
├── client.js # 浏览器半边(必须自包含):React 手写 h(),书架/正文/笔记/设置四个视图
└── host/
├── library.js # 书架:导入、编码探测、切章、进度、绑定、分类、reindex
├── chapters.js # 章节标题正则与固定块降级
├── encoding.js # BOM → 严格 UTF-8 → GB18030 探测
├── paths.js # 路径闸与目录闸(所有落盘先过它)
├── atomic-json.js # 原子写 + revision CAS
├── notes.js # 笔记:机器锚点、分页(游标)、草稿、旧文件兼容
├── tags.js # 确定性 tag 词表(零模型调用)
├── background.js # 背景认识:分区解析、注入渲染、裁剪、人物卡
├── background-update.js# 改块提示词与字段校验
├── memory.js # 缺口计算与补齐循环
├── compact.js # 压缩(五条硬校验)与历代备份
├── spoiler.js # 守则 / 情况 / 读窗 / 讨论的 prompt 装配 + 注入体积度量
├── discussions.js # 讨论时间线
├── export.js # 导出:标记、消歧、只追加、历代快照
└── subagent-run.js # 借宿主会话跑补齐调用
scripts/
├── link-into-profile.mjs # 本地开发:往 profile 里建目录联接(纯加法,--unlink 回滚)
├── reindex-books.mjs # 让已导入的书吃到新的切分规则(默认预览,--apply 才写)
├── rebuild-library-index.mjs # 索引损坏后唯一的恢复入口:扫每本书的 meta.json 重建(默认预览)
├── archive-background.mjs # 冷归档:把超出活跃窗口的旧条目搬进「冷档案」(纯代码、零模型调用)
├── merge-background-history.mjs # 取并集:把历代增量备份 + 当前文件合成"最详细的全文分析"
└── clean-background-note.mjs # 清理 background.md 的注释残留(默认预览,需 --file 指定)
docs/ # design(现行)/ design-v1-archive(封存)/ manual-testing / publishing
关键设计:
- 单一坐标:进度是唯一坐标,投喂窗口、缺口、闸门、倒退过滤全都从它派生——好处是能力之间不打架,代价是它滞后就会连锁出错(面板因此同时显示「读到第 N 章 · 记忆到第 M 章」)。
- 硬闸与启发式分开:路径闸是硬保证(与会话无关),联网闸是启发式(明说挡不住刻意查询),提示词守则常驻。不把启发式包装成保证。
- 只增不减:笔记只追加、背景只增条目;压缩是唯一会删的一步,且有五条硬校验 + 历代备份。
- 客户端半边必须自包含:宿主把它当构建产物整份读取,所以
lib/client.js不能拆多文件。 - 不改写用户的历史:
background.bak.*一代不删,导出的"压缩前"一代一个文件;合并这类需要判断的事交给外部工具。
开发
npm test # 全部测试(Node 内置 test runner;跑前自动清 test/.tmp)
npm run test:no-isolation # 受限沙箱里(无法 spawn 子进程)用这条
npm run guard:census # 守卫语料普查(只读):用例总数 / 接线守卫 / 数值钉子 / 注释占比
node scripts/reindex-books.mjs # 预演:让书架里已有的书吃到新切分规则
node scripts/reindex-books.mjs --apply # 真的落盘(先把要改的文件备份到 backups/)
node scripts/rebuild-library-index.mjs # 预演:扫 books/<bookId>/meta.json 重建书架索引
node scripts/rebuild-library-index.mjs --apply # 真的落盘(先把 library.json 整份备份)
node scripts/clean-background-note.mjs --file <background.md 路径> # 清理注释残留(默认预览)
node scripts/archive-background.mjs --file <background.md 路径> --progress 300 --window 120 # 冷归档(默认预览)
node scripts/archive-background.mjs --file <background.md 路径> --progress 300 --apply # 真的落盘(先整份备份 + 写增量记录)
node scripts/merge-background-history.mjs --dir <background.history> --include-current --file <background.md> # 取并集(默认预览)
⚠️ 上面不是完整清单(
scripts/里还有merge-background-subjects.mjs、link-into-profile.mjs、guard-census.mjs)—— 以目录为准,别在这里维护第二份。 同理用例数不写死:每加一条守卫它就过期一次,要看当前数请跑npm run guard:census。
⚠️ 这些开发脚本只在源码仓库里,发行包不带:发行包只发布
scripts/reindex-books.mjs(读者面的那一个 —— 升级后书库索引要重建一次)。 其余(重建索引 / 清背景笔记 / 冷归档 / 合并背景史 / 改主体名 / 挂进 profile / 普查工具) 属于开发侧,不随包发布。从 npm 装来的读者只能跑reindex-books; 要跑其余的请 clone 仓库。⚠️ 这条不是"记得改文档"——test/plugin.test.mjs里有一条 派生的守卫:目录里每个scripts/*.mjs都必须被明确分类(随包发布 / 显式排除), 新增脚本时它会红,逼你表态。
冷归档是什么:把超出活跃窗口(默认 120 章)的旧条目从活分区搬进
## 冷档案—— 纯代码、零模型调用、原文一字不改。它不进提示词(读者族),所以注入量由"活跃窗口"决定, 而不是由"全书条数"决定。每次搬运都会:① 整份备份background.bak.<时间戳>.md; ② 往background.history/写一份只记这一笔的增量(0007-20261002-031500-归档.md,序号即时间序、 互不重复);③ 于是你可以随时用merge-background-history.mjs取并集,得到最详细的全文分析。 动机与上限(模型单次输出 32768 tokens、压缩=整份重写 ⇒ 约 1.2–1.5 万字就压不动)见docs/design-history.mdv2.22。自动备份(3.0):自动归档 / 自动压缩时,还会把处理后的全文导出到导出文件夹的
陪读导出_<书名>/自动备份/<书名>-第N次自动备份.md(后缀只有一个,归档与压缩共用序号池; 内容与已有备份一字不差时不重复写);手动压缩的备份位置不变(陪读文件夹里的background.bak.<时间戳>.md)。
rebuild-library-index.mjs是library.json损坏之后唯一的恢复入口:损坏时书架会显示成空的 (书其实都还在磁盘上),这个脚本按每本书自己的meta.json把索引重建回来 —— 只补不丢, 读不出meta.json的书保留原条目。
- 改完即生效:
lib/就是源码,重启 DSH Desktop 即可(本插件没有构建产物,所以也没有"改src/触发重载"那一层)。 - 改切分规则不会自动作用于已导入的书(导入是幂等的),所以老书要么删掉重导(丢笔记、丢进度),要么用
reindex-books.mjs就地重切。 - CI:GitHub Actions 跑
node --test,矩阵Node 22.19 / 24 × ubuntu / windows。⚠️ 不要在 CI 里加--test-isolation=none——那个开关在 Node 22.19 上不存在,会让两档以退出码 9 当场失败(v2.0.4 首发时真踩过,见docs/design-v1-archive.mdv1.40)。 - 代码结构、测试清单、以及客户端测试替身的盲区都在
CONTRIBUTING.md。
贡献者
| 贡献者 | 负责 |
|---|---|
| xling001 | 功能设计、方案取舍、真机验证 |
| AI(DSH 内的编码 agent) | 代码实现、测试、文档 |
本仓库的代码主要由 AI 编写。 人类作者负责提出要解决什么问题、在几个方案之间做选择、以及在真机上发现"哪里不对"。
与同类插件的区别,以及参考了哪些插件
同类里定位最接近的是 dsh-reader(用 DOM 选择器冒充插槽,在本机 DSH 上中央列会被清空,且没有任何 AI 机制)与 dsh-novel-forge(创作工具台,本项目只读)—— 本项目只往官方插槽注册,页签落在右侧栏、不接管中央列,因此与 dsh-tavern 这类插件共存无冲突。具体借鉴的是几处模块:dsh-reader 的编码探测顺序与章节正则基线(连同它几个真实故障的反面教训)、dsh-tavern 的路径闸做法、官方 dsh-client-ui-sidebar-right / dsh-client-modules 的客户端半边写法与发现契约;dsh-adaptive-context 提供过一条踩坑形状,dsh-novel-solo / dsh-talebook-plugin 只做过定位对比。出处都在源码注释里(grep dsh- 就能找到)。另有一个不同形态的参考:对坐 duizuo-reading-companion-skill(Agent Skill,MIT)—— 我们只借鉴了它守则里"防说"的那一半:元剧透清单、引文只能来自原文、来源声明与"读者优先于二手来源"。它"防读"的那一半(阅读范围 / 阅读单元 / scope_handle 契约 / 前后材料隔离)没有抄:那些是为"电子本就在模型手边"设计的,而我们的投喂层已经结构性地不含后文 —— 判据是「凡是在防"读"的不抄,凡是在防"说"的抄」。