novel_summary
章节摘要存储。摘要文本由调用方模型生成,插件只负责按章存取:add / update 把一段摘要连同关键事件、关键设定写入单部作品的 JSON 文件,list / get 按章号读回,delete 移除。章号是存储键,写入与查询都先经 normalizeChapterKey 归一,因此「2」「第02章」「第02章.md」「第二章」指向同一个槽位。
参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
book | string | 是 | 书名,即 novels/ 下的子目录名。经 sanitizeSegment 过滤。 |
action | string | 否 | 枚举 list / get / add / update / delete。非枚举取值与省略一律按 list 处理。 |
chapter | string | 否 | 章节标识(章号 / 文件名 / 标题子串)。非 list 动作必需。 |
summary | string | 否 | add / update 必需:本章摘要。参数文档建议 200–500 字,覆盖剧情走向 / 关键事件 / 结尾状态。 |
keyEvents | string[] | 否 | 可选:关键事件列表。 |
keySettings | string[] | 否 | 可选:本章出现的关键设定 / 信息。 |
root | string | 否 | 章节库根目录。 |
action 取值
| 取值 | 语义 | 前置要求 | 是否写盘 | 返回体形态 |
|---|---|---|---|---|
list | 列出全部摘要(默认值),无 message | 无 | 否 | 全量 |
get | 取指定章的摘要 | chapter | 否 | 全量(命中 1 条或空数组),无 message |
add | 新增 / 覆盖指定章的摘要 | chapter、summary | 是 | 单条 + message |
update | 修改指定章的摘要(与 add 同一分支) | chapter、summary | 是 | 单条 + message |
delete | 删除指定章的摘要 | chapter,且该章摘要已存在 | 是 | 全量 + message |
输出结构
契约字段(required: ["book","action","summaries"])恒返;message 仅在写操作的收尾路径出现。
| 字段 | 类型 | 内容 |
|---|---|---|
book | string | 净化后的书名 |
action | string | 归一后的动作 |
summaries | object[] | 摘要条目数组 |
message | string | 「已保存 第03章 的摘要」「已删除 第03章 的摘要」等 |
summaries 的元素契约(required: ["chapter","summary"],additionalProperties:false):
| 字段 | 类型 | 内容 |
|---|---|---|
chapter | string | 归一键(如 第03章、第1001章),不是调用方传入的原文 |
summary | string | 摘要正文,原样保存、不截断 |
keyEvents | string[] | 仅当传入的是数组时写入 |
keySettings | string[] | 仅当传入的是数组时写入 |
updatedAt | string | ISO 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),并给 normalizeChapterKey 与 parseChapterNumber 入口都加了宽度归一,避免全角章号绕过重号防护。
「万」仍是未实现单位。字符类允许「万」,但
cjkToNumber 只实现「十 / 百 / 千」三档与 0–9 数字,遇到「万」返回 undefined,归一步骤 ③ 落空后一路走到 ⑥,最终把原文(如「第一万章」)原样作为键。这类章号不会与数字形式的键合并,也不会报错。
读 - 改 - 写流程
- 全部动作先经
withFileTx(summariesFile(root, book), …)串行化同一文件的读改写,不同书互不阻塞。 - 读盘后立即套用
cleanSummaryEntry白名单并过滤空值,后续的findIndex与排序只面对结构完整的条目。 list/get不写盘。add/update/delete在变更后mkdir目标目录、atomicWriteJson整文件覆盖,并把书库根记入全局状态。- 收尾块(
list与delete走此路径)在返回前按章号数值排序: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同理,传空串等同没传。get与delete对「不存在」的处置不同: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也算了但没用),且get与list共用同一套白名单(旧版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 —— 工具注册与
execute:registerNovelSummary(约 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 行)。