dsh-novel-writer 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,64 +1,23 @@
1
- # dsh-novel-writer — 小说写作助手插件(v0.7.0:全工具 UI 开关)
1
+ # dsh-novel-writer — 小说写作助手插件(v0.8.1:记忆层)
2
2
 
3
3
  DSH(DeepSeek Harness)小说写作助手 bundle 插件。宿主端零第三方依赖(仅 Node 内置模块),
4
4
  浏览器端仅依赖 Web GUI 自带的 react。
5
- **v0.7.0 在 v0.6.0 基础上为全部 10 个工具增加独立 UI 开关(侧边栏「写作助手功能」面板统一管理)**:分析缓存与报告导出、风格自检、伏笔登记表、
5
+ **v0.8.1 在 v0.7.0 基础上补全「记忆层」:四张设定表、章节摘要、连贯性审计、伏笔字段化与提及追踪、细节密度指标、统一数据目录、UI 路径可视化修复**:分析缓存与报告导出、风格自检、伏笔登记表、
6
6
  关键词三字组/疑似人名、情感词典去噪、祈使句与环境分类改进、局域网开关可选放行。
7
7
 
8
- ## 工具清单(10 个,全部带独立 UI 开关)
8
+ ## 工具清单(13 个,全部带独立 UI 开关)
9
9
 
10
- | 工具 | 说明 |
11
- |---|---|
12
- | `novel_books` | 列出章节库全部作品 |
13
- | `novel_chapters` | 列出某作品章节清单 |
14
- | `novel_read` | 阅读某章正文(offset/limit 分段,编码自动探测) |
15
- | `novel_keywords` | 关键词:二字组 + **三字组** + **疑似人名** + 英文词 |
16
- | `novel_new_chapter` | 创建新章节文件 |
17
- | `novel_import` | 原稿件批量导入/自动分类(含**异名同书提示**) |
18
- | `novel_sentence_analysis` | 句式模式分析(九类/情感曲线/章节节奏/指纹,**带缓存与报告文件**) |
19
- | `novel_sentence_config` | 查看/修改句式分析开关 |
20
- | `novel_style_check` | **风格自检(v0.6.0 新增)**:章节 vs 全书指纹对比 + 偏差清单 |
21
- | `novel_plot` | **伏笔/剧情线登记表(v0.6.0 新增)**:list/add/update/done/delete |
10
+ novel_books / novel_chapters / novel_read / novel_keywords / novel_new_chapter / novel_import / novel_sentence_analysis / novel_sentence_config / novel_style_check / novel_plot / **novel_settings(设定管理)** / **novel_summary(章节摘要)** / **novel_continuity_check(连贯性审计)**
22
11
 
23
- ## v0.6.0 新增能力
12
+ ## v0.8.1 新增能力
24
13
 
25
- 1. **分析缓存 + 报告导出**:novel_sentence_analysis 结果自动缓存到 `~/.dsh/dsh-novel-writer/cache/<书名>-<hash>.json`,
26
- 返回值带 `reportFile` / `cache`(hit/miss)字段;章节未改动时重复分析秒回,传 `fresh=true` 强制重算。
27
- 2. **风格自检 novel_style_check**:任意章节 vs 全书其余章节 → 余弦相似度 + 句式/句长/情绪偏差清单 + 续写建议(写完新章自动查一次,防止文风漂移)。
28
- 3. **伏笔登记表 novel_plot**:`novels 根目录/.novel-writer/<书名>.json` 持久化;续写前 list 查看未回收伏笔。
29
- 4. **关键词升级**:新增三字组与疑似人名("XX说/道/问"与"XX小姐/导师"模式),人名不再被拆成"露西+西亚"。
30
- 5. **情感词典去噪**:单字词搭配排除("叹气"不算"气"、"笑道"不算"笑")、否定过滤("不害怕"不计)、副词距离窗口化("非常开心"才加权)。
31
- 6. **分类改进**:祈使句支持"快睡吧,明天还要上班"式(命令动词开头+吧);环境词表扩充(晚风/晨曦/森林/群山…)。
32
- 7. **情感曲线粒度可调**:`curveSegments` 参数(1-50,默认 20)。
33
- 8. **novel_import 异名同书提示**:scan 结果中组名包含/高重合时列出 `maybe` 字段,提示用 `book` 合并。
34
- 9. **局域网开关可选放行**:config `allowLanState: true` 时,state 路由对同源局域网请求放行(默认仍仅 loopback)。
35
-
36
- ## UI 开关(v0.7.0:10 个工具全开关)
37
-
38
- - **唯一开关位置:侧边栏「写作助手功能」面板**——总开关(enabled,控制句式分析/风格自检)+ 自动分析(autoAnalyze)+ **10 个工具各自独立开关**(全部默认开启);
39
- - 关闭的工具,AI 调用时会收到明确提示;novel_sentence_config 可查/改全部开关(含 tools 字段);
40
- - 设置 > 插件配置 卡片仅显示状态 + 「打开开关面板」跳转;
41
- - 持久化 `~/.dsh/dsh-novel-writer/state.json`;兼容 v0.4.0 的 novel-writer.json / config.stylePattern;
42
- - 默认开启;路由 `/api/dsh-novel-writer/state`(loopback 围栏,可选局域网放行)。
43
-
44
- ## 安装
45
-
46
- ```sh
47
- dsh plugin --profile web add D:/tools/dsh-novel-writer
48
- ```
49
-
50
- 或手动:放入 profile 的 node_modules + `dsh.profile.bundles` 加 `dsh-novel-writer`;重启 web 应用。
51
-
52
- ## 配置
53
-
54
- ```yaml
55
- - id: novel-writer
56
- config:
57
- root: 'D:/我的小说库'
58
- sentenceAnalysis:
59
- enabled: true
60
- autoAnalyze: true
61
- allowLanState: false # true=局域网访问 GUI 时也允许保存开关
14
+ - **novel_settings(四张表)**:人物卡/地点卡/道具清单/时间线,list/add/update/delete + scan 候选提取;
15
+ - **novel_summary**:模型生成、插件存储的每章摘要(长书续写先读摘要);
16
+ - **novel_continuity_check**:对照设定表扫描全书 → 数字口径/人物缺场/别名/重复条目矛盾候选;
17
+ - **novel_plot 字段化**:type/priority/relatedCharacters/locations/payoffCondition/mentionedIn/lastMentioned + scan 自动提及追踪;
18
+ - **细节密度指标**:动作链密度/物件名词/感官词(千字归一)进 novel_sentence_analysis 与 novel_style_check;
19
+ - **统一数据目录**:§BT§<书库根>/.novel-writer/§BT§ 下 plots/ settings/ summaries/ analysis/ audits/(伏笔旧位置自动迁移;分析缓存与关键词报告落盘到 analysis/);
20
+ - **UI 路径修复**:cordis 空字符串 root 回退 lastRoot、面板刷新按钮、打开/复制按钮不再禁用、显示 dataDir。
62
21
 
63
22
  ## v0.7.0 更新内容
64
23
 
package/lib/analysis.js CHANGED
@@ -599,6 +599,7 @@ export function analyzeText(text, options = {}) {
599
599
  const guidance = buildGuidance(ratios, motifs, chapterPatterns);
600
600
 
601
601
  const fingerprint = buildFingerprint(categories, lengths, style, emotion, motifs);
602
+ const density = densityOf(text);
602
603
 
603
604
  return {
604
605
  totalChars,
@@ -612,6 +613,7 @@ export function analyzeText(text, options = {}) {
612
613
  emotion,
613
614
  chapterPatterns,
614
615
  guidance,
616
+ density,
615
617
  fingerprint
616
618
  };
617
619
  }
@@ -714,3 +716,48 @@ export function styleDiffs(a, b, threshold = 0.06) {
714
716
  }
715
717
  return diffs;
716
718
  }
719
+
720
+ /** v0.8.0 细节密度词表:动作动词/物件名词/感官词。 */
721
+ const DENSITY_ACTION_VERBS = /摸|拿|推|拉|走|跑|蹲|站|坐|转|伸|握|点|掀|翻|跪|爬|钻|抱|搂|抬|放|挂|摘|系|披|踩|迈|跨|扑|撞|躲|闪|看|盯|瞥|望|听|闻|尝|咬|喝|吃|敲|拍|捏|揉|擦|吹|按|拨|拧|捡|拾|丢|抛|接|递|塞|掏|捞|舀|倒|浇|洗|叠|铺|盖|关|开|掩|合/;
722
+ const DENSITY_OBJECT_WORDS = /油灯|灯|床|门|窗|桌|椅|凳|杯|碗|碟|盘|筷|勺|剑|刀|枪|书|本|纸|笔|衣|裙|帽|鞋|镜|烛台|蜡烛|铁罐|罐|盒|箱|马车|车|墙|柱|帘|毯|被|枕|簪|铃|钟|画|架|梯|绳|袋|篮|桶|壶|锅|铲|柴|炭|火|灰/;
723
+ const DENSITY_SENSE = {
724
+ visual: /看|见|映入|光|亮|暗|色|影|闪|照|映|目光|眼神|脸|面|状|样/,
725
+ auditory: /听|声|音|响|钟|脚步|敲|喊|叫|低语|呢喃|咕哝|咚|砰|咔/,
726
+ tactile: /凉|热|疼|痛|滑|糙|冷|暖|烫|冰|湿|干|硬|软|麻|痒/,
727
+ olfactory: /香|臭|味|气|腥|甜|苦|酸/,
728
+ temperature: /热|烫|凉|冷|暖|温/
729
+ };
730
+
731
+ /** 细节密度统计(v0.8.0):动作链/物件名词/感官词,按千字归一。 */
732
+ export function densityOf(text) {
733
+ const sentences = splitSentences(text);
734
+ const totalChars = text.replace(/\s/g, "").length;
735
+ let actionVerbs = 0;
736
+ let actionChainSentences = 0;
737
+ let objectHits = 0;
738
+ const sense = { visual: 0, auditory: 0, tactile: 0, olfactory: 0, temperature: 0 };
739
+ const actionChains = [];
740
+ for (const sentence of sentences) {
741
+ let verbs = 0;
742
+ for (const m of sentence.matchAll(new RegExp(DENSITY_ACTION_VERBS.source, "g"))) verbs += 1;
743
+ if (verbs >= 2) {
744
+ actionChainSentences += 1;
745
+ if (actionChains.length < 3) actionChains.push(sentence.slice(0, 40));
746
+ }
747
+ actionVerbs += verbs;
748
+ for (const m of sentence.matchAll(new RegExp(DENSITY_OBJECT_WORDS.source, "g"))) objectHits += 1;
749
+ for (const [kind, re] of Object.entries(DENSITY_SENSE)) {
750
+ for (const m of sentence.matchAll(new RegExp(re.source, "g"))) sense[kind] += 1;
751
+ }
752
+ }
753
+ const per1000 = (n) => Math.round((n / Math.max(totalChars, 1)) * 1000 * 10) / 10;
754
+ const ratio = sentences.length === 0 ? 0 : Math.round((actionChainSentences / sentences.length) * 1000) / 10;
755
+ return {
756
+ actionVerbsPer1000: per1000(actionVerbs),
757
+ actionChainRatio: ratio,
758
+ actionChainExamples: actionChains,
759
+ objectNounsPer1000: per1000(objectHits),
760
+ sensePer1000: per1000(Object.values(sense).reduce((x, y) => x + y, 0)),
761
+ sense: { ...sense }
762
+ };
763
+ }
package/lib/client.js CHANGED
@@ -36,6 +36,8 @@ window.__ModuleLoader__.load({
36
36
  ".nwPanelHeader{display:flex;align-items:center;gap:10px}" +
37
37
  ".nwPanelTitle{font-size:16px;font-weight:700;flex:1;min-width:0}" +
38
38
  ".nwClose{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);width:30px;height:30px;line-height:1}" +
39
+ ".nwRefresh{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1;padding:5px 9px}" +
40
+ ".nwRefresh:hover{background:var(--dsw-alias-bg-layer-2)}" +
39
41
  ".nwDesc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.6;margin:0}" +
40
42
  ".nwBanner{display:flex;align-items:center;gap:10px;border-radius:12px;padding:14px 16px;font-size:15px;font-weight:700;border:1px solid}" +
41
43
  ".nwBannerOn{background:rgba(34,197,94,.12);border-color:#22c55e;color:#15803d}" +
@@ -132,13 +134,21 @@ window.__ModuleLoader__.load({
132
134
  "tool.novel_sentence_config.desc": "查看/修改全部开关(AI 通道)。",
133
135
  "tool.novel_style_check.desc": "本章文风与全书其余章节的相似度检查。",
134
136
  "tool.novel_plot.desc": "登记/回收剧情伏笔,续写前查看未回收项。",
135
- "plot.pathLabel": "存储位置",
136
- "plot.pathUnknown": "位于书库根的 .novel-writer 文件夹(每本书一个 JSON)。先让 AI 调用一次 novel_plot,此处会显示真实路径。",
137
+ "tool.novel_settings": "设定管理 novel_settings",
138
+ "tool.novel_summary": "章节摘要 novel_summary",
139
+ "tool.novel_continuity_check": "连贯性审计 novel_continuity_check",
140
+ "tool.novel_settings.desc": "四张设定表:人物卡/地点卡/道具/时间线。",
141
+ "tool.novel_summary.desc": "保存/读取每章摘要,长书续写先读摘要。",
142
+ "tool.novel_continuity_check.desc": "对照设定表扫描全书,输出矛盾候选。",
143
+ "plot.pathLabel": "数据目录",
144
+ "plot.pathUnknown": "位于书库根的 .novel-writer 文件夹(plots 伏笔 / settings 设定 / summaries 摘要 / analysis 分析报告)。先让 AI 调用一次 novel_plot 或 novel_settings,此处会显示真实路径。",
137
145
  "plot.open": "打开文件夹",
138
146
  "plot.copy": "复制路径",
139
147
  "plot.copied": "已复制",
140
148
  "plot.revealOk": "已打开文件夹",
141
149
  "plot.revealErr": "打开失败",
150
+ "panel.refresh": "刷新",
151
+ "panel.refreshed": "已刷新",
142
152
  "card.title": "小说写作助手 novel-writer",
143
153
  "card.desc": "句式模式分析:分析原文陈述/环境/心理/对话/疑问/反问/感叹等句子的排列节奏来辅助模仿文风。⚠️ 若机械套用导致文风僵硬,模型会优先回归自然表达。",
144
154
  "card.status": "写作助手功能:已启用 · 自动分析:开",
@@ -190,13 +200,21 @@ window.__ModuleLoader__.load({
190
200
  "tool.novel_sentence_config.desc": "View / change all switches (AI channel).",
191
201
  "tool.novel_style_check.desc": "Style similarity of this chapter vs the rest of the book.",
192
202
  "tool.novel_plot.desc": "Track / close plot hooks; check before continuing.",
193
- "plot.pathLabel": "Storage",
194
- "plot.pathUnknown": "Located in .novel-writer under the library root (one JSON per book). Ask the AI to run novel_plot once to show the real path.",
203
+ "tool.novel_settings": "Settings novel_settings",
204
+ "tool.novel_summary": "Summaries novel_summary",
205
+ "tool.novel_continuity_check": "Continuity novel_continuity_check",
206
+ "tool.novel_settings.desc": "Four tables: characters / locations / items / timeline.",
207
+ "tool.novel_summary.desc": "Store / read per-chapter summaries.",
208
+ "tool.novel_continuity_check.desc": "Scan against settings, list contradiction candidates.",
209
+ "plot.pathLabel": "Data dir",
210
+ "plot.pathUnknown": "Located in .novel-writer under the library root (plots / settings / summaries / analysis). Ask the AI to run novel_plot or novel_settings once to show the real path.",
195
211
  "plot.open": "Open folder",
196
212
  "plot.copy": "Copy path",
197
213
  "plot.copied": "Copied",
198
214
  "plot.revealOk": "Opened",
199
215
  "plot.revealErr": "Open failed",
216
+ "panel.refresh": "Refresh",
217
+ "panel.refreshed": "Refreshed",
200
218
  "card.title": "Novel Writer (dsh-novel-writer)",
201
219
  "card.desc": "Sentence-pattern analysis: reads the rhythm of statements/environment/inner-thought/dialogue/questions etc. to help mimic the author's style. ⚠️ If mechanical imitation stiffens the prose, natural expression wins.",
202
220
  "card.status": "Writing assistant: ON · auto-analyze: ON",
@@ -214,7 +232,7 @@ window.__ModuleLoader__.load({
214
232
  }
215
233
 
216
234
  // ---- 工具清单(与宿主端 ALL_TOOLS 一致)----
217
- var ALL_TOOLS = ["novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter", "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot"];
235
+ var ALL_TOOLS = ["novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter", "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot", "novel_settings", "novel_summary", "novel_continuity_check"];
218
236
 
219
237
  // ---- 与宿主端同步 ----
220
238
  var STATE_ROUTE = "/api/dsh-novel-writer/state";
@@ -248,6 +266,7 @@ window.__ModuleLoader__.load({
248
266
  saveFailed: false,
249
267
  panelOpen: false,
250
268
  plotsDir: "",
269
+ dataDir: "",
251
270
  revealMsg: "",
252
271
  revealErr: false,
253
272
  revealing: false
@@ -262,15 +281,35 @@ window.__ModuleLoader__.load({
262
281
  listeners.add(fn);
263
282
  return function () { listeners.delete(fn); };
264
283
  },
265
- openPlotsDir: function () {
284
+ refresh: function () {
285
+ return fetchState().then(function (remote) {
286
+ controller.set({
287
+ enabled: !!remote.enabled,
288
+ autoAnalyze: !!remote.autoAnalyze,
289
+ tools: remote.tools || {},
290
+ plotsDir: remote.plotsDir || "",
291
+ dataDir: remote.dataDir || "",
292
+ file: remote.file || "",
293
+ hostOk: true,
294
+ loading: false,
295
+ revealMsg: t("panel.refreshed"),
296
+ revealErr: false
297
+ });
298
+ return remote;
299
+ }).catch(function () {
300
+ controller.set({ hostOk: false, loading: false, revealMsg: t("plot.revealErr") + ":宿主端不可达", revealErr: true });
301
+ return null;
302
+ });
303
+ },
304
+ openDataDir: function () {
266
305
  controller.set({ revealMsg: "", revealErr: false, revealing: true });
267
306
  fetch("/api/dsh-novel-writer/reveal", {
268
307
  method: "POST",
269
308
  headers: { "content-type": "application/json" },
270
- body: JSON.stringify({ target: "plots-dir" })
309
+ body: JSON.stringify({ target: "data-dir" })
271
310
  }).then(function (r) { return r.json(); }).then(function (data) {
272
311
  if (data.ok === true) {
273
- controller.set({ plotsDir: data.path || controller.getSnapshot().plotsDir, revealMsg: t("plot.revealOk") + ":" + (data.path || ""), revealErr: false });
312
+ controller.set({ dataDir: data.path || controller.getSnapshot().dataDir, revealMsg: t("plot.revealOk") + ":" + (data.path || ""), revealErr: false });
274
313
  } else {
275
314
  controller.set({ revealMsg: data.error || t("plot.revealErr"), revealErr: true });
276
315
  }
@@ -281,8 +320,11 @@ window.__ModuleLoader__.load({
281
320
  });
282
321
  },
283
322
  copyPlotsPath: function () {
284
- var dir = controller.getSnapshot().plotsDir;
285
- if (dir === "") return;
323
+ var dir = controller.getSnapshot().dataDir || controller.getSnapshot().plotsDir;
324
+ if (dir === "") {
325
+ controller.set({ revealMsg: t("plot.pathUnknown"), revealErr: true });
326
+ return;
327
+ }
286
328
  var done = function () {
287
329
  controller.set({ revealMsg: t("plot.copied") + ":" + dir, revealErr: false });
288
330
  };
@@ -403,6 +445,11 @@ window.__ModuleLoader__.load({
403
445
  "div",
404
446
  { className: "nwPanelHeader" },
405
447
  react.createElement("div", { className: "nwPanelTitle" }, t("panel.title")),
448
+ react.createElement(
449
+ "button",
450
+ { type: "button", className: "nwRefresh", onClick: function () { props.controller.refresh(); } },
451
+ t("panel.refresh")
452
+ ),
406
453
  react.createElement("button", { type: "button", className: "nwClose", "aria-label": t("panel.close"), onClick: props.onClose }, "×")
407
454
  ),
408
455
  react.createElement(
@@ -459,20 +506,21 @@ window.__ModuleLoader__.load({
459
506
  var desc = t("tool." + name + ".desc");
460
507
  var plotExtra = null;
461
508
  if (name === "novel_plot") {
462
- var pathText = state.plotsDir !== "" ? state.plotsDir : t("plot.pathUnknown");
509
+ var dir = state.dataDir || state.plotsDir;
510
+ var pathText = dir !== "" ? dir : t("plot.pathUnknown");
463
511
  plotExtra = react.createElement(
464
512
  "div",
465
513
  { className: "nwPlotBox" },
466
- react.createElement("div", { className: "nwPlotPath", title: state.plotsDir !== "" ? state.plotsDir : "" }, t("plot.pathLabel") + ":" + pathText),
514
+ react.createElement("div", { className: "nwPlotPath", title: dir !== "" ? dir : "" }, t("plot.pathLabel") + ":" + pathText),
467
515
  react.createElement(
468
516
  "div",
469
517
  { className: "nwBtnGroup" },
470
518
  react.createElement("button", {
471
- type: "button", className: "nwBtn", disabled: state.plotsDir === "" || state.revealing,
472
- onClick: function () { props.controller.openPlotsDir(); }
519
+ type: "button", className: "nwBtn", disabled: state.revealing,
520
+ onClick: function () { props.controller.openDataDir(); }
473
521
  }, t("plot.open")),
474
522
  react.createElement("button", {
475
- type: "button", className: "nwBtn", disabled: state.plotsDir === "",
523
+ type: "button", className: "nwBtn",
476
524
  onClick: function () { props.controller.copyPlotsPath(); }
477
525
  }, t("plot.copy"))
478
526
  ),
@@ -647,6 +695,7 @@ window.__ModuleLoader__.load({
647
695
  autoAnalyze: !!remote.autoAnalyze,
648
696
  tools: remote.tools || {},
649
697
  plotsDir: remote.plotsDir || "",
698
+ dataDir: remote.dataDir || "",
650
699
  file: remote.file || "",
651
700
  source: "host",
652
701
  hostOk: true,
package/lib/index.js CHANGED
@@ -572,12 +572,14 @@ const SENTENCE_ANALYSIS_DEFAULTS = Object.freeze({ enabled: true, autoAnalyze: t
572
572
  /** v0.7.0:全部工具清单(每个工具都有独立 UI 开关,默认全开)。 */
573
573
  const ALL_TOOLS = Object.freeze([
574
574
  "novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter",
575
- "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot"
575
+ "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot",
576
+ "novel_settings", "novel_summary", "novel_continuity_check"
576
577
  ]);
577
578
  const TOOL_LABELS = Object.freeze({
578
579
  novel_books: "作品列表", novel_chapters: "章节清单", novel_read: "阅读章节", novel_keywords: "关键词分析",
579
580
  novel_new_chapter: "新建章节", novel_import: "稿件导入", novel_sentence_analysis: "句式模式分析",
580
- novel_sentence_config: "开关配置", novel_style_check: "风格自检", novel_plot: "伏笔登记"
581
+ novel_sentence_config: "开关配置", novel_style_check: "风格自检", novel_plot: "伏笔登记",
582
+ novel_settings: "设定管理", novel_summary: "章节摘要", novel_continuity_check: "连贯性审计"
581
583
  });
582
584
  /** 工具级开关是否开启(默认开;state.tools[name] === false 时关闭)。 */
583
585
  function toolEnabled(state, name) {
@@ -738,12 +740,15 @@ function makeStateRoutes(allowLan, config) {
738
740
  const method = req.method ?? "GET";
739
741
  if (method === "GET") {
740
742
  const state = await readSentenceState();
741
- const plotsRoot = config?.root ?? state.lastRoot;
743
+ // v0.8.0:修复 cordis 注入空字符串 root 时 ?? 不生效的问题
744
+ const root = (typeof config?.root === "string" && config.root.length > 0) ? config.root : state.lastRoot;
745
+ const dataDir = typeof root === "string" && root.length > 0 ? novelDataDir(root) : "";
742
746
  return writeJson(res, 200, {
743
747
  enabled: state.enabled,
744
748
  autoAnalyze: state.autoAnalyze,
745
749
  tools: state.tools ?? {},
746
- plotsDir: typeof plotsRoot === "string" && plotsRoot.length > 0 ? join(plotsRoot, ".novel-writer") : "",
750
+ dataDir,
751
+ plotsDir: dataDir !== "" ? join(dataDir, "plots") : "",
747
752
  file: stateFilePath()
748
753
  });
749
754
  }
@@ -764,13 +769,14 @@ function makeStateRoutes(allowLan, config) {
764
769
  if ((req.method ?? "GET") !== "POST") return writeJson(res, 405, { error: "method not allowed" });
765
770
  const body = await readJsonBody(req);
766
771
  const target = body?.target;
767
- if (target !== "plots-dir") return writeJson(res, 400, { error: 'unknown target (allowed: "plots-dir")' });
772
+ if (target !== "data-dir" && target !== "plots-dir") return writeJson(res, 400, { error: 'unknown target (allowed: "data-dir", "plots-dir")' });
768
773
  const state = await readSentenceState();
769
- const plotsRoot = config?.root ?? state.lastRoot;
770
- if (typeof plotsRoot !== "string" || plotsRoot.length === 0) {
771
- return writeJson(res, 404, { error: "书库根未知:先在对话中调用一次 novel_plot(或配置插件 root),即可在 UI 中打开伏笔文件夹。" });
774
+ // v0.8.0:空字符串 root 回退 lastRoot
775
+ const root = (typeof config?.root === "string" && config.root.length > 0) ? config.root : state.lastRoot;
776
+ if (typeof root !== "string" || root.length === 0) {
777
+ return writeJson(res, 404, { error: "书库根未知:先在对话中调用一次 novel_plot / novel_settings(或配置插件 root),即可在 UI 中打开数据文件夹。" });
772
778
  }
773
- const dir = join(plotsRoot, ".novel-writer");
779
+ const dir = target === "data-dir" ? novelDataDir(root) : join(novelDataDir(root), "plots");
774
780
  try {
775
781
  await mkdir(dir, { recursive: true });
776
782
  } catch { /* 目录已存在或创建失败都不阻塞打开 */ }
@@ -1009,7 +1015,19 @@ function registerNovelSentenceAnalysis(ctx, config) {
1009
1015
  }
1010
1016
  },
1011
1017
  guidance: { type: "string" },
1012
- fingerprint: { type: "string" }
1018
+ fingerprint: { type: "string" },
1019
+ density: {
1020
+ type: "object",
1021
+ additionalProperties: false,
1022
+ properties: {
1023
+ actionVerbsPer1000: { type: "number" },
1024
+ actionChainRatio: { type: "number" },
1025
+ actionChainExamples: { type: "array", items: { type: "string" } },
1026
+ objectNounsPer1000: { type: "number" },
1027
+ sensePer1000: { type: "number" },
1028
+ sense: { type: "object", additionalProperties: true }
1029
+ }
1030
+ }
1013
1031
  },
1014
1032
  required: ["book", "scope", "enabled", "totalChars", "totalSentences"]
1015
1033
  },
@@ -1063,8 +1081,8 @@ function registerNovelSentenceAnalysis(ctx, config) {
1063
1081
  message: "句式模式分析当前已关闭。可在 Web GUI 侧边栏「句式分析」面板开启,或用 novel_sentence_config 设置 enabled=true。"
1064
1082
  };
1065
1083
  }
1066
- // v0.6.0:分析结果缓存 + 报告导出(用户建议的临时文件方案)
1067
- const cacheDir = join(homedir(), ".dsh", "dsh-novel-writer", "cache");
1084
+ // v0.8.0:分析结果缓存 + 报告导出(书库统一数据目录 <root>/.novel-writer/analysis)
1085
+ const cacheDir = join(novelDataDir(root), "analysis");
1068
1086
  const cacheKey = createHash("sha1").update(book + "|" + scope + "|" + scopeKeyParts.join(",")).digest("hex").slice(0, 20);
1069
1087
  const reportFile = join(cacheDir, book + "-" + cacheKey + ".json");
1070
1088
  if (!fresh) {
@@ -1281,16 +1299,33 @@ function registerNovelStyleCheck(ctx, config) {
1281
1299
  }
1282
1300
 
1283
1301
  /** v0.6.0 伏笔/剧情线登记表。 */
1302
+ /** v0.8.0:书库统一数据目录(<root>/.novel-writer/),子目录:plots/settings/summaries/analysis/audits。 */
1303
+ function novelDataDir(root) {
1304
+ return join(root, ".novel-writer");
1305
+ }
1284
1306
  function plotsFile(root, book) {
1285
- return join(root, ".novel-writer", sanitizeSegment(book, "book") + ".json");
1307
+ return join(novelDataDir(root), "plots", sanitizeSegment(book, "book") + ".json");
1308
+ }
1309
+ function legacyPlotsFile(root, book) {
1310
+ return join(novelDataDir(root), sanitizeSegment(book, "book") + ".json");
1286
1311
  }
1287
1312
  async function readPlots(root, book) {
1313
+ const file = plotsFile(root, book);
1314
+ let parsed = null;
1288
1315
  try {
1289
- const parsed = JSON.parse(await readFile(plotsFile(root, book), "utf8"));
1290
- return Array.isArray(parsed.entries) ? parsed.entries : [];
1291
- } catch {
1292
- return [];
1316
+ parsed = JSON.parse(await readFile(file, "utf8"));
1317
+ } catch { /* 新位置不存在 */ }
1318
+ if (!parsed) {
1319
+ // v0.8.0 迁移:旧位置 <root>/.novel-writer/<书>.json → plots/<书>.json
1320
+ try {
1321
+ parsed = JSON.parse(await readFile(legacyPlotsFile(root, book), "utf8"));
1322
+ if (Array.isArray(parsed?.entries)) {
1323
+ await mkdir(dirname(file), { recursive: true });
1324
+ await writeFile(file, JSON.stringify(parsed, null, 2), "utf8");
1325
+ }
1326
+ } catch { /* 无旧文件 */ }
1293
1327
  }
1328
+ return Array.isArray(parsed?.entries) ? parsed.entries : [];
1294
1329
  }
1295
1330
  async function writePlots(root, book, entries) {
1296
1331
  const file = plotsFile(root, book);
@@ -1298,16 +1333,446 @@ async function writePlots(root, book, entries) {
1298
1333
  await writeFile(file, JSON.stringify({ book, entries }, null, 2), "utf8");
1299
1334
  }
1300
1335
 
1336
+ /** v0.8.0:从伏笔内容提取 2-4 字中文关键词(用于 scan 提及检测),跳过停用字。 */
1337
+ function plotKeywords(content) {
1338
+ const text = String(content).replace(/[^\u4e00-\u9fff]/g, "");
1339
+ const stop = new Set("的了是在我有和就都不一一个这那与及或但是因为所以如果然后而且比如比如什么怎么自己她们他们你们我们咱们没有不是别莫未".split(""));
1340
+ const keywords = [];
1341
+ for (const length of [4, 3, 2]) {
1342
+ for (let i = 0; i + length <= text.length; i += 1) {
1343
+ const sub = text.slice(i, i + length);
1344
+ const nonStop = [...sub].filter((ch) => !stop.has(ch)).length;
1345
+ if (nonStop >= Math.ceil(length * 0.6)) keywords.push(sub);
1346
+ }
1347
+ }
1348
+ // 去重并按长度降序,最多取 8 个
1349
+ return [...new Set(keywords)].sort((a, b) => b.length - a.length).slice(0, 8);
1350
+ }
1351
+
1301
1352
  /** v0.7.0:清洗伏笔条目为 lossless JSON(剔除 undefined 字段,DSH 通道要求)。 */
1302
1353
  function normalizePlotEntry(entry) {
1303
1354
  const out = { id: entry.id, content: entry.content, status: entry.status };
1304
- if (entry.chapter !== void 0 && entry.chapter !== null) out.chapter = String(entry.chapter);
1305
- if (entry.note !== void 0 && entry.note !== null) out.note = String(entry.note);
1355
+ for (const key of ["chapter", "note", "payoffCondition", "lastMentioned", "type", "priority"]) {
1356
+ if (entry[key] !== void 0 && entry[key] !== null) out[key] = String(entry[key]);
1357
+ }
1358
+ for (const key of ["relatedCharacters", "locations", "mentionedIn"]) {
1359
+ if (Array.isArray(entry[key])) out[key] = entry[key].map((x) => String(x));
1360
+ }
1306
1361
  if (entry.createdAt !== void 0) out.createdAt = entry.createdAt;
1307
1362
  if (entry.updatedAt !== void 0) out.updatedAt = entry.updatedAt;
1308
1363
  return out;
1309
1364
  }
1310
1365
 
1366
+
1367
+ /** v0.8.0 设定管理(四张表:人物/地点/道具/时间线)。 */
1368
+ function settingsFile(root, book) {
1369
+ return join(novelDataDir(root), "settings", sanitizeSegment(book, "book") + ".json");
1370
+ }
1371
+ async function readSettings(root, book) {
1372
+ try {
1373
+ const parsed = JSON.parse(await readFile(settingsFile(root, book), "utf8"));
1374
+ return {
1375
+ characters: Array.isArray(parsed.characters) ? parsed.characters : [],
1376
+ locations: Array.isArray(parsed.locations) ? parsed.locations : [],
1377
+ items: Array.isArray(parsed.items) ? parsed.items : [],
1378
+ timeline: Array.isArray(parsed.timeline) ? parsed.timeline : []
1379
+ };
1380
+ } catch {
1381
+ return { characters: [], locations: [], items: [], timeline: [] };
1382
+ }
1383
+ }
1384
+ async function writeSettings(root, book, data) {
1385
+ const file = settingsFile(root, book);
1386
+ await mkdir(dirname(file), { recursive: true });
1387
+ await writeFile(file, JSON.stringify({ book, ...data }, null, 2), "utf8");
1388
+ }
1389
+ function normalizeSettingEntry(entry) {
1390
+ const out = { name: String(entry.name) };
1391
+ for (const key of ["description", "traits", "relationships", "firstSeen", "notes", "owner", "status", "lastSeen", "day", "event", "chapter"]) {
1392
+ if (entry[key] !== void 0 && entry[key] !== null) out[key] = String(entry[key]);
1393
+ }
1394
+ for (const key of ["alias"]) {
1395
+ if (Array.isArray(entry[key])) out[key] = entry[key].map(String);
1396
+ }
1397
+ return out;
1398
+ }
1399
+ function registerNovelSettings(ctx, config) {
1400
+ ctx.tools.register({
1401
+ name: "novel_settings",
1402
+ description: "设定管理(四张表):人物卡/地点卡/道具清单/时间线。list/add/update/delete 按 category 维护;scan 用规则扫描章节提取人物/道具候选供登记。用于续写时保持人物性格、环境、道具与时间线一致。",
1403
+ parameters: {
1404
+ type: "object",
1405
+ properties: {
1406
+ book: { type: "string", description: "书名。" },
1407
+ category: { type: "string", enum: ["character", "location", "item", "timeline"], description: "表类别(list/add/update/delete/scan 需要)。" },
1408
+ action: { type: "string", enum: ["list", "add", "update", "delete", "scan"], description: "list=查看(默认);add=登记;update=修改;delete=删除;scan=扫描章节提取候选。" },
1409
+ name: { type: "string", description: "条目名(人物名/地名/道具名;timeline 用 day 字段)。" },
1410
+ description: { type: "string", description: "可选:描述。" },
1411
+ traits: { type: "string", description: "character 专用:性格/外貌特征。" },
1412
+ relationships: { type: "string", description: "character 专用:人际关系。" },
1413
+ alias: { type: "array", items: { type: "string" }, description: "character 专用:别名。" },
1414
+ firstSeen: { type: "string", description: "可选:首次出现的章节。" },
1415
+ owner: { type: "string", description: "item 专用:当前持有者。" },
1416
+ status: { type: "string", description: "item 专用:状态(如 在琉璃处/已遗失)。" },
1417
+ lastSeen: { type: "string", description: "item 专用:最近出现的章节。" },
1418
+ day: { type: "string", description: "timeline 专用:时间点(如 穿越第1天/考核前10日)。" },
1419
+ event: { type: "string", description: "timeline 专用:事件。" },
1420
+ chapter: { type: "string", description: "timeline 专用:对应章节。" },
1421
+ notes: { type: "string", description: "可选:备注。" },
1422
+ root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
1423
+ },
1424
+ required: ["book"],
1425
+ additionalProperties: false
1426
+ },
1427
+ output: {
1428
+ schema: {
1429
+ type: "object",
1430
+ additionalProperties: false,
1431
+ properties: {
1432
+ book: { type: "string" },
1433
+ category: { type: "string" },
1434
+ action: { type: "string" },
1435
+ characters: { type: "array", items: { type: "object", additionalProperties: true } },
1436
+ locations: { type: "array", items: { type: "object", additionalProperties: true } },
1437
+ items: { type: "array", items: { type: "object", additionalProperties: true } },
1438
+ timeline: { type: "array", items: { type: "object", additionalProperties: true } },
1439
+ candidates: { type: "array", items: { type: "string" } },
1440
+ message: { type: "string" }
1441
+ },
1442
+ required: ["book", "category", "action"]
1443
+ },
1444
+ render: (_args, value) => {
1445
+ const lines = [`<path>novels/${value.book}</path>`, "<type>novel-settings</type>", "<content>", ""];
1446
+ if (value.message) lines.push(value.message);
1447
+ const categoryLabel = { character: "人物卡", location: "地点卡", item: "道具清单", timeline: "时间线" }[value.category] ?? value.category;
1448
+ const list = value.characters ?? value.locations ?? value.items ?? value.timeline ?? [];
1449
+ lines.push(`${categoryLabel}(${list.length} 条):`);
1450
+ for (const e of list) {
1451
+ const extra = [e.traits, e.description, e.relationships, e.owner, e.status, e.lastSeen, e.day + (e.event ? " " + e.event : "")].filter(Boolean).join(" | ");
1452
+ lines.push(` - ${e.name}${extra ? ":" + extra : ""}${e.chapter ? "(" + e.chapter + ")" : ""}`);
1453
+ }
1454
+ if (Array.isArray(value.candidates) && value.candidates.length > 0) {
1455
+ lines.push("", "扫描候选(供登记):");
1456
+ for (const cand of value.candidates) lines.push(" ? " + cand);
1457
+ }
1458
+ lines.push("", "</content>");
1459
+ return [{ type: "text", text: lines.join("\n") }];
1460
+ }
1461
+ },
1462
+ async execute(args, exec) {
1463
+ const book = sanitizeSegment(requiredString(args, "book"), "book");
1464
+ const category = ["character", "location", "item", "timeline"].includes(args?.category) ? args.category : "character";
1465
+ const action = ["add", "update", "delete", "scan"].includes(args?.action) ? args.action : "list";
1466
+ const root = resolveRoot(config, args, exec);
1467
+ const data = await readSettings(root, book);
1468
+ const now = new Date().toISOString();
1469
+ let message = "";
1470
+ const categoryLabel = { character: "人物卡", location: "地点卡", item: "道具清单", timeline: "时间线" }[category] ?? category;
1471
+ const listKey = { character: "characters", location: "locations", item: "items", timeline: "timeline" }[category];
1472
+ const list = data[listKey];
1473
+ if (action === "add") {
1474
+ const name = optionalString(args, "name") ?? optionalString(args, "day");
1475
+ assert(name !== void 0, "novel_settings add 需要 name(timeline 用 day)参数");
1476
+ const entry = { name };
1477
+ for (const key of ["description", "traits", "relationships", "firstSeen", "owner", "status", "lastSeen", "event", "chapter", "notes"]) {
1478
+ if (args?.[key] !== void 0) entry[key] = String(args[key]);
1479
+ }
1480
+ if (Array.isArray(args?.alias)) entry.alias = args.alias.map(String);
1481
+ list.push(entry);
1482
+ message = `已登记 ${categoryLabel}:${name}`;
1483
+ await writeSettings(root, book, data);
1484
+ } else if (action === "update" || action === "delete") {
1485
+ const name = optionalString(args, "name") ?? optionalString(args, "day");
1486
+ assert(name !== void 0, `novel_settings ${action} 需要 name(timeline 用 day)参数`);
1487
+ const index = list.findIndex((e) => e.name === name);
1488
+ assert(index !== -1, `${categoryLabel}中不存在「${name}」`);
1489
+ if (action === "delete") {
1490
+ list.splice(index, 1);
1491
+ message = `已删除 ${categoryLabel}:${name}`;
1492
+ } else {
1493
+ for (const key of ["description", "traits", "relationships", "firstSeen", "owner", "status", "lastSeen", "event", "chapter", "notes"]) {
1494
+ if (args?.[key] !== void 0) list[index][key] = String(args[key]);
1495
+ }
1496
+ if (Array.isArray(args?.alias)) list[index].alias = args.alias.map(String);
1497
+ message = `已更新 ${categoryLabel}:${name}`;
1498
+ }
1499
+ await writeSettings(root, book, data);
1500
+ } else if (action === "scan") {
1501
+ // 规则候选:人物(XX说/道/问 高频 2-3 字),道具(含 owner 的常见物件词)
1502
+ const dir = bookDir(root, book);
1503
+ const chapters = await scanChapters(dir);
1504
+ let text = "";
1505
+ for (const chapter of chapters) text += await readTextFile(join(dir, chapter.file), exec);
1506
+ const nameCounts = new Map();
1507
+ for (const m of text.matchAll(/([\u4e00-\u9fff]{2,3})(?:说|道|问|喊|叫|笑|叹|点头|摇头)/g)) {
1508
+ const n = m[1];
1509
+ if (!["那个", "这个", "什么", "怎么", "自己", "她们", "他们", "你们", "我们"].includes(n)) {
1510
+ nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1);
1511
+ }
1512
+ }
1513
+ const known = new Set(list.map((e) => e.name));
1514
+ const candidates = [...nameCounts.entries()].filter(([n, count]) => count >= 2 && !known.has(n)).map(([n, count]) => n + "(" + count + "次)").slice(0, 15);
1515
+ message = `扫描全书,提取 ${candidates.length} 个人物候选`;
1516
+ const result = { book, category, action, candidates, message };
1517
+ if (category === "character") result.characters = data.characters.map(normalizeSettingEntry);
1518
+ if (category === "location") result.locations = data.locations.map(normalizeSettingEntry);
1519
+ if (category === "item") result.items = data.items.map(normalizeSettingEntry);
1520
+ if (category === "timeline") result.timeline = data.timeline.map(normalizeSettingEntry);
1521
+ return result;
1522
+ }
1523
+ const result = { book, category, action };
1524
+ result.characters = data.characters.map(normalizeSettingEntry);
1525
+ result.locations = data.locations.map(normalizeSettingEntry);
1526
+ result.items = data.items.map(normalizeSettingEntry);
1527
+ result.timeline = data.timeline.map(normalizeSettingEntry);
1528
+ if (message) result.message = message;
1529
+ return result;
1530
+ }
1531
+ });
1532
+ }
1533
+
1534
+ /** v0.8.0 章节摘要(模型生成内容,插件负责存储/读取)。 */
1535
+ function summariesFile(root, book) {
1536
+ return join(novelDataDir(root), "summaries", sanitizeSegment(book, "book") + ".json");
1537
+ }
1538
+ async function readSummaries(root, book) {
1539
+ try {
1540
+ const parsed = JSON.parse(await readFile(summariesFile(root, book), "utf8"));
1541
+ return Array.isArray(parsed.summaries) ? parsed.summaries : [];
1542
+ } catch {
1543
+ return [];
1544
+ }
1545
+ }
1546
+ function registerNovelSummary(ctx, config) {
1547
+ ctx.tools.register({
1548
+ name: "novel_summary",
1549
+ description: "章节摘要(模型生成、插件存储):阅读章节后把摘要存盘,续写长书时先读摘要回忆剧情,避免全文重读。add/update/get/list/delete 按章节管理。",
1550
+ parameters: {
1551
+ type: "object",
1552
+ properties: {
1553
+ book: { type: "string", description: "书名。" },
1554
+ action: { type: "string", enum: ["list", "get", "add", "update", "delete"], description: "list=全部摘要(默认);get=取某章;add=新增/覆盖摘要;update=修改;delete=删除。" },
1555
+ chapter: { type: "string", description: "章节标识(章号/文件名/标题)。" },
1556
+ summary: { type: "string", description: "add/update 时:本章摘要(200-500 字,覆盖剧情走向/关键事件/结尾状态)。" },
1557
+ keyEvents: { type: "array", items: { type: "string" }, description: "可选:关键事件列表。" },
1558
+ keySettings: { type: "array", items: { type: "string" }, description: "可选:本章出现的关键设定/信息。" },
1559
+ root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
1560
+ },
1561
+ required: ["book"],
1562
+ additionalProperties: false
1563
+ },
1564
+ output: {
1565
+ schema: {
1566
+ type: "object",
1567
+ additionalProperties: false,
1568
+ properties: {
1569
+ book: { type: "string" },
1570
+ action: { type: "string" },
1571
+ summaries: {
1572
+ type: "array",
1573
+ items: {
1574
+ type: "object",
1575
+ additionalProperties: false,
1576
+ properties: {
1577
+ chapter: { type: "string" },
1578
+ summary: { type: "string" },
1579
+ keyEvents: { type: "array", items: { type: "string" } },
1580
+ keySettings: { type: "array", items: { type: "string" } },
1581
+ updatedAt: { type: "string" }
1582
+ },
1583
+ required: ["chapter", "summary"]
1584
+ }
1585
+ },
1586
+ message: { type: "string" }
1587
+ },
1588
+ required: ["book", "action", "summaries"]
1589
+ },
1590
+ render: (_args, value) => {
1591
+ const lines = [`<path>novels/${value.book}</path>`, "<type>novel-summary</type>", "<content>", ""];
1592
+ if (value.message) lines.push(value.message);
1593
+ for (const s of value.summaries) {
1594
+ lines.push(`【${s.chapter}】`);
1595
+ lines.push(s.summary);
1596
+ if (Array.isArray(s.keyEvents) && s.keyEvents.length > 0) lines.push("关键事件:" + s.keyEvents.join(";"));
1597
+ if (Array.isArray(s.keySettings) && s.keySettings.length > 0) lines.push("关键设定:" + s.keySettings.join(";"));
1598
+ lines.push("");
1599
+ }
1600
+ lines.push("</content>");
1601
+ return [{ type: "text", text: lines.join("\n") }];
1602
+ }
1603
+ },
1604
+ async execute(args, exec) {
1605
+ const book = sanitizeSegment(requiredString(args, "book"), "book");
1606
+ const action = ["get", "add", "update", "delete"].includes(args?.action) ? args.action : "list";
1607
+ const root = resolveRoot(config, args, exec);
1608
+ const summaries = await readSummaries(root, book);
1609
+ const now = new Date().toISOString();
1610
+ let message = "";
1611
+ if (action !== "list") {
1612
+ const chapterArg = optionalString(args, "chapter");
1613
+ assert(chapterArg !== void 0, `novel_summary ${action} 需要 chapter 参数`);
1614
+ const index = summaries.findIndex((s) => s.chapter === chapterArg);
1615
+ if (action === "get") {
1616
+ const found = index !== -1 ? [summaries[index]] : [];
1617
+ return { book, action, summaries: found.map((s) => ({ ...s })) };
1618
+ }
1619
+ if (action === "delete") {
1620
+ if (index !== -1) summaries.splice(index, 1);
1621
+ message = `已删除 ${chapterArg} 的摘要`;
1622
+ } else {
1623
+ const summary = optionalString(args, "summary");
1624
+ assert(summary !== void 0, `novel_summary ${action} 需要 summary 参数`);
1625
+ const entry = {
1626
+ chapter: chapterArg,
1627
+ summary,
1628
+ keyEvents: Array.isArray(args?.keyEvents) ? args.keyEvents.map(String) : void 0,
1629
+ keySettings: Array.isArray(args?.keySettings) ? args.keySettings.map(String) : void 0,
1630
+ updatedAt: now
1631
+ };
1632
+ if (index !== -1) summaries[index] = entry;
1633
+ else summaries.push(entry);
1634
+ message = `已保存 ${chapterArg} 的摘要`;
1635
+ }
1636
+ await mkdir(dirname(summariesFile(root, book)), { recursive: true });
1637
+ await writeFile(summariesFile(root, book), JSON.stringify({ book, summaries }, null, 2), "utf8");
1638
+ }
1639
+ summaries.sort((a, b) => String(a.chapter).localeCompare(String(b.chapter)));
1640
+ const clean = summaries.map((s) => {
1641
+ const out = { chapter: String(s.chapter), summary: String(s.summary) };
1642
+ if (Array.isArray(s.keyEvents)) out.keyEvents = s.keyEvents.map(String);
1643
+ if (Array.isArray(s.keySettings)) out.keySettings = s.keySettings.map(String);
1644
+ if (s.updatedAt !== void 0) out.updatedAt = s.updatedAt;
1645
+ return out;
1646
+ });
1647
+ return { book, action, summaries: clean, ...message ? { message } : {} };
1648
+ }
1649
+ });
1650
+ }
1651
+
1652
+ /** v0.8.0 连贯性审计:设定表 + 全书扫描 → 矛盾候选清单。 */
1653
+ function registerNovelContinuityCheck(ctx, config) {
1654
+ ctx.tools.register({
1655
+ name: "novel_continuity_check",
1656
+ description: "连贯性审计:对照设定表扫描全书,输出矛盾候选(数字写法差异、登记人物在章节中的出现分布、设定别名变体),供模型判断并修正设定。",
1657
+ parameters: {
1658
+ type: "object",
1659
+ properties: {
1660
+ book: { type: "string", description: "书名。" },
1661
+ root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
1662
+ },
1663
+ required: ["book"],
1664
+ additionalProperties: false
1665
+ },
1666
+ output: {
1667
+ schema: {
1668
+ type: "object",
1669
+ additionalProperties: false,
1670
+ properties: {
1671
+ book: { type: "string" },
1672
+ candidates: {
1673
+ type: "array",
1674
+ items: {
1675
+ type: "object",
1676
+ additionalProperties: false,
1677
+ properties: {
1678
+ type: { type: "string" },
1679
+ detail: { type: "string" },
1680
+ chapters: { type: "array", items: { type: "string" } }
1681
+ },
1682
+ required: ["type", "detail"]
1683
+ }
1684
+ },
1685
+ reportFile: { type: "string" },
1686
+ advice: { type: "string" }
1687
+ },
1688
+ required: ["book", "candidates", "advice"]
1689
+ },
1690
+ render: (_args, value) => {
1691
+ const lines = [`<path>novels/${value.book}</path>`, "<type>novel-continuity-check</type>", "<content>", ""];
1692
+ if (value.candidates.length === 0) {
1693
+ lines.push("未发现明显矛盾候选。");
1694
+ } else {
1695
+ lines.push(`矛盾候选 ${value.candidates.length} 条(供人工判断):`);
1696
+ for (const cand of value.candidates) {
1697
+ lines.push(` - [${cand.type}] ${cand.detail}${Array.isArray(cand.chapters) && cand.chapters.length > 0 ? "(涉及:" + cand.chapters.join("、") + ")" : ""}`);
1698
+ }
1699
+ }
1700
+ lines.push("", "【建议】" + value.advice, "", "</content>");
1701
+ return [{ type: "text", text: lines.join("\n") }];
1702
+ }
1703
+ },
1704
+ async execute(args, exec) {
1705
+ const book = sanitizeSegment(requiredString(args, "book"), "book");
1706
+ const root = resolveRoot(config, args, exec);
1707
+ const dir = bookDir(root, book);
1708
+ const chapters = await scanChapters(dir);
1709
+ assert(chapters.length > 0, `book "${book}" has no chapter files`);
1710
+ const settings = await readSettings(root, book);
1711
+ const chapterTexts = [];
1712
+ for (const chapter of chapters) {
1713
+ chapterTexts.push({ file: chapter.file, text: await readTextFile(join(dir, chapter.file), exec) });
1714
+ }
1715
+ const candidates = [];
1716
+ // 1) 数字写法差异(亿/千万/百万 口径)
1717
+ const numberForms = new Map();
1718
+ for (const { file, text } of chapterTexts) {
1719
+ for (const m of text.matchAll(/\d+\s*(?:万亿|千万|百万|十万|[万亿千百])|[一二三四五六七八九十]{1,2}(?:万亿|千万|百万|十万|[万亿千百])/g)) {
1720
+ const form = m[0].replace(/\s/g, "");
1721
+ // 只保留"带单位"的数量表达;过滤纯数字(五十/二十九)、连词(万一)、约数(七八)
1722
+ if (!/[万亿千百]$/.test(form)) continue;
1723
+ if (/^[十百千万亿]$/.test(form)) continue;
1724
+ if (!numberForms.has(form)) numberForms.set(form, []);
1725
+ if (!numberForms.get(form).includes(file)) numberForms.get(form).push(file);
1726
+ }
1727
+ }
1728
+ if (numberForms.size >= 2) {
1729
+ const forms = [...numberForms.entries()];
1730
+ for (let i = 0; i < forms.length; i += 1) {
1731
+ for (let j = i + 1; j < forms.length; j += 1) {
1732
+ const [a, ca] = forms[i];
1733
+ const [b, cb] = forms[j];
1734
+ if (a !== b) {
1735
+ candidates.push({ type: "数字口径", detail: `「${a}」与「${b}」写法并存(可能指同一数量),请确认口径`, chapters: [...new Set(ca.concat(cb))] });
1736
+ }
1737
+ }
1738
+ }
1739
+ }
1740
+ // 2) 登记人物出现分布(缺失章节提示)
1741
+ for (const character of settings.characters) {
1742
+ const absent = chapterTexts.filter(({ file, text }) => !text.includes(character.name)).map(({ file }) => file);
1743
+ if (absent.length === chapterTexts.length) {
1744
+ candidates.push({ type: "人物缺失", detail: `设定表人物「${character.name}」在全书章节中均未出现,请确认是否已出场`, chapters: absent });
1745
+ } else if (absent.length > 0 && chapterTexts.length > 1) {
1746
+ candidates.push({ type: "人物缺场", detail: `设定表人物「${character.name}」未出现在 ${absent.length} 个章节中(如无出场必要可忽略)`, chapters: absent.slice(0, 5) });
1747
+ }
1748
+ if (Array.isArray(character.alias) && character.alias.length > 0) {
1749
+ const used = chapterTexts.filter(({ text }) => character.alias.some((al) => text.includes(al))).map(({ file }) => file);
1750
+ if (used.length === 0) {
1751
+ candidates.push({ type: "别名未用", detail: `人物「${character.name}」登记的别名 ${character.alias.join("、")} 在全书未出现`, chapters: [] });
1752
+ }
1753
+ }
1754
+ }
1755
+ // 3) 设定表重复条目
1756
+ for (const key of ["characters", "locations", "items"]) {
1757
+ const names = settings[key].map((e) => e.name);
1758
+ const dup = names.filter((n, i) => names.indexOf(n) !== i);
1759
+ if (dup.length > 0) candidates.push({ type: "设定重复", detail: `${key} 中存在重复条目:${[...new Set(dup)].join("、")}`, chapters: [] });
1760
+ }
1761
+ const advice = candidates.length === 0
1762
+ ? "设定表与章节基本一致。建议继续登记新章节出现的新人物/新地点。"
1763
+ : "以上为规则候选,请逐条人工判断:确认为矛盾则更新设定表(novel_settings update)或修正正文;非矛盾(如回忆/刻意缺席)可忽略。";
1764
+ // 落盘审计报告
1765
+ const auditDir = join(novelDataDir(root), "audits");
1766
+ const reportFile = join(auditDir, book + "-" + createHash("sha1").update(book + "|" + chapters.map((x) => x.file).join(",")).digest("hex").slice(0, 16) + ".json");
1767
+ try {
1768
+ await mkdir(auditDir, { recursive: true });
1769
+ await writeFile(reportFile, JSON.stringify({ book, generatedAt: new Date().toISOString(), candidates }, null, 2), "utf8");
1770
+ } catch { /* 落盘失败不阻塞 */ }
1771
+ return { book, candidates, reportFile, advice };
1772
+ }
1773
+ });
1774
+ }
1775
+
1311
1776
  function registerNovelPlot(ctx, config) {
1312
1777
  ctx.tools.register({
1313
1778
  name: "novel_plot",
@@ -1316,11 +1781,16 @@ function registerNovelPlot(ctx, config) {
1316
1781
  type: "object",
1317
1782
  properties: {
1318
1783
  book: { type: "string", description: "书名。" },
1319
- action: { type: "string", enum: ["list", "add", "update", "done", "delete"], description: "list=查看(默认);add=登记新伏笔;update=修改;done=标记已回收;delete=删除。" },
1784
+ action: { type: "string", enum: ["list", "add", "update", "done", "delete", "scan"], description: "list=查看(默认);add=登记新伏笔;update=修改;done=标记已回收;delete=删除;scan=扫描章节文本,自动更新每条 open 伏笔的提及章节(mentionedIn/lastMentioned)。" },
1320
1785
  id: { type: "string", description: "伏笔 id(update/done/delete 时需要)。" },
1321
1786
  content: { type: "string", description: "add/update 时:伏笔内容描述。" },
1322
1787
  chapter: { type: "string", description: "可选:伏笔出现的章节。" },
1323
1788
  note: { type: "string", description: "可选:备注(如何回收/何时回收)。" },
1789
+ type: { type: "string", enum: ["剧情", "设定", "道具", "人物", "其他"], description: "可选:伏笔类型(add/update)。" },
1790
+ priority: { type: "string", enum: ["high", "medium", "low"], description: "可选:优先级(add/update)。" },
1791
+ relatedCharacters: { type: "array", items: { type: "string" }, description: "可选:关联人物(add/update)。" },
1792
+ locations: { type: "array", items: { type: "string" }, description: "可选:关联地点(add/update)。" },
1793
+ payoffCondition: { type: "string", description: "可选:回收条件(add/update)。" },
1324
1794
  root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
1325
1795
  },
1326
1796
  required: ["book"],
@@ -1343,6 +1813,13 @@ function registerNovelPlot(ctx, config) {
1343
1813
  content: { type: "string" },
1344
1814
  chapter: { type: "string" },
1345
1815
  note: { type: "string" },
1816
+ type: { type: "string" },
1817
+ priority: { type: "string" },
1818
+ relatedCharacters: { type: "array", items: { type: "string" } },
1819
+ locations: { type: "array", items: { type: "string" } },
1820
+ payoffCondition: { type: "string" },
1821
+ mentionedIn: { type: "array", items: { type: "string" } },
1822
+ lastMentioned: { type: "string" },
1346
1823
  status: { type: "string", enum: ["open", "done"] },
1347
1824
  createdAt: { type: "string" },
1348
1825
  updatedAt: { type: "string" }
@@ -1361,7 +1838,10 @@ function registerNovelPlot(ctx, config) {
1361
1838
  const done = value.entries.filter((e) => e.status === "done");
1362
1839
  lines.push(`未回收伏笔 ${open.length} 条:`);
1363
1840
  if (open.length === 0) lines.push(" (无)");
1364
- for (const e of open) lines.push(` - [${e.id}] ${e.content}${e.chapter ? "(出自 " + e.chapter + ")" : ""}${e.note ? "|" + e.note : ""}`);
1841
+ for (const e of open) {
1842
+ const tags = [e.type, e.priority, e.chapter ? "出自 " + e.chapter : "", e.payoffCondition ? "回收:" + e.payoffCondition : "", e.lastMentioned ? "最近提及:" + e.lastMentioned : ""].filter(Boolean).join(" | ");
1843
+ lines.push(` - [${e.id}] ${e.content}${tags ? "(" + tags + ")" : ""}${e.note ? "|" + e.note : ""}`);
1844
+ }
1365
1845
  if (done.length > 0) {
1366
1846
  lines.push(`已回收 ${done.length} 条:`);
1367
1847
  for (const e of done) lines.push(` - [${e.id}] ${e.content}`);
@@ -1373,7 +1853,7 @@ function registerNovelPlot(ctx, config) {
1373
1853
  async execute(args, exec) {
1374
1854
  await assertToolEnabled(config, "novel_plot");
1375
1855
  const book = sanitizeSegment(requiredString(args, "book"), "book");
1376
- const action = ["add", "update", "done", "delete"].includes(args?.action) ? args.action : "list";
1856
+ const action = ["add", "update", "done", "delete", "scan"].includes(args?.action) ? args.action : "list";
1377
1857
  const root = resolveRoot(config, args, exec);
1378
1858
  const entries = await readPlots(root, book);
1379
1859
  const now = new Date().toISOString();
@@ -1382,8 +1862,48 @@ function registerNovelPlot(ctx, config) {
1382
1862
  const content = optionalString(args, "content");
1383
1863
  assert(content !== void 0, "novel_plot add 需要 content 参数");
1384
1864
  const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
1385
- entries.push({ id, content, chapter: optionalString(args, "chapter"), note: optionalString(args, "note"), status: "open", createdAt: now, updatedAt: now });
1865
+ const entry = {
1866
+ id,
1867
+ content,
1868
+ chapter: optionalString(args, "chapter"),
1869
+ note: optionalString(args, "note"),
1870
+ type: ["剧情", "设定", "道具", "人物", "其他"].includes(args?.type) ? args.type : void 0,
1871
+ priority: ["high", "medium", "low"].includes(args?.priority) ? args.priority : void 0,
1872
+ relatedCharacters: Array.isArray(args?.relatedCharacters) ? args.relatedCharacters.map(String) : void 0,
1873
+ locations: Array.isArray(args?.locations) ? args.locations.map(String) : void 0,
1874
+ payoffCondition: optionalString(args, "payoffCondition"),
1875
+ status: "open",
1876
+ createdAt: now,
1877
+ updatedAt: now
1878
+ };
1879
+ entries.push(entry);
1386
1880
  message = `已登记伏笔 #${id}`;
1881
+ } else if (action === "scan") {
1882
+ // v0.8.0:扫描章节文本,更新 open 伏笔的提及章节
1883
+ const scanChapter = optionalString(args, "chapter");
1884
+ const dir = bookDir(root, book);
1885
+ const chapters = await scanChapters(dir);
1886
+ assert(chapters.length > 0, `book "${book}" has no chapter files`);
1887
+ const targets = scanChapter !== void 0 ? [findChapter(chapters, scanChapter)] : chapters;
1888
+ assert(targets[0] !== void 0, `cannot find chapter "${scanChapter}"`);
1889
+ let mentioned = 0;
1890
+ for (const chapter of targets) {
1891
+ const chapterText = await readTextFile(join(dir, chapter.file), exec);
1892
+ for (const entry of entries) {
1893
+ if (entry.status !== "open") continue;
1894
+ const keywords = plotKeywords(entry.content);
1895
+ if (keywords.length > 0 && keywords.some((k) => chapterText.includes(k))) {
1896
+ const mentionedIn = Array.isArray(entry.mentionedIn) ? entry.mentionedIn : [];
1897
+ if (!mentionedIn.includes(chapter.file)) {
1898
+ entry.mentionedIn = mentionedIn.concat(chapter.file);
1899
+ entry.lastMentioned = chapter.file;
1900
+ entry.updatedAt = now;
1901
+ mentioned += 1;
1902
+ }
1903
+ }
1904
+ }
1905
+ }
1906
+ message = `扫描 ${targets.length} 章,更新 ${mentioned} 条伏笔的提及记录`;
1387
1907
  } else if (action === "update" || action === "done" || action === "delete") {
1388
1908
  const id = optionalString(args, "id");
1389
1909
  assert(id !== void 0, `novel_plot ${action} 需要 id 参数`);
@@ -1400,6 +1920,11 @@ function registerNovelPlot(ctx, config) {
1400
1920
  if (args?.content !== void 0) entries[index].content = String(args.content);
1401
1921
  if (args?.note !== void 0) entries[index].note = String(args.note);
1402
1922
  if (args?.chapter !== void 0) entries[index].chapter = String(args.chapter);
1923
+ if (["剧情", "设定", "道具", "人物", "其他"].includes(args?.type)) entries[index].type = args.type;
1924
+ if (["high", "medium", "low"].includes(args?.priority)) entries[index].priority = args.priority;
1925
+ if (Array.isArray(args?.relatedCharacters)) entries[index].relatedCharacters = args.relatedCharacters.map(String);
1926
+ if (Array.isArray(args?.locations)) entries[index].locations = args.locations.map(String);
1927
+ if (args?.payoffCondition !== void 0) entries[index].payoffCondition = String(args.payoffCondition);
1403
1928
  entries[index].updatedAt = now;
1404
1929
  message = `已更新伏笔 #${id}`;
1405
1930
  }
@@ -1721,7 +2246,8 @@ function registerNovelKeywords(ctx, config) {
1721
2246
  },
1722
2247
  required: ["word", "count", "kind"]
1723
2248
  }
1724
- }
2249
+ },
2250
+ reportFile: { type: "string" }
1725
2251
  },
1726
2252
  required: ["book", "scope", "totalChars", "keywords"]
1727
2253
  },
@@ -1753,12 +2279,21 @@ function registerNovelKeywords(ctx, config) {
1753
2279
  for (const chapter of selected) {
1754
2280
  text += await readTextFile(join(dir, chapter.file), exec);
1755
2281
  }
1756
- return {
2282
+ const result = {
1757
2283
  book,
1758
2284
  scope,
1759
2285
  totalChars: text.length,
1760
2286
  keywords: extractKeywords(text, top)
1761
2287
  };
2288
+ // v0.8.0:关键词结果落盘到书库统一数据目录(与设定同文件夹)
2289
+ try {
2290
+ const reportDir = join(novelDataDir(root), "analysis");
2291
+ const reportFile = join(reportDir, book + "-keywords-" + createHash("sha1").update(book + "|" + scope + "|" + text.length).digest("hex").slice(0, 12) + ".json");
2292
+ await mkdir(reportDir, { recursive: true });
2293
+ await writeFile(reportFile, JSON.stringify({ ...result, generatedAt: new Date().toISOString() }, null, 2), "utf8");
2294
+ result.reportFile = reportFile;
2295
+ } catch { /* 落盘失败不阻塞 */ }
2296
+ return result;
1762
2297
  }
1763
2298
  });
1764
2299
  }
@@ -1864,8 +2399,9 @@ const WORKFLOW_TEXT = [
1864
2399
  "6. 续写 / 辅助写作时:先通读最近的章节(保持人物、时间线、情节伏笔、文风与已提炼的关键词一致),再动笔。",
1865
2400
  " 新章节写入 novels/<书名>/ 文件夹:用 novel_new_chapter 创建章节文件,或直接用 write 工具写文件。",
1866
2401
  "7. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。",
1867
- "8. 工具链提示:续写/分析前可用 novel_plot 查看未回收伏笔,保持剧情连贯;写完新章节可用 novel_style_check 做风格自检(相似度+偏差清单);",
1868
- " novel_sentence_analysis 结果自动缓存到 ~/.dsh/dsh-novel-writer/cache/(返回值含 reportFile 字段),重复分析直接命中缓存,需要重算时传 fresh=true。"
2402
+ "8. 工具链提示:续写/分析前可用 novel_plot 查看未回收伏笔(open 条目,含类型/优先级/提及章节);写完新章节可用 novel_style_check 做风格自检(相似度+偏差+细节密度),novel_plot scan 自动更新伏笔提及记录;",
2403
+ " novel_settings 维护四张设定表(人物/地点/道具/时间线),novel_summary 保存每章摘要(长书续写先读摘要再按需细读),novel_continuity_check 输出设定矛盾候选;",
2404
+ " novel_sentence_analysis 结果自动缓存到书库数据目录 <root>/.novel-writer/analysis/(reportFile 字段),novel_keywords 结果同样落盘,需要重算时传 fresh=true。"
1869
2405
  ].join("\n");
1870
2406
 
1871
2407
  function apply(ctx, config = {}) {
@@ -1879,6 +2415,9 @@ function apply(ctx, config = {}) {
1879
2415
  registerNovelSentenceConfig(ctx, config);
1880
2416
  registerNovelStyleCheck(ctx, config);
1881
2417
  registerNovelPlot(ctx, config);
2418
+ registerNovelSettings(ctx, config);
2419
+ registerNovelSummary(ctx, config);
2420
+ registerNovelContinuityCheck(ctx, config);
1882
2421
  // 注册 UI 开关状态路由(可选注入,headless 自动跳过)
1883
2422
  registerStyleConfigRoute(ctx, config);
1884
2423
  ctx.systemPrompt.section({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-novel-writer",
3
- "version": "0.7.0",
4
- "description": "小说写作助手插件(v0.7.0):10 个工具全部带独立 UI 开关(侧边栏「写作助手功能」面板统一管理)、句式分析/风格自检/伏笔登记/批量导入、分析缓存与报告导出、情感词典去噪、编码探测",
3
+ "version": "0.8.1",
4
+ "description": "小说写作助手插件(v0.8.1 记忆层):四张设定表(人物/地点/道具/时间线)、章节摘要、连贯性审计、伏笔字段化+提及追踪、细节密度指标、统一数据目录(plots/settings/summaries/analysis/audits)、UI 路径可视化修复",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -36,4 +36,4 @@
36
36
  },
37
37
  "dependencies": {},
38
38
  "license": "MIT"
39
- }
39
+ }
@@ -29,6 +29,17 @@
29
29
  - 写完新章节后调用 `novel_style_check`:本章 vs 全书其余章节 → 相似度 + 偏差清单 + 建议;
30
30
  - 偏差明显时主动调整文风(对话/心理占比、句长、情绪),防止漂移。
31
31
 
32
+ ## 设定管理(v0.8.0 新增)
33
+
34
+ - §BT§novel_settings§BT§:四张表(人物卡/地点卡/道具清单/时间线),list/add/update/delete/scan;
35
+ - 登记新人物/新地点/道具去向,时间线记录"第几天/倒计时";
36
+ - §BT§novel_continuity_check§BT§:续写前跑一次,输出设定矛盾候选(数字口径/人物缺场/别名/重复)。
37
+
38
+ ## 章节摘要(v0.8.0 新增)
39
+
40
+ - 读完每章后调用 §BT§novel_summary§BT§ add 保存 200-500 字摘要 + 关键事件 + 关键设定;
41
+ - 续写长书时先 §BT§novel_summary§BT§ list 回忆剧情,再按需 §BT§novel_read§BT§ 细读。
42
+
32
43
  ## 伏笔登记表(v0.6.0 新增)
33
44
 
34
45
  - `novel_plot`:list / add / update / done / delete 管理伏笔与剧情钩子;
package/test/e2e-test.mjs CHANGED
@@ -21,7 +21,7 @@ apply(ctx, { root: testRoot, sentenceAnalysis: { enabled: true, autoAnalyze: tru
21
21
  const defs = Object.fromEntries(registry.map((d) => [d.name, d]));
22
22
  const names = registry.map((d) => d.name);
23
23
  console.log("工具数:", names.length, names.join(", "));
24
- if (names.length !== 10) throw new Error("expected 10 tools");
24
+ if (names.length !== 13) throw new Error("expected 13 tools (v0.8.0)");
25
25
 
26
26
  const exec = { agent: { session: { header: { cwd: testRoot } } } };
27
27
 
@@ -54,7 +54,7 @@ console.log("DSH schema 规则检查: 全部通过");
54
54
 
55
55
  // 1) 分析 + 缓存(第一次 miss,第二次 hit)
56
56
  const first = await defs.novel_sentence_analysis.execute({ book: "测试" }, exec);
57
- console.log("首次分析 cache:", first.cache, "| reportFile:", first.reportFile?.includes("cache"));
57
+ console.log("首次分析 cache:", first.cache, "| reportFile:", first.reportFile?.includes("analysis") ? "✓ 新目录" : "✗ " + first.reportFile);
58
58
  if (first.cache !== "miss") throw new Error("first should be miss");
59
59
  const second = await defs.novel_sentence_analysis.execute({ book: "测试" }, exec);
60
60
  console.log("二次分析 cache:", second.cache, "| 指纹一致:", second.fingerprint === first.fingerprint);
@@ -79,7 +79,7 @@ await defs.novel_plot.execute({ book: "测试", action: "done", id }, exec);
79
79
  const afterDone = await defs.novel_plot.execute({ book: "测试", action: "list" }, exec);
80
80
  console.log("done 后:", afterDone.entries.map((e) => e.content + ":" + e.status).join(" "));
81
81
  if (afterDone.entries.find((e) => e.id === id).status !== "done") throw new Error("plot done broken");
82
- const plotFile = join(testRoot, ".novel-writer", "测试.json");
82
+ const plotFile = join(testRoot, ".novel-writer", "plots", "测试.json");
83
83
  if (!existsSync(plotFile)) throw new Error("plot file not persisted");
84
84
  console.log("伏笔文件:", plotFile, "存在 ✓");
85
85