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

novel_diagnose

诊断报告(v6.6.0 新增,第 20 个工具)。读本地事件日志并做五处体检——书库根、书库内容、插件数据目录(含损坏检测)、语义引擎(惰性探测,不加载模型)、开关与状态快照——把「平时看不出来的问题」聚合成一份中文报告:近期调用 / 降级 / 失败统计、「工具 × 书名」反复出问题的组合、高频失败类型、可执行建议,以及「本次覆盖与未覆盖」清单。

它刻意不做成「给作者看日志文件」——本插件的使用者是小说作者,不会去读 .jsonl;真正的消费者是替他操作的 AI(经本工具读成中文报告)与维护者(收到用户附上的诊断包)。所以要修的是「跨调用 / 跨会话才看得出来的规律」:同一本书同一个工具错了 5 次、语义引擎反复不可用、书库根其实一直不对、某个工具被自己关掉了却忘了——这些在单次调用的友好报错里全都看不见,而用户报 bug 时手上也没有任何可附带的证据。

它不判定对错、也不减少 bug,只让定位变快。报告不替代工具自己返回的中文提示(那条提示仍是第一手信息),它补的是「这件事已经重复发生」与「环境/开关侧其实有事」这两层。

报告只含元数据,绝不含正文,连原始异常消息也不含。本地事件日志以元数据为主(时间 / 工具名 / 书名 / 章号 / 状态 / 耗时 / 计数 / 路径 / 错误 code),但自由文本字段(错误消息等)经收敛后至多保留一句(≤160 字)——所以本机日志里可能出现被错误消息回显的输入片段(实测:novel_lexicon 找不到词条时会把传入的词条名回显进 detail;口径见「两级隐私设计」一节)。任何离开本机的内容(本报告、面板「复制诊断包」、诊断路由)都会整体剔除自由文本,而各类错误在生成报告前就被归类成固定 code 与中文短标签——因为 JSON.parse 的报错会回显文件内容片段,而 summaries/、plots/、settings/ 正是由正文派生的。因此本工具的报告可以直接贴进 issue;报 bug 请用它或面板「复制诊断包」,不要直接附本机日志文件。

参数

参数类型必填说明
bookstring否只看这本书相关的事件。取值先 trim,空串即「不过滤」;过滤是严格相等(事件里的 book 必须与它逐字符相同),所以末尾多一个空格就什么都匹配不到。
daysinteger否统计窗口(天)。默认 7、上限 90;非数字 / 0 / 负数一律回退 7,小数被 Math.trunc 截断(days: 7.9 = 7 天)。
actionstring否report(默认)= 生成报告;events = 额外附带事件明细(≤ 200 条);clear = 清空本地事件日志。插件内部只区分 clear 与 events,其余取值一律按 report 行为处理(action 字段仍原样回传);三个合法取值由参数 schema 的 enum 在宿主侧约束。
rootstring否章节库根目录(含 novels 子目录)。省略时按 config.root → 会话工作目录回退(见「根解析与来源披露」)。

参数 schema 声明 additionalProperties: false,即只接受以上四项。本工具的 execute 不调用 assertToolEnabled——诊断必须在「把一堆工具都关掉了」的时候仍然能用(否则最先需要它的人恰好是被自己关掉的人)。

输出结构

契约字段(output.schema.required):action、days、root、calls、issues、checklist、report。

字段类型内容
reportstring渲染好的中文报告(与 output.render 同源,均由 formatDiagnosis 生成)
actionstring本次生效的 action(默认 report;clear 时另附 cleared:true、days:0)
daysinteger本次生效的窗口天数(clear 时为 0)
rootobject根解析 + 书库内容体检:path / source / looksLikeLibraryRoot / lastRoot / lastRootLooksLikeLibraryRoot,以及 booksPath / bookCount / chapterCount / chars / books[] / booksListed / emptyBookCount / unreadableBookCount / badNameTotal / shortTotal / unreadableTotal / badNames[] / shortChapters[] / unreadableFiles[] / scanPartial / scannedFiles
root.books[]array每部作品一行 { name, chapters, chars, badNames, shortChapters, unreadable };按字数降序、最多 40 部(booksListed 为实际列出数)
dataDirobject数据目录体检:path / exists / bytes / files / subdirs[](≤12)/ expectedSubdirs / presentSubdirs / missingSubdirs[] / checkedJson / nonJsonFiles / compressedJson / compressedSkipped / corruptTotal + corruptFiles[] / emptyTotal + emptyFiles[] / oversizedTotal + oversizedFiles[] / unreadableTotal + unreadableFiles[]
dataDir.corruptFiles[]array{ file, rel, subdir, bytes, error };error 是固定文案(「无法解析为合法 JSON」/「zstd 缓存解压失败(写入被打断或文件被改坏)」/「zstd 解压后不是合法 JSON」),绝不含原始异常文本;每类清单最多 12 条,只给计数之外的明细
logobject{ dir, bytes, files, kinds, exists, selfCallExcluded }:日志目录、现有 events*.jsonl 合计字节数、文件份数、已定义的事件类型数(当前 9)、是否已有日志,以及下面那条契约声明
log.selfCallExcludedboolean恒为 true:合约声明——本报告的读数取自本次诊断自身那条审计记录落盘之前(审计包装要等 execute 返回后才写),因此比事后调 logStats() 少恰好一条事件。想「与磁盘现算比对」的一方必须同样在调用之前读盘、或把 novel_diagnose 那条减掉,否则会差出恰好一条事件的计数与字节数——看起来像 bug,其实是口径
callsobject{ total, ok, degraded, failed },只统计 kind: "tool_call" 的事件
byToolarray{ tool, total, ok, degraded, failed, msTotal, msAvg };按 total 降序、前 12 个
byKindarray{ kind, count, codes[] },按计数降序;codes 是该类型下的 code 分布(≤4 项)——「12 次 cache 事件」不可行动,「12 次都是同一个 code」才能定位到具体文件
topCodesarray高频失败类型 { code, count },按计数降序、前 8 个;无 code 的失败会聚到空串上
problemsarray「工具 × 书名」反复出问题的组合:{ tool, book, failed, degraded, codes, chapters }(codes / chapters 各截前 4 项);按「失败 + 降级」次数降序、前 10 组
issuesarray问题与建议,元素 { level, title, detail, suggestion };level ∈ error / warn / info(报告里渲染为「⚠ 阻断 / △ 注意 / · 提示」),已按级别排序(error → warn → info,同级保持发现顺序)。元素 schema 是 additionalProperties:false,只允许这四个键
checklistarray本次覆盖与未覆盖(9 项),元素 { item, state, note };state ∈ checked / warn / skipped / off
toolsobject能力 + 开关快照:registered / enabled / disabled[] / disabledCount / features(功能开关生效值)/ disabledFeatures[] / master{enabled,autoAnalyze,source} / prompt{systemPromptMode,promptScene,leanWorkflow} / styleTolerance / stateFile{path,exists} / engine{…}
tools.engineobject{ modelDir, model, modelPresent, loaded, errorCode, errorLabel, lastCacheErrorCode, lastCacheErrorLabel, events{count,codes[]} }
eventsarray仅 action=events 时出现:事件明细(≤ 200 条,仍为分享层,自由文本已剔除)
bookstring仅在传了非空 book 时出现

渲染层(output.render)返回 [{ type: "text", text: value.report }]:报告是一条纯文本,首行固定为 【诊断报告】生成于 <ISO 时间>,之后的小节号由渲染器动态分配,顺序固定为:一、书库根 / 二、书库内容体检(只统计,不读正文)/ 三、插件数据目录 / 四、语义引擎(本地 embedding,惰性加载)/ 五、开关与状态 / 六、近 N 天调用 / 七、问题与建议 / 八、事件明细(仅 action=events 且有明细时才有这一节,只渲染前 40 行)/ 九、本次覆盖与未覆盖。因此「本次覆盖与未覆盖」在没有事件明细时是第八节、有明细时是第九节——按节号解析的读取方请注意这一点(按标题解析则不受影响)。末尾固定附一句「本报告只含元数据……日志从设计上就不记录正文」——该句的后半截属旧口径残留:报告本身确实不含正文(分享层已剔除自由文本),但「日志从设计上就不记录正文」对本地层不成立(本地层至多保留一句 ≤160 字,见「约束与边界」一节的实测复现)。这句固定文案在 lib/diagnose.js 的 formatDiagnosis 里,属本轮未改动的实现文件,留待下版与代码一起改;此处文档口径已改准。

行为与实现

1 · 根解析与来源披露

root = resolveRoot(config, args, exec) // 与其他工具同一实现 root.source(三选一): 调用参数 root 非空 → "调用参数 root" 否则 config.root 非空 → "插件配置 config.root" 否则 → "cwd(当前工作目录兜底)" root.looksLikeLibraryRoot = 根下存在 novels/ 或 .novel-writer/ root.lastRoot = state.json 里记住的上一次写操作根("" = 无) root.lastRootLooksLikeLibraryRoot = lastRoot 为 "" 时是 null,否则同上判据 resolveRoot 只在「可疑决策」时记 root_resolve(level:"warn"): 显式 root 不像书库根 / config.root 不像书库根 / 走到 cwd 兜底且 cwd 不像书库根 noteRoot(写操作之后记 lastRoot)另有三类留痕: 显式 root → root_resolve(说明"跳过落盘",每次显式 root 的写操作都会记) cwd 兜底且不像书库根 → root_resolve(level:"warn",拒绝记为 lastRoot) lastRoot 真的变化 → root_resolve("lastRoot 首次记录" / "lastRoot 变更",含旧值与新值前 120 字) 写 lastRoot 失败 → config(status:"failed"、level:"warn",面板下次可能列不出书)

looksLikeLibraryRoot 为假时直接产出一条 level: "error" 的问题(「书库根可能不对」),并给出可执行修法:根必须是 novels 的父目录(把 novels 目录本身填进去,会去找 novels/novels)。lastRoot 失效则单独一条 warn——面板会静默跳过失效的 lastRoot,但每次解析根都要白跑一次,所以必须点名。

留痕是刻意「有选择性」的,但选择性不等于全静默。resolveRoot 每次工具调用都会跑,因此只记可疑决策;而 noteRoot 在显式 root 的写操作后固定记一条(说明"不改写宿主记住的书库位置"),lastRoot 变化时再记一条。后果之一:一次 novel_new_chapter(或其他写操作)可能同时留下 root_resolve 与 tool_call 两条事件——统计口径上 calls 只数 tool_call,所以计数不会被这条多算,但「一次调用一条记录」这个说法不成立,读日志时按 kind 区分。

2 · 书库内容体检(只统计,不读正文)

listBookNames(root) → 逐本 scanChapters(只 readdir + 解析文件名) 逐章 countChapterChars(file) = readTextFile(file).length ← 返回值是 number,正文不出这个函数 统计:书数 / 章数 / 总字数 命名异常 badNames :解析不出章号的文件(章序排到书末、无法用章号定位) 过短章节 shortChapters:< 40 字(空壳章、只有标题的占位章——会把六维基线与风格指纹拉偏) 空书目录 emptyBookCount / 读不出的章节与书目录 unreadableTotal、unreadableBookCount 读取预算:SCAN_MAX_FILES = 2000 个文件、SCAN_MAX_CHARS = 8 MB 用尽即停并把 scanPartial 置真(报告写「字数按已读的 N 章计」) 排序:books 按 chars 降序;输出最多 MAX_BOOKS_LISTED = 40 部 清单:badNames / shortChapters / unreadableFiles 各最多 12 条(MAX_LIST)

为什么要这一节:< 40 字的章与「解析不出章号」的文件在 novel_chapters 里都只表现为「有一章」,但它们会(前者)把六维基线与风格指纹拉偏、(后者)让章序排到末尾且无法被章号定位——两类问题在别处都看不出来。

3 · 插件数据目录体检(含损坏检测)

dir = novelDataDir(root) = <root>/.novel-writer 应有子目录(9 个):analysis audits baseline embedding lexicon plots settings style-reports summaries → presentSubdirs / missingSubdirs(用到时才建,缺失属正常,报告会说明这一点) 目录根下的散落文件也扫一眼(正常应为空) 逐文件:累加 bytes / files,然后分类 0 字节 → emptyFiles(写入被打断;调用方读到会当成"空表") 非 .json → nonJsonFiles(不解析) > 8 MB(MAX_PARSE_BYTES)→ oversizedFiles(只记体积、不整读) 读不出来 → unreadableFiles(权限 / 占用) zstd 魔数 28 b5 2f fd → compressedJson(embedding/*.json 与 *__style*.json 是压缩体) 可解压 → 解压后再 JSON.parse 判定 旧 Node(无 zstdDecompressSync)或 > 2 MB(MAX_DECOMPRESS_BYTES)→ compressedSkipped(不判损坏) 其余文本 JSON → checkedJson → JSON.parse 只判可解析性,结果立刻丢弃 解析失败 / 解压失败 → corruptFiles(error 是固定文案,绝不含原始异常文本)
「按魔数认出压缩缓存」这条是踩过坑才加的。 embedding/*.json 不是文本 JSON,而是 zstd 压缩后直接落盘(扩展名仍叫 .json)。第一版检测器只做 JSON.parse(readFileSync),于是在真实书库上把 15 个健康的缓存全报成「损坏」——正是「报告说坏了、其实没坏」这类最伤信任的错。现在按魔数识别、按需解压再判;纯文本才走 JSON.parse;解压后超过上限时按「缓存很大」处理,不判损坏。

损坏的判定口径与 core.js 的词表守卫一致:文件存在但读不出内容 = 损坏,文件不存在是另一回事(missingSubdirs / 0 字节各有独立归类)。

4 · 语义引擎体检(惰性探测,不加载模型)

inspectEngine(events) 用 embedding.status() —— 纯探测:existsSync 模型文件 + 读进程内存里的引擎状态 不创建 WASM 会话、不预热模型、不写缓存(诊断必须便宜且无副作用) 输出:modelDir / model / modelPresent / loaded / errorCode / errorLabel / lastCacheErrorCode / lastCacheErrorLabel / events{ count, codes[] }(日志里历史的 kind==="engine" 事件,≤6 个 code) 引擎错误归类(固定 code + 中文短标签,原文一律不透出): runtime-missing 运行时依赖没加载(onnxruntime-web / @huggingface/tokenizers 未解析到) model-missing 模型文件缺失(lib/models/ 下的 onnx 模型或 tokenizer) wasm-failed WASM 会话创建或推理失败 unknown 未能归类(原始文本按隐私设计不入报告) 缓存错误归类: no-space / denied / locked / corrupt / unknown

三件事必须在报告里讲清,否则会误导人:① loaded:false 不等于坏——模型是首次真正用到时才惰性加载的;② 只有 modelPresent:false 或 errorCode 非空才是确定的问题;③ 进程内的错误状态重启宿主即丢,所以还要看日志里历史的 engine 事件(跨进程证据)。分类不是装饰:它决定给用户哪一条修法(重装依赖 / 补齐模型 / 换宿主版本 / 清盘 / 给权限 / 关掉占用程序)。

5 · 开关与状态快照

readSentenceStateSync() + effectiveSentenceAnalysis()(core 的单一裁决点,不重写判断) 状态文件 stateFile{ path, exists }(同步读拿不到 exists,这里自己 existsSync 补上) 工具:registered / enabled / disabled[](逐个 toolEnabled 求值) 功能开关:已声明的全查 + 用户自加的布尔键(≤20),缺失回退 FEATURE_DEFAULTS 总开关:master{ enabled, autoAnalyze, source }(source = "state.json" 或 "config / 默认值") 提示词:prompt{ systemPromptMode, promptScene, leanWorkflow }(枚举外的值回退默认) 容差:clampStyleTolerance(state.styleTolerance)(走同一套钳制,脏值不带出来) lastRoot

这是本次增强里最值钱的一条:工具被关掉时 AI 只会收到一句「当前已在 UI 中关闭」,而用户往往不记得是自己关的——于是反复重试、以为插件坏了。报告把「哪些工具关了、哪些功能关了、总开关在哪一档」直接列出来,并给出 novel_sentence_config 的开启写法,一句话就能对上。注意口径:开关缺失 = 开启(不是关着),无状态文件时全部走默认值,属正常而不是异常。

6 · 两级隐私设计(本模块的核心)

日志天生会被贴出去(贴进 issue、贴给 AI 看),所以隐私红线是「任何离开本机的内容都不含自由文本」——不是「日志里绝不含正文」:后半句在本地层做不到(本地层的自由文本至多保留一句,见本节口径修正)。实现手段是字段白名单,而不是「检查值里有没有正文」:

本地日志(logs/events.jsonl)——只落白名单键,白名单之外一律丢弃: ts kind level tool action status code book chapter file path root rootSource mode scope name ms count degradedCount bytes files note detail suggestion source phase version plugin contentChars textChars srcChars filesCount chaptersCount termsCount ← 参数摘要的长度/计数派生键 truncated ← 超长事件被裁过的显式标记 显式黑名单(纵深防御,即使在白名单里也丢): text content body excerpt raw paragraph paragraphs anchor anchors skeleton skeletons summary brief report prompt term terms entries chaptertext fulltext 自由文本(note / detail / suggestion)额外收敛 —— 这不是安全保证,只是把误用的爆炸半径压到一句话: 换行/制表压成空格 → 遇句末标点(。!?!?…)即从该处截断并加 … → 超 160 字上限截断 → 连续表意文字超 40 字截断 ⇒ 因此本地层「至多保留一句(≤160 字)」:无句末标点且连续表意文字 ≤40 字时,那一句会被完整保留 (F1 已知边界,实测复现命令见「约束与边界」一节;协调方判定不收紧本地层,只改口径) 其余标量 200 字上限;数组最多 20 项且只收标量;对象 / 函数 / 符号一律丢弃 对外分享层(readLogEvents 的默认行为,fail-closed): 只有显式传 forShare === false 才返回本地层;其余(1 / "true" / {} / 忘传)一律按分享层处理 分享层删除 note / detail / suggestion 三个键,并恒带 redacted: true(层级标志,不是"有内容"标志) 报告生成前再走一道错误归类(引擎 / 缓存 / JSON)→ 只出固定 code 与短标签
  • 本地日志(口径修正,本版定稿时改准的一句):以元数据为主(时间 / 工具名 / 书名 / 章号 / 状态 / 耗时 / 计数 / 路径 / 错误 code / 插件自己写的固定短语),但它做不到「绝不记录章节正文」——自由文本字段(note / detail / suggestion,也就是错误消息那条通道)经 clampFreeText 收敛后至多保留一句(≤160 字),因此本机日志里可能出现被错误消息回显的输入片段。实测复现(真实工具路径):novel_lexicon action=update term=… 传一句 34 字、无句末标点的文字 → 抛「词表中找不到词条「…」」→ 该句逐字落进 logs/events.jsonl。这不影响「正文不外传」的承诺(日志文件不出本机),且协调方判定不收紧本地层(改成 40 字上限之类会削弱诊断价值、又仍会泄漏若干字)——正确的修法是改口径,不是改代码。白名单机制的关键性质是「未知键一律丢弃」:调用方「顺手多传一个字段」不会把正文带出去。读侧还会再净化一遍——历史文件可能由旧版本写入,读取方不能假设它已经干净。
    本版内修正:参数摘要的长度 / 计数派生键(contentChars / textChars / srcChars / filesCount / chaptersCount / termsCount)与 name 此前漏登记在白名单外、被整批丢掉(实测 15 个键落盘只剩 8 个),也就是「参数摘要只记长度」这句当时并不成立——隐私侧没问题(正文本来也进不来),损失的是可观测性:「这次调用带了多少字正文」永远查不到。现 7 个键已补齐。
  • 对外分享层:本工具的报告、面板的「复制诊断包」、诊断路由返回的内容,一律走分享层——自由文本整体剔除,只留结构性字段(工具名 / 书名 / 章号 / 状态 / 耗时 / 错误 code / 计数 / 时间)。分享层是默认行为(fail-closed):readLogEvents 只有显式传 forShare === false 才返回本地层,其余一切情况(1 / "true" / {} / 忘传)一律按分享层处理——旧写法是 !== true 就返回含自由文本的本地层,属于 fail-open,默认值必须偏向"少给"。代价:本地调试想直接看 detail / note,必须显式传 false(日志文件本身始终可以直读)。
  • redacted: true 恒带(本版内修正):原先只在"本来就有 note / detail / suggestion"时才加,导致一条没有自由文本的事件在本地层与分享层里长得一模一样,读取方无法自证这条走过哪一层;现在恒带——它是层级标志,不是「有内容」标志。
  • 报告层再挡一道:原始异常消息一律不透出。JSON.parse 的报错会把文件内容片段带进 message(Unexpected token 'x', "…" is not valid JSON 会回显约 10 个字),而 summaries/、plots/、settings/ 正是由正文派生的;语义引擎的异常则可能带绝对路径与系统信息。所以本模块把错误归类成固定 code,只输出 code 与中文短标签,永不输出原文。
  • 为什么必须分两级:值层面的长度 / 标点启发式从原理上无法保证自由文本里不含正文——正文的一句话就是正文,截多短都还是。所以真正的保证不是「让日志不含正文」,而是「任何离开本机的东西都不含自由文本」。本地日志文件不出本机,不违背「正文不外传」的承诺。
日志落点在状态目录旁,不在书库根。~/.dsh/dsh-novel-writer/logs/events.jsonl(受 DSH_NOVEL_WRITER_STATE 覆盖;目录取状态文件所在目录),不是 <书库根>/.novel-writer/。理由很实在:最常见的故障就是「书库根不对」,那时 .novel-writer/ 根本不存在或不可写,偏偏最需要留下证据。顺带一个副作用:状态目录可被环境变量覆盖,测试因此天然隔离。

7 · 事件类型与轮转

kinds(9 类,诊断按 kind 聚合;其中 degraded 目前由 tool_call 的 status 表达,独立事件暂未使用): tool_call root_resolve silent_skip degraded cache engine config panel internal 轮转:单文件 512 KB(LOG_MAX_BYTES)→ events.jsonl → events.1.jsonl → … → events.3.jsonl(删除最旧) 保留最近 3 份历史(LOG_KEEP_FILES),加当前文件共 4 份,总量有界 单条事件:序列化后上限 4096 字节。超长时逐级丢弃信息量最低的字段(先 detail/note/suggestion/files/path/file/name/scope/root, 仍超限就只留结构性骨架)并置 truncated:true —— 永远写一整行合法 JSON (本版内修正:旧写法 delete detail + slice() 会写出半行 JSON,读侧当坏行跳过 → 整条事件从诊断里凭空消失,比"被截断"更糟) 写入:sanitize → JSON.stringify → mkdir(首次)→ rotateIfNeeded → appendFileSync(同步)

写入刻意用同步 appendFileSync:一次 append 只有几十字节,代价远小于「异步队列在进程退出时丢事件」的风险(本插件没有常驻进程的生命周期钩子)。任何写入失败都被吞掉——写日志绝不能让工具调用失败(那才是本末倒置);唯一的例外是首次失败时往 stderr 打一行提示,且只打一次,否则连「日志本身坏了」都无从知晓。

8 · 工具调用审计:注册层统一包一层

审计不是散落在各个工具体内的埋点,而是在注册层临时替换 ctx.tools.register,对每个工具定义包一层 execute,注册完立刻还原。理由是「体内埋点迟早会漏,而且新加的工具必然忘」,而 register 是唯一必经之路。

每次调用记录(kind: "tool_call"): tool / status(ok | degraded | failed)/ ms / degradedCount / code / detail + summarizeArgs(args) ← 安全参数摘要 summarizeArgs 取哪些: 定位字段(标量)book chapter action mode scope root file path name 只记"给了几个":content text src files chapters terms 字符串 → <键>Chars = 长度;数组 → <键>Count = 条数 ⚠ 这些派生键必须同时登记在 ALLOWED_KEYS 里才落得下来:本版曾因漏登记被整批丢掉 (实测 15 个键落盘只剩 8 个,"只记长度"当时并不成立)——现已补齐 状态判定: 返回体 ok === false → failed,code = error 前 80 字 返回体 degraded 数组非空 → degraded(条数 = 数组长度) 抛异常 → failed,code = e.code 前 40 字,detail = message(异常原样抛出) 三条铁律: ① 审计绝不影响工具本身(异常不吞、不转换、不改返回) ② 参数绝不记正文(只走 summarizeArgs。注意:失败调用还会把错误消息写进 detail, 那条路经 clampFreeText 至多保留一句 ≤160 字——见「两级隐私设计」的本地层口径) ③ 注册完立刻还原 register,不留副作用 包装装不上时(宿主把 register 设成只读)→ 放弃审计,但记一条 internal 事件说明 「本次会话的 tool_call 审计已关闭」——否则日志里"什么都没有"会被读成"什么都没发生"

本工具自身也在被审计之列(包装在注册之前装好、在最后一个工具注册完之后才还原),所以「谁在什么时候调了诊断、跑了几毫秒」同样会进日志。代价已知:每次调用多一次同步写盘。

静默失败留痕是「选择性接线」,两个总体分开给、便于自行复算。6 个服务端模块(core / index / brief / vibe / embedding / analysis)里 catch 点 163(块级 } catch 161 + 链式 .catch( 2;分文件 core 80 / index 52 / brief 7 / vibe 11 / embedding 11 / analysis 2)——163 是屏蔽注释与字符串字面量后的代码真值:旧稿写 164,因为 core.js 有一条注释引用了 try { … } catch { … } 的写法、被「数 catch 关键字」的口径算成了代码(口径过计,不是漏接)。另有 84 处 logEvent( 调用点(core 46 / index 24 / brief 2 / vibe 5 / embedding 6 / analysis 1)——它不是「已接住的 catch 数」:其中 77 处落在 catch 体内(=有留痕的 catch 点 77),其余 7 处根本不在 catch 里(书库根轨迹 resolveRoot 3 处 + noteRoot 3 处,都是 root_resolve;以及工具调用审计 installToolAudit 的 tool_call 1 处,它在 finally 里)。因此静默的 catch 点 = 163 − 77 = 86;旧稿的「静默 80」是拿 84 去减 164 得来的,等于把 7 处 catch 外的调用点也算成「接住了 catch」,会少算 6 处。口径:屏蔽注释与字符串后按括号配对求每个 catch 体区间(链式 .catch( 取实参区间),再判定每个 logEvent( 调用点是否落在某个 catch 体区间内。
静默的 80 处分 7 类、每类都有理由:① 「文件不存在 / 空」是常态(ENOENT 不是故障);② 该失败已如实披露给调用方(degraded[] / notes[] / checklist[] 已写明);③ 重抛由审计层统一记(记两次反而重复);④ 纯清理 / 统计路径(失败无后果);⑤ 按设计允许的探测("先试一个不存在的路径");⑥ 有意不记的「每项」分支(记了会刷屏);⑦ 无法区分 ENOENT 的场合(宁可漏报,不制造噪音)。接线的类型是 silent_skip / cache / engine / config / panel / root_resolve / internal,覆盖「缓存写不进去 → 每次都要重算」「词表/设定表读不出 → 调用方当成没登记」「语义引擎失败 → 静默回退纯规则」这些此前只能靠猜的路径。因此日志不等于「所有被吞掉的失败都留了痕」。

9 · 聚合口径与「问题清单」的触发规则

calls:只数 kind === "tool_call";status 缺失按 ok 计 byTool:按 total 降序取前 12;msTotal 只累加有限数值的 ms,msAvg = 四舍五入均值 byKind:每类计数 + 该类下 code 分布(各留前 4) topCodes:code × 次数,降序取前 8 problems(工具 × 书名):仅 failed / degraded 的事件参与; 同一 (tool, book) 归一组,收集 code 与 chapter 集合(各留前 4) 按 (failed + degraded) 降序取前 10

problems 是本版最有价值的产出:插件里大量用法错误是 assert 抛的普通 Error(没有 code),而这类恰恰是「使用者的操作错误」(书名写错、章号不存在、书库根填低一层)——按书分组后,「同一本书同一个工具错了 5 次」这种模式才看得出来,而这正是单次调用的友好报错给不了的。

issues 由体检结果逐条推导,按级别排序(error → warn → info)。触发条件按来源分四组(每条都给一句「照着做就行」的动作,不给「建议检查一下环境」这种空话):

来源触发条件级别
日志聚合同一「工具 × 书名」失败 ≥ 2 次,或失败 + 降级 ≥ 3 次warn
书库根结构不对(根下既无 novels/ 也无 .novel-writer/)error
窗口内有失败调用warn
窗口内有降级调用info
数据目录不存在info
日志合计 > 300 KB(接近轮转上限)info
传了 book 但窗口内没有该书事件info
语义引擎功能总门控 features.semanticEmbedding=falseinfo
modelPresent === false(模型文件不在位)warn
errorCode 非空(初始化失败,已回退纯规则)warn
lastCacheErrorCode 非空(索引缓存读写失败)info
窗口内有 engine 事件(跨进程证据)info
开关与状态有工具被关闭(点名是哪些)warn
有功能开关被关闭(除「仅 semanticEmbedding 且模型也缺」外)info
总开关 enabled=falsewarn
状态文件不存在(全部走默认值)/lastRoot 已失效info / warn
数据目录有损坏文件(corruptTotal > 0)error
有读不出来的文件(权限 / 占用)warn
有 0 字节文件warn
有 ≥ 8 MB 的超大文件(只记体积)info
书库内容novels/ 下没有作品目录warn
有空书目录(无章节文件)info
有解析不出章号的文件名warn
有过短章节(< 40 字)info
有章节 / 书目录读不出来warn
字数统计未覆盖全部章节(读取预算用尽)info

一条都不触发时给一条 info「未发现异常」(说明窗口内调用都正常完成,书库根、数据目录、语义引擎与各开关都没发现问题)。

10 · 「本次覆盖与未覆盖」清单(9 项)

清单项state 取值说明
书库根与目录结构checked / warn根下存在 novels/ 或 .novel-writer/ 即 checked,否则 warn 并注明「既无 novels/ 也无 .novel-writer/」
书库内容体检(书数/章数/字数/命名)恒 checked只统计书数、章数、字数、命名异常与过短章(含本次章数);不读正文内容,读文件只取 length;读取预算用尽时在 note 里说明
插件数据目录体检checked / skipped存在则 checked:体积 / 文件数 + JSON 可解析性(含 zstd 压缩缓存解压校验)+ 0 字节 / 超大文件;不存在则 skipped
语义引擎(本地 embedding)恒 checked只做状态探测(模型文件是否在位、进程内是否已加载、上次错误归类)——不触发模型加载,所以「未加载」不等于坏
开关与状态快照恒 checked有 state.json 则读出被关的工具 / 功能、总开关、风格容差;没有则说明全部走默认值(首次使用属正常,不是异常)
近 N 天事件聚合恒 checked按工具 / 类型 / code 聚合,注明「自由文本已在分享层剔除」;读数上限:本工具传 2000 条(事件更多时计数只覆盖最近 2000 条;readLogEvents 自身的默认值是 1000、钳制上限 5000)
事件原文checked / off只有 action=events 才 checked(明细仍为分享层,无自由文本)
章节内容 / 正文恒 off本工具不读正文,也不把正文写进任何离开本机的内容——本模块读章节只为取字数(length),正文不出那个函数。但别把它读成「本机日志里绝不含正文」:日志的自由文本字段(错误消息等)至多保留一句(≤160 字)
并发与性能画像恒 off本工具不做压测;只给已记录到的耗时合计(byTool[].msTotal / msAvg)

清单存在的理由与 novel_continuity_check 的 checklist 一致:「0 条候选」不等于「没问题」。恒为 off 的两项就是明说「这两件事本工具不管」。

11 · action=clear 与 action=events

clear:删除 logs/events.jsonl 与 events.1/2/3.jsonl(force,单个失败忽略) 返回 action:"clear"、days:0、cleared:true、 root:{ path, source:"", looksLikeLibraryRoot:false }、 calls 全 0、一条「本地事件日志已清空」的 issue、一条 checklist、一段专用 report events:读取条数上限从 2000 降到 200(明细),并写入返回体的 events 字段; 报告里只渲染前 40 行,格式为「时:分:秒 kind tool status code Nms」

clear 是删除而不是轮转,不可恢复;它删的是本机日志,与书库内容无关。清空后 root 字段刻意不做体检(looksLikeLibraryRoot:false、source:""),避免把「清日志」误读成「根有问题」。另需知道:clear 自身也是一次工具调用,所以清空之后日志里通常立刻又多一条记录(这正是审计包装在生效)。

12 · 诊断路由与面板(与工具同一实现)

GET /api/dsh-novel-writer/diagnose?days=&book=&events=1 守卫 isAllowedRequest(与 state / lexicon / baseline 等路由同一条): isLoopbackRequest 要求 socket.remoteAddress ∈ {127.0.0.1, ::1, ::ffff:127.0.0.1} 且 Host 头解析出的 hostname ∈ {127.0.0.1, localhost, [::1]} 且 sec-fetch-site ≠ cross-site;带 Origin 时必须与 Host 同源 否则 allowLanState === true 才放行;allowLanState 关闭时一律 403 非回环地址(且 allowLanState 未开)→ 403 { error: "forbidden: loopback-only" } 非 GET → 405 { error: "method not allowed: …" } days 非有限或 ≤ 0 → 7 events=1 → action = "events",否则 "report" 成功 → 200 { ok: true, report, root, dataDir, log, calls, byTool, byKind, topCodes, problems, issues, checklist, tools } 异常 → 500 { ok: false, error: "diagnose failed: <前 160 字>" }

注意守卫看的是 Host 头 + 连接来源两者,而不是只看来源地址:这解释了「本机用真实浏览器访问正常,但伪造一个不带 Host 头(或没有 socket 来源地址)的请求会拿到 403」。

路由与 novel_diagnose 共用 buildDiagnosis + formatDiagnosis,所以面板看到的数字与 AI 看到的数字必然一致(口径分叉正是最伤信任的那类问题)。它注册在 lib/index.js 而不是 core.makeStateRoutes,是因为 core.js 不能被 diagnose.js 反向依赖(会形成循环导入,ESM 下 const 导出的 TDZ 风险高)。

面板侧新增「诊断报告」页(首页「记录与维护」分区进入):展示报告、选择统计窗口(默认 7 天、上限 90,与工具同一套钳制),并支持一键复制诊断包(用于贴进 issue)。复制的正文 = 接口给的 report + 一段极简环境信息(书库根 / 窗口 / 调用计数 / 数据目录 / 日志目录)——这段环境信息只由结构化字段拼成,面板只展示不重拼报告(否则面板与工具的报告会各写一份、随时间分叉),所以复制出去的内容既无自由文本也无正文;刷新失败时保留上一份报告(不把仅有的可分享材料清掉)。剪贴板不可用时不谎报成功,改为提示手动全选报告块。

约束与边界

  • 本地日志的边界(本版定稿时改准;别读成「日志里绝不含正文」):它以元数据为主,但自由文本字段(错误消息等)经收敛后至多保留一句(≤160 字)——所以本机 logs/events.jsonl 里可能出现被错误消息回显的输入片段(实测复现:novel_lexicon action=update term=「一句 34 字、无句末标点的文字」 → 抛「词表中找不到词条」,该句逐字落盘);这个文件不出本机。分享层把自由文本整体剔除、报告层再把错误归类成固定 code 之后,报告里能看到「失败了 5 次、code 是什么」,但看不到原始错误消息——需要原文时请看本机 logs/events.jsonl 的 detail,但不要把它附进 issue:报 bug 请用面板「复制诊断包」或 novel_diagnose 的输出(那条路可安全贴出)。协调方判定不收紧本地层(改成 40 字上限之类会削弱诊断价值、又仍会泄漏若干字),正确的修法是改口径而不是改代码。
  • 它不减少 bug,只加快定位:报告给的是「发生了什么、几次、最可能的原因、下一步做什么」,不判定对错、不自动修,也不替代工具自己返回的中文提示。它不改变任何工具的返回体,也不写书库数据(action=clear 只删自己的日志)。
  • 静默失败只接了有诊断价值的那部分:catch 点 163(块级 161 + 链式 2)里有留痕的只有 77 处、保持静默 86 处(分 7 类、口径与过计说明见上文注记;注意「84 处 logEvent( 调用点」是另一个总体——其中 7 处在 catch 之外,是工具调用审计与书库根轨迹,不能拿它当「已接住的 catch 数」去减);「文件不存在 / 本来就没有」这类正常分支刻意不记;root_resolve 的记账规则见「根解析与来源披露」(resolveRoot 只记可疑决策,noteRoot 另有三类固定留痕)。因此「日志里没有 silent_skip」不等于「没有失败被吞掉」。
  • 已知残留(独立验证代理实测,本版不动代码):把某章文件换成同名目录后,core.js 的 scanChapters 里那句 if (!entry.isFile()) continue 会静默跳过它——① 该章从章表消失(novel_chapters 少一章,无任何提示);② 本工具的「读不出来的文件」计数为 0、报告里完全不提这个目录(只显示「章节 N 个」);③ novel_chapter_brief 的 degraded 里没有任何「读取章节失败」——只有「可测章变少」这类间接副作用(实测锚段抽样池由 2 条降为 1 条)。也就是这一条路径上「读不到」仍被讲成「没有」,留待下版处理;遇到「章数莫名少了一章」时请先确认书目录里是不是有一个叫章节名的目录。
  • 分享层是默认行为,本地层必须显式索取:readLogEvents 只有显式 forShare === false 才返回带自由文本的本地层,其余(1 / "true" / {} / 忘传)一律脱敏(fail-closed);分享层恒带 redacted: true。写新代码做本地调试时请显式传 false,不要依赖旧默认。
  • 报告与磁盘会差出「本次调用」那一条:见 log.selfCallExcluded——要拿报告的数字与磁盘现算比对,必须在调用之前读盘,或把 novel_diagnose 自身那条减掉(差的恰好是这一条事件)。
  • 五处体检都只读元数据,但书库体检会把章节读一遍:数据目录只做「能不能解析」而不看内容(解析结果立刻丢弃);书库体检为算字数必须读每个章节文件,因此有读取预算兜底——2000 个文件 / 8 MB 用尽即停,并把字数标为「按已读部分计」(此时单本准确字数请看 novel_chapters)。数据目录里 > 8 MB 的 JSON 只记体积、不整读;zstd 压缩缓存 > 2 MB 或旧 Node(< 23.8,无 zstdDecompressSync)一律不解压校验(宁可漏报,不误报)。
  • 清单有上限:损坏 / 0 字节 / 超大 / 读不出 / 命名异常 / 过短章节每类最多 12 条,作品列表最多 40 部,子目录最多 12 个,byTool 12 行、topCodes 8 行、problems 10 组——一个坏掉的库不该把报告撑爆,超出部分只给计数(报告写「仅列前 N 个」)。
  • 语义引擎的「上次错误」是进程内状态,重启宿主即丢;跨进程证据只有日志里的 engine 事件。并且诊断本身不会触发模型加载——loaded:false 只表示「本进程还没用到它」,报告里会明写这一点。
  • 不做压测:报告里的耗时只是「已记录事件的 ms 合计」(byTool[].msTotal / msAvg),checklist 里的「并发与性能画像」恒为 off。
  • 窗口与条数有上限:days 默认 7、上限 90;条数上限是三层不同的东西——novel_diagnose 给 readLogEvents 传 2000、readLogEvents 自身的默认值是 1000、其钳制上限是 5000(另有 days 的 365 天上限,工具层已先收到 90);action=events 的明细最多 200 条,报告只渲染前 40 行。窗口越大越可能截断,排查「很久以前那一次失败」时不要只靠它。
  • 数字口径是「日志里的事件」,不是「真实发生过的调用」:日志自 v6.6.0 起才开始写,因此升级后第一份报告的窗口内可能只有很少几条记录(甚至只有「这次调用」)——这是预期,不是「日志没生效」。action=clear 之后历史归零且不可恢复,且 clear 自身会立刻留下一条新记录。
  • 日志写入失败是静默的:失败只表现为「诊断信息缺失」,功能不受影响;唯一提示是首次失败时的一行 stderr。log.exists 为假且窗口内确实调用过工具时,应怀疑日志目录不可写(而不是「最近没调用过」)。
  • 轮转意味着老事件会被删掉:单文件 512 KB、只留 3 份历史(合计约 2 MB 上限),高频使用下更早的事件会被自动清理。想留证就及时把报告贴出去,别指望日志长期留存。
  • 本地日志不出本机:它写在本机磁盘上,不参与任何网络请求;插件唯一的外呼仍只有 GitHub Releases 的版本检查(3 秒超时、24 小时缓存、不含任何稿件内容)。novel_diagnose 本身也不调用任何在线模型。
  • book 过滤是严格相等:事件里的 book 来自调用参数(同一份 summarizeArgs),因此「按要求过滤却什么都没查到」时,先核对书名的首尾空格与全半角。
  • 体检结论是「当下快照」,不是历史:除事件聚合外,其余体检(根、书库内容、数据目录、引擎、开关)读的都是本次调用那一刻的状态;想对比「昨天与今天」请用 action=events 看事件时间线。

相关工具

  • novel_sentence_config —— 开关状态的真源;报告里的 tools.* 与它读的是同一份状态文件与同一个裁决点,报告给出的是「照抄就能开回来」的写法。
  • novel_books · novel_chapters —— 核对书名的原文与章号格式;「同一本书同一个工具反复失败」绝大多数就是这两处写法不一致造成的。准确字数也以它们为准。报告给出的「命名异常 / 过短章节 / 读不出来」三张清单也都是可行动的:命名异常用 novel_import 重新导入可按章号改名并避让重号;过短章节补齐正文、或先把占位章移出 novels/<书名>/(六维基线只统计该书目录下的章节文件);读不出来的章节先查编码(UTF-8 / GBK / UTF-16)与占用(网盘同步常见)。
  • novel_lexicon · novel_settings · novel_plot · novel_summary —— 这些工具的数据文件就在被体检的目录里;报告点出「损坏 / 0 字节」后,按提示删除或改名让插件重建即可(词表损坏走 action=import force=true)。
  • novel_semantic_search —— 报告说「语义引擎初始化失败」时,用它跑一次复现;引擎的降级路径与缓存重建也由它触发。
  • novel_continuity_check —— 同样带 checklist(「本次覆盖了什么、跳过了什么及原因」),两者是同一套「不许把 0 条候选读成没问题」的披露习惯。
  • v6.6.0 的一条共同口径:「读不到」不再被说成「没有」。只有 ENOENT(真的没有那个文件)才说「尚未初始化 / 未登记 / 尚未保存 / 无创作资料」;文件存在却读不出来(损坏 / 无权限 / 编码识别不了 / 被取消)时只说事实「该文件存在,但读不出来」+ 路径 + 原因,并明确不必去 init。所以日志里那条 config 事件常常是「真因」,而工具返回的那句提示可能是旧口径的残留——两者不一致时以日志为准,并去看 novel_outline / novel_chapter_brief / novel_style_report / novel_continuity_check 四页里各自的口径说明。

源码位置

以符号名(函数名 / 常量名)为准:下附的行号是 v6.6.0 定稿时的位置,只作快速跳转用——实现一增删就会整体漂移(本页行号已随 lib/log.js / lib/diagnose.js 的两次增长重定位过一次)。

  • lib/index.js —— 工具注册与执行(registerNovelDiagnose,第 4846 行起;action=clear 分支第 4888 行起)、调用审计包装(installToolAudit,第 4922 行起;装不上时的 internal 留痕在第 4970 行)、诊断路由(registerStyleConfigRoute 第 412 行,路由在第 423 行注册)、apply 中的装配与还原(第 4984–5014 行)
  • lib/diagnose.js —— 常量与隐私红线(第 64–114 行:DATA_SUBDIRS / MAX_LIST / SHORT_CHAPTER_CHARS / MAX_PARSE_BYTES / MAX_DECOMPRESS_BYTES / ZSTD_MAGIC / 归类标签与预算)、classifyEngineError(第 117 行)、classifyCacheError(第 127 行)、looksLikeRoot(第 138 行)、inspectDataDir(第 157 行)、inspectEngine(第 258 行)、countChapterChars(第 291 行)、inspectLibrary(第 306 行)、inspectSwitches(第 368 行)、aggregate(第 423 行)、deriveIssues(第 493 行)、buildDiagnosis(第 757 行;log.selfCallExcluded 在第 824 行)、formatDiagnosis(第 857 行)
  • lib/log.js —— ALLOWED_KEYS / FORBIDDEN_KEYS / LOG_KINDS(第 100–135 行)、logDir / logFilePath(第 148–153 行)、sanitizeLogData(第 172 行)、summarizeArgs(第 203 行)、rotateIfNeeded(第 224 行)、logEvent(第 244 行)、logToolCall(第 264 行)、readLogEvents(第 292 行;fail-closed 与恒带 redacted 在第 320–335 行)、logStats(第 341 行)、clearLogs(第 357 行);常量 LOG_MAX_BYTES / LOG_KEEP_FILES / LOG_MAX_EVENT_BYTES / LOG_FREE_TEXT_MAX / LOG_CJK_RUN_MAX(第 58–69 行)
  • lib/core.js —— resolveRoot(第 323 行,三种可疑决策的 root_resolve 留痕在第 331 / 343 / 355 行)、looksLikeLibraryRoot(第 375 行)、ALL_TOOLS(第 958 行,第 20 项为 novel_diagnose)、TOOL_LABELS(第 970 行)、isLoopbackRequest / isAllowedRequest(第 1383 / 1396 行)、novelDataDir(第 2575 行)、noteRoot(第 2953 行起:显式 root 的说明、cwd 兜底拒落盘、lastRoot 首次记录/变更、写失败各一条留痕)
  • lib/client.js —— 面板侧「诊断报告」页:取数与重取(api.loadDiagnose、api.setDiagDays)、诊断包文本与复制(api.diagPackText / api.diagCopy,复用 controller 既有的剪贴板降级路径)、视图渲染(PanelView 里 view === "diagnose" 的分支)、入口登记(PanelView 首页「记录与维护」分区里的 entry(... "diagnose"))与文案键 diag.*(v6.7.0 改版后行号已变,请按符号名检索)
  • mcp/server.mjs —— MCP 形态下的工具清单从 ALL_TOOLS 派生并与实际注册表比对,因此本工具在 MCP 客户端里自动可见,无需改动