← Back to home@xling001

dsh-reading-companion

No description

Stars
0
Language
JavaScript
Created
Sep 23, 2026
Updated
Oct 6, 2026
GitHub repo

Introduction

dsh-reading-companion

License: MIT dsh-plugin DeepSeek Harness

给小说读者的本地阅读器 + 不剧透的 AI 陪读。 把一本书装进 DSH 右侧栏,让「读」和「聊」在同一屏发生:你在正文里划一段、写下想法,它接着聊 —— 而它只读到你读到的地方。

它跟"把书丢给 AI 聊"最大的区别是:它记住的是这本书,不是这段对话。 读一本 1400 章的书,它不会忘、不会乱、也不会提前把后面的情节说给你。

  • 读什么:本地 TXT(自动探测编码、自动切章;无标题时降级为固定块),原书、笔记、AI 的理解全在你自己磁盘上 —— 无账号、无云、无书源
  • 留下什么:摘抄 → 我的感想 → tag → AI 回应,写成结构化 Markdown,可直接进 Obsidian 之类的笔记库
  • 怎么装:DSH 插件(Cordis bundle),零运行时依赖、零构建步骤 —— lib/ 就是源码

本地 TXT → 自动目录 → 正文阅读 → 进度持久化 → 绑定会话陪读
→ 三层防剧透 → 摘抄笔记 + 自动 tag → 背景认识增量补齐 → 导出到笔记库

书架与聊天窗口:左侧会话里陪读 AI 在聊读后感想,右侧「本地书架」面板列出导入的书、分组、分类与绑定状态

缘起 · 五个特色 · 怎么用 · 防剧透 · 安装 · 配置参考 · 数据目录 · 开发

每个版本改了什么 → 见 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 回应(可选,留空就不落盘)。按钮分两行:① 发到会话去聊 / ② 抓取选中文字作回应, 然后是落盘用的「写入笔记」(主按钮)与暂存用的「保存草稿」;导出在「设置」页 (那一页还能记住导出目录)。换笔记存放位置也在这一页。

笔记页:笔记保存位置、原文摘抄、我的感想、tag、AI 回应,以及「发到会话去聊 / 抓取选中文字作回应」「写入笔记 / 保存草稿」与笔记 / 草稿 / 回收站三个分页选项卡

④ 设置(「本地书架」页):绑定、人设、记忆

右侧栏「+」里选「本地书架」:绑定会话、写「书友设定」(你想要的口吻与关注点)、 看背景认识记住到第几章、翻人物卡(只含你读到的部分), 以及这本书的讨论时间线。

设置(陪读)页:陪读会话绑定与解除、书友设定(写给你自己的 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 Desktopdesktop$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 同理:重启那个进程)。

验证

  1. 打开任意会话,点右侧栏的「+」;
  2. 列表里应出现「本地书架」(一本摊开的书的图标);
  3. 点开进入书架视图。

看不到时按顺序查:重启了没?(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 覆盖。 标注「运行时」的项,读者也能在面板里改,且面板优先于配置文件。

字段类型默认值说明
storageDirstring''书库与笔记根目录。留空是有意的:默认走宿主的 dshHomePath() 解析成 $DSH_HOME/dsh-reading-companion,这样 profile 迁移时书库跟着走
inboxDirstring'inbox'「扫描导入目录」扫的收件箱,相对 storageDir
fallbackBlockCharsnumber4000TXT 没有可用章节标题、降级为固定块时的块大小(字符)
importRootsstring[][]POST /library/import 的白名单。空 = 不限制(导入本来就是"从磁盘任意处读书"这个功能本身)。填了就只接受落在这些根目录内的路径,判定走真实路径,用链接绕不过去
exportDirstring''默认导出目录。空 = 落到这本书所绑会话的工作区根;面板里改过一次就写进 settings.json,优先级 settings > 此值 > 工作区根
spoilerGatebooleantrue路径闸:指向本书原始文本(content.txt / source.txt / chapters.json)的工具调用一律拒绝,与会话归属无关
webGate'block-all' | 'block-book' | 'off''block-all'联网闸强度(运行时可在面板改)。block-book 是启发式:放行联网,但拒绝看起来在问这本书的查询
window.currentChapterMode'full' | 'read-so-far''full'当前章给全文,还是只给到光标处
window.headAllowanceCharsnumber1500仅 read-so-far 用:至少给当前章开头这么多字符
window.previousChapterMode'tail' | 'full''tail'上一章给多少:只给结尾(在段落处切)还是整章
window.backgroundBudgetCharsnumber9000背景认识那段的上限;超了按优先级裁剪,面板会说明裁掉了什么
window.backgroundCoarseDegradebooleantrue降级第二档:主体整体被丢之前,先降成"### 主体 + 最近一条"
window.compactThresholdnumber0.85背景超过 backgroundBudgetChars × 此值 时,下一次补齐先压缩(设为 1 = 关闭自动压缩)
window.archiveWindowChaptersnumber120冷归档的活跃窗口(3.0):补齐前,纯代码把整条落在窗口之外的旧条目搬进 ## 冷档案(原文只搬运、零模型调用、不再进提示词,但仍在文件里可查)。先归档、后压缩;设 0 = 关掉
window.personOfflineChaptersnumber60在线折叠(3.0 ②c):人物"最后被提及"距今超过这么多章 ⇒ 注入时折叠成锚(只留最新一条、状态行也不注入——文件不动,他再出场自动展开)。治"窗口只向前看 ⇒ 离场配角全卡一直占注入";设 0 = 关闭
window.discussionLimitnumber8注入多少条讨论时间线
sample.budgetCharsnumber18000一次补齐调用的字符预算——它决定一次能闭合多大的缺口。3.0 从 24000 降到这里:批更小 ⇒ 单次回复更小 ⇒ 不撞模型 32768 输出上限、也不容易卡住
sample.foundationBudgetCharsnumber24000打底批(首次补齐那批)专用预算(3.0):它只有一个、输出有界,所以保住旧预算 ⇒ "开头 30 章读厚、一次成型"
sample.minPerChapternumber600默认形态下这是"均分额度的下限",同时决定一批能吞多少章:一批章数 ≈ budgetChars / minPerChapter(600 → 约 30 章)。⚠️ 不要设得比 maxPerChapter 高,否则这个下限会被上限吞掉
sample.maxPerChapternumber1200单章上限(重点章可拿到它的 emphasisFactor 倍)。批越窄,budgetChars ÷ 权重和 算出的额度越高,靠它放行
sample.foundationChaptersnumber30只对第一次补齐生效的上限:第一次就厚读开头,而不是把预算摊到几百章
sample.emphasisChaptersnumber5开头前 N 章(以及每卷的卷首章)按 emphasisFactor 加权
sample.emphasisFactornumber3加权倍数
sample.jumpGateChaptersnumber50跳读闸阈值:一次补齐要闭合的缺口超过它就先问(回 409 与缺口范围,面板给三个选项)。设 0 关闭
sample.recentWindowChaptersnumber200选「只记最近这一段」时的窗口大小(调用时可临时改,这是默认值不是上限)
sample.recentMinPerChapternumber1200只给 recent 路径用的每章下限(比 minPerChapter 厚:那条路要的是"能聊这一章",不是"不致迷路")。⚠️ 必须 ≤ maxPerChapter,否则形同虚设
memoryTimeoutMsnumber600000一次补齐最多阻塞多久(插件内部另有中止定时器,不会永久挂住)。⚠️ 超时会把子代理 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.md v2.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.md v1.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 契约 / 前后材料隔离)没有抄:那些是为"电子本就在模型手边"设计的,而我们的投喂层已经结构性地不含后文 —— 判据是「凡是在防"读"的不抄,凡是在防"说"的抄」。

许可

MIT