novel-writer-cli 0.0.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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/agents/chapter-writer.md +142 -0
  4. package/agents/character-weaver.md +117 -0
  5. package/agents/consistency-auditor.md +85 -0
  6. package/agents/plot-architect.md +128 -0
  7. package/agents/quality-judge.md +232 -0
  8. package/agents/style-analyzer.md +109 -0
  9. package/agents/style-refiner.md +97 -0
  10. package/agents/summarizer.md +128 -0
  11. package/agents/world-builder.md +161 -0
  12. package/dist/__tests__/character-voice.test.js +445 -0
  13. package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
  14. package/dist/__tests__/engagement.test.js +382 -0
  15. package/dist/__tests__/foreshadow-visibility.test.js +131 -0
  16. package/dist/__tests__/hook-ledger.test.js +1028 -0
  17. package/dist/__tests__/naming-lint.test.js +132 -0
  18. package/dist/__tests__/narrative-health-injection.test.js +359 -0
  19. package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
  20. package/dist/__tests__/next-step-title-fix.test.js +153 -0
  21. package/dist/__tests__/platform-profile.test.js +274 -0
  22. package/dist/__tests__/promise-ledger.test.js +189 -0
  23. package/dist/__tests__/readability-lint.test.js +209 -0
  24. package/dist/__tests__/text-utils.test.js +39 -0
  25. package/dist/__tests__/title-policy.test.js +147 -0
  26. package/dist/advance.js +75 -0
  27. package/dist/character-voice.js +805 -0
  28. package/dist/checkpoint.js +126 -0
  29. package/dist/cli.js +563 -0
  30. package/dist/cliche-lint.js +515 -0
  31. package/dist/commit.js +1460 -0
  32. package/dist/consistency-auditor.js +684 -0
  33. package/dist/engagement.js +687 -0
  34. package/dist/errors.js +7 -0
  35. package/dist/fingerprint.js +16 -0
  36. package/dist/foreshadow-visibility.js +214 -0
  37. package/dist/fs-utils.js +68 -0
  38. package/dist/hook-ledger.js +721 -0
  39. package/dist/hook-policy.js +107 -0
  40. package/dist/instruction-gates.js +51 -0
  41. package/dist/instructions.js +406 -0
  42. package/dist/latest-summary-loader.js +29 -0
  43. package/dist/lock.js +121 -0
  44. package/dist/naming-lint.js +531 -0
  45. package/dist/ner.js +73 -0
  46. package/dist/next-step.js +408 -0
  47. package/dist/novel-ask.js +270 -0
  48. package/dist/output.js +9 -0
  49. package/dist/platform-constraints.js +518 -0
  50. package/dist/platform-profile.js +325 -0
  51. package/dist/prejudge-guardrails.js +370 -0
  52. package/dist/project.js +40 -0
  53. package/dist/promise-ledger.js +723 -0
  54. package/dist/readability-lint.js +555 -0
  55. package/dist/safe-parse.js +36 -0
  56. package/dist/safe-path.js +29 -0
  57. package/dist/scoring-weights.js +290 -0
  58. package/dist/steps.js +60 -0
  59. package/dist/text-utils.js +18 -0
  60. package/dist/title-policy.js +251 -0
  61. package/dist/type-guards.js +6 -0
  62. package/dist/validate.js +131 -0
  63. package/docs/user/README.md +17 -0
  64. package/docs/user/guardrails.md +179 -0
  65. package/docs/user/interactive-gates.md +124 -0
  66. package/docs/user/novel-cli.md +289 -0
  67. package/docs/user/ops.md +123 -0
  68. package/docs/user/quick-start.md +97 -0
  69. package/docs/user/spec-system.md +166 -0
  70. package/docs/user/storylines.md +144 -0
  71. package/package.json +48 -0
  72. package/schemas/README.md +18 -0
  73. package/schemas/character-voice-drift.schema.json +135 -0
  74. package/schemas/character-voice-profiles.schema.json +141 -0
  75. package/schemas/engagement-metrics.schema.json +38 -0
  76. package/schemas/hook-ledger.schema.json +108 -0
  77. package/schemas/platform-profile.schema.json +235 -0
  78. package/schemas/promise-ledger.schema.json +97 -0
  79. package/scripts/calibrate-quality-judge.sh +91 -0
  80. package/scripts/compare-regression-runs.sh +86 -0
  81. package/scripts/lib/_common.py +131 -0
  82. package/scripts/lib/calibrate_quality_judge.py +312 -0
  83. package/scripts/lib/compare_regression_runs.py +142 -0
  84. package/scripts/lib/run_regression.py +621 -0
  85. package/scripts/lint-blacklist.sh +201 -0
  86. package/scripts/lint-cliche.sh +370 -0
  87. package/scripts/lint-readability.sh +404 -0
  88. package/scripts/query-foreshadow.sh +252 -0
  89. package/scripts/run-ner.sh +669 -0
  90. package/scripts/run-regression.sh +122 -0
  91. package/skills/cli-step/SKILL.md +158 -0
  92. package/skills/continue/SKILL.md +348 -0
  93. package/skills/continue/references/context-contracts.md +169 -0
  94. package/skills/continue/references/continuity-checks.md +187 -0
  95. package/skills/continue/references/file-protocols.md +64 -0
  96. package/skills/continue/references/foreshadowing.md +130 -0
  97. package/skills/continue/references/gate-decision.md +53 -0
  98. package/skills/continue/references/periodic-maintenance.md +46 -0
  99. package/skills/novel-writing/SKILL.md +77 -0
  100. package/skills/novel-writing/references/quality-rubric.md +140 -0
  101. package/skills/novel-writing/references/style-guide.md +145 -0
  102. package/skills/start/SKILL.md +458 -0
  103. package/skills/start/references/quality-review.md +86 -0
  104. package/skills/start/references/setting-update.md +44 -0
  105. package/skills/start/references/vol-planning.md +61 -0
  106. package/skills/start/references/vol-review.md +58 -0
  107. package/skills/status/SKILL.md +116 -0
  108. package/skills/status/references/sample-output.md +60 -0
  109. package/templates/ai-blacklist.json +79 -0
  110. package/templates/brief-template.md +46 -0
  111. package/templates/genre-weight-profiles.json +90 -0
  112. package/templates/novel-ask/example.answer.json +12 -0
  113. package/templates/novel-ask/example.question.json +51 -0
  114. package/templates/platform-profile.json +148 -0
  115. package/templates/style-profile-template.json +58 -0
  116. package/templates/web-novel-cliche-lint.json +41 -0
@@ -0,0 +1,805 @@
1
+ import { rename, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { NovelCliError } from "./errors.js";
4
+ import { ensureDir, pathExists, readJsonFile, readTextFile, removePath, writeJsonFile } from "./fs-utils.js";
5
+ import { resolveProjectRelativePath } from "./safe-path.js";
6
+ import { pad3 } from "./steps.js";
7
+ import { isPlainObject } from "./type-guards.js";
8
+ const PROFILES_REL = "character-voice-profiles.json";
9
+ const DRIFT_REL = "character-voice-drift.json";
10
+ const DEFAULT_POLICY = {
11
+ window_chapters: 10,
12
+ min_dialogue_samples: 5,
13
+ drift_thresholds: {
14
+ avg_dialogue_chars_ratio_low: 0.6,
15
+ avg_dialogue_chars_ratio_high: 1.67,
16
+ exclamation_per_100_chars_delta: 3.5,
17
+ question_per_100_chars_delta: 3.5,
18
+ ellipsis_per_100_chars_delta: 3.5,
19
+ signature_overlap_min: 0.2
20
+ },
21
+ recovery_thresholds: {
22
+ avg_dialogue_chars_ratio_low: 0.75,
23
+ avg_dialogue_chars_ratio_high: 1.33,
24
+ exclamation_per_100_chars_delta: 2.0,
25
+ question_per_100_chars_delta: 2.0,
26
+ ellipsis_per_100_chars_delta: 2.0,
27
+ signature_overlap_min: 0.3
28
+ }
29
+ };
30
+ function pickCommentFields(obj) {
31
+ const out = Object.create(null);
32
+ for (const [k, v] of Object.entries(obj)) {
33
+ if (!k.startsWith("_"))
34
+ continue;
35
+ if (k === "__proto__" || k === "constructor" || k === "prototype")
36
+ continue;
37
+ out[k] = v;
38
+ }
39
+ return out;
40
+ }
41
+ function safeInt(v) {
42
+ return typeof v === "number" && Number.isInteger(v) ? v : null;
43
+ }
44
+ function safeNumber(v) {
45
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
46
+ }
47
+ function safeString(v) {
48
+ if (typeof v !== "string")
49
+ return null;
50
+ const t = v.trim();
51
+ return t.length > 0 ? t : null;
52
+ }
53
+ function normalizeStringIds(raw) {
54
+ if (!Array.isArray(raw))
55
+ return [];
56
+ const uniq = Array.from(new Set(raw.map((v) => (typeof v === "string" ? v.trim() : "")).filter((v) => v.length > 0)));
57
+ return uniq;
58
+ }
59
+ function countNonWhitespaceChars(text) {
60
+ const compact = text.replace(/\s+/gu, "");
61
+ return Array.from(compact).length;
62
+ }
63
+ function countSubstring(text, needle) {
64
+ if (!needle)
65
+ return 0;
66
+ let count = 0;
67
+ let idx = 0;
68
+ while (true) {
69
+ const next = text.indexOf(needle, idx);
70
+ if (next < 0)
71
+ break;
72
+ count += 1;
73
+ idx = next + needle.length;
74
+ }
75
+ return count;
76
+ }
77
+ function snippet(text, maxLen) {
78
+ const s = text.trim().replace(/\s+/gu, " ");
79
+ if (s.length <= maxLen)
80
+ return s;
81
+ let end = Math.max(0, maxLen - 1);
82
+ if (end > 0) {
83
+ const last = s.charCodeAt(end - 1);
84
+ if (last >= 0xd800 && last <= 0xdbff) {
85
+ const next = s.charCodeAt(end);
86
+ if (next >= 0xdc00 && next <= 0xdfff)
87
+ end -= 1;
88
+ }
89
+ }
90
+ return `${s.slice(0, end)}…`;
91
+ }
92
+ function percentileInt(sortedAsc, p) {
93
+ if (sortedAsc.length === 0)
94
+ return 0;
95
+ const clamped = Math.max(0, Math.min(1, p));
96
+ const idx = Math.floor((sortedAsc.length - 1) * clamped);
97
+ return sortedAsc[idx] ?? 0;
98
+ }
99
+ function average(nums) {
100
+ if (nums.length === 0)
101
+ return 0;
102
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
103
+ }
104
+ function extractDialogueSamples(chapterText, chapter) {
105
+ const out = [];
106
+ const re = /“([^”]{2,2000})”|「([^」]{2,2000})」|『([^』]{2,2000})』|"([^"]{2,2000})"/gu;
107
+ for (const m of chapterText.matchAll(re)) {
108
+ const raw = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? "").trim();
109
+ const text = raw.replace(/\s+/gu, " ");
110
+ if (text.length < 2)
111
+ continue;
112
+ const idx = m.index ?? -1;
113
+ if (idx < 0)
114
+ continue;
115
+ out.push({ chapter, start: idx, end: idx + (m[0] ?? "").length, text });
116
+ }
117
+ return out;
118
+ }
119
+ function attributeSpeaker(args) {
120
+ const WINDOW_CHARS = 80;
121
+ const before = Math.max(0, args.sample.start - WINDOW_CHARS);
122
+ const after = Math.min(args.chapterText.length, args.sample.end + WINDOW_CHARS);
123
+ // Exclude quoted dialogues from the context to avoid mis-attribution when dialogues mention other characters.
124
+ const ctxRaw = args.chapterText.slice(before, after);
125
+ const dialogueRe = /“[^”]{0,2000}”|「[^」]{0,2000}」|『[^』]{0,2000}』|"[^"]{0,2000}"/gu;
126
+ const ctx = ctxRaw.replace(dialogueRe, " ");
127
+ const matched = [];
128
+ for (const [id, variants] of args.characterVariants) {
129
+ const ok = variants.some((v) => v.length >= 2 && ctx.includes(v));
130
+ if (ok)
131
+ matched.push(id);
132
+ if (matched.length > 1)
133
+ return null;
134
+ }
135
+ return matched.length === 1 ? matched[0] ?? null : null;
136
+ }
137
+ function computeSignaturePhrases(dialogues) {
138
+ const candidates = [
139
+ "哈哈",
140
+ "呵呵",
141
+ "哼",
142
+ "嗯",
143
+ "唉",
144
+ "哎",
145
+ "呃",
146
+ "咳",
147
+ "喂",
148
+ "嘿",
149
+ "啧",
150
+ "呀",
151
+ "啊",
152
+ "呢",
153
+ "吧",
154
+ "嘛",
155
+ "哟"
156
+ ];
157
+ const counts = new Map();
158
+ for (const d of dialogues) {
159
+ for (const c of candidates) {
160
+ const hits = countSubstring(d, c);
161
+ if (hits > 0)
162
+ counts.set(c, (counts.get(c) ?? 0) + hits);
163
+ }
164
+ }
165
+ const scored = Array.from(counts.entries())
166
+ .filter(([, n]) => n >= 2)
167
+ .sort((a, b) => b[1] - a[1] || b[0].length - a[0].length || a[0].localeCompare(b[0], "zh"));
168
+ return scored.slice(0, 8).map(([t]) => t);
169
+ }
170
+ function computeVoiceMetrics(dialogues) {
171
+ const lens = [];
172
+ const sentenceLens = [];
173
+ let exclamations = 0;
174
+ let questions = 0;
175
+ let ellipsis = 0;
176
+ for (const d of dialogues) {
177
+ const len = countNonWhitespaceChars(d);
178
+ lens.push(len);
179
+ exclamations += countSubstring(d, "!") + countSubstring(d, "!");
180
+ questions += countSubstring(d, "?") + countSubstring(d, "?");
181
+ ellipsis += countSubstring(d, "…") + countSubstring(d, "...");
182
+ const parts = d.split(/[。!?!?]+/u).map((p) => p.trim()).filter((p) => p.length > 0);
183
+ if (parts.length === 0) {
184
+ sentenceLens.push(len);
185
+ }
186
+ else {
187
+ for (const p of parts)
188
+ sentenceLens.push(countNonWhitespaceChars(p));
189
+ }
190
+ }
191
+ lens.sort((a, b) => a - b);
192
+ sentenceLens.sort((a, b) => a - b);
193
+ const dialogueChars = lens.reduce((a, b) => a + b, 0);
194
+ const exclamationPer100 = dialogueChars > 0 ? (exclamations * 100) / dialogueChars : 0;
195
+ const questionPer100 = dialogueChars > 0 ? (questions * 100) / dialogueChars : 0;
196
+ const ellipsisPer100 = dialogueChars > 0 ? (ellipsis * 100) / dialogueChars : 0;
197
+ return {
198
+ dialogue_samples: dialogues.length,
199
+ dialogue_chars: dialogueChars,
200
+ dialogue_len_avg: average(lens),
201
+ dialogue_len_p25: percentileInt(lens, 0.25),
202
+ dialogue_len_p50: percentileInt(lens, 0.5),
203
+ dialogue_len_p75: percentileInt(lens, 0.75),
204
+ sentence_len_avg: average(sentenceLens),
205
+ sentence_len_p25: percentileInt(sentenceLens, 0.25),
206
+ sentence_len_p50: percentileInt(sentenceLens, 0.5),
207
+ sentence_len_p75: percentileInt(sentenceLens, 0.75),
208
+ exclamation_per_100_chars: exclamationPer100,
209
+ question_per_100_chars: questionPer100,
210
+ ellipsis_per_100_chars: ellipsisPer100
211
+ };
212
+ }
213
+ async function loadCharacterDisplayNameMap(rootDir) {
214
+ const out = new Map();
215
+ const stateAbs = join(rootDir, "state/current-state.json");
216
+ if (!(await pathExists(stateAbs)))
217
+ return out;
218
+ let raw;
219
+ try {
220
+ raw = await readJsonFile(stateAbs);
221
+ }
222
+ catch {
223
+ return out;
224
+ }
225
+ if (!isPlainObject(raw))
226
+ return out;
227
+ const obj = raw;
228
+ const characters = obj.characters;
229
+ if (!isPlainObject(characters))
230
+ return out;
231
+ for (const [id, v] of Object.entries(characters)) {
232
+ if (!isPlainObject(v))
233
+ continue;
234
+ const dn = safeString(v.display_name);
235
+ if (!dn)
236
+ continue;
237
+ out.set(id, dn);
238
+ }
239
+ return out;
240
+ }
241
+ function normalizeThresholds(raw, label) {
242
+ if (!isPlainObject(raw))
243
+ throw new NovelCliError(`Invalid ${PROFILES_REL}: '${label}' must be an object.`, 2);
244
+ const obj = raw;
245
+ const num = (k) => {
246
+ const v = safeNumber(obj[k]);
247
+ if (v === null)
248
+ throw new NovelCliError(`Invalid ${PROFILES_REL}: '${label}.${k}' must be a finite number.`, 2);
249
+ return v;
250
+ };
251
+ const ratioLow = num("avg_dialogue_chars_ratio_low");
252
+ const ratioHigh = num("avg_dialogue_chars_ratio_high");
253
+ if (ratioLow <= 0 || ratioHigh <= 0 || ratioLow > ratioHigh) {
254
+ throw new NovelCliError(`Invalid ${PROFILES_REL}: '${label}.avg_dialogue_chars_ratio_*' must satisfy 0 < low <= high.`, 2);
255
+ }
256
+ const exDelta = num("exclamation_per_100_chars_delta");
257
+ const qDelta = num("question_per_100_chars_delta");
258
+ const eDelta = num("ellipsis_per_100_chars_delta");
259
+ if (exDelta < 0 || qDelta < 0 || eDelta < 0) {
260
+ throw new NovelCliError(`Invalid ${PROFILES_REL}: '${label}.*_delta' must be >= 0.`, 2);
261
+ }
262
+ const overlapMin = num("signature_overlap_min");
263
+ if (overlapMin < 0 || overlapMin > 1) {
264
+ throw new NovelCliError(`Invalid ${PROFILES_REL}: '${label}.signature_overlap_min' must be in [0, 1].`, 2);
265
+ }
266
+ return {
267
+ avg_dialogue_chars_ratio_low: ratioLow,
268
+ avg_dialogue_chars_ratio_high: ratioHigh,
269
+ exclamation_per_100_chars_delta: exDelta,
270
+ question_per_100_chars_delta: qDelta,
271
+ ellipsis_per_100_chars_delta: eDelta,
272
+ signature_overlap_min: overlapMin
273
+ };
274
+ }
275
+ export async function loadCharacterVoiceProfiles(rootDir) {
276
+ const rel = PROFILES_REL;
277
+ const abs = join(rootDir, rel);
278
+ if (!(await pathExists(abs)))
279
+ return { profiles: null, warnings: [], rel };
280
+ const raw = await readJsonFile(abs);
281
+ if (!isPlainObject(raw))
282
+ throw new NovelCliError(`Invalid ${rel}: expected a JSON object.`, 2);
283
+ const obj = raw;
284
+ const comments = pickCommentFields(obj);
285
+ const canonicalSchema = "schemas/character-voice-profiles.schema.json";
286
+ if (obj.schema_version === undefined)
287
+ throw new NovelCliError(`Invalid ${rel}: missing required 'schema_version'.`, 2);
288
+ if (obj.schema_version !== 1)
289
+ throw new NovelCliError(`Invalid ${rel}: 'schema_version' must be 1.`, 2);
290
+ const created_at = safeString(obj.created_at);
291
+ if (!created_at)
292
+ throw new NovelCliError(`Invalid ${rel}: missing required 'created_at'.`, 2);
293
+ const selectionRaw = obj.selection;
294
+ if (!isPlainObject(selectionRaw))
295
+ throw new NovelCliError(`Invalid ${rel}: missing required 'selection' object.`, 2);
296
+ const selectionObj = selectionRaw;
297
+ const protagonist_id = safeString(selectionObj.protagonist_id);
298
+ if (!protagonist_id)
299
+ throw new NovelCliError(`Invalid ${rel}: selection.protagonist_id must be a non-empty string.`, 2);
300
+ const core_cast_ids = normalizeStringIds(selectionObj.core_cast_ids);
301
+ const warnings = [];
302
+ const rawSchema = safeString(obj.$schema);
303
+ if (rawSchema && rawSchema !== canonicalSchema) {
304
+ warnings.push(`Character voice profiles: ignoring non-canonical '$schema' value; using ${canonicalSchema}.`);
305
+ }
306
+ let policy = { ...DEFAULT_POLICY };
307
+ if (obj.policy !== undefined) {
308
+ if (!isPlainObject(obj.policy)) {
309
+ warnings.push("Character voice profiles: ignoring invalid 'policy' (expected object).");
310
+ }
311
+ else {
312
+ const p = obj.policy;
313
+ const window_chapters = safeInt(p.window_chapters);
314
+ const min_dialogue_samples = safeInt(p.min_dialogue_samples);
315
+ const drift_thresholds = p.drift_thresholds;
316
+ const recovery_thresholds = p.recovery_thresholds;
317
+ if (window_chapters === null || window_chapters < 1) {
318
+ warnings.push("Character voice profiles: invalid policy.window_chapters; defaulted.");
319
+ }
320
+ else {
321
+ policy.window_chapters = window_chapters;
322
+ }
323
+ if (min_dialogue_samples === null || min_dialogue_samples < 1) {
324
+ warnings.push("Character voice profiles: invalid policy.min_dialogue_samples; defaulted.");
325
+ }
326
+ else {
327
+ policy.min_dialogue_samples = min_dialogue_samples;
328
+ }
329
+ try {
330
+ if (drift_thresholds !== undefined)
331
+ policy.drift_thresholds = normalizeThresholds(drift_thresholds, "policy.drift_thresholds");
332
+ if (recovery_thresholds !== undefined)
333
+ policy.recovery_thresholds = normalizeThresholds(recovery_thresholds, "policy.recovery_thresholds");
334
+ }
335
+ catch (err) {
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ warnings.push(`Character voice profiles: invalid thresholds; defaulted. ${message}`);
338
+ policy = { ...DEFAULT_POLICY };
339
+ }
340
+ }
341
+ }
342
+ if (!Array.isArray(obj.profiles))
343
+ throw new NovelCliError(`Invalid ${rel}: missing required 'profiles' array.`, 2);
344
+ const profiles = [];
345
+ for (const it of obj.profiles) {
346
+ if (!isPlainObject(it))
347
+ continue;
348
+ const po = it;
349
+ const entryComments = pickCommentFields(po);
350
+ const character_id = safeString(po.character_id);
351
+ const display_name = safeString(po.display_name);
352
+ if (!character_id || !display_name) {
353
+ warnings.push("Character voice profiles: dropped invalid profile entry missing character_id/display_name.");
354
+ continue;
355
+ }
356
+ const name_variants = normalizeStringIds(po.name_variants);
357
+ const baselineRaw = po.baseline_range;
358
+ if (!isPlainObject(baselineRaw)) {
359
+ warnings.push(`Character voice profiles: dropped '${character_id}' profile missing baseline_range.`);
360
+ continue;
361
+ }
362
+ const br = baselineRaw;
363
+ const chapter_start = safeInt(br.chapter_start);
364
+ const chapter_end = safeInt(br.chapter_end);
365
+ if (chapter_start === null || chapter_start < 1 || chapter_end === null || chapter_end < chapter_start) {
366
+ warnings.push(`Character voice profiles: dropped '${character_id}' profile with invalid baseline_range.`);
367
+ continue;
368
+ }
369
+ const metricsRaw = po.baseline_metrics;
370
+ if (!isPlainObject(metricsRaw)) {
371
+ warnings.push(`Character voice profiles: dropped '${character_id}' profile missing baseline_metrics.`);
372
+ continue;
373
+ }
374
+ const mo = metricsRaw;
375
+ const metrics = {
376
+ dialogue_samples: safeInt(mo.dialogue_samples) ?? 0,
377
+ dialogue_chars: safeInt(mo.dialogue_chars) ?? 0,
378
+ dialogue_len_avg: safeNumber(mo.dialogue_len_avg) ?? 0,
379
+ dialogue_len_p25: safeInt(mo.dialogue_len_p25) ?? 0,
380
+ dialogue_len_p50: safeInt(mo.dialogue_len_p50) ?? 0,
381
+ dialogue_len_p75: safeInt(mo.dialogue_len_p75) ?? 0,
382
+ sentence_len_avg: safeNumber(mo.sentence_len_avg) ?? 0,
383
+ sentence_len_p25: safeInt(mo.sentence_len_p25) ?? 0,
384
+ sentence_len_p50: safeInt(mo.sentence_len_p50) ?? 0,
385
+ sentence_len_p75: safeInt(mo.sentence_len_p75) ?? 0,
386
+ exclamation_per_100_chars: safeNumber(mo.exclamation_per_100_chars) ?? 0,
387
+ question_per_100_chars: safeNumber(mo.question_per_100_chars) ?? 0,
388
+ ellipsis_per_100_chars: safeNumber(mo.ellipsis_per_100_chars) ?? 0
389
+ };
390
+ const signature_phrases = normalizeStringIds(po.signature_phrases);
391
+ if (signature_phrases.length === 0)
392
+ warnings.push(`Character voice profiles: '${character_id}' has empty signature_phrases.`);
393
+ const taboo_phrases = normalizeStringIds(po.taboo_phrases);
394
+ profiles.push({
395
+ ...entryComments,
396
+ character_id,
397
+ display_name,
398
+ ...(name_variants.length > 0 ? { name_variants } : {}),
399
+ baseline_range: { chapter_start, chapter_end },
400
+ baseline_metrics: metrics,
401
+ signature_phrases,
402
+ ...(taboo_phrases.length > 0 ? { taboo_phrases } : {})
403
+ });
404
+ }
405
+ // Stable ordering: protagonist first, then core cast, then the rest.
406
+ const order = [protagonist_id, ...core_cast_ids];
407
+ profiles.sort((a, b) => {
408
+ const ia = order.indexOf(a.character_id);
409
+ const ib = order.indexOf(b.character_id);
410
+ if (ia >= 0 || ib >= 0) {
411
+ if (ia < 0)
412
+ return 1;
413
+ if (ib < 0)
414
+ return -1;
415
+ return ia - ib;
416
+ }
417
+ return a.character_id.localeCompare(b.character_id, "en");
418
+ });
419
+ return {
420
+ rel,
421
+ warnings,
422
+ profiles: {
423
+ $schema: canonicalSchema,
424
+ schema_version: 1,
425
+ created_at,
426
+ selection: { protagonist_id, ...(core_cast_ids.length > 0 ? { core_cast_ids } : {}) },
427
+ policy,
428
+ profiles,
429
+ ...comments
430
+ }
431
+ };
432
+ }
433
+ async function loadChaptersDialogues(args) {
434
+ const samples = [];
435
+ for (let chapter = args.chapterRange.start; chapter <= args.chapterRange.end; chapter += 1) {
436
+ const rel = `chapters/chapter-${pad3(chapter)}.md`;
437
+ const abs = resolveProjectRelativePath(args.rootDir, rel, "chapterRel");
438
+ if (!(await pathExists(abs))) {
439
+ args.warnings.push(`Character voice: missing chapter file: ${rel}`);
440
+ continue;
441
+ }
442
+ let text;
443
+ try {
444
+ text = await readTextFile(abs);
445
+ }
446
+ catch (err) {
447
+ const message = err instanceof Error ? err.message : String(err);
448
+ args.warnings.push(`Character voice: failed to read chapter file: ${rel}. ${message}`);
449
+ continue;
450
+ }
451
+ const extracted = extractDialogueSamples(text, chapter);
452
+ for (const it of extracted) {
453
+ const character_id = attributeSpeaker({ chapterText: text, sample: it, characterVariants: args.characterVariants });
454
+ samples.push({ chapter, character_id, text: it.text });
455
+ }
456
+ }
457
+ return samples;
458
+ }
459
+ function buildVariantMap(profiles) {
460
+ const m = new Map();
461
+ for (const p of profiles) {
462
+ const variants = new Set();
463
+ if (p.display_name)
464
+ variants.add(p.display_name);
465
+ for (const v of p.name_variants ?? [])
466
+ variants.add(v);
467
+ m.set(p.character_id, Array.from(variants.values()).filter((v) => v.trim().length > 0));
468
+ }
469
+ return m;
470
+ }
471
+ export async function buildCharacterVoiceProfiles(args) {
472
+ const protagonistId = safeString(args.protagonistId);
473
+ if (!protagonistId)
474
+ throw new NovelCliError(`Invalid protagonistId: must be a non-empty string.`, 2);
475
+ const coreCastIds = Array.from(new Set(args.coreCastIds.map((s) => s.trim()).filter((s) => s.length > 0)));
476
+ const start = args.baselineRange.start;
477
+ const end = args.baselineRange.end;
478
+ if (!Number.isInteger(start) || start < 1)
479
+ throw new NovelCliError(`Invalid baselineRange.start: ${String(start)} (expected int >= 1).`, 2);
480
+ if (!Number.isInteger(end) || end < start)
481
+ throw new NovelCliError(`Invalid baselineRange.end: ${String(end)} (expected int >= start=${start}).`, 2);
482
+ const warnings = [];
483
+ const displayNames = await loadCharacterDisplayNameMap(args.rootDir);
484
+ const trackedIds = [protagonistId, ...coreCastIds].filter((v, i, a) => a.indexOf(v) === i);
485
+ const profiles = [];
486
+ const policy = {
487
+ ...DEFAULT_POLICY,
488
+ ...(typeof args.windowChapters === "number" && Number.isInteger(args.windowChapters) && args.windowChapters >= 1
489
+ ? { window_chapters: args.windowChapters }
490
+ : {})
491
+ };
492
+ // Prepare profiles list with display names first (used for speaker attribution).
493
+ const stubProfiles = trackedIds.map((id) => {
494
+ const dn = displayNames.get(id) ?? id;
495
+ return { character_id: id, display_name: dn, name_variants: [dn] };
496
+ });
497
+ const variants = buildVariantMap(stubProfiles);
498
+ const samples = await loadChaptersDialogues({
499
+ rootDir: args.rootDir,
500
+ chapterRange: { start, end },
501
+ characterVariants: variants,
502
+ warnings
503
+ });
504
+ for (const p of stubProfiles) {
505
+ const dialogues = samples.filter((s) => s.character_id === p.character_id).map((s) => s.text);
506
+ const baseline_metrics = computeVoiceMetrics(dialogues);
507
+ const signature_phrases = computeSignaturePhrases(dialogues);
508
+ if (baseline_metrics.dialogue_samples < policy.min_dialogue_samples) {
509
+ warnings.push(`Character voice: '${p.character_id}' baseline has only ${baseline_metrics.dialogue_samples} dialogue samples (< min_dialogue_samples=${policy.min_dialogue_samples}).`);
510
+ }
511
+ profiles.push({
512
+ character_id: p.character_id,
513
+ display_name: p.display_name,
514
+ name_variants: p.name_variants,
515
+ baseline_range: { chapter_start: start, chapter_end: end },
516
+ baseline_metrics,
517
+ signature_phrases,
518
+ taboo_phrases: []
519
+ });
520
+ }
521
+ // Stable order: protagonist first.
522
+ profiles.sort((a, b) => {
523
+ if (a.character_id === protagonistId)
524
+ return -1;
525
+ if (b.character_id === protagonistId)
526
+ return 1;
527
+ const ia = coreCastIds.indexOf(a.character_id);
528
+ const ib = coreCastIds.indexOf(b.character_id);
529
+ if (ia >= 0 || ib >= 0) {
530
+ if (ia < 0)
531
+ return 1;
532
+ if (ib < 0)
533
+ return -1;
534
+ return ia - ib;
535
+ }
536
+ return a.character_id.localeCompare(b.character_id, "en");
537
+ });
538
+ const now = new Date().toISOString();
539
+ const file = {
540
+ $schema: "schemas/character-voice-profiles.schema.json",
541
+ schema_version: 1,
542
+ created_at: now,
543
+ selection: { protagonist_id: protagonistId, ...(coreCastIds.length > 0 ? { core_cast_ids: coreCastIds } : {}) },
544
+ policy,
545
+ profiles
546
+ };
547
+ return { profiles: file, warnings, rel: PROFILES_REL };
548
+ }
549
+ function jaccard(a, b) {
550
+ const aa = new Set(a.filter((s) => s.trim().length > 0));
551
+ const bb = new Set(b.filter((s) => s.trim().length > 0));
552
+ if (aa.size === 0 && bb.size === 0)
553
+ return null;
554
+ const inter = Array.from(aa.values()).filter((v) => bb.has(v)).length;
555
+ const union = new Set([...aa.values(), ...bb.values()]).size;
556
+ return union === 0 ? null : inter / union;
557
+ }
558
+ function metricsWithinThresholds(args) {
559
+ const out = [];
560
+ const ratio = args.baseline.dialogue_len_avg > 0 ? args.current.dialogue_len_avg / args.baseline.dialogue_len_avg : null;
561
+ if (ratio !== null && (ratio < args.thresholds.avg_dialogue_chars_ratio_low || ratio > args.thresholds.avg_dialogue_chars_ratio_high)) {
562
+ out.push({
563
+ id: "avg_dialogue_chars_ratio",
564
+ baseline: args.baseline.dialogue_len_avg,
565
+ current: args.current.dialogue_len_avg,
566
+ detail: `ratio=${ratio.toFixed(2)} (allowed ${args.thresholds.avg_dialogue_chars_ratio_low.toFixed(2)}..${args.thresholds.avg_dialogue_chars_ratio_high.toFixed(2)})`
567
+ });
568
+ }
569
+ const exDelta = Math.abs(args.current.exclamation_per_100_chars - args.baseline.exclamation_per_100_chars);
570
+ if (exDelta > args.thresholds.exclamation_per_100_chars_delta) {
571
+ out.push({
572
+ id: "exclamation_per_100_chars_delta",
573
+ baseline: args.baseline.exclamation_per_100_chars,
574
+ current: args.current.exclamation_per_100_chars,
575
+ detail: `abs_delta=${exDelta.toFixed(2)} (> ${args.thresholds.exclamation_per_100_chars_delta.toFixed(2)})`
576
+ });
577
+ }
578
+ const qDelta = Math.abs(args.current.question_per_100_chars - args.baseline.question_per_100_chars);
579
+ if (qDelta > args.thresholds.question_per_100_chars_delta) {
580
+ out.push({
581
+ id: "question_per_100_chars_delta",
582
+ baseline: args.baseline.question_per_100_chars,
583
+ current: args.current.question_per_100_chars,
584
+ detail: `abs_delta=${qDelta.toFixed(2)} (> ${args.thresholds.question_per_100_chars_delta.toFixed(2)})`
585
+ });
586
+ }
587
+ const eDelta = Math.abs(args.current.ellipsis_per_100_chars - args.baseline.ellipsis_per_100_chars);
588
+ if (eDelta > args.thresholds.ellipsis_per_100_chars_delta) {
589
+ out.push({
590
+ id: "ellipsis_per_100_chars_delta",
591
+ baseline: args.baseline.ellipsis_per_100_chars,
592
+ current: args.current.ellipsis_per_100_chars,
593
+ detail: `abs_delta=${eDelta.toFixed(2)} (> ${args.thresholds.ellipsis_per_100_chars_delta.toFixed(2)})`
594
+ });
595
+ }
596
+ const overlap = jaccard(args.baselineSig, args.currentSig);
597
+ if (overlap !== null && overlap < args.thresholds.signature_overlap_min) {
598
+ out.push({
599
+ id: "signature_overlap",
600
+ baseline: args.thresholds.signature_overlap_min,
601
+ current: overlap,
602
+ detail: `overlap=${overlap.toFixed(2)} (< ${args.thresholds.signature_overlap_min.toFixed(2)})`
603
+ });
604
+ }
605
+ return { ok: out.length === 0, drifted: out };
606
+ }
607
+ function buildDirectives(args) {
608
+ const directives = [];
609
+ directives.push(`角色「${args.displayName}」:保持台词语气与节奏一致(句长、语气词、标点习惯)。`);
610
+ for (const m of args.driftedMetrics) {
611
+ if (m.id === "avg_dialogue_chars_ratio") {
612
+ if (m.current > m.baseline)
613
+ directives.push("台词偏长:把长句拆短,减少解释性赘述,更多用行动/反应补足信息。");
614
+ else
615
+ directives.push("台词偏短:适度补充完整句与内在动机,让表达更符合角色习惯(避免只剩“嗯/行/好”)。");
616
+ }
617
+ else if (m.id === "exclamation_per_100_chars_delta") {
618
+ if (m.current > m.baseline)
619
+ directives.push("情绪标点偏激:减少感叹号/怒吼式表达,用更克制的措辞体现情绪。");
620
+ else
621
+ directives.push("情绪标点偏弱:适度提升情绪起伏,让句尾更有“锋芒/力度”。");
622
+ }
623
+ else if (m.id === "question_per_100_chars_delta") {
624
+ if (m.current > m.baseline)
625
+ directives.push("反问偏多:减少连续追问与质询式台词,避免角色变成审问机器。");
626
+ else
627
+ directives.push("反问偏少:必要时加入一两句追问/质疑,保留角色的压迫感或好奇心。");
628
+ }
629
+ else if (m.id === "ellipsis_per_100_chars_delta") {
630
+ if (m.current > m.baseline)
631
+ directives.push("拖沓停顿偏多:减少省略号/拖音,避免台词显得犹豫或灌水。");
632
+ else
633
+ directives.push("停顿偏少:需要时加入自然停顿/留白,让台词更像“人在说话”。");
634
+ }
635
+ else if (m.id === "signature_overlap") {
636
+ const sig = args.baselineSig.slice(0, 3);
637
+ if (sig.length > 0)
638
+ directives.push(`口癖回归:适度加入其常用表达/语气词(例如:${sig.join("、")}),但不要堆叠。`);
639
+ else
640
+ directives.push("口癖回归:回忆该角色常用的语气词/习惯句式,并在关键对话中自然出现。");
641
+ }
642
+ }
643
+ return directives;
644
+ }
645
+ function pickEvidence(args) {
646
+ if (args.samples.length === 0)
647
+ return [];
648
+ const score = (s) => {
649
+ const len = countNonWhitespaceChars(s.text);
650
+ const ex = countSubstring(s.text, "!") + countSubstring(s.text, "!");
651
+ const q = countSubstring(s.text, "?") + countSubstring(s.text, "?");
652
+ const e = countSubstring(s.text, "…") + countSubstring(s.text, "...");
653
+ // Prefer samples relevant to drift types.
654
+ let base = len;
655
+ if (args.driftedMetricIds.includes("exclamation_per_100_chars_delta"))
656
+ base += ex * 30;
657
+ if (args.driftedMetricIds.includes("question_per_100_chars_delta"))
658
+ base += q * 25;
659
+ if (args.driftedMetricIds.includes("ellipsis_per_100_chars_delta"))
660
+ base += e * 15;
661
+ return base;
662
+ };
663
+ const sorted = args.samples.slice().sort((a, b) => score(b) - score(a) || b.chapter - a.chapter);
664
+ return sorted.slice(0, 3).map((s) => ({ chapter: s.chapter, excerpt: snippet(s.text, 140) }));
665
+ }
666
+ export async function loadActiveCharacterVoiceDriftIds(rootDir) {
667
+ const abs = join(rootDir, DRIFT_REL);
668
+ if (!(await pathExists(abs)))
669
+ return new Set();
670
+ let raw;
671
+ try {
672
+ raw = await readJsonFile(abs);
673
+ }
674
+ catch {
675
+ return new Set();
676
+ }
677
+ if (!isPlainObject(raw))
678
+ return new Set();
679
+ const obj = raw;
680
+ if (obj.schema_version !== 1)
681
+ return new Set();
682
+ const chars = obj.characters;
683
+ if (!Array.isArray(chars))
684
+ return new Set();
685
+ const ids = new Set();
686
+ for (const it of chars) {
687
+ if (!isPlainObject(it))
688
+ continue;
689
+ const id = safeString(it.character_id);
690
+ if (id)
691
+ ids.add(id);
692
+ }
693
+ return ids;
694
+ }
695
+ export async function computeCharacterVoiceDrift(args) {
696
+ if (!Number.isInteger(args.asOfChapter) || args.asOfChapter < 1) {
697
+ throw new Error(`Invalid asOfChapter: ${String(args.asOfChapter)} (expected int >= 1).`);
698
+ }
699
+ if (!Number.isInteger(args.volume) || args.volume < 0)
700
+ throw new Error(`Invalid volume: ${String(args.volume)} (expected int >= 0).`);
701
+ const warnings = [];
702
+ const policy = args.profiles.policy ?? DEFAULT_POLICY;
703
+ const windowChapters = policy.window_chapters ?? DEFAULT_POLICY.window_chapters;
704
+ const start = Math.max(1, args.asOfChapter - windowChapters + 1);
705
+ const end = args.asOfChapter;
706
+ const characterVariants = buildVariantMap(args.profiles.profiles);
707
+ const rawSamples = await loadChaptersDialogues({
708
+ rootDir: args.rootDir,
709
+ chapterRange: { start, end },
710
+ characterVariants,
711
+ warnings
712
+ });
713
+ const previousActive = args.previousActiveCharacterIds ?? new Set();
714
+ const activeCharacterIds = new Set();
715
+ const driftedCharacters = [];
716
+ for (const p of args.profiles.profiles) {
717
+ const baseline = p.baseline_metrics;
718
+ const baselineSig = p.signature_phrases ?? [];
719
+ const currentDialogues = rawSamples.filter((s) => s.character_id === p.character_id).map((s) => s.text);
720
+ const currentSamples = rawSamples.filter((s) => s.character_id === p.character_id).map((s) => ({ chapter: s.chapter, text: s.text }));
721
+ const current = computeVoiceMetrics(currentDialogues);
722
+ const currentSig = computeSignaturePhrases(currentDialogues);
723
+ const enough = baseline.dialogue_samples >= policy.min_dialogue_samples && current.dialogue_samples >= policy.min_dialogue_samples;
724
+ const wasActive = previousActive.has(p.character_id);
725
+ if (!enough && !wasActive) {
726
+ if (current.dialogue_samples > 0) {
727
+ warnings.push(`Character voice: insufficient dialogue samples for '${p.character_id}' in window (baseline=${baseline.dialogue_samples}, current=${current.dialogue_samples}, min=${policy.min_dialogue_samples}).`);
728
+ }
729
+ continue;
730
+ }
731
+ const thresholds = wasActive ? policy.recovery_thresholds : policy.drift_thresholds;
732
+ const check = metricsWithinThresholds({ baseline, current, baselineSig, currentSig, thresholds });
733
+ // When !enough && wasActive: freeze state (neither clear existing drift nor re-evaluate recovery).
734
+ const isActive = enough ? !check.ok : wasActive;
735
+ if (!isActive)
736
+ continue;
737
+ activeCharacterIds.add(p.character_id);
738
+ const overlap = jaccard(baselineSig, currentSig);
739
+ const driftedMetricIds = check.drifted.map((m) => m.id);
740
+ const evidence = pickEvidence({ samples: currentSamples, driftedMetricIds });
741
+ const directives = buildDirectives({ displayName: p.display_name, driftedMetrics: check.drifted, baselineSig });
742
+ if (!enough)
743
+ directives.unshift("(数据不足)本窗口该角色台词样本偏少:请在后续章节增加少量该角色对白以便复核漂移恢复。");
744
+ driftedCharacters.push({
745
+ character_id: p.character_id,
746
+ display_name: p.display_name,
747
+ drifted_metrics: check.drifted,
748
+ signature_phrases: { baseline: baselineSig, current: currentSig, overlap },
749
+ baseline_metrics: baseline,
750
+ current_metrics: current,
751
+ evidence,
752
+ directives
753
+ });
754
+ }
755
+ if (driftedCharacters.length === 0) {
756
+ return { drift: null, activeCharacterIds, warnings };
757
+ }
758
+ const drift = {
759
+ $schema: "schemas/character-voice-drift.schema.json",
760
+ schema_version: 1,
761
+ generated_at: new Date().toISOString(),
762
+ as_of: { chapter: args.asOfChapter, volume: args.volume },
763
+ window: { chapter_start: start, chapter_end: end, window_chapters: windowChapters },
764
+ profiles_path: PROFILES_REL,
765
+ characters: driftedCharacters
766
+ };
767
+ return { drift, activeCharacterIds, warnings };
768
+ }
769
+ export async function writeCharacterVoiceDriftFile(args) {
770
+ const rel = DRIFT_REL;
771
+ const abs = join(args.rootDir, rel);
772
+ await ensureDir(dirname(abs));
773
+ const dirAbs = dirname(abs);
774
+ const tmpAbs = join(dirAbs, `.tmp-character-voice-drift-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
775
+ await writeJsonFile(tmpAbs, args.drift);
776
+ try {
777
+ await rename(tmpAbs, abs);
778
+ }
779
+ finally {
780
+ await rm(tmpAbs, { force: true }).catch(() => { });
781
+ }
782
+ return { rel };
783
+ }
784
+ export async function writeCharacterVoiceProfilesFile(args) {
785
+ const rel = PROFILES_REL;
786
+ const abs = join(args.rootDir, rel);
787
+ await ensureDir(dirname(abs));
788
+ const dirAbs = dirname(abs);
789
+ const tmpAbs = join(dirAbs, `.tmp-character-voice-profiles-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
790
+ await writeJsonFile(tmpAbs, args.profiles);
791
+ try {
792
+ await rename(tmpAbs, abs);
793
+ }
794
+ finally {
795
+ await rm(tmpAbs, { force: true }).catch(() => { });
796
+ }
797
+ return { rel };
798
+ }
799
+ export async function clearCharacterVoiceDriftFile(rootDir) {
800
+ const abs = join(rootDir, DRIFT_REL);
801
+ if (!(await pathExists(abs)))
802
+ return false;
803
+ await removePath(abs);
804
+ return true;
805
+ }