@gobing-ai/knowledge-kit 0.0.35 → 0.0.36

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 (122) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/board/collect/index.css +42 -0
  4. package/dist/board/collect/index.js +787 -0
  5. package/dist/board/daily-voice/index.css +13 -0
  6. package/dist/board/daily-voice/index.js +902 -0
  7. package/dist/board/itc/index.css +14 -0
  8. package/dist/board/itc/index.js +1026 -0
  9. package/dist/index.js +11768 -9249
  10. package/package.json +10 -9
  11. package/plugins/generations/content-gen/dist/index.js +1015 -10814
  12. package/plugins/generations/content-gen/package.json +1 -1
  13. package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
  14. package/plugins/generations/core-facts-gen/package.json +1 -1
  15. package/plugins/generations/daily-article-gen/dist/index.js +1004 -10803
  16. package/plugins/generations/daily-article-gen/package.json +1 -1
  17. package/plugins/generations/dailynews-gen/dist/index.js +1092 -10891
  18. package/plugins/generations/dailynews-gen/package.json +1 -1
  19. package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
  20. package/plugins/generations/episode-plan-gen/package.json +1 -1
  21. package/plugins/generations/gemini-tts/dist/index.js +1104 -10903
  22. package/plugins/generations/gemini-tts/package.json +1 -1
  23. package/plugins/generations/image-gen/dist/index.js +1239 -11035
  24. package/plugins/generations/image-gen/package.json +1 -1
  25. package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
  26. package/plugins/generations/news-report-gen/package.json +1 -1
  27. package/plugins/generations/voice-gen/dist/index.js +1062 -10861
  28. package/plugins/generations/voice-gen/package.json +1 -1
  29. package/plugins/generations/voxcpm-tts/README.md +72 -0
  30. package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
  31. package/plugins/generations/voxcpm-tts/package.json +16 -0
  32. package/plugins/generations/voxcpm-tts/plugin.json +6 -0
  33. package/plugins/generations/voxcpm-tts/profiles.json +10 -0
  34. package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
  35. package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
  36. package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
  37. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
  38. package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
  39. package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
  40. package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
  41. package/plugins/ingestions/aihot-ingest/package.json +1 -1
  42. package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
  43. package/plugins/ingestions/horizon-ingest/package.json +1 -1
  44. package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
  45. package/plugins/ingestions/karakeep-local/package.json +1 -1
  46. package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
  47. package/plugins/ingestions/last30days-ingest/package.json +1 -1
  48. package/plugins/ingestions/web-search/dist/index.js +1250 -11033
  49. package/plugins/ingestions/web-search/package.json +1 -1
  50. package/plugins/kk/README.md +29 -2
  51. package/plugins/kk/plugin.json +1 -1
  52. package/plugins/kk/schedules/cron.tmpl +9 -1
  53. package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
  54. package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
  55. package/plugins/kk/scripts/daily-stages.ts +428 -46
  56. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -146
  57. package/plugins/kk/skills/publish/SKILL.md +8 -4
  58. package/plugins/kk/workflows/feature-verification.yaml +93 -0
  59. package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
  60. package/plugins/kk/workflows/kk-collect.yaml +43 -11
  61. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +4 -2
  62. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +86 -15
  63. package/plugins/kk/workflows/kk-daily-safety.yaml +15 -2
  64. package/plugins/kk/workflows/kk-itc.yaml +8 -1
  65. package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
  66. package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
  67. package/plugins/publishings/emdash-pub/package.json +1 -1
  68. package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
  69. package/plugins/publishings/infoq-pub/dist/profile-check.js +1677 -11199
  70. package/plugins/publishings/infoq-pub/package.json +1 -1
  71. package/plugins/publishings/infoq-pub/plugin.json +5 -1
  72. package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
  73. package/plugins/publishings/juejin-pub/dist/profile-check.js +1677 -11199
  74. package/plugins/publishings/juejin-pub/package.json +1 -1
  75. package/plugins/publishings/juejin-pub/plugin.json +5 -1
  76. package/plugins/publishings/medium-pub/dist/index.js +978 -10805
  77. package/plugins/publishings/medium-pub/package.json +1 -1
  78. package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
  79. package/plugins/publishings/podcast-pub/package.json +1 -1
  80. package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
  81. package/plugins/publishings/qiita-pub/package.json +1 -1
  82. package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
  83. package/plugins/publishings/substack-pub/dist/profile-check.js +1698 -11200
  84. package/plugins/publishings/substack-pub/package.json +1 -1
  85. package/plugins/publishings/substack-pub/plugin.json +4 -1
  86. package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
  87. package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
  88. package/plugins/publishings/surfdash-pub/package.json +1 -1
  89. package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
  90. package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
  91. package/plugins/publishings/threads-pub/dist/index.js +12394 -0
  92. package/plugins/publishings/threads-pub/package.json +17 -0
  93. package/plugins/publishings/threads-pub/plugin.json +7 -0
  94. package/plugins/publishings/threads-pub/src/index.ts +340 -0
  95. package/plugins/publishings/threads-pub/tsconfig.json +4 -0
  96. package/plugins/publishings/wechatmp-pub/dist/index.js +2508 -11695
  97. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +1656 -11188
  98. package/plugins/publishings/wechatmp-pub/package.json +1 -1
  99. package/plugins/publishings/wechatmp-pub/plugin.json +5 -1
  100. package/plugins/publishings/wechatmp-pub/src/enrich.ts +244 -71
  101. package/plugins/publishings/wechatmp-pub/src/flow.ts +412 -96
  102. package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
  103. package/plugins/publishings/wechatmp-pub/src/page.ts +16 -0
  104. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
  105. package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
  106. package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
  107. package/plugins/publishings/weibo-pub/package.json +19 -0
  108. package/plugins/publishings/weibo-pub/plugin.json +12 -0
  109. package/plugins/publishings/weibo-pub/src/index.ts +868 -0
  110. package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
  111. package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
  112. package/plugins/publishings/x-pub/dist/index.js +1948 -11440
  113. package/plugins/publishings/x-pub/dist/profile-check.js +1677 -11199
  114. package/plugins/publishings/x-pub/package.json +1 -1
  115. package/plugins/publishings/x-pub/plugin.json +5 -1
  116. package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
  117. package/plugins/publishings/xhs-pub/dist/profile-check.js +1697 -11212
  118. package/plugins/publishings/xhs-pub/package.json +1 -1
  119. package/plugins/publishings/xhs-pub/plugin.json +5 -1
  120. package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
  121. package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
  122. package/plugins/publishings/zenn-pub/package.json +1 -1
@@ -124,6 +124,8 @@ export function dailyQcContent(args: string[]): void {
124
124
  minUrgency = '',
125
125
  minImpact = '',
126
126
  minEpisodeItems = '',
127
+ minMinutes = '',
128
+ vdriver = '',
127
129
  ] = args;
128
130
  const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
129
131
  const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
@@ -158,6 +160,7 @@ export function dailyQcContent(args: string[]): void {
158
160
  },
159
161
  },
160
162
  );
163
+ applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
161
164
  });
162
165
  // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
163
166
  // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
@@ -208,6 +211,79 @@ export function dailyQcContent(args: string[]): void {
208
211
  * episode-plan-gen's `titleSimilarity`. Duplicated on purpose: this file is node-builtins-only
209
212
  * (no workspace imports), so it cannot reach that module.
210
213
  */
214
+ /**
215
+ * The factor between a candidate's raw body characters and the characters the episode actually
216
+ * speaks, calibrated on the two 2026-10-11 runs: 3 thin candidates (1.8k chars) scripted to 1270
217
+ * spoken characters (4.0 min, failed the 15-min gate) and 7 candidates (4.3k chars) scripted to 5925
218
+ * (16.8 min, shipped). The agent expands the news register, so the raw count under-predicts by ~1.4x;
219
+ * with it, both runs land on the right side of the floor.
220
+ */
221
+ const SPEAK_CHAR_EXPANSION = 1.4;
222
+
223
+ /**
224
+ * The calibrated pre-spend estimate over a kept pool (title+body characters × the 1.4 expansion,
225
+ * over the driver's measured char rate, plus the 8 s intro sting) — shared by the pre-spend floor
226
+ * gate (applyFloorGate) and the post-spend duration refusal (dailyEpisodeDuration, 0266), so both
227
+ * artifacts report the same arithmetic. `seconds` is unrounded: the floor gate compares seconds.
228
+ */
229
+ function estimateSpokenMinutes(docs: unknown[], vdriver: string): { minutes: number; seconds: number; chars: number } {
230
+ const chars = docs.reduce((sum, doc) => {
231
+ const entry = (doc ?? {}) as Record<string, unknown>;
232
+ return sum + `${String(entry.title ?? '')}`.length + String(entry.body ?? '').length;
233
+ }, 0);
234
+ const seconds = 8 + (chars * SPEAK_CHAR_EXPANSION) / spokenCharRate(vdriver);
235
+ return { minutes: Math.round((seconds / 60) * 10) / 10, seconds, chars };
236
+ }
237
+
238
+ /**
239
+ * Pre-spend floor gate (2026-10-12). `daily-episode-duration` also refuses a final episode below
240
+ * the floor, but it runs after the TTS spend — a thin pool would still pay for a full synthesis
241
+ * pass (the 04:30 tick of 2026-10-11: 3 candidates → 4.0 min episode, nothing published; since
242
+ * 0266 that post-spend refusal closes as no-content instead of aborting, but the spend is gone).
243
+ * This estimates the spoken length from the kept
244
+ * candidates and, when it cannot reach the floor, empties the candidate list: every downstream
245
+ * `pool-count --min 1` guard then skips the content, audio, publish and XHS subtrees, and the run
246
+ * closes through run-report and the ledger exactly like an empty pool. The estimate and its inputs
247
+ * land in a refusal artifact next to the candidates.
248
+ */
249
+ function applyFloorGate(
250
+ candidates: string,
251
+ workDir: string,
252
+ runDate: string,
253
+ minMinutes: string,
254
+ vdriver: string,
255
+ ): void {
256
+ const floor = Number(minMinutes);
257
+ if (minMinutes === '' || !Number.isFinite(floor) || floor <= 0) return;
258
+ const content = readJson(candidates);
259
+ const docs = metadataOf(content).docs;
260
+ if (!Array.isArray(docs) || docs.length === 0) return;
261
+ const { minutes: estimatedMinutes, seconds: estimatedSeconds, chars } = estimateSpokenMinutes(docs, vdriver);
262
+ if (estimatedSeconds >= floor * 60) {
263
+ console.log(`floor gate: estimated ${estimatedMinutes} min from ${chars} chars — above the ${floor} min floor`);
264
+ return;
265
+ }
266
+ const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
267
+ const ids = docs.map((doc) => String((doc as Record<string, unknown> | null)?.id ?? ''));
268
+ sted('write', refusal, () =>
269
+ writeJqText(refusal, {
270
+ estimatedMinutes,
271
+ minMinutes: floor,
272
+ candidateChars: chars,
273
+ expansion: SPEAK_CHAR_EXPANSION,
274
+ charRate: spokenCharRate(vdriver),
275
+ ids,
276
+ reason: `a ${docs.length}-item pool estimates ${estimatedMinutes} min of speech, below the ${floor} min floor — refusing before any TTS spend`,
277
+ }),
278
+ );
279
+ sted('write', candidates, () =>
280
+ writeJsonText(candidates, { ...(content ?? {}), metadata: { ...metadataOf(content), docs: [] } }),
281
+ );
282
+ console.error(
283
+ `floor gate: ${docs.length} candidates estimate ${estimatedMinutes} min < ${floor} min — candidates emptied, the run closes as no-content (see ${refusal})`,
284
+ );
285
+ }
286
+
211
287
  export function dailyArticle(args: string[]): void {
212
288
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
213
289
  const articleMd = join(workDir, '22-article', `${runDate}_06_article_article.md`);
@@ -383,9 +459,16 @@ export function dailyCoverNormalize(args: string[]): void {
383
459
  * content's `metadata.duration` — after the intro sting merged, this is the true episode length —
384
460
  * and fails loud outside `[min, max]` seconds-worth of minutes. A missing duration is a failure,
385
461
  * not a pass: the gate must never silently let an under-length episode through again.
462
+ *
463
+ * 0266 (policy (c), 2026-10-13): an under-minutes episode is a thin day, not a broken run — the
464
+ * gate refuses it like the pre-spend floor gate does instead of aborting before run-report/ledger:
465
+ * write `_11_generate_content_duration-refusal.json` beside the content, empty the candidates
466
+ * file (every pool-count-gated node downstream then skips) and exit 0, so the run closes as
467
+ * no-content with the measured vs estimated minutes recorded. Over-max still fails loud — trimming
468
+ * is a different problem.
386
469
  */
387
470
  export function dailyEpisodeDuration(args: string[]): void {
388
- const [workDir = '', runDate = '', minArg = '', maxArg = ''] = args;
471
+ const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = ''] = args;
389
472
  if (!workDir) fail('missing work_dir');
390
473
  if (!runDate) fail('missing run_date');
391
474
  const minMinutes = Number(minArg);
@@ -411,10 +494,43 @@ export function dailyEpisodeDuration(args: string[]): void {
411
494
  const minutes = seconds / 60;
412
495
  const shown = minutes.toFixed(1);
413
496
  if (minutes < minMinutes) {
414
- failWith(
415
- 1,
416
- `episode-duration: episode runs ${shown} min, below the ${minMinutes}–${maxMinutes} min requirement (duration is authoritative). The candidate pool is too thin — deepen per-item treatment or add items; do not ship a short episode silently.`,
497
+ const refusal = join(workDir, '30-audio', `${runDate}_11_generate_content_duration-refusal.json`);
498
+ const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
499
+ const content = readJson(candidates);
500
+ const docs = Array.isArray(metadataOf(content).docs) ? (metadataOf(content).docs as unknown[]) : [];
501
+ // Idempotency first: a rerun must not recompute an estimate from the already-emptied candidates.
502
+ // A kill between the two writes below leaves the refusal beside non-empty candidates, which would
503
+ // let the pool-gated publish fan package a sub-floor episode — so finish the emptying here.
504
+ if (isFile(refusal)) {
505
+ if (content !== null && docs.length > 0) {
506
+ sted('write', candidates, () =>
507
+ writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
508
+ );
509
+ }
510
+ console.log(`episode-duration: duration refusal already recorded (${refusal}) — nothing to do`);
511
+ return;
512
+ }
513
+ const estimated = estimateSpokenMinutes(docs, vdriver);
514
+ const measuredMinutes = Math.round(minutes * 10) / 10;
515
+ sted('write', refusal, () =>
516
+ writeJqText(refusal, {
517
+ measuredMinutes,
518
+ estimatedMinutes: estimated.minutes,
519
+ minMinutes,
520
+ keptCount: docs.length,
521
+ candidateChars: estimated.chars,
522
+ reason: `the episode ran ${shown} min of speech, below the ${minMinutes} min floor (pre-spend estimate ${estimated.minutes} min) — refusing after synthesis`,
523
+ }),
417
524
  );
525
+ if (content !== null) {
526
+ sted('write', candidates, () =>
527
+ writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
528
+ );
529
+ }
530
+ console.error(
531
+ `episode-duration: episode ran ${shown} min (estimate ${estimated.minutes} min), below the ${minMinutes}–${maxMinutes} min requirement — candidates emptied, the run closes as no-content (see ${refusal})`,
532
+ );
533
+ return;
418
534
  }
419
535
  if (minutes > maxMinutes) {
420
536
  failWith(
@@ -460,6 +576,158 @@ function geminiQuotaProbeOk(workDir: string, pluginsPath: string, transcodeMp3:
460
576
  return true;
461
577
  }
462
578
 
579
+ /** `VOICE_GEN_MP3` truthiness, matching voice-gen's own parse (`plugins/generations/voice-gen/src/mp3.ts`). */
580
+ const MP3_TRUTHY = new Set(['1', 'true', 'yes', 'on']);
581
+
582
+ /**
583
+ * `transcode_mp3=true` is a workflow contract, not a driver hint: the run publishes an MP3
584
+ * enclosure and `daily-publish-prep` refuses a WAV (0171 R2). Only voice-gen honours
585
+ * `VOICE_GEN_MP3`; gemini-tts and omni-voice-gen write WAV only, so `metadata.mp3Path` stayed
586
+ * unset for them and every publishing run died at publish-prep. Derive the sibling MP3 here —
587
+ * the one place that knows both the requested transcode and what the driver actually wrote —
588
+ * so `daily-intro` re-transcodes the intro-merged audio to the same recorded path.
589
+ */
590
+ function ensurePublishMp3(contentPath: string, transcodeMp3: string): void {
591
+ if (!MP3_TRUTHY.has(transcodeMp3.trim().toLowerCase())) return;
592
+ const content = readJson(contentPath);
593
+ if (content === null) return;
594
+ const meta = (content.metadata ?? {}) as Record<string, unknown>;
595
+ const recorded = meta.mp3Path;
596
+ if (typeof recorded === 'string' && recorded !== '' && existsSync(resolve(recorded))) return;
597
+ const wav = [meta.audioPath, content.audioPath].find(
598
+ (candidate): candidate is string =>
599
+ typeof candidate === 'string' && candidate.endsWith('.wav') && existsSync(resolve(candidate)),
600
+ );
601
+ if (wav === undefined) return;
602
+ const wavPath = resolve(wav);
603
+ const mp3Path = wavPath.replace(/\.wav$/i, '.mp3');
604
+ const proc = spawnSync('ffmpeg', [
605
+ '-y',
606
+ '-hide_banner',
607
+ '-loglevel',
608
+ 'error',
609
+ '-i',
610
+ wavPath,
611
+ '-codec:a',
612
+ 'libmp3lame',
613
+ '-b:a',
614
+ '64k',
615
+ mp3Path,
616
+ ]);
617
+ if (proc.status !== 0 || !existsSync(mp3Path)) {
618
+ const detail = (proc.stderr?.toString() ?? '').trim() || `exit ${String(proc.status)}`;
619
+ failWith(1, `generate: publish MP3 transcode failed: ${detail}`);
620
+ }
621
+ meta.mp3Path = mp3Path;
622
+ content.metadata = meta;
623
+ sted('write', contentPath, () => writeJsonText(contentPath, content));
624
+ console.log(`generate: wrote publish MP3 ${mp3Path}`);
625
+ }
626
+
627
+ /** ffprobe one audio attribute; empty string when the file is absent/unreadable. */
628
+ function probeAudio(file: string, entries: string): string {
629
+ return (
630
+ spawnSync('ffprobe', [
631
+ '-v',
632
+ 'error',
633
+ '-select_streams',
634
+ 'a:0',
635
+ '-show_entries',
636
+ entries,
637
+ '-of',
638
+ 'csv=p=0',
639
+ file,
640
+ ])
641
+ .stdout?.toString()
642
+ .trim() ?? ''
643
+ );
644
+ }
645
+
646
+ /** A playable local audio artifact: present, positive native sample rate and positive duration. */
647
+ function validNativeAudio(path: unknown): boolean {
648
+ if (typeof path !== 'string' || path === '' || !existsSync(resolve(path))) return false;
649
+ const rate = Number.parseFloat(probeAudio(resolve(path), 'stream=sample_rate'));
650
+ const duration = Number.parseFloat(probeAudio(resolve(path), 'format=duration'));
651
+ return Number.isFinite(rate) && rate > 0 && Number.isFinite(duration) && duration > 0;
652
+ }
653
+
654
+ /**
655
+ * Whole-document speech cache hit (0105 R2 / 0257 R2): the recorded Content must carry THIS run's
656
+ * generator identity (`kk:<plugin>`), a passing QC report and a playable native-rate WAV. Foreign,
657
+ * legacy or corrupt output is a miss — never relabel another driver's audio. A valid cache missing
658
+ * only the requested MP3 is repaired in place from the already-rendered speech (no resynthesis).
659
+ */
660
+ function wholeDocCache(contentPath: string, plugin: string, transcodeMp3: string): boolean {
661
+ const content = readJson(contentPath);
662
+ if (content === null) return false;
663
+ const meta = metadataOf(content);
664
+ const qc = meta.qc as Record<string, unknown> | undefined;
665
+ if (meta.generator !== `kk:${plugin}`) return false;
666
+ if (qc?.passed === true && validNativeAudio(meta.audioPath)) {
667
+ ensurePublishMp3(contentPath, transcodeMp3);
668
+ return true;
669
+ }
670
+ return false;
671
+ }
672
+
673
+ /**
674
+ * Joined-final cache identity (0257 R3): exact ordered `metadata.sectionHashes` equality, matching
675
+ * generator, passing QC and a playable WAV. A stale or legacy final is a miss even when every
676
+ * section still hits — the caller rejoins the cached sections without model calls. The requested
677
+ * MP3 is repaired from the valid joined WAV; a failed derivative fails loud.
678
+ */
679
+ function joinedFinalFresh(contentPath: string, plugin: string, hashes: string[], transcodeMp3: string): boolean {
680
+ const content = readJson(contentPath);
681
+ if (content === null) return false;
682
+ const meta = metadataOf(content);
683
+ if (meta.generator !== `kk:${plugin}`) return false;
684
+ const frozen = meta.sectionHashes;
685
+ if (!Array.isArray(frozen) || frozen.length !== hashes.length || !frozen.every((h, i) => h === hashes[i])) {
686
+ return false;
687
+ }
688
+ const qc = meta.qc as Record<string, unknown> | undefined;
689
+ if (qc?.passed !== true || !validNativeAudio(meta.audioPath)) return false;
690
+ ensurePublishMp3(contentPath, transcodeMp3);
691
+ return true;
692
+ }
693
+
694
+ /**
695
+ * Aggregate the current sections' QC into the shared VoiceQualityReport names (0257 R3): passed
696
+ * iff every section passed; overallScore is the minimum when every section reports a finite one
697
+ * (absent evidence stays unavailable, never an invented zero); segmentAudits are concatenated with
698
+ * globally ordered segmentIndex; criticalIssues are flattened with section labels.
699
+ */
700
+ function aggregateSectionQc(
701
+ plans: Array<{ def: { name: string }; jsonPath: string }>,
702
+ totalDuration: number,
703
+ ): Record<string, unknown> {
704
+ const segmentAudits: Array<Record<string, unknown>> = [];
705
+ const criticalIssues: string[] = [];
706
+ const scores: number[] = [];
707
+ let allPassed = true;
708
+ let allScored = true;
709
+ for (const plan of plans) {
710
+ const report = metadataOf(readJson(plan.jsonPath)).qc;
711
+ const qc = report !== null && typeof report === 'object' ? (report as Record<string, unknown>) : {};
712
+ if (qc.passed !== true) allPassed = false;
713
+ if (typeof qc.overallScore === 'number' && Number.isFinite(qc.overallScore)) scores.push(qc.overallScore);
714
+ else allScored = false;
715
+ if (Array.isArray(qc.segmentAudits)) {
716
+ for (const audit of qc.segmentAudits) {
717
+ if (audit !== null && typeof audit === 'object') {
718
+ segmentAudits.push({ ...(audit as Record<string, unknown>), segmentIndex: segmentAudits.length });
719
+ }
720
+ }
721
+ }
722
+ if (Array.isArray(qc.criticalIssues)) {
723
+ for (const issue of qc.criticalIssues) criticalIssues.push(`${plan.def.name}: ${String(issue)}`);
724
+ }
725
+ }
726
+ const aggregate: Record<string, unknown> = { passed: allPassed, totalDuration, segmentAudits, criticalIssues };
727
+ if (allScored && scores.length > 0) aggregate.overallScore = Math.min(...scores);
728
+ return aggregate;
729
+ }
730
+
463
731
  export function dailyGenerate(args: string[]): void {
464
732
  const [
465
733
  workDir = '',
@@ -476,6 +744,7 @@ export function dailyGenerate(args: string[]): void {
476
744
  ominivoice: 'omni-voice-gen',
477
745
  voicebox: 'voice-gen',
478
746
  gemini: 'gemini-tts',
747
+ voxcpm: 'voxcpm-tts',
479
748
  };
480
749
  let voiceGenerator = VOICE_DRIVERS[vdriver];
481
750
  if (!voiceGenerator) {
@@ -494,12 +763,9 @@ export function dailyGenerate(args: string[]): void {
494
763
 
495
764
  if (!sectionConcurrency) {
496
765
  timed(workDir, 'generate', () => {
497
- if (isFile(content) && metadataOf(readJson(content)).qc !== undefined) {
498
- const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
499
- if (qc?.passed === true) {
500
- console.log(`audio cached: ${content} (QC passed)`);
501
- return;
502
- }
766
+ if (wholeDocCache(content, voiceGenerator, transcodeMp3)) {
767
+ console.log(`audio cached: ${content} (QC passed)`);
768
+ return;
503
769
  }
504
770
  // Gemini quota probe (20261006): the 06:00 cron hit gemini-3.8-flash-tts 429 with a
505
771
  // multi-hour retry-after after the shared key's window drained overnight. One tiny
@@ -513,11 +779,19 @@ export function dailyGenerate(args: string[]): void {
513
779
  !geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [{ ...docsList[0], body: 'Hello.' }])
514
780
  ) {
515
781
  voiceGenerator = VOICE_DRIVERS.voicebox;
782
+ // The fallback driver's own cache may already hold this episode (0257 R2) —
783
+ // reuse it instead of paying for a second local render. VoxCPM never probes
784
+ // Gemini and never falls back, so this branch is Gemini-only.
785
+ if (wholeDocCache(content, voiceGenerator, transcodeMp3)) {
786
+ console.log(`audio cached: ${content} (QC passed)`);
787
+ return;
788
+ }
516
789
  }
517
790
  }
518
791
  kk(voiceExecutorArgs(voiceGenerator, docs, content, pluginsPath), {
519
792
  env: { VOICE_GEN_MP3: transcodeMp3 },
520
793
  });
794
+ ensurePublishMp3(content, transcodeMp3);
521
795
  });
522
796
  return;
523
797
  }
@@ -642,6 +916,8 @@ export function dailyGenerate(args: string[]): void {
642
916
  // 4645 / 789.9 s of section speech = 5.88 — a 5.3–5.9 range, so 5.4 deliberately errs low (a
643
917
  // slightly early refusal is cheaper than a rendered episode the duration gate then rejects).
644
918
  // voice-gen: 20261006 6376 / 1135 = 5.62. A 20-minute ceiling is therefore ~6400 characters.
919
+ // VoxCPM (voxcpm-tts) shares the 5.6 voicebox estimate — PROVISIONAL: no measured VoxCPM
920
+ // episode exists yet (0258 supplies the evidence); the measured duration stays authoritative.
645
921
  // The style sidecar's 3.3 / 6.3 figures are stale and mutually inconsistent with its own
646
922
  // 4300–5800 字 budget (which is 13–29 min at 3.3); using them refused a script that really
647
923
  // renders in 17 min. Recalibrate from a fresh pair of `metadata.duration` readings when a
@@ -687,7 +963,16 @@ export function dailyGenerate(args: string[]): void {
687
963
  try {
688
964
  const sidecar = readJson(jsonPath) as Record<string, unknown>;
689
965
  const sidecarMeta = metadataOf(sidecar) as Record<string, unknown>;
690
- if (sidecar.hash === hash || sidecarMeta.hash === hash) {
966
+ const sidecarQc = sidecarMeta.qc as Record<string, unknown> | undefined;
967
+ // A completed section is reusable only when its provenance, QC and audio
968
+ // all belong to this driver/text/profile — a foreign or half-written
969
+ // sidecar re-renders (0257 R2). The hash already folds in the driver.
970
+ if (
971
+ (sidecar.hash === hash || sidecarMeta.hash === hash) &&
972
+ sidecarMeta.generator === `kk:${driver}` &&
973
+ sidecarQc?.passed === true &&
974
+ validNativeAudio(wavPath)
975
+ ) {
691
976
  cached = true;
692
977
  }
693
978
  } catch {}
@@ -705,7 +990,18 @@ export function dailyGenerate(args: string[]): void {
705
990
  let plans = computePlans(voiceGenerator);
706
991
  const anySynthesisNeeded = plans.some((p) => p.needsSynthesis);
707
992
 
708
- if (!anySynthesisNeeded && existsSync(joinedWavPath) && existsSync(joinedContentPath)) {
993
+ // All sections hit, but the joined final is reusable only when its frozen identity matches
994
+ // this run's ordered section hashes (0257 R3). A stale or legacy final — e.g. a previous
995
+ // join failed — falls through and rejoins the cached sections below, with no model calls.
996
+ if (
997
+ !anySynthesisNeeded &&
998
+ joinedFinalFresh(
999
+ joinedContentPath,
1000
+ voiceGenerator,
1001
+ plans.map((p) => p.hash),
1002
+ transcodeMp3,
1003
+ )
1004
+ ) {
709
1005
  console.log('generate: all sections cached, joined speech reused');
710
1006
  return;
711
1007
  }
@@ -752,10 +1048,25 @@ export function dailyGenerate(args: string[]): void {
752
1048
  };
753
1049
  writeJsonText(secDocIn, [doc]);
754
1050
  try {
755
- const status = kk(voiceExecutorArgs(voiceGenerator, secDocIn, plan.jsonPath, pluginsPath), {
1051
+ // 0269-shaped hardening (20261012): one retry for a failed section. The design already
1052
+ // bounds the episode's risk with the pre-flight quota probe and the per-section cache,
1053
+ // but a failure that arrives MID-render (a 429 once the window drains, a network blip)
1054
+ // still cost the whole episode — the one step nothing can publish without. The retry is
1055
+ // cheap and bounded: the successful sections are cache hits, so at most one extra
1056
+ // request per failed section is issued, and a persistent failure still fails loud.
1057
+ let status = kk(voiceExecutorArgs(voiceGenerator, secDocIn, plan.jsonPath, pluginsPath), {
756
1058
  env: { VOICE_GEN_MP3: transcodeMp3 },
757
1059
  soft: true,
758
1060
  });
1061
+ if (status !== 0) {
1062
+ console.log(
1063
+ `generate: section ${plan.def.stem} synthesis failed (exit ${status}) — retrying it once`,
1064
+ );
1065
+ status = kk(voiceExecutorArgs(voiceGenerator, secDocIn, plan.jsonPath, pluginsPath), {
1066
+ env: { VOICE_GEN_MP3: transcodeMp3 },
1067
+ soft: true,
1068
+ });
1069
+ }
759
1070
  if (status !== 0) {
760
1071
  failWith(status, `generate: section synthesis failed for ${plan.def.stem}`);
761
1072
  }
@@ -814,21 +1125,52 @@ export function dailyGenerate(args: string[]): void {
814
1125
  }
815
1126
 
816
1127
  const joinedDuration = Number.parseFloat(probeHelper(joinedWavPath, 'format=duration'));
1128
+ if (!validNativeAudio(joinedWavPath) || !Number.isFinite(joinedDuration)) {
1129
+ failWith(1, `generate: joined audio is not a playable native-rate WAV: ${joinedWavPath}`);
1130
+ }
817
1131
 
818
- let contentObj: Record<string, unknown> = {};
819
- if (existsSync(joinedContentPath)) {
820
- try {
821
- contentObj = readJson(joinedContentPath) as Record<string, unknown>;
822
- } catch {}
1132
+ // podcast-pub renders the show-notes timeline from `metadata.segments[]` (per-segment
1133
+ // duration + ASR transcription). The one-shot path receives them from the voice plugin, but
1134
+ // this sectioned path synthesizes one sidecar per section and dropped them at the join — every
1135
+ // timeline row rendered 0:00 (2026-10-11). Rebuild the list in section order from the CURRENT
1136
+ // sidecars, never inherit the previous final's list (0257 R3).
1137
+ const joinedSegments: Array<Record<string, unknown>> = [];
1138
+ for (const plan of plans) {
1139
+ const sectionSegments = metadataOf(readJson(plan.jsonPath)).segments;
1140
+ if (Array.isArray(sectionSegments)) {
1141
+ joinedSegments.push(...(sectionSegments as Array<Record<string, unknown>>));
1142
+ }
823
1143
  }
824
- const meta = (contentObj.metadata ?? {}) as Record<string, unknown>;
825
- delete meta.introMerged;
826
- meta.audioPath = joinedWavPath;
827
- meta.duration = joinedDuration;
828
- contentObj.audioPath = joinedWavPath;
829
- contentObj.mediaType = 'audio/wav';
830
- contentObj.metadata = meta;
1144
+ // Rebuild the Content envelope from the current script/sections. The previous final's
1145
+ // generator/QC/mp3Path/sectionHashes must never survive a fresh join, and mp3Path is
1146
+ // deliberately absent so ensurePublishMp3 regenerates the requested derivative (0257 R3).
1147
+ const baseMeta: Record<string, unknown> = {
1148
+ generator: `kk:${voiceGenerator}`,
1149
+ audioPath: joinedWavPath,
1150
+ duration: joinedDuration,
1151
+ };
1152
+ if (joinedSegments.length > 0) baseMeta.segments = joinedSegments;
1153
+ const contentObj: Record<string, unknown> = {
1154
+ title: typeof script.title === 'string' && script.title !== '' ? script.title : 'Daily AI News',
1155
+ body: scriptRaw,
1156
+ format: 'audio',
1157
+ references: [],
1158
+ audioPath: joinedWavPath,
1159
+ mediaType: 'audio/wav',
1160
+ metadata: baseMeta,
1161
+ };
831
1162
  writeJsonText(joinedContentPath, contentObj);
1163
+ // Transcode the requested MP3 before any success claim is written: a failed conversion
1164
+ // fails loud with no qc.passed on disk, so the next run rejoins the cached sections rather
1165
+ // than trusting a broken final (0257 R3).
1166
+ ensurePublishMp3(joinedContentPath, transcodeMp3);
1167
+ // Join and derivative are proven; only now record the frozen identity and aggregated QC.
1168
+ const finalized = readJson(joinedContentPath) ?? contentObj;
1169
+ const finalMeta = metadataOf(finalized);
1170
+ finalMeta.qc = aggregateSectionQc(plans, joinedDuration);
1171
+ finalMeta.sectionHashes = plans.map((p) => p.hash);
1172
+ finalized.metadata = finalMeta;
1173
+ writeJsonText(joinedContentPath, finalized);
832
1174
  });
833
1175
  }
834
1176
 
@@ -1647,7 +1989,7 @@ function voicescriptToBlockYaml(script: Record<string, unknown>): string {
1647
1989
  return lines.join('\n');
1648
1990
  }
1649
1991
 
1650
- /** Measured driver rates from dailyGenerate. Unknown drivers use the voicebox rate. */
1992
+ /** Measured driver rates from dailyGenerate. Unknown drivers (incl. voxcpm, provisionally) use the voicebox rate. */
1651
1993
  function spokenCharRate(vdriver: string): number {
1652
1994
  return vdriver === 'gemini' ? 5.4 : 5.6;
1653
1995
  }
@@ -2082,6 +2424,37 @@ export function xhsOverflowPoints(stderr: string, card: XhsCard): number[] | nul
2082
2424
  /** The sentence-ending marks the shorten rule cuts at. */
2083
2425
  const POINT_SENTENCE_MARKS = new Set(['。', '!', '?']);
2084
2426
 
2427
+ /** Fewest bullets a card keeps: below two, the block stops reading as a list. */
2428
+ const XHS_MIN_POINTS = 2;
2429
+
2430
+ /** How many rewrites a refused card gets; each attempt re-bills one illustration. */
2431
+ const XHS_OVERFLOW_ATTEMPTS = 3;
2432
+
2433
+ /**
2434
+ * 0251 R1 + 2026-10-12: reduce the points of a card the composer refused, so the next compose pass
2435
+ * can fit them. Rule 1 shortens each point the composer named ({@link shortenOverflowedPoint}). When
2436
+ * no point can get shorter the block itself is over budget — measured on 20261011-ai-2026-10-11, whose
2437
+ * cover points are 12–16 code-point single sentences that both shortening rules leave untouched, so
2438
+ * the old single-shot retry never fired and the whole series was lost — so rule 2 drops the trailing
2439
+ * point. Returns whether the card changed, the caller's signal to retry.
2440
+ */
2441
+ export function reduceOverflowingPoints(points: string[], named: readonly number[]): boolean {
2442
+ let changed = false;
2443
+ for (const n of named) {
2444
+ const point = points[n] ?? '';
2445
+ const next = shortenOverflowedPoint(point);
2446
+ if (next !== point) {
2447
+ points[n] = next;
2448
+ changed = true;
2449
+ }
2450
+ }
2451
+ if (!changed && points.length > XHS_MIN_POINTS) {
2452
+ points.pop();
2453
+ changed = true;
2454
+ }
2455
+ return changed;
2456
+ }
2457
+
2085
2458
  /**
2086
2459
  * 0251 R1: shorten one overflowing card point — drop its last sentence (cut at the last
2087
2460
  * 。/!/? that is not the final character, inclusive); a one-sentence point longer than 36
@@ -2191,30 +2564,39 @@ export function dailyXhsRender(args: string[]): void {
2191
2564
  const resumeLine = (status: number): string =>
2192
2565
  `xhs render: card ${card.index} failed (image-gen exit ${status}); cards before it are kept — re-run daily-xhs-render to resume from card ${card.index}`;
2193
2566
  let result = kkCaptured(imageGenArgs, { soft: true });
2194
- if (result.status !== 0) {
2195
- // 0251 R1: retry ONCE, only on a points[n] safe-area overflow where at least one
2196
- // named point got shorter. The envelope is rewritten in the same shape and
2197
- // image-gen is called once more; a second failure takes the resume line.
2198
- let shortened = false;
2199
- for (const n of xhsOverflowPoints(result.stderr, card) ?? []) {
2200
- const point = card.points[n] ?? '';
2201
- const next = shortenOverflowedPoint(point);
2202
- if (next !== point) {
2203
- card.points[n] = next;
2204
- shortened = true;
2205
- }
2206
- }
2207
- if (shortened) {
2208
- (imageGen.vars as Record<string, unknown>).points = card.points.join('\n');
2209
- sted('write', envPath, () =>
2210
- writeJsonText(envPath, [
2211
- { id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
2212
- ]),
2567
+ // 0269: a failure the composer's overflow sentence does NOT explain is retried once with
2568
+ // the SAME envelope, because the observed cause of those is the provider, not the input:
2569
+ // on 2026-10-12 card 02 was rewritten for a points[3] overflow and the second call then
2570
+ // failed with only a vendor-fallback warning (minimax flaked); re-running that exact
2571
+ // saved envelope by hand rendered on the first try. 0251 R2 read "not an overflow ⇒ do not
2572
+ // retry" as fail-fast, which is what lost the whole series that day (and every day since
2573
+ // 10-08). The budget stays bounded: overflow rewrites keep their 3, a provider failure
2574
+ // gets exactly 1, so a permanently broken card costs one extra illustration and not four.
2575
+ let transientRetryUsed = false;
2576
+ for (let attempt = 0; result.status !== 0 && attempt < XHS_OVERFLOW_ATTEMPTS; attempt += 1) {
2577
+ // 0251 R1 (+ the block-level rule 2): a points[n] safe-area overflow is rewritable —
2578
+ // shorten the named points, then drop the trailing one. The envelope is rewritten in
2579
+ // the same shape and image-gen is called again.
2580
+ const named = xhsOverflowPoints(result.stderr, card);
2581
+ if (named === null) {
2582
+ if (transientRetryUsed) break;
2583
+ transientRetryUsed = true;
2584
+ console.log(
2585
+ `xhs render: card ${card.index} failed without a safe-area overflow — retrying the same envelope once (exit ${result.status})`,
2213
2586
  );
2214
2587
  result = kkCaptured(imageGenArgs, { soft: true });
2588
+ continue;
2215
2589
  }
2216
- if (result.status !== 0) failWith(result.status, resumeLine(result.status));
2590
+ if (!reduceOverflowingPoints(card.points, named)) break;
2591
+ (imageGen.vars as Record<string, unknown>).points = card.points.join('\n');
2592
+ sted('write', envPath, () =>
2593
+ writeJsonText(envPath, [
2594
+ { id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
2595
+ ]),
2596
+ );
2597
+ result = kkCaptured(imageGenArgs, { soft: true });
2217
2598
  }
2599
+ if (result.status !== 0) failWith(result.status, resumeLine(result.status));
2218
2600
  artifact = firstArtifact(requireJson(out, 'xhs render'));
2219
2601
  }
2220
2602
  if (artifact === null) failWith(1, `xhs render: card ${card.index} recorded no image artifact (${out})`);