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

novel_new_chapter

v7.0.0:新增可选参数 plan(动笔前针对本章各事件子句的具象执行计划,不写入正文文件,只用于写盘后核对兑现)与返回字段 postWriteAudit(写盘即时体检,最多 5 条,按严重度降序:未兑现的 plan 子句 / 章末升华 / 连续站桩对话 / 高频套话)。体检只告警、不阻断保存——章已经写好了,它只告诉你哪几处值得回头改。
v6.5.5:建章时附的六维基线 μ 摘要与「风格锚包」改为按面板「基线范围」取样(按全书 默认,=旧行为;按部分 只用勾选的章,按书存储),且始终排除刚创建的新章;返回体新增 baselineScope(文案)与 baselineMode(all / custom),渲染文本也带上取样范围。此前锚段仍取全书、与基线口径不一致(书越长 AI 续写占比越高,锚段就越像 AI 自己写的——正是 issue #1 描述的漂移),本版一并修正。

新建章节。为指定作品创建一个章节文件,章号可显式指定或自动取下一个,可同时写入 Markdown 一级标题与初始正文。同号章节一律拒绝创建,绝不覆盖既有稿件;创建成功后返回体自带按面板「基线范围」取样的六维风格基线 μ 摘要(附 baselineScope / baselineMode 范围披露)与「风格锚包」(代表性段落 + 句式骨架,与基线同范围),使调用方模型在动笔前即可拿到测量数据与用于校准语感的样例,无需先调用风格报告工具。v7.0.0 起还可传入动笔前写下的 plan,写盘后立刻得到一份只告警、不阻断保存的体检结论(postWriteAudit)。

参数

参数类型必填说明
bookstring是书名,即 novels/ 下的子目录名。经 sanitizeSegment 清洗:剥离 \ / : * ? " < > |、首尾点与空白,Windows 保留名(CON / NUL 等)加下划线前缀;清洗后为空则报错。目录不存在时自动创建。
chapterinteger否显式章号,须为 1–99999 的整数。省略时取现有最大可解析章号 + 1。
titlestring否章节标题,写为正文首行 # 标题,并(v5.0.0 起)一并进文件名:第NN章 标题.md。空串或纯空白视为未提供。
planstring否v7.0.0:动笔前针对本章各事件子句的具象执行计划(每行对应一条子句,写明具体器物名词、肢体动作与这一幕停在哪)。绝不写入正文文件——它只用于写盘后核对「写没写出来」(结果进 postWriteAudit)。参数声明里它排在 content 之前,因为多数模型按 schema 顺序生成参数,计划先落笔会形成一层「自我承诺」;这只是经验做法、不是协议保证——模型把它生成在 content 之后时兑现核对照常工作,只是承诺效应弱一些,所以不要依赖顺序做逻辑判断。
contentstring否初始正文。空串或纯空白视为未提供。
rootstring否章节库根目录。缺省回退顺序:args.root → config.root → 会话工作目录。MCP 形态下必须落在启动参数 --root 之内。

参数对象声明 additionalProperties: false,多传的键会被宿主拒绝。

输出结构

契约字段(output.schema.required):book / number / file / path / chars 五项必返;其余字段按条件出现。返回前经 cleanOutput 处理,值为 undefined 的键会被整体剔除。

字段类型内容
bookstring必返。清洗后的书名
numberinteger必返。本次实际使用的章号
filestring必返。文件名:无 title 时为 第NN章.md(章号左补零到至少 2 位);给了 title 时为 第NN章 标题.md(v5.0.0 修正:标题经 sanitizeSegment 剥掉 \ / : * ? " < > | 等非法字符并截断到 24 字;标题全是非法字符则退回不带标题的文件名,不因此拒绝创建)
pathstring必返。落盘绝对路径
charsinteger必返。写入文本的字符数(合成文本整体长度,含标题行与结尾换行)
baselinestring六维基线 μ 摘要,形如「标签 值 / 标签 值 …」,无法计算时为缺失。语料按面板「基线范围」取样(v6.5.5;按部分 只用勾选的章),并排除刚创建的新章——范围文案见同级的 baselineScope
baselineScopestring取样范围披露(v6.5.5,baseline / anchors 的共同范围):按全书 时为「全书除本章外的 N 章」,按部分 时为「自定义基线 N 章(选中 M 章)」(有取不到的章时括注为「(选中 M 章,其中 K 章未纳入基线(已删除、或正文过短无法测量))」;N = 本路语料池里实际入选的章数——本路递的是逐章指标,故 N 就是真正进 μ/σ 的可测章数);一章都取不到而回退时按原因给两种文案:「(自定义选择已失效,已回退全书)」或「(选中的章只有本章自身,不能作为自己的基线,已回退全书)」。渲染层把它写进模型可见的文本:【风格基线 μ】…(取样范围:<文案>)
baselineModestring范围档位(v6.5.5):all = 按全书(默认) / custom = 按面板选中的部分章。回退发生后取值是 all,要区分「本来就是按全书」与「回退」,读 baselineScope 文案里的后缀——两种原因两种文案:「(自定义选择已失效,已回退全书)」/「(选中的章只有本章自身,不能作为自己的基线,已回退全书)」。渲染层据它切换锚段标签:custom 时「【风格锚·原著段落】」改为「【风格锚·取样章节】动笔照此味道写(取样范围:…;数字基线只做事后校验):」——按部分时那些章未必是「原著」
reminderstring「基线怎么用」提示(v5.1.0 改口径:此前这段标题里带「强制」二字,现改为「基线怎么用」):① 锚段读一遍校准语感(不要照 skeletons 造句)② 写完建议跑一次 novel_style_check 自检,偏离明显时才逐句修正。当前实现下恒有值
anchorsarray风格锚·代表性段落,元素 { label, text },最多 5 条。v6.5.5 起与基线同范围:语料先按面板「基线范围」筛选既有章节(新章本就不参与——它是刚创建的稿子,不能当「原著样例」)
skeletonsarray句式骨架,元素 { type, text },最多 4 条
rawModestringv6.9.0,仅非净化模式(features.rawWriting)开启时出现:与开写包【非净化模式·目标带】同源的一段写作约束(留在场景里 / 细节密度对齐原著 / 不用省略号或跳场带过 / 不用总结代称代替场面 / 不插评论与免责)。开关关闭时该键不存在(不是空串)
postWriteAuditarrayv7.0.0:写盘即时体检,最多 5 条、按严重度降序,元素 { key, severity, title, detail }。key 取值 plan_unfulfilled(plan 里写了、正文里找不到的物象 / 动作,severity 5)、plan_unqualified(整份 plan 没有一行含具体器物或动作 → 跳过兑现核对,severity 1 提示)、duplicate_h1(v7.0.0:title 与 content 开头都带一级标题 → 已自动合并,severity 2;正文里还剩多处一级标题时为 severity 3 的「正文含多个一级标题」且不做合并)、以及单章症状 ending_preach / bare_dialogue / slop。只告警、不阻断保存;本章体检为空(或体检本身失败)时该键不存在(体检失败会记 silent_skip 事件日志,章照常保存)

行为与实现

本工具不涉及统计模型,全部为确定性文件操作与规则抽取。执行顺序:校验 → 建目录 → 扫描现有章节 → 定章号 → 查重 → 合成文本 → 独占写入 → 计算基线 → 构建锚包 → 记录书库根。

1 · 章号解析与文件名

number = chapter ?? ( max( 现有章节的可解析章号 ) + 1 ) ← 无任何可解析章号时 max 取 0,即自动落到第 1 章 fileName = "第" + String(number).padStart(2, "0") + "章.md"

现有章节由 scanChapters 收集:只认书目录顶层的 .md / .markdown / .txt(扩展名小写比较),子目录中的文件不参与,排序为「章号升序(不可解析者排最后)+ 文件名 localeCompare」。章号解析入口先做全角宽度归一(第01章 → 第01章),因此下列写法都解析为同一章号:第1章.md、第01章.md、第一章.md、书名-01-标题.md、书名 01.md。中文数字支持到「千」位(如 第一千零一章 解析为 1001)。

2 · 防覆盖与独占写入

mkdir( bookDir, { recursive: true } ) ← 目录缺失不抛错,空目录按零章处理 chapters = scanChapters( bookDir ) sameNo = chapters.find( c => (c.number ?? -1) === number ) if (sameNo) → 抛错:第 N 章已存在(旧文件名)——不会覆盖旧稿 parts = [] if (title 已提供) parts.push("# " + title, "") if (content 已提供) parts.push(content, "") text = parts.join("\n") writeFile( filePath, text, { encoding: "utf8", flag: "wx" } ) ← O_EXCL 独占创建 EEXIST → 抛错:第 N 章刚被并发创建,本次写入已放弃;请重试或指定其他章号

查重(按解析出的章号)与写入(按文件名是否存在)是两套口径,两者之间存在竞态窗口;wx 标志把该窗口收敛为一次可上报的失败,而不是静默互相覆盖。已知取舍:建目录发生在查重之前,因此同号创建失败时书目录仍可能已被创建。

3 · 文本合成与字符数口径

仅 title → "# 标题" + "\n" 仅 content → 正文 + "\n" title 与 content → "# 标题" + "\n" + "\n" + 正文 + "\n" 两者皆无 → ""(空文件,chars = 0) chars = text.length ← UTF-16 码元数,不是纯正文长度

标题行与正文之间由数组 join 产生的空行是格式的一部分,会一并计入 chars。该字段用于核对「是否真的写入了内容」,不代表正文字数。

v7.0.0:title 与 content 开头的重复 # 标题 自动合并成一条 H1。只在同时传了 title 时才做:写盘时先落 # <title>;若 content 以一级标题行开头,先剥掉第一行标题以及紧随其后的一个换行(或一个空行),其余正文原样写入。
title 已提供 && content 以 /^\s*#\s+[^\r\n]+/ 开头 cleanContent = content.replace(/^\s*#\s+[^\r\n]+(?:\r?\n[ \t]*\r?\n|\r?\n|$)/, "") text = "# " + title + "\n" + "\n" + cleanContent + "\n" 随后在 postWriteAudit 里核对(以剥离后的正文为准;正文为空则整段体检不出任何项): extraH1 = 正文中 /^[ \t]*#\s+\S/m 的命中数 ( title 已提供 && extraH1 >= 1 ) || ( title 未提供 && extraH1 >= 2 ) → { key:"duplicate_h1", severity:3, title:"正文含多个一级标题" } ← 不合并 否则若确实剥掉过开头标题 → { key:"duplicate_h1", severity:2, title:"正文开头含重复一级标题(已自动合并)" }
为什么必须合并:正文出现两条 H1,之后的 splitBlocks 与 extractChapterTitle 会把标题行当成正文段落、或把章标题取到第二条,进而影响站桩对话、章末升华这类按段落判定的体检症状口径。两条告警互斥,且都只是格式提示:保存早已完成,没有任何一项能否决这次写入;它们同属 postWriteAudit(最多 5 条、按 severity 降序),可能被更高优先级的项(如 plan 未兑现的 severity 5)挤出。

4 · 风格基线 μ 摘要

bTexts = 重扫目录后的全部章节(含刚创建的新章,其文本直接复用内存中的 text) perCh = metricChaptersCached( root, book, bTexts ) ← 按内容哈希缓存六维测量 scoped = resolveBaselineScope( root, book, perCh, 新章文件名 ) ← v6.5.5:基线范围(按全书 / 按部分) mode = all → 全部章(默认,=旧行为) mode = custom → 只用面板选中的章(「只用」,不是「优先 + 不够再补」) 新章无条件排除(即使它被选中);选中章里取不到的(已删除 / 正文过短)不纳入基线并如实计数——文案写「…,其中 K 章未纳入基线(已删除、或正文过短无法测量)」(本路递的是逐章指标,短章本就不在其中,两种原因都列出来才不冤报);一章都取不到时回退 all 并按实披露两种原因。 excluding = scoped.items if ( excluding.length > 0 ) baseline = METRIC_ORDER.map( k => 标签[k] + " " + ( 有 mu ? mu : "-" ) ).join(" / ") baselineScope = scoped.scope ← 渲染文本里的「(取样范围:…)」 baselineMode = scoped.mode ← all / custom,渲染层据此切换锚段标签

基线测量集合包含新章,以与风格报告、风格自检的「全章集合」保持同一缓存键(否则三者交替调用会全部缓存 miss);但摘要本身排除新章,因为新章不能作为自己的基线。无其它章节时不输出 μ=0 的假基线。novel_style_report 基线计算中「正文不足 40 字的章节不参与统计」的规则由测量层统一执行。整段被 try/catch 包裹:失败静默,不影响章节创建。

范围是一起回报的(v6.5.5)。摘要可能是「按用户勾选的那几章」算出来的——不说明范围,作者会把它当成全书基线。所以返回体给 baselineScope(文案)与 baselineMode(档位),渲染文本也带上:【风格基线 μ】…(取样范围:全书除本章外的 N 章) 或 …(取样范围:自定义基线 2 章(选中 2 章))。范围按书存于 <root>/.novel-writer/baseline/<书名>.json({ mode, chapters, updatedAt },文件不存在即等于「按全书」),与全局的容差设置(styleTolerance)不同层。选中章一章都取不到时回退「按全书」,并在文案里按原因注明:「(自定义选择已失效,已回退全书)」或「(选中的章只有本章自身,不能作为自己的基线,已回退全书)」——绝不返回空基线(空基线算出的 μ 是假的,会把「没有基线」伪装成「全都在带内」)。

5 · 风格锚包

语料 = 创建前的既有章节文本(新章不作为「原著样例」) scope = resolveBaselineScope( root, book, 语料, 新章文件名 ) ← v6.5.5:与六维基线同一范围 → 按部分时只用面板选中的章(按全书时=全部既有章);新章与取不到的章都不在语料里(回报文案用中性措辞,见 §4) 段落 = 按 /\n+/ 切分、trim、保留长度 ≥ 40 字的段;抽样方式为语料内等距 (首尾保留、均匀取 n 条;n=1 取中位段) [对话] 含引号且 ≤ 200 字 → 2 条 [心理] 含 想|觉得|心|怕|慌|记忆 且无引号且 ≤ 200 字 → 1 条 [描写] 无引号且 80–200 字 → 2 条 以上全空 → 退化为 [原文] 3 条 每条截断至 200 字;合计 slice(0, 5) 骨架 = 按 /[。!?…]+/ 切句、去空白、长度 20–45 字、去重后等距取 4 条 类型判定:含引号 → 对话;以 ?/? 结尾 → 疑问;含 想|觉得|仿佛|好像|似乎 → 心理;否则 陈述 骨架全空 → 退化为 4 条 ≥ 15 字的陈述型段落(截 60 字)

锚包是「校准语感」的载体,不是造句模板:anchors 读一遍对上味道即可,skeletons 只在写不出该类型句子时参考,不要照它造句、不要套句式模板;数字基线只用于事后校验。整段同样被 try/catch 包裹,失败时返回空锚包。渲染标签与语料同受「基线范围」影响(见 §6)。

6 · 渲染文本

<path>…</path> <type>novel-chapter-created</type> <content> 已创建 第NN章(N 字) [可选] 【风格基线 μ】标签 值 / 标签 值 …(取样范围:<baselineScope>) 例:句法复杂度 2 / 修饰密度 39.96 / 抽象度 0 / 动作密度 79.92 / 不确定性 39.96 / 留白指数 0 (取样范围:自定义基线 2 章(选中 2 章)) [可选] 锚段标签随 baselineMode 变(v6.5.5): all → 【风格锚·原著段落】动笔照此味道写(数字基线只做事后校验): custom → 【风格锚·取样章节】动笔照此味道写(取样范围:<baselineScope>;数字基线只做事后校验): [标签] 段落… [可选] 【句式骨架】按此形状造句: [类型] 句子… reminder ← 【基线怎么用】块:① 锚段读一遍校准语感,不要照 skeletons 造句 ② 写完建议自检,偏离明显才逐句修正 [可选] 【写盘即时体检·只告警不阻断】(v7.0.0,postWriteAudit 非空时) - <title>:<detail> 说明:这是刚写入的这一章的自动体检结论;要改就逐处改,别整章重写 </content>

7 · 写盘即时体检 postWriteAudit(v7.0.0)

体检 = diagnoseChapterSymptoms( 本次写入的正文, null ) ← 与开写包同一实现 只跑结构性症状:章末升华 / 连续站桩对话 / 高频套话 相对分位类(抽象度、动作 / 物件 / 感官)不做——那需要参照集,是下一章开写包的事 若传了 plan: checkPlanFulfillment( plan, 正文 ) → 合格且有条目没兑现:unshift「计划里有 N 条没兑现」(severity 5,列出前 4 个片段) → 整份 plan 没有一行含具体器物或动作:追加「plan 格式不合格,已跳过兑现核对」(severity 1) postWriteAudit = 取前 5 条 → 按 severity 降序

为什么放在建章返回里:事后审计(novel_style_check / novel_fix_plan)在默认工作流里很少被主动调用,而这一段是零额外调用的触点——章刚写完、材料还在模型手里。它只告警:写入早已完成,清单里没有任何一项能否决这次保存。

plan 格式不合格不是错误:整份计划里连一行具体器物或动作都没有(例如全是「按大纲推进」这类元指令),就没有可核对的东西,此时只给一条 severity 1 的提示并跳过兑现核对,不产「未兑现」告警。要它真起作用,每行至少写一个具体器物或动作,例如「柜台上的铜钱:他数了三枚推过去」。

约束与边界

  • 同号即失败,不覆盖:第1章.md / 第01章.md / 第一章.md 都按章号 1 拦截。需要改写既有章节时应另选章号或先由调用方删除旧文件——本工具不提供任何覆盖或更新通道。
  • chapter 的取值范围是硬断言:非整数(浮点、数字字符串)、0、负数、大于 99999 一律被拒绝并报错,而不是截断到边界。
  • 并发只报错不重试:独占创建失败时返回「刚被并发创建」,需调用方自行重试或改用其它章号。
  • 建目录是前置副作用:即使后续因同号失败,书目录也已被创建。
  • 基线/锚包均为可选增强:任一环节抛错都被吞掉,返回体中相应字段缺失或为空数组。调用方不应假定 baseline 恒有值——单章作品、基线计算失败、锚包抽取失败都会导致缺失。本工具没有 degraded 通道,所以这些失败对调用方是沉默的;v6.6.0 起它们会在事件日志里留痕(silent_skip:如「建章时的原著锚段/句式骨架不可用(返回空 anchors/skeletons)」),可用 novel_diagnose 区分「这本书抽不出锚段」与「生成过程失败了」——前者不是故障,后者要查日志。
  • plan 不落盘、不进正文(v7.0.0):它只参与写盘后的兑现核对,正文文件里没有一个字来自它;核对结果同样不落盘,只出现在本次返回体的 postWriteAudit 里。postWriteAudit 同属沉默路径:体检失败时章照常保存、该键不出现,只在事件日志留一条 silent_skip。
  • 取样范围与回退(v6.5.5):baseline 与 anchors 的语料由面板「基线范围」决定(按全书 默认 / 按部分 只用勾选的章,按书存储),并始终排除刚创建的新章;勾选的章若取不到(已删除 / 正文过短)会被跳过并如实计数(文案「…,其中 K 章未纳入基线(已删除、或正文过短无法测量)」),一章都取不到时回退「按全书」并按原因写进 baselineScope(「(自定义选择已失效,已回退全书)」或「(选中的章只有本章自身,不能作为自己的基线,已回退全书)」),不返回空基线。范围文案恒在渲染文本里(【风格基线 μ】…(取样范围:…)),读取方不要把失去范围说明的 μ 当作全书基线——按部分时它只代表那几章。
  • 只统计顶层文件:scanChapters 不递归,放在子目录里的章节文件对本书「不可见」,既不计入章号也不参与基线。
  • 自动章号依赖可解析章号:目录内若存在无法解析章号的文件(命名完全不含数字或中文数字),它们被排到末尾且不参与 max;此时「下一个章号」由可解析的最大值决定,可能与文件名视觉顺序不一致。
  • 章号解析的已知缺口:中文数字解析器识别 十 / 百 / 千,但不识别「万」——第一万章.md 解析不出章号,会被当作不可解析文件排到列表末尾,既不参与自动章号计算,也不阻止同号冲突检测(该文件不占用任何章号)。四位以内的阿拉伯章号不受此影响。
  • root 与 MCP 边界:MCP 形态下服务器为未显式提供 root 的调用注入 --root 书库根;显式传入越界路径会被静默回退书库根。
  • 工具开关:execute 首行执行 assertToolEnabled,若该工具在「写作助手功能」UI 中被关闭,调用直接报错(可用 novel_sentence_config 重新开启)。
  • 写盘编码固定为 UTF-8,不写 BOM,换行为 \n;不生成备份文件与临时文件。

相关工具

  • novel_chapters —— 创建前查看现有章号,避免显式 chapter 撞号。
  • novel_read —— 按章号或标题读回刚创建的正文。
  • novel_style_check —— reminder 建议的写后自检,用同一套六维基线与目标章比对;偏离明显时才据其修正。
  • novel_import —— 已有整批原稿件时的入库入口,替代逐章创建。
  • novel_chapter_brief —— 动笔前的材料装配:它的 clauseFrames 就是本工具 plan 要逐条兑现的子句框架,prevChapterIssues 与本工具的 postWriteAudit 同源。

源码位置

  • lib/index.js —— 工具注册与 execute:参数 schema、输出契约、基线摘要、提醒文案(registerNovelNewChapter,行号随版本变动,请按函数名检索;plan 参数与 postWriteAudit 的装配也在同一函数内)
  • lib/chapter-diagnose.js —— v7.0.0 写盘体检的实现:diagnoseChapterSymptoms(结构性症状,与开写包共用)与 checkPlanFulfillment(plan 兑现核对与 qualified 格式门);套话词表在 lib/lexicons/slop.js
  • lib/core.js —— sanitizeSegment 路径段清洗、resolveRoot 根目录回退、bookDir / novelsDir 路径拼接、scanChapters 章节扫描、parseChapterNumber 章号解析、cleanOutput 输出清理
  • lib/core.js —— buildStyleAnchorPackage 风格锚包抽样与分类(约 2071 行)、metricChaptersCached 六维测量缓存(约 1864 行)、resolveBaselineScope(v6.5.5 基线范围的唯一裁决点,配套 readBaselineScope / writeBaselineScope,行号随版本变动,请按函数名检索)
  • lib/style-metrics.js —— computeBaselineFromPerChapter / METRIC_ORDER / METRIC_LABELS,基线 μ 的计算与维度顺序