← dsh-novel-writer 技术文档 / novel_summary

novel_summary

章节摘要存储。摘要文本由调用方模型生成,插件只负责按章存取add / update 把一段摘要连同关键事件、关键设定写入单部作品的 JSON 文件,list / get 按章号读回,delete 移除。章号是存储键,写入与查询都先经 normalizeChapterKey 归一,因此「2」「第02章」「第02章.md」「第二章」指向同一个槽位。

参数

参数类型必填说明
bookstring书名,即 novels/ 下的子目录名。经 sanitizeSegment 过滤。
actionstring枚举 list / get / add / update / delete。非枚举取值与省略一律按 list 处理。
chapterstring章节标识(章号 / 文件名 / 标题子串)。list 动作必需
summarystringadd / update 必需:本章摘要。参数文档建议 200–500 字,覆盖剧情走向 / 关键事件 / 结尾状态。
keyEventsstring[]可选:关键事件列表。
keySettingsstring[]可选:本章出现的关键设定 / 信息。
rootstring章节库根目录。

action 取值

取值语义前置要求是否写盘返回体形态
list列出全部摘要(默认值),无 message全量
get取指定章的摘要chapter全量(命中 1 条或空数组),无 message
add新增 / 覆盖指定章的摘要chaptersummary单条 + message
update修改指定章的摘要(与 add 同一分支)chaptersummary单条 + message
delete删除指定章的摘要chapter,且该章摘要已存在全量 + message

输出结构

契约字段(required: ["book","action","summaries"])恒返;message 仅在写操作的收尾路径出现。

字段类型内容
bookstring净化后的书名
actionstring归一后的动作
summariesobject[]摘要条目数组
messagestring「已保存 第03章 的摘要」「已删除 第03章 的摘要」等

summaries 的元素契约(required: ["chapter","summary"]additionalProperties:false):

字段类型内容
chapterstring归一键(如 第03章第1001章),不是调用方传入的原文
summarystring摘要正文,原样保存、不截断
keyEventsstring[]仅当传入的是数组时写入
keySettingsstring[]仅当传入的是数组时写入
updatedAtstringISO 8601,只在真正写入时取时间戳

数据模型与落盘

{
  "book": "雨夜灯",
  "summaries": [
    {
      "chapter": "第03章",
      "summary": "沈砚回到西市当铺,用半枚铜钱换得掌柜的旧账本;账本末页夹着一张写着沈家旧宅地址的纸条,他决定连夜出城。",
      "keyEvents": ["沈砚以铜钱换账本", "账本夹页暴露沈家旧宅地址"],
      "keySettings": ["半枚铜钱可换旧账本", "沈家旧宅在西市之外"],
      "updatedAt": "2026-09-13T02:11:07.412Z"
    }
  ]
}

落盘路径 <root>/.novel-writer/summaries/<book>.json,写入时固定为 { book, summaries },2 空格缩进。读取时只认 parsed.summaries 为数组的情形,文件缺失或 JSON 损坏一律返回空数组,不抛错。

章号键归一 normalizeChapterKey

归一是存储键的唯一来源:写入时把 chapter 参数归一后存为 chapter 字段,查询 / 删除时把入参与每条已存键都归一后比较。步骤:

① 宽度归一:全角数字与全角连字符类(0-9 - . /)映射为半角 ② trim,去掉结尾的 .md / .MD,去掉行首的 # 与空白 ③ 匹配 /第?(\d{1,4}|[零一二三四五六七八九十百千万两]+)[章回话节]/ 命中且能解析为整数 → "第" + String(n).padStart(2, "0") + "章" ④ 否则匹配 /^(\d{1,4})[-._\s]/ → 同上格式 ⑤ 否则匹配 /^(\d{1,4})$/ → 同上格式 ⑥ 都不命中 → 返回第 ② 步处理后的原始字符串
输入归一键
2第02章
第02章.md第02章
第二章第02章
第01章第01章
第一千零一章第1001章
07-陨落第07章(命中的是行首数字 + 分隔符规则)
番外·雨夜番外·雨夜(原样保留)
v4.3.0 修正:中文章号漏「千」导致摘要静默覆盖。旧版 normalizeChapterKey 的字符类缺「千」,正则从「千」之后开始匹配,把「第一千零一章」归一成 第01章,而 parseChapterNumber("第一千零一章.md") 得 1001——同一输入两种口径。由于摘要以该键存取,给第 1001 章写摘要会覆盖第 1 章的摘要并回复「已保存」。同版本一并修正:5 处章号正则的字符类统一补「千万」(normalizeChapterKey / cleanChapterTitle / nextFreeChapterFile / bookNameFromFileName / bookNameFromContent),并给 normalizeChapterKeyparseChapterNumber 入口都加了宽度归一,避免全角章号绕过重号防护。
「万」仍是未实现单位。字符类允许「万」,但 cjkToNumber 只实现「十 / 百 / 千」三档与 0–9 数字,遇到「万」返回 undefined,归一步骤 ③ 落空后一路走到 ⑥,最终把原文(如「第一万章」)原样作为键。这类章号不会与数字形式的键合并,也不会报错。

读 - 改 - 写流程

  • 全部动作先经 withFileTx(summariesFile(root, book), …) 串行化同一文件的读改写,不同书互不阻塞。
  • 读盘后立即套用 cleanSummaryEntry 白名单并过滤空值,后续的 findIndex 与排序只面对结构完整的条目。
  • list / get 不写盘。add / update / delete 在变更后 mkdir 目标目录、atomicWriteJson 整文件覆盖,并把书库根记入全局状态。
  • 收尾块(listdelete 走此路径)在返回前按章号数值排序:parseChapterNumber(chapter) ?? 999 升序,同号再按字符串字典序。因此「第02章」排在「第10章」之前,无法解析章号的条目(含标题式键)统一沉到末尾。get / add / update 在排序之前就已返回(结果至多一条),不受该排序影响。

add 与 update 是同一分支

两个动作走完全相同的代码路径,仅 action 字段原样回填:

index = summaries.findIndex(s => normalizeChapterKey(s.chapter) === normalizeChapterKey(chapter)) index !== -1 → summaries[index] = 新条目(整条替换) index === -1 → summaries.push(新条目)

没有「仅更新摘要正文」的增量语义:keyEvents / keySettings 未传时新条目里就是 undefined(序列化后不出现),旧值不会被保留。要保留原有列表就必须在调用时重新传全。

约束与边界

  • book 是唯一 schema 必填项required: ["book"]);action 非枚举值静默退化为 list,调用方应以返回体的 action 判断实际执行的动作。
  • list 动作必须传 chapter,否则报「novel_summary <action> 需要 chapter 参数」。
  • add / update 必须传 summary,且空字符串与纯空白视为未提供optionalString 会把它归一成 undefined),报「需要 summary 参数」。chapter 同理,传空串等同没传。
  • getdelete 对「不存在」的处置不同get 返回 summaries: [] 且不报错;delete 抛「尚未保存 X 的摘要,无需删除」并终止。v3.9.5 之前 delete 会假报「已删除」并空写盘。
  • 写入是覆盖语义,没有版本历史:同一归一键的第二次写入直接替换旧条目,旧的 updatedAt 与文本都不保留。
  • 章号归一只作用于键summary 正文里写「第 1001 章」与否都不影响存取;但反过来说,标题式章号(如「番外·雨夜」)不会与任何数字章号合并,同一章用两种写法登记会产生两条记录。
  • 同章号的两份文件共用一个摘要槽位:存储键只由章号归一而来,不包含文件名。因此当一本书里存在两份同章号文件(拆分章节后原稿仍在等情形),它们读写的是同一条摘要——即使按文件名分别打开了两份正文,摘要也只有一份。novel_chapters 会提示撞号分组,novel_continuity_check 有对应候选,建议先消除撞号再登记摘要。
  • 数组字段的类型门槛keyEvents / keySettings 只在传入为数组时写入。空数组是合法值(会写成 [] 并在返回体中出现),非数组(如字符串)则静默忽略。
  • summary 不做长度限制与截断:参数文档的「200–500 字」是给调用方模型的写作建议,实现层不校验、不裁剪,超长摘要原样落盘并原样返回。
  • 白名单清洗会丢弃未知字段:磁盘上被手改加入的额外键(例如 tags)在读取时被 cleanSummaryEntry 剥掉,下次写入即从文件中消失;同时 null / 字符串 / 数字这类脏元素整体被过滤,不再让整次调用抛 TypeError
  • 返回体的 chapter 是归一键:调用方传入 第02章.md,返回与落盘的 chapter 都是 第02章。需要显示原标题时不能依赖该字段。
  • 动作分支与返回形态的对应add / update 早返回单条;delete 落到收尾块返回全表;get 早返回且不带 message
  • 并发按文件串行:同书的并发写被 withFileTx 排序;atomicWriteJson 另有一层按文件路径的写队列,临时文件命名为 <文件>.tmp-<pid>-<时间戳>rename 失败回退直写,两者都失败抛 ENOVELWRITEFAIL
  • 跨版本口径:v4.3.0 修正中文章号归一(见上文 note)与读盘白名单清洗;v4.0.0 起 updatedAt 只在真正写入时取时间戳(旧版 list / get / delete 也算了但没用),且 getlist 共用同一套白名单(旧版 get 透传磁盘原文,脏键会让宿主拒绝整次调用);v3.5.0 起按章号数值排序。因此旧版本写下的文件在新版本读取时可能键已经被重新归一——若旧文件里存在「第一千零一章」这类历史脏键,读回后返回的 chapter 显示为新归一键,但磁盘内容要到下次写入才改变。
  • MCP 形态下的 root 限制:参数 root 必须落在服务器启动参数 --root 的书库根之内,越界会被静默回退,同时记 stderr。
  • 参数表禁止额外字段parameters.additionalProperties = false

相关工具

  • novel_read —— 读章节正文;与摘要配合构成「摘要回忆前情 → 需要细节时翻原文」的工作流。
  • novel_chapters —— 章节清单(章号 / 标题 / 字数 / 行数),是摘要章的权威章号来源。
  • novel_outline —— 创作资料里的「创作状态卡」承担「进度 / 上一章结尾 / 下章方向」的快照角色,与逐章摘要互补。
  • novel_continuity_check —— 连贯性审计;其衔接检查直接读章节正文而非摘要。

源码位置

  • lib/index.js —— 工具注册与 executeregisterNovelSummary(约 1760–1878 行),含五个动作分支、排序与单条返回逻辑。
  • lib/core.js —— summariesFile / readSummaries / cleanSummaryEntry(约 2164–2182 行)。
  • lib/core.js —— normalizeChapterKey(约 158–177 行)、normalizeChapterWidth(约 134 行)、cjkToNumber(约 100 行)、parseChapterNumber(约 137 行)。
  • lib/core.js —— withFileTx(约 969 行)、atomicWriteJson(约 942 行)、sanitizeSegment(约 211 行)。
  • mcp/server.mjs —— buildArgs / isInsideRoot(约 259–289 行)。