novel_diagnose
诊断报告(v6.6.0 新增,第 20 个工具)。读本地事件日志并做五处体检——书库根、书库内容、插件数据目录(含损坏检测)、语义引擎(惰性探测,不加载模型)、开关与状态快照——把「平时看不出来的问题」聚合成一份中文报告:近期调用 / 降级 / 失败统计、「工具 × 书名」反复出问题的组合、高频失败类型、可执行建议,以及「本次覆盖与未覆盖」清单。
它刻意不做成「给作者看日志文件」——本插件的使用者是小说作者,不会去读 .jsonl;真正的消费者是替他操作的 AI(经本工具读成中文报告)与维护者(收到用户附上的诊断包)。所以要修的是「跨调用 / 跨会话才看得出来的规律」:同一本书同一个工具错了 5 次、语义引擎反复不可用、书库根其实一直不对、某个工具被自己关掉了却忘了——这些在单次调用的友好报错里全都看不见,而用户报 bug 时手上也没有任何可附带的证据。
它不判定对错、也不减少 bug,只让定位变快。报告不替代工具自己返回的中文提示(那条提示仍是第一手信息),它补的是「这件事已经重复发生」与「环境/开关侧其实有事」这两层。
novel_lexicon 找不到词条时会把传入的词条名回显进 detail;口径见「两级隐私设计」一节)。任何离开本机的内容(本报告、面板「复制诊断包」、诊断路由)都会整体剔除自由文本,而各类错误在生成报告前就被归类成固定 code 与中文短标签——因为 JSON.parse 的报错会回显文件内容片段,而 summaries/、plots/、settings/ 正是由正文派生的。因此本工具的报告可以直接贴进 issue;报 bug 请用它或面板「复制诊断包」,不要直接附本机日志文件。
参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
book | string | 否 | 只看这本书相关的事件。取值先 trim,空串即「不过滤」;过滤是严格相等(事件里的 book 必须与它逐字符相同),所以末尾多一个空格就什么都匹配不到。 |
days | integer | 否 | 统计窗口(天)。默认 7、上限 90;非数字 / 0 / 负数一律回退 7,小数被 Math.trunc 截断(days: 7.9 = 7 天)。 |
action | string | 否 | report(默认)= 生成报告;events = 额外附带事件明细(≤ 200 条);clear = 清空本地事件日志。插件内部只区分 clear 与 events,其余取值一律按 report 行为处理(action 字段仍原样回传);三个合法取值由参数 schema 的 enum 在宿主侧约束。 |
root | string | 否 | 章节库根目录(含 novels 子目录)。省略时按 config.root → 会话工作目录回退(见「根解析与来源披露」)。 |
参数 schema 声明 additionalProperties: false,即只接受以上四项。本工具的 execute 不调用 assertToolEnabled——诊断必须在「把一堆工具都关掉了」的时候仍然能用(否则最先需要它的人恰好是被自己关掉的人)。
输出结构
契约字段(output.schema.required):action、days、root、calls、issues、checklist、report。
| 字段 | 类型 | 内容 |
|---|---|---|
report | string | 渲染好的中文报告(与 output.render 同源,均由 formatDiagnosis 生成) |
action | string | 本次生效的 action(默认 report;clear 时另附 cleared:true、days:0) |
days | integer | 本次生效的窗口天数(clear 时为 0) |
root | object | 根解析 + 书库内容体检: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 为实际列出数) |
dataDir | object | 数据目录体检: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 条,只给计数之外的明细 |
log | object | { dir, bytes, files, kinds, exists, selfCallExcluded }:日志目录、现有 events*.jsonl 合计字节数、文件份数、已定义的事件类型数(当前 9)、是否已有日志,以及下面那条契约声明 |
log.selfCallExcluded | boolean | 恒为 true:合约声明——本报告的读数取自本次诊断自身那条审计记录落盘之前(审计包装要等 execute 返回后才写),因此比事后调 logStats() 少恰好一条事件。想「与磁盘现算比对」的一方必须同样在调用之前读盘、或把 novel_diagnose 那条减掉,否则会差出恰好一条事件的计数与字节数——看起来像 bug,其实是口径 |
calls | object | { total, ok, degraded, failed },只统计 kind: "tool_call" 的事件 |
byTool | array | { tool, total, ok, degraded, failed, msTotal, msAvg };按 total 降序、前 12 个 |
byKind | array | { kind, count, codes[] },按计数降序;codes 是该类型下的 code 分布(≤4 项)——「12 次 cache 事件」不可行动,「12 次都是同一个 code」才能定位到具体文件 |
topCodes | array | 高频失败类型 { code, count },按计数降序、前 8 个;无 code 的失败会聚到空串上 |
problems | array | 「工具 × 书名」反复出问题的组合:{ tool, book, failed, degraded, codes, chapters }(codes / chapters 各截前 4 项);按「失败 + 降级」次数降序、前 10 组 |
issues | array | 问题与建议,元素 { level, title, detail, suggestion };level ∈ error / warn / info(报告里渲染为「⚠ 阻断 / △ 注意 / · 提示」),已按级别排序(error → warn → info,同级保持发现顺序)。元素 schema 是 additionalProperties:false,只允许这四个键 |
checklist | array | 本次覆盖与未覆盖(9 项),元素 { item, state, note };state ∈ checked / warn / skipped / off |
tools | object | 能力 + 开关快照:registered / enabled / disabled[] / disabledCount / features(功能开关生效值)/ disabledFeatures[] / master{enabled,autoAnalyze,source} / prompt{systemPromptMode,promptScene,leanWorkflow} / styleTolerance / stateFile{path,exists} / engine{…} |
tools.engine | object | { modelDir, model, modelPresent, loaded, errorCode, errorLabel, lastCacheErrorCode, lastCacheErrorLabel, events{count,codes[]} } |
events | array | 仅 action=events 时出现:事件明细(≤ 200 条,仍为分享层,自由文本已剔除) |
book | string | 仅在传了非空 book 时出现 |
渲染层(output.render)返回 [{ type: "text", text: value.report }]:报告是一条纯文本,首行固定为 【诊断报告】生成于 <ISO 时间>,之后的小节号由渲染器动态分配,顺序固定为:一、书库根 / 二、书库内容体检(只统计,不读正文)/ 三、插件数据目录 / 四、语义引擎(本地 embedding,惰性加载)/ 五、开关与状态 / 六、近 N 天调用 / 七、问题与建议 / 八、事件明细(仅 action=events 且有明细时才有这一节,只渲染前 40 行)/ 九、本次覆盖与未覆盖。因此「本次覆盖与未覆盖」在没有事件明细时是第八节、有明细时是第九节——按节号解析的读取方请注意这一点(按标题解析则不受影响)。末尾固定附一句「本报告只含元数据……日志从设计上就不记录正文」——该句的后半截属旧口径残留:报告本身确实不含正文(分享层已剔除自由文本),但「日志从设计上就不记录正文」对本地层不成立(本地层至多保留一句 ≤160 字,见「约束与边界」一节的实测复现)。这句固定文案在 lib/diagnose.js 的 formatDiagnosis 里,属本轮未改动的实现文件,留待下版与代码一起改;此处文档口径已改准。
行为与实现
1 · 根解析与来源披露
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 · 书库内容体检(只统计,不读正文)
为什么要这一节:< 40 字的章与「解析不出章号」的文件在 novel_chapters 里都只表现为「有一章」,但它们会(前者)把六维基线与风格指纹拉偏、(后者)让章序排到末尾且无法被章号定位——两类问题在别处都看不出来。
3 · 插件数据目录体检(含损坏检测)
embedding/*.json 不是文本 JSON,而是 zstd 压缩后直接落盘(扩展名仍叫 .json)。第一版检测器只做 JSON.parse(readFileSync),于是在真实书库上把 15 个健康的缓存全报成「损坏」——正是「报告说坏了、其实没坏」这类最伤信任的错。现在按魔数识别、按需解压再判;纯文本才走 JSON.parse;解压后超过上限时按「缓存很大」处理,不判损坏。
损坏的判定口径与 core.js 的词表守卫一致:文件存在但读不出内容 = 损坏,文件不存在是另一回事(missingSubdirs / 0 字节各有独立归类)。
4 · 语义引擎体检(惰性探测,不加载模型)
三件事必须在报告里讲清,否则会误导人:① loaded:false 不等于坏——模型是首次真正用到时才惰性加载的;② 只有 modelPresent:false 或 errorCode 非空才是确定的问题;③ 进程内的错误状态重启宿主即丢,所以还要看日志里历史的 engine 事件(跨进程证据)。分类不是装饰:它决定给用户哪一条修法(重装依赖 / 补齐模型 / 换宿主版本 / 清盘 / 给权限 / 关掉占用程序)。
5 · 开关与状态快照
这是本次增强里最值钱的一条:工具被关掉时 AI 只会收到一句「当前已在 UI 中关闭」,而用户往往不记得是自己关的——于是反复重试、以为插件坏了。报告把「哪些工具关了、哪些功能关了、总开关在哪一档」直接列出来,并给出 novel_sentence_config 的开启写法,一句话就能对上。注意口径:开关缺失 = 开启(不是关着),无状态文件时全部走默认值,属正常而不是异常。
6 · 两级隐私设计(本模块的核心)
日志天生会被贴出去(贴进 issue、贴给 AI 看),所以隐私红线是「任何离开本机的内容都不含自由文本」——不是「日志里绝不含正文」:后半句在本地层做不到(本地层的自由文本至多保留一句,见本节口径修正)。实现手段是字段白名单,而不是「检查值里有没有正文」:
- 本地日志(口径修正,本版定稿时改准的一句):以元数据为主(时间 / 工具名 / 书名 / 章号 / 状态 / 耗时 / 计数 / 路径 / 错误 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 · 事件类型与轮转
写入刻意用同步 appendFileSync:一次 append 只有几十字节,代价远小于「异步队列在进程退出时丢事件」的风险(本插件没有常驻进程的生命周期钩子)。任何写入失败都被吞掉——写日志绝不能让工具调用失败(那才是本末倒置);唯一的例外是首次失败时往 stderr 打一行提示,且只打一次,否则连「日志本身坏了」都无从知晓。
8 · 工具调用审计:注册层统一包一层
审计不是散落在各个工具体内的埋点,而是在注册层临时替换 ctx.tools.register,对每个工具定义包一层 execute,注册完立刻还原。理由是「体内埋点迟早会漏,而且新加的工具必然忘」,而 register 是唯一必经之路。
本工具自身也在被审计之列(包装在注册之前装好、在最后一个工具注册完之后才还原),所以「谁在什么时候调了诊断、跑了几毫秒」同样会进日志。代价已知:每次调用多一次同步写盘。
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 · 聚合口径与「问题清单」的触发规则
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=false | info |
modelPresent === false(模型文件不在位) | warn | |
errorCode 非空(初始化失败,已回退纯规则) | warn | |
lastCacheErrorCode 非空(索引缓存读写失败) | info | |
窗口内有 engine 事件(跨进程证据) | info | |
| 开关与状态 | 有工具被关闭(点名是哪些) | warn |
| 有功能开关被关闭(除「仅 semanticEmbedding 且模型也缺」外) | info | |
总开关 enabled=false | warn | |
状态文件不存在(全部走默认值)/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 是删除而不是轮转,不可恢复;它删的是本机日志,与书库内容无关。清空后 root 字段刻意不做体检(looksLikeLibraryRoot:false、source:""),避免把「清日志」误读成「根有问题」。另需知道:clear 自身也是一次工具调用,所以清空之后日志里通常立刻又多一条记录(这正是审计包装在生效)。
12 · 诊断路由与面板(与工具同一实现)
注意守卫看的是 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 个,
byTool12 行、topCodes8 行、problems10 组——一个坏掉的库不该把报告撑爆,超出部分只给计数(报告写「仅列前 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 客户端里自动可见,无需改动