novel_plot
伏笔 / 剧情钩子登记表。以「一部作品一个 JSON 文件」维护伏笔条目,用 status 区分 open(待回收)与 done(已回收);scan 动作把伏笔内容切成关键词后遍历章节正文,自动在条目上标注「在哪几章被提及」;action: "graph" 是结构视图,对全书做跨章计算——伏笔生命周期、人物出场矩阵与连续缺席、线索活跃度、时间线登记顺序、大纲与正文的对照。纯本地文本统计与包含判定,不做语义推理,也不调用在线模型。
novel_plot 的领域,且 action 已是枚举,加分支向后兼容。
参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
book | string | 是 | 书名,即 novels/ 下的子目录名。经 sanitizeSegment 过滤。 |
action | string | 否 | 枚举七值,见下表。非枚举取值与省略一律按 list 处理。 |
id | string | 否 | update / done / delete 必需:目标伏笔 id,必须已存在。 |
content | string | 否 | add 必需;update 可选:伏笔内容描述。 |
chapter | string | 否 | 伏笔出现的章节。scan 时用作「只扫该章」的章节标识。 |
note | string | 否 | 备注(如何回收 / 何时回收)。 |
type | string | 否 | 枚举:剧情 / 设定 / 道具 / 人物 / 其他。非枚举值被静默丢弃。 |
priority | string | 否 | 枚举:high / medium / low。非枚举值被静默丢弃。 |
relatedCharacters | string[] | 否 | 关联人物。add 写入;update 整组替换。 |
locations | string[] | 否 | 关联地点。add 写入;update 整组替换。 |
payoffCondition | string | 否 | 回收条件。 |
absenceThreshold | integer | 否 | 仅 graph 使用:人物连续缺席多少章才算一条 absences(默认 5)。只接受正整数,其它值按默认处理。其余动作忽略该参数。 |
root | string | 否 | 章节库根目录。缺省时取插件配置的 root,再缺省取会话工作目录。 |
action 取值
| 取值 | 语义 | 前置要求 | 返回体形态 |
|---|---|---|---|
list | 列出全部伏笔(默认值)。 | 无 | 全表 |
add | 登记新伏笔,status 固定为 open。 | content | 单条 |
update | 修改指定条目的文本字段。 | id 且存在 | 单条 |
done | 标记已回收(status → done)。 | id 且存在 | 全表 |
delete | 删除条目。 | id 且存在 | 全表 |
scan | 扫描章节正文,更新 open 条目的提及章节。 | 作品目录下至少一个章节文件 | 全表 + 统计文案 |
graph | 结构视图:对全书做跨章计算,输出伏笔生命周期、人物出场矩阵、线索活跃度、时间线顺序与大纲对照。 | 无(材料缺失时按字段降级) | 五项计算结果 + 汇总 + 降级说明 |
输出结构
契约字段(output.schema.required):book、action、entries 恒返;message 按动作出现。
| 字段 | 类型 | 内容 |
|---|---|---|
book | string | 净化后的书名 |
action | string | 归一后的动作(可能与传入值不同) |
entries | object[] | 伏笔条目数组,见下。graph 分支恒返回空数组 []——它不为放松共享 output.schema 的 required: ["book","action","entries"] 而改动契约(放松会让另外 6 个动作少一层断言),因此用一个空数组占位;结构视图的数据全部在 plotLifecycle 等专用字段里。 |
message | string | 操作结果文案,例如「已登记伏笔 #m1x2k9ab3f」「扫描 12 章,更新 3 条伏笔的提及记录」。graph 不返回该字段(改用 summary)。 |
totalChapters / plotLifecycle / characterMatrix / threadActivity / timelineOrder / planVsActual / summary / degraded | — | 仅 graph 返回:五项跨章计算结果与章数、概览文案、降级说明,见下文「结构视图」。 |
entries 的元素契约(required: ["id","content","status"]):
| 字段 | 类型 | 来源 |
|---|---|---|
id | string | 插件生成,调用方不可指定 |
content | string | content 参数 |
status | string | 枚举 open / done,归一后写入 |
chapter / note / payoffCondition | string | 同名参数 |
type / priority | string | 同名参数,仅在命中枚举时写入 |
relatedCharacters / locations | string[] | 同名参数 |
mentionedIn | string[] | scan 写入,元素是章节文件名 |
lastMentioned | string | scan 写入,最近一次命中的章节文件名 |
createdAt / updatedAt | string | ISO 8601 时间戳 |
chapter 是调用方传入的原文(如 第03章 或「第三章」),mentionedIn / lastMentioned 由扫描器写入,元素固定为磁盘文件名(如 第03章.md)。调用方做跨字段比对时不应假定二者同构。
数据模型与落盘
{
"book": "雨夜灯",
"entries": [
{
"id": "m1x2k9ab3f",
"content": "母亲留下的半枚铜钱",
"chapter": "第03章",
"note": "与第19章当铺掌柜的银票核对",
"type": "道具",
"priority": "high",
"relatedCharacters": ["沈砚"],
"locations": ["西市当铺"],
"payoffCondition": "主角认出铜钱缺口与银票吻合",
"status": "open",
"mentionedIn": ["第03章.md", "第07章.md"],
"lastMentioned": "第07章.md",
"createdAt": "2026-09-13T02:11:07.412Z",
"updatedAt": "2026-09-13T02:40:55.008Z"
}
]
}
上例的 id 与时间戳为示意值,字段名与结构取自 writePlots 与 normalizePlotEntry 的实际读写口径。
落盘位置与迁移
- 当前路径:
<root>/.novel-writer/plots/<book>.json,内容为{ book, entries },以 2 空格缩进序列化。 - 旧路径:
<root>/.novel-writer/<book>.json。readPlots在新路径不存在时回读旧路径,命中且含entries数组则复制到新路径;旧文件保留不删。 - 读不到文件或 JSON 损坏时返回空数组,不抛错——首次登记即视为「空表」。
读写流程与事务语义
- 所有动作先经
withFileTx(plotsFile(root, book), …)取得该文件的独占票:同一路径的读-改-写串行执行,不同书(不同路径)互不阻塞。 list是只读分支,不写盘;其余动作在变更后调用writePlots,并把书库根记入全局状态(writeSentenceState({ lastRoot }))。writePlots内部走atomicWriteJson:先写<文件>.tmp-<pid>-<时间戳>再rename;rename失败回退整文件直写;两者都失败抛出带code = "ENOVELWRITEFAIL"的错误,不再静默假报成功。- 返回前对全表做一次
normalizePlotEntry清洗与排序:status相同的按createdAt字典序升序,open恒排在done之前。
id 生成与状态机
时间基部分保证趋势有序,随机后缀 4 位用于降低同毫秒碰撞概率:add 一次只生成一个 id,不查重。
状态是单向的:add 固定写 open,done 固定写 done,update 不接受 status 参数。没有任何动作可以把 done 改回 open——需要「重新打开」时只能 delete 后重新 add(id 与 createdAt 都会变)。读取时脏数据兜底为 open:status !== "done" 的一切取值(含 in-progress、缺失)都归一为 open。
scan 的提及扫描算法
扫描分两步:先把每条 open 伏笔的 content 转成关键词集合(进入章节循环前只算一次),再逐章做纯子串包含判定。
关键词提取 plotKeywords
- 剥掉全部非汉字字符:
content.replace(/[^\u4e00-\u9fff]/g, "")。标点、数字、拉丁字母都不参与。 - 按长度 4 → 3 → 2 滑窗枚举子串。
- 子串中非停用字的占比须 ≥
ceil(长度 × 0.6)(长度 2 需 2 个非停用字、长度 3 需 2 个、长度 4 需 3 个)。停用字表含「的了是在我有和就都不一一个这那与及或但是因为所以如果然后而且比如什么怎么自己她们他们你们我们咱们没有不是别莫未」。 - 去重后按长度降序取前 8 个。
命中判定
任一关键词在章节正文中出现即算该章提及:把 chapter.file 追加进 mentionedIn(已在数组内则跳过),同时刷新 lastMentioned 与 updatedAt。计数器只在「本条目本不存在于该章」时递增,因此返回文案里的更新条数反映的是新增的提及,而不是命中总数。
遍历范围有限定:只处理 status === "open" 的条目——已回收的伏笔不再更新提及记录;chapter 参数存在时只扫该章(先用 findChapter 解析,找不到则报错),否则扫全部章节。
结构视图 action: "graph"
纯读、纯计算的跨章视图:除 book(与可选的 root / absenceThreshold)外不需要任何参数,不写任何文件(与 scan 不同——scan 会更新提及记录),也不复用会在旧位置迁移写盘的 readPlots。它把三张登记表(伏笔 / 设定 / 创作资料)放到同一条章号轴上,输出五项结果。
章号轴的口径:章号取自 scanChapters 的 number;解析不出章号的文件按扫描顺序占位编号(i + 1)参与计算,并在 degraded 里点名(否则它们会被「全书最大章号」口径整段吞掉)。totalChapters 是章节文件个数,而所有距离 / 空档计算的基准上界是 maxChapter= 现有章节里最大的章号——章号不连续(如缺第 5 章)时两者不相等,距离按章号之差而非文件序号之差计算。
degraded 说明。assembleGraph 内部若有异常数据或 IO 事故,也会兜底返回字段齐全的空结构并写清中文原因,保证调用方永远拿得到同一套字段。
1 · plotLifecycle 伏笔生命周期
这是 novel_chapter_brief 的 openPlots[].distance 的全书版,但两者口径并不完全相同:开写包只从登记字段(chapter / mentionedIn / lastMentioned)推最早提及章号,本视图还会拿伏笔内容的关键词去全书正文里找首次命中章,因此「从未 scan 过」的伏笔在本视图里更可能算出 firstChapter。已回收条目不在本数组中,只以条数出现在 summary 里。
2 · characterMatrix 人物出场矩阵与连续缺席
只统计「首次出场之后」的区间:人物在第 20 章才登场,前 19 章当然没有他,那不是缺席。判定为纯字符串包含(含别名),不做语义推理——别名登记不全时会漏判出场,进而把正常出场误报为缺席,因此结果应视为候选。全书都未出场的人物不进 absences,而是单独产出一条 degraded 提示。
length 是「连续缺席的章数」,不是「章号之差」。这与 threadActivity 的 length / gap 口径不同——后两者算的是章号之差。同一条缺席区间 from: 8, to: 14 在章号连续时 length = 7;若中间缺了第 10 章,length 仍是实际缺席的章数(6),而 to − from = 6 恰好相等只是巧合,一般情形下二者不等。另外,设定表登记了 firstSeen 时,还会与正文实际首现章做一次交叉核对,不一致时产出一条以「注:」开头的 degraded 提示(与「缺数据」类降级原因区分开)。
3 · threadActivity 线索活跃度
length 与 gap 都是「章号之差」而不是「章数」:只活跃 1 章的线 length = 0、gap = 0;活跃于第 1 / 2 / 3 / 7 章的线 gap = 4。跨度大而最大空档也大的线,通常就是「断了」的那条:人物还在、地点还在,但这条线上的伏笔连续十几章没有任何推进。plotIds 只在内部使用,不出现在返回体里。聚类只基于 open 伏笔;已回收的条数只进 summary。
4 · timelineOrder 时间线登记顺序核对
也就是说:issue 只标在「章号小于此前已登记过的最大章号」的那些行上——即登记顺序与章号升序不一致;章号相同的并列不算不一致,章号解析不出的行既不判错也不影响后续基准。时间线表是手工维护的,这种不一致通常意味着「补登记时插在了中间」或「章号写错了」。本项只指出不一致,不自动重排表——重排会掩盖登记时的真实意图。
5 · planVsActual 大纲方向行对照
输出的是一行一条方向行的记录,不是一章一条:没有正文的章节不产生记录,只累加 unwritten 与一条 degraded 说明;关键词为空的记 overlap: 0 并单独计数。返回体里没有「全部方向行关键词」这一项——keywords 字段就是命中列表,因此它同时也说明了「命中了哪几个词」。这是 novel_continuity_check 大纲走偏模式的全书版:彼处一次只比对一章,此处一次列出全书各章的重合率趋势,便于发现「连续若干章都在偏离大纲」这类系统性走偏。
汇总与降级
| 字段 | 类型 | 内容 |
|---|---|---|
totalChapters | integer | 章节文件个数(scanChapters 的结果长度)。它是章数,不是章号上界——距离 / 空档计算的基准是「全书最大可解析章号」。 |
summary | string | 一句话汇总,固定按此顺序拼接:全书 N 章;未回收伏笔 X 条(已回收 Y 条);人物 Z 名,连续缺席区间 W 处;剧情线 V 条,最大空档 G 章;时间线 T 条(若有逆序则补「(K 条章号顺序与登记顺序不一致)」);大纲方向 K 条,平均重合率 P%(若有 overlap < 0.2 的章则补「(L 章低于 20%,可能偏离方向)」)或「大纲方向未计算」;若 degraded 非空则补「降级 D 项(见 degraded)」。最后追加一段【风险】:空档最大的线、缺席最长的人物、埋得最久的伏笔(三者各取第一)。 |
degraded | array | 取不到的材料与原因,元素是字符串(不是对象)。空数组表示材料完整。除「少了什么数据」外,人物首现章与登记不符这类提示也以「注:」开头出现在这里。 |
无伏笔表 / 无人物表 / 无时间线 / 无大纲 / 全书只有 1 章等情形,一律返回空值 + degraded 说明,绝不抛错:跨章视图在资料不全时仍然可用(能算的那几项照算),只是结果不完整。
graph 与其它动作的差别。list / add / update / done / delete 面向单条伏笔的增删改查,scan 面向提及记录的回填(有副作用),graph 面向全书的跨章关系(只读)。四类动作的输出形状互不相同,调用方应以返回体里的 action 字段判断实际执行的是哪一个。
约束与边界
- book 必填,经
sanitizeSegment:剥除\ / : * ? " < > |、首尾点与空白;命中 Windows 保留名(con/nul/com1…)时前置下划线;清洗后为空则报错终止。 - action 非枚举值静默退化为
list:传入action:"close"不会报错,而是返回全表。调用方必须以返回体里的action字段判断实际执行了哪个动作。 type/priority的非枚举值是静默丢弃,不报错也不写字段;add时该键为undefined,序列化后不出现。- id 必须已存在:
update/done/delete找不到目标时抛「伏笔 #xxx 不存在」,整次调用失败且不写盘。 - 纯符号或纯英文的伏笔永远扫不到:
plotKeywords只保留汉字,content若无汉字则关键词为空数组,任何章节都不会被标注为提及。 - scan 需要章节文件:作品目录不存在或没有符合
.md/.markdown/.txt的章节时抛错;空作品不会返回「0 条更新」。 mentionedIn只增不减、无上限:条目在open期间每被新章命中就追加一项,全书扫一遍后可能积累上百个章节名;它不参与任何截断,也不随正文删改而回收。- 并发语义是「整表读-改-写 + 按文件串行」:同一部作品的并发
add会依次串行,不会互相覆盖;但这也意味着跨进程(插件进程 + 另一个直接改 JSON 的脚本)没有锁保护,手改文件的并行写入可能被整表覆盖。 - 返回体形态依动作而异:
add/update只返回被操作的那条;list/done/delete/scan返回全表。想在done后只取单条,需要在返回的entries里按 id 自行筛选。 - 跨版本口径:v0.8.0 起数据文件由
.novel-writer/<book>.json迁到plots/子目录(读取时自动迁移);v3.5.0 起非done的status统一归一为open;v4.0.0 起scan的关键词集合改为进入章节循环前预计算,并对null/ 标量脏条目做守卫(旧版会在坏数据上抛TypeError打挂整个工具)。跨版本对同一份数据的扫描耗时与容错表现不同,提及结果本身的口径未变。 graph是只读分支:不更新提及记录、不写盘、不改表,也不经过withFileTx写事务与readPlots(后者在「新位置缺失、旧位置存在」时会迁移写盘);只有scan会写(更新mentionedIn/lastMentioned),其余增量动作走writePlots。想在不产生副作用的前提下看清全书结构,用graph。graph依赖登记质量,但埋设章不只看登记:plotLifecycle.firstChapter取「全书正文关键词命中 ∪mentionedIn∪chapter」三者的最小值——从未scan过、也没写chapter的伏笔,只要内容关键词在正文里出现,仍能算出埋设章。三处都推不出时才把firstChapter/distance整键省略并记degraded。人物出场靠纯字符串包含判定,别名未登记会漏判;时间线核对只认登记顺序,不推断意图。graph的缺席阈值是「连续 ≥ 5 章」:低于 5 章视为正常轮换,不列为缺席;阈值可用参数absenceThreshold改(只接受正整数);且只统计首次出场之后的区间——人物登场之前的章节不计入缺席。absences[].length是连续缺席的章数,与threadActivity的「章号之差」口径不同。- 章号轴包含「解析不出章号」的章节:这类文件按扫描顺序占位编号(
i + 1)参与全部跨章计算,并记一条degraded(占位编号可能与真实章号冲突)。所有距离 / 空档的基准是全书最大可解析章号maxChapter,不是totalChapters;章号不连续时两者不等。 - MCP 形态下的 root 限制:工具参数里的
root必须落在服务器启动参数--root指定的书库根之内(含根本身),越界会被静默回退为书库根,同时记 stderr 日志。 - 参数表禁止额外字段:
parameters.additionalProperties = false,未声明的参数名不在契约内。新增的graph分支要求output.schema同步声明其返回字段(schema 为additionalProperties: false,漏声明会让宿主直接拒收返回体)。
相关工具
- novel_settings —— 人物 / 地点 / 道具 / 时间线 / 世界观用语规范五张设定表,伏笔里的关联人物与地点通常先在此登记;
graph的人物出场矩阵与时间线核对直接读这三张表。 - novel_outline —— 创作资料(含「钩子记录.md」、「剧情大纲.md」):逐章结尾钩子的行级回填与大纲方向行,与伏笔表是两套不同的追踪对象;
graph的planVsActual读的是大纲方向行。 - novel_continuity_check —— 对照设定表扫描全书输出矛盾候选;世界观禁用词与语用规范检查在彼处实现,大纲走偏的单章版亦在彼处。
- novel_chapter_brief —— 动笔前的材料装配,其
openPlots[].distance与graph的plotLifecycle.distance是同一族口径(都是「全书最大章号 − 最早提及章号」),但开写包只从登记字段推最早提及章号,本视图还会拿关键词去正文里找首次命中章,因此两者可能给出不同的firstChapter。 - novel_summary —— 章节摘要,用于回忆前情而非追踪伏笔。
源码位置
- lib/graph.js —— 结构视图主体:
buildStoryGraph(唯一对外导出)与内部的assembleGraph(主流程:章节 → 设定表 → 摘要 → 伏笔 → 五项计算)、buildPlotLifecycle/plotKeywordSets/textMentionsPlot(伏笔埋设章)、collectCharacters/characterPresent/computeAbsences(人物矩阵与连续缺席)、clusterPlots/threadLabel/buildThreadActivity(剧情线聚类与活跃度)、buildTimelineOrder(时间线顺序核对)、directionKeywords/pushBigrams/buildPlanVsActual(方向行关键词与重合率)、buildSummary/emptyGraph(概览文案与空结构兜底)。 - lib/index.js ——
registerNovelPlot:工具注册、action枚举(含graph)、absenceThreshold参数、输出 schema(含五项结果字段)、graph的只读早返回分支({ ...graph, entries: [], action: "graph" },在withFileTx之外)与渲染文本。 - lib/core.js ——
plotsFile/legacyPlotsFile/readPlots/writePlots/plotKeywords/normalizePlotEntry/normalizeSettingEntry(伏笔与设定表的读写口径);withFileTx、atomicWriteJson、sanitizeSegment、scanChapters/findChapter、readSettings、readSummaries、creationDir/CREATION_FILES(大纲方向行的读取路径)、extractKeywords/CJK_STOP_CHARS(方向行关键词的主路径与停用字集)。 - mcp/server.mjs ——
buildArgs/isInsideRoot:root 越界回退与参数注入。