@gobing-ai/knowledge-kit 0.0.33 → 0.0.35

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 (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +25191 -24167
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +9 -9
  5. package/plugins/generations/core-facts-gen/dist/index.js +12 -12
  6. package/plugins/generations/daily-article-gen/dist/index.js +31 -23
  7. package/plugins/generations/daily-article-gen/src/index.ts +39 -14
  8. package/plugins/generations/dailynews-gen/dist/index.js +23 -21
  9. package/plugins/generations/dailynews-gen/src/script-builder.ts +30 -26
  10. package/plugins/generations/episode-plan-gen/dist/index.js +12 -12
  11. package/plugins/generations/gemini-tts/dist/index.js +13 -12
  12. package/plugins/generations/image-gen/dist/index.js +26 -11
  13. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +16 -14
  14. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -2
  15. package/plugins/generations/image-gen/src/compose/card.ts +17 -2
  16. package/plugins/generations/news-report-gen/dist/index.js +12 -12
  17. package/plugins/generations/omni-voice-gen/plugin.json +1 -1
  18. package/plugins/generations/voice-gen/dist/index.js +13 -12
  19. package/plugins/generations/voice-gen/src/index.ts +9 -3
  20. package/plugins/ingestions/aihot-ingest/dist/index.js +12 -12
  21. package/plugins/ingestions/horizon-ingest/dist/index.js +12 -12
  22. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  23. package/plugins/ingestions/last30days-ingest/dist/index.js +12 -12
  24. package/plugins/ingestions/web-search/dist/index.js +10 -10
  25. package/plugins/kk/decisions/extract/ai-news.yaml +6 -0
  26. package/plugins/kk/decisions/kk-items.yaml +69 -3
  27. package/plugins/kk/plugin.json +1 -1
  28. package/plugins/kk/scripts/daily-stages.ts +834 -87
  29. package/plugins/kk/scripts/daily-voice-style.zh.md +15 -10
  30. package/plugins/kk/scripts/kk-workflow-stages.ts +46 -3
  31. package/plugins/kk/skills/content-safety/SKILL.md +1 -1
  32. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +2 -2
  33. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
  34. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
  35. package/plugins/kk/workflows/kk-collect.yaml +7 -9
  36. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +32 -7
  37. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +169 -47
  38. package/plugins/kk/workflows/kk-daily-safety.yaml +9 -12
  39. package/plugins/publishings/emdash-pub/dist/index.js +9 -9
  40. package/plugins/publishings/infoq-pub/dist/index.js +22 -22
  41. package/plugins/publishings/infoq-pub/dist/profile-check.js +30336 -0
  42. package/plugins/publishings/infoq-pub/plugin.json +1 -1
  43. package/plugins/publishings/juejin-pub/dist/index.js +22 -22
  44. package/plugins/publishings/juejin-pub/dist/profile-check.js +30322 -0
  45. package/plugins/publishings/juejin-pub/plugin.json +1 -1
  46. package/plugins/publishings/medium-pub/dist/index.js +9 -9
  47. package/plugins/publishings/podcast-pub/dist/index.js +9 -9
  48. package/plugins/publishings/qiita-pub/dist/index.js +9 -9
  49. package/plugins/publishings/substack-pub/dist/index.js +22 -22
  50. package/plugins/publishings/substack-pub/dist/profile-check.js +30392 -0
  51. package/plugins/publishings/substack-pub/plugin.json +1 -1
  52. package/plugins/publishings/surfdash-pub/dist/index.js +9 -9
  53. package/plugins/publishings/wechatmp-pub/dist/index.js +329 -154
  54. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +30271 -0
  55. package/plugins/publishings/wechatmp-pub/plugin.json +1 -1
  56. package/plugins/publishings/wechatmp-pub/src/enrich.ts +106 -145
  57. package/plugins/publishings/wechatmp-pub/src/flow.ts +283 -42
  58. package/plugins/publishings/wechatmp-pub/src/page.ts +20 -0
  59. package/plugins/publishings/x-pub/dist/index.js +22 -22
  60. package/plugins/publishings/x-pub/dist/profile-check.js +30328 -0
  61. package/plugins/publishings/x-pub/plugin.json +1 -1
  62. package/plugins/publishings/xhs-pub/dist/index.js +76 -27
  63. package/plugins/publishings/xhs-pub/dist/profile-check.js +30538 -0
  64. package/plugins/publishings/xhs-pub/plugin.json +1 -1
  65. package/plugins/publishings/xhs-pub/src/flow.ts +9 -3
  66. package/plugins/publishings/xhs-pub/src/index.ts +111 -5
  67. package/plugins/publishings/zenn-pub/dist/index.js +9 -9
  68. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
  69. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
@@ -77,8 +77,10 @@ import {
77
77
  failWith,
78
78
  firstArtifact,
79
79
  firstPostPath,
80
+ firstTargetUrl,
80
81
  isFile,
81
82
  kk,
83
+ kkCaptured,
82
84
  metadataOf,
83
85
  NESTED_SPUR_UNSET,
84
86
  PII_FINDING_CATEGORY,
@@ -104,8 +106,10 @@ import {
104
106
  writeJqText,
105
107
  writeJsonText,
106
108
  writeRaw,
109
+ XHS_BODY_CAP,
107
110
  XHS_FORMAT,
108
111
  XHS_MAX_CARDS,
112
+ type XhsCard,
109
113
  xhsPlan,
110
114
  } from './kk-workflow-stages.ts';
111
115
 
@@ -162,25 +166,41 @@ export function dailyQcContent(args: string[]): void {
162
166
  const boundaryDocs = metadataOf(readJson(candidates)).docs;
163
167
  if (!Array.isArray(boundaryDocs)) return;
164
168
  const { kept, excluded } = applyTopicBoundary(boundaryDocs, runDate, readTopicBoundary());
165
- if (excluded.length === 0) return;
166
- sted('write', exclusions, () =>
167
- writeJsonText(exclusions, {
168
- version: 1,
169
- runDate,
170
- excluded,
171
- kept: kept.map((doc) => (doc as { id: string }).id),
172
- }),
173
- );
174
- const content = requireJson(candidates, 'topic-boundary');
175
- const meta = content.metadata;
176
- if (meta === null || typeof meta !== 'object') {
177
- failWith(1, 'topic-boundary: candidates content carries no metadata object');
169
+ // A no-op boundary must not rewrite the file: the idempotent-rerun test compares bytes.
170
+ if (excluded.length > 0) {
171
+ sted('write', exclusions, () =>
172
+ writeJsonText(exclusions, {
173
+ version: 1,
174
+ runDate,
175
+ excluded,
176
+ kept: kept.map((doc) => (doc as { id: string }).id),
177
+ }),
178
+ );
179
+ const content = requireJson(candidates, 'topic-boundary');
180
+ const meta = content.metadata;
181
+ if (meta === null || typeof meta !== 'object') {
182
+ failWith(1, 'topic-boundary: candidates content carries no metadata object');
183
+ }
184
+ (meta as Record<string, unknown>).docs = kept;
185
+ sted('write', candidates, () => writeJsonText(candidates, content));
186
+ console.log(
187
+ `topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
188
+ );
189
+ }
190
+ // Title dedup before plan-translate / script-write. dailyArticle repeats this on plan.json, but
191
+ // that runs in parallel with script-write: an id dropped after the script plan is authored is
192
+ // `unknown-id`, the whole plan is rejected, and generate then sees a template with fewer sections.
193
+ const currentDocs = excluded.length > 0 ? kept : boundaryDocs;
194
+ const { kept: deduped, dropped } = dedupPlanByTitle(currentDocs as Array<{ id?: unknown; title?: unknown }>);
195
+ if (dropped.length === 0) return;
196
+ const dedupedContent = requireJson(candidates, 'qc-dedup');
197
+ const dedupedMeta = dedupedContent.metadata;
198
+ if (dedupedMeta === null || typeof dedupedMeta !== 'object') {
199
+ failWith(1, 'qc-dedup: candidates content carries no metadata object');
178
200
  }
179
- (meta as Record<string, unknown>).docs = kept;
180
- sted('write', candidates, () => writeJsonText(candidates, content));
181
- console.log(
182
- `topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
183
- );
201
+ (dedupedMeta as Record<string, unknown>).docs = deduped;
202
+ sted('write', candidates, () => writeJsonText(candidates, dedupedContent));
203
+ console.error(`qc: dropped ${dropped.length} near-duplicate candidate(s): ${dropped.join(', ')}`);
184
204
  }
185
205
 
186
206
  /**
@@ -197,12 +217,31 @@ export function dailyArticle(args: string[]): void {
197
217
  timed(workDir, 'article', () => {
198
218
  let planUsable = true;
199
219
  const raw = isFile(planIn) ? readFileSync(planIn, 'utf-8') : '';
220
+ // The plan file is canonically the plan `Doc[]` — `dailyPlan` writes the generator's
221
+ // `metadata.docs` — and every downstream stage reads it as one. `plan-translate`'s executor
222
+ // has been observed writing the generator's Content ENVELOPE back instead: valid JSON of the
223
+ // wrong shape, which the article generator then refuses with `expected array, received
224
+ // object`. Unwrap that envelope rather than discard the translation; anything else unusable
225
+ // still falls back to the untranslated candidates.
226
+ let planShape: unknown;
200
227
  try {
201
- JSON.parse(raw);
228
+ planShape = JSON.parse(raw);
202
229
  } catch (error) {
203
230
  console.error(
204
231
  `article: plan.json invalid JSON — replacing with untranslated plan (downstream states read this file): ${(error as Error).message}`,
205
232
  );
233
+ }
234
+ if (planShape !== undefined && !Array.isArray(planShape)) {
235
+ const enveloped = metadataOf(planShape as Record<string, unknown>).docs;
236
+ if (Array.isArray(enveloped) && enveloped.length > 0) {
237
+ console.error(
238
+ `article: plan.json carries the Content envelope, not the Doc[] — unwrapping metadata.docs (${enveloped.length} doc(s))`,
239
+ );
240
+ sted('write', planIn, () => writeJsonText(planIn, enveloped));
241
+ planShape = enveloped;
242
+ }
243
+ }
244
+ if (!Array.isArray(planShape)) {
206
245
  const docs = metadataOf(readJson(candidates)).docs;
207
246
  if (!Array.isArray(docs) || docs.length === 0) {
208
247
  console.error('article: candidates.json metadata.docs missing');
@@ -386,8 +425,51 @@ export function dailyEpisodeDuration(args: string[]): void {
386
425
  console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
387
426
  }
388
427
 
428
+ /** Ceiling for the pre-spend estimate when the caller passes no `episode_max_minutes`. */
429
+ const DEFAULT_EPISODE_MAX_MINUTES = 20;
430
+
431
+ /** `kk executor run` argv shared by the quota probe, the voice1 one-shot, and each section. */
432
+ function voiceExecutorArgs(generator: string, input: string, output: string, pluginsPath: string): string[] {
433
+ return ['executor', 'run', generator, '--in', input, '--out', output, ...pluginsArgs(pluginsPath)];
434
+ }
435
+
436
+ /**
437
+ * One tiny gemini-tts render before any episode speech. A non-zero exit (quota, key, network)
438
+ * means this run should use voicebox. Probe files are removed either way.
439
+ * Returns false when the caller should fall back.
440
+ */
441
+ function geminiQuotaProbeOk(workDir: string, pluginsPath: string, transcodeMp3: string, probeDocs: unknown): boolean {
442
+ const probeDir = join(workDir, '30-audio');
443
+ const probeIn = join(probeDir, '.gemini-probe-in.json');
444
+ const probeOut = join(probeDir, '.gemini-probe-out.json');
445
+ writeJsonText(probeIn, probeDocs);
446
+ const probe = spawnSync('kk', voiceExecutorArgs('gemini-tts', probeIn, probeOut, pluginsPath), {
447
+ encoding: 'utf8',
448
+ timeout: 120_000,
449
+ env: { ...process.env, VOICE_GEN_MP3: transcodeMp3 },
450
+ });
451
+ for (const stale of [probeIn, probeOut, probeOut.replace(/\.json$/, '.wav'), probeOut.replace(/\.json$/, '.mp3')]) {
452
+ rmSync(stale, { force: true });
453
+ }
454
+ if (probe.status !== 0) {
455
+ const reason = (probe.stderr ?? '').trim().split('\n').slice(-1)[0] || `exit ${probe.status}`;
456
+ console.log(`generate: gemini quota probe failed (${reason}) — falling back to voicebox for this run`);
457
+ return false;
458
+ }
459
+ console.log('generate: gemini quota probe ok');
460
+ return true;
461
+ }
462
+
389
463
  export function dailyGenerate(args: string[]): void {
390
- const [workDir = '', runDate = '', pluginsPath = '', transcodeMp3 = '', vdriver = ''] = args;
464
+ const [
465
+ workDir = '',
466
+ runDate = '',
467
+ pluginsPath = '',
468
+ transcodeMp3 = '',
469
+ vdriver = '',
470
+ sectionConcurrency = '',
471
+ episodeMaxMinutes = '',
472
+ ] = args;
391
473
  // 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
392
474
  // passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
393
475
  const VOICE_DRIVERS: Record<string, string> = {
@@ -402,54 +484,351 @@ export function dailyGenerate(args: string[]): void {
402
484
  `generate: unknown vdriver '${vdriver}' (expected one of: ${Object.keys(VOICE_DRIVERS).join(', ')})`,
403
485
  );
404
486
  }
487
+ // Strict `Number`, not `parseFloat`: a typo'd var ('2O') must refuse, not cap the episode at 2 min.
488
+ const maxMinutes = episodeMaxMinutes === '' ? DEFAULT_EPISODE_MAX_MINUTES : Number(episodeMaxMinutes);
489
+ if (!Number.isFinite(maxMinutes) || maxMinutes <= 0) {
490
+ failWith(1, `generate: invalid episode_max_minutes: ${episodeMaxMinutes}`);
491
+ }
405
492
  const docs = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
406
493
  const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
494
+
495
+ if (!sectionConcurrency) {
496
+ 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
+ }
503
+ }
504
+ // Gemini quota probe (20261006): the 06:00 cron hit gemini-3.8-flash-tts 429 with a
505
+ // multi-hour retry-after after the shared key's window drained overnight. One tiny
506
+ // render (~1 request) before the real episode; ANY probe failure (quota, key, network)
507
+ // flips this run to the local voicebox driver instead of dying mid-episode — voicebox
508
+ // needs no key. Operator decision 20261006: keep the gemini default + auto-fallback.
509
+ if (vdriver === 'gemini') {
510
+ const docsList = readJson(docs) as Array<Record<string, unknown>>;
511
+ if (
512
+ docsList.length > 0 &&
513
+ !geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [{ ...docsList[0], body: 'Hello.' }])
514
+ ) {
515
+ voiceGenerator = VOICE_DRIVERS.voicebox;
516
+ }
517
+ }
518
+ kk(voiceExecutorArgs(voiceGenerator, docs, content, pluginsPath), {
519
+ env: { VOICE_GEN_MP3: transcodeMp3 },
520
+ });
521
+ });
522
+ return;
523
+ }
524
+
525
+ if (sectionConcurrency !== '1') {
526
+ failWith(1, `generate: section_concurrency '${sectionConcurrency}' not supported (must be 1)`);
527
+ }
528
+
407
529
  timed(workDir, 'generate', () => {
408
- if (isFile(content) && metadataOf(readJson(content)).qc !== undefined) {
409
- const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
410
- if (qc?.passed === true) {
411
- console.log(`audio cached: ${content} (QC passed)`);
412
- return;
530
+ const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
531
+ if (!existsSync(scriptPath)) {
532
+ failWith(1, `generate: voicescript not found: ${scriptPath}`);
533
+ }
534
+ const scriptRaw = readFileSync(scriptPath, 'utf8');
535
+ const script = Bun.YAML.parse(scriptRaw) as Record<string, unknown>;
536
+ const segments = (script.segments ?? []) as Array<Record<string, unknown>>;
537
+ if (!Array.isArray(segments) || segments.length < 2) {
538
+ failWith(1, 'generate: invalid voicescript (expected at least opening and closing segments)');
539
+ }
540
+
541
+ // Grouping: first is opening, last is closing, middle segments split after gap_ms: 1200
542
+ const middleSegments = segments.slice(1, -1);
543
+ const itemSections: Array<Array<Record<string, unknown>>> = [];
544
+ let currentItem: Array<Record<string, unknown>> = [];
545
+ for (const seg of middleSegments) {
546
+ currentItem.push(seg);
547
+ if (seg.gap_ms === 1200) {
548
+ itemSections.push(currentItem);
549
+ currentItem = [];
413
550
  }
414
551
  }
415
- // Gemini quota probe (20261006): the 06:00 cron hit gemini-3.8-flash-tts 429 with a
416
- // multi-hour retry-after after the shared key's window drained overnight. One tiny
417
- // render (~1 request) before the real episode; ANY probe failure (quota, key, network)
418
- // flips this run to the local voicebox driver instead of dying mid-episode — voicebox
419
- // needs no key. Operator decision 20261006: keep the gemini default + auto-fallback.
420
- if (vdriver === 'gemini') {
421
- const probeDir = join(workDir, '30-audio');
422
- const probeIn = join(probeDir, '.gemini-probe-in.json');
423
- const probeOut = join(probeDir, '.gemini-probe-out.json');
424
- const docsList = readJson(docs) as Array<Record<string, unknown>>;
425
- if (docsList.length > 0) {
426
- writeJsonText(probeIn, [{ ...docsList[0], body: 'Hello.' }]);
427
- const probe = spawnSync(
428
- 'kk',
429
- ['executor', 'run', 'gemini-tts', '--in', probeIn, '--out', probeOut, ...pluginsArgs(pluginsPath)],
430
- { encoding: 'utf8', timeout: 120_000, env: { ...process.env, VOICE_GEN_MP3: transcodeMp3 } },
431
- );
432
- for (const stale of [
433
- probeIn,
434
- probeOut,
435
- probeOut.replace(/\.json$/, '.wav'),
436
- probeOut.replace(/\.json$/, '.mp3'),
437
- ])
438
- rmSync(stale, { force: true });
439
- if (probe.status !== 0) {
440
- const reason = (probe.stderr ?? '').trim().split('\n').slice(-1)[0] || `exit ${probe.status}`;
441
- console.log(
442
- `generate: gemini quota probe failed (${reason}) — falling back to voicebox for this run`,
552
+ if (currentItem.length > 0) {
553
+ itemSections.push(currentItem);
554
+ }
555
+
556
+ const planPath = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
557
+ if (existsSync(planPath)) {
558
+ try {
559
+ const plan = readJson(planPath) as { items?: unknown[] };
560
+ if (Array.isArray(plan.items) && plan.items.length !== itemSections.length) {
561
+ failWith(
562
+ 1,
563
+ `generate: plan item count (${plan.items.length}) does not match script item sections (${itemSections.length})`,
443
564
  );
444
- voiceGenerator = VOICE_DRIVERS.voicebox;
445
- } else {
446
- console.log('generate: gemini quota probe ok');
447
565
  }
566
+ } catch (e: unknown) {
567
+ if (e instanceof StageFailure) throw e;
448
568
  }
449
569
  }
450
- kk(['executor', 'run', voiceGenerator, '--in', docs, '--out', content, ...pluginsArgs(pluginsPath)], {
451
- env: { VOICE_GEN_MP3: transcodeMp3 },
570
+
571
+ interface SectionDef {
572
+ index: number;
573
+ role: string;
574
+ stem: string;
575
+ name: string;
576
+ segments: Array<Record<string, unknown>>;
577
+ characters: number;
578
+ gap_ms: number;
579
+ text: string;
580
+ }
581
+
582
+ const sectionDefs: SectionDef[] = [];
583
+ // 00-opening
584
+ const openSegs = [segments[0]];
585
+ const openText = openSegs.map((s) => String(s.text ?? '')).join('');
586
+ sectionDefs.push({
587
+ index: 0,
588
+ role: 'opening',
589
+ stem: '00-opening',
590
+ name: '00-opening',
591
+ segments: openSegs,
592
+ characters: [...openText].length,
593
+ gap_ms:
594
+ typeof openSegs[openSegs.length - 1].gap_ms === 'number'
595
+ ? (openSegs[openSegs.length - 1].gap_ms as number)
596
+ : 1200,
597
+ text: openText,
452
598
  });
599
+
600
+ // 01-item ...
601
+ itemSections.forEach((segs, idx) => {
602
+ const itemIdx = idx + 1;
603
+ const pad = String(itemIdx).padStart(2, '0');
604
+ const stem = `${pad}-item`;
605
+ const itemText = segs.map((s) => String(s.text ?? '')).join('');
606
+ const lastSeg = segs[segs.length - 1];
607
+ sectionDefs.push({
608
+ index: itemIdx,
609
+ role: 'item',
610
+ stem,
611
+ name: stem,
612
+ segments: segs,
613
+ characters: [...itemText].length,
614
+ gap_ms: typeof lastSeg?.gap_ms === 'number' ? (lastSeg.gap_ms as number) : 1200,
615
+ text: itemText,
616
+ });
617
+ });
618
+
619
+ // closing
620
+ const closeIdx = sectionDefs.length;
621
+ const closePad = String(closeIdx).padStart(2, '0');
622
+ const closeSegs = [segments[segments.length - 1]];
623
+ const closeText = closeSegs.map((s) => String(s.text ?? '')).join('');
624
+ sectionDefs.push({
625
+ index: closeIdx,
626
+ role: 'closing',
627
+ stem: `${closePad}-closing`,
628
+ name: `${closePad}-closing`,
629
+ segments: closeSegs,
630
+ characters: [...closeText].length,
631
+ gap_ms:
632
+ typeof closeSegs[closeSegs.length - 1].gap_ms === 'number'
633
+ ? (closeSegs[closeSegs.length - 1].gap_ms as number)
634
+ : 300,
635
+ text: closeText,
636
+ });
637
+
638
+ // Character-rate estimate: section characters / driver rate + 8 s sting > the workflow's
639
+ // ceiling (`episode_max_minutes`, arg 8; default 20 for a bare five/six-argument call).
640
+ // The rates are MEASURED from shipped renders, not the style contract's prose. gemini-tts:
641
+ // 20261004 5466 chars / 1026 s = 5.33, 20261007 5970 / 1096 = 5.45, and the 0237 live dry run
642
+ // 4645 / 789.9 s of section speech = 5.88 — a 5.3–5.9 range, so 5.4 deliberately errs low (a
643
+ // slightly early refusal is cheaper than a rendered episode the duration gate then rejects).
644
+ // voice-gen: 20261006 6376 / 1135 = 5.62. A 20-minute ceiling is therefore ~6400 characters.
645
+ // The style sidecar's 3.3 / 6.3 figures are stale and mutually inconsistent with its own
646
+ // 4300–5800 字 budget (which is 13–29 min at 3.3); using them refused a script that really
647
+ // renders in 17 min. Recalibrate from a fresh pair of `metadata.duration` readings when a
648
+ // driver changes model or speed.
649
+ const charRate = voiceGenerator === 'gemini-tts' ? 5.4 : 5.6;
650
+ const totalChars = sectionDefs.reduce((acc, s) => acc + s.characters, 0);
651
+ const estimatedSeconds = 8 + totalChars / charRate;
652
+ if (estimatedSeconds > maxMinutes * 60) {
653
+ const longest3 = [...sectionDefs].sort((a, b) => b.characters - a.characters).slice(0, 3);
654
+ failWith(
655
+ 1,
656
+ `generate: character-rate estimate ${Math.round(estimatedSeconds / 60)} min exceeds ${maxMinutes} min limit (longest sections: ${longest3.map((s) => s.name).join(', ')})`,
657
+ );
658
+ }
659
+
660
+ const voiceProfile =
661
+ (typeof script.default_profile_id === 'string' && script.default_profile_id) ||
662
+ (typeof script.default_profile === 'string' && script.default_profile) ||
663
+ process.env.VOICEBOX_DEFAULT_PROFILE ||
664
+ 'robin-news';
665
+
666
+ const secDir = join(workDir, '30-audio', 'sections', runDate);
667
+ mkdirSync(secDir, { recursive: true });
668
+
669
+ const joinedWavPath = join(workDir, '30-audio', `${runDate}_11_generate_content.wav`);
670
+ const joinedContentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
671
+
672
+ interface SectionPlan {
673
+ def: SectionDef;
674
+ hash: string;
675
+ wavPath: string;
676
+ jsonPath: string;
677
+ needsSynthesis: boolean;
678
+ }
679
+
680
+ const computePlans = (driver: string): SectionPlan[] => {
681
+ return sectionDefs.map((def) => {
682
+ const hash = createHash('sha256').update(`${def.text}\0${voiceProfile}\0${driver}`).digest('hex');
683
+ const wavPath = join(secDir, `${def.stem}.wav`);
684
+ const jsonPath = join(secDir, `${def.stem}.json`);
685
+ let cached = false;
686
+ if (existsSync(wavPath) && existsSync(jsonPath)) {
687
+ try {
688
+ const sidecar = readJson(jsonPath) as Record<string, unknown>;
689
+ const sidecarMeta = metadataOf(sidecar) as Record<string, unknown>;
690
+ if (sidecar.hash === hash || sidecarMeta.hash === hash) {
691
+ cached = true;
692
+ }
693
+ } catch {}
694
+ }
695
+ return {
696
+ def,
697
+ hash,
698
+ wavPath,
699
+ jsonPath,
700
+ needsSynthesis: !cached,
701
+ };
702
+ });
703
+ };
704
+
705
+ let plans = computePlans(voiceGenerator);
706
+ const anySynthesisNeeded = plans.some((p) => p.needsSynthesis);
707
+
708
+ if (!anySynthesisNeeded && existsSync(joinedWavPath) && existsSync(joinedContentPath)) {
709
+ console.log('generate: all sections cached, joined speech reused');
710
+ return;
711
+ }
712
+
713
+ // Same quota probe as the one-shot path, before any section request. A fallback changes
714
+ // the cache key, so plans are recomputed and no Gemini file is played as voicebox speech.
715
+ if (
716
+ anySynthesisNeeded &&
717
+ vdriver === 'gemini' &&
718
+ !geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [
719
+ {
720
+ id: 'probe',
721
+ title: 'Probe',
722
+ body: Bun.YAML.stringify({
723
+ title: 'Probe',
724
+ segments: [{ text: 'Hello.' }],
725
+ }),
726
+ sourceUri: 'file://probe',
727
+ mediaType: 'application/yaml',
728
+ metadata: { voiceProfile },
729
+ },
730
+ ])
731
+ ) {
732
+ voiceGenerator = VOICE_DRIVERS.voicebox;
733
+ plans = computePlans(voiceGenerator);
734
+ }
735
+
736
+ // Synthesize sections sequentially (concurrency 1)
737
+ for (const plan of plans) {
738
+ if (!plan.needsSynthesis) {
739
+ console.log(`generate: section cached: ${plan.def.stem}`);
740
+ continue;
741
+ }
742
+ console.log(`generate: synthesizing section: ${plan.def.stem}`);
743
+ const secScript = { ...script, segments: plan.def.segments };
744
+ const secDocIn = join(secDir, `.${plan.def.stem}_in.json`);
745
+ const doc = {
746
+ id: createHash('sha256').update(`section-${plan.def.stem}`).digest('hex').slice(0, 16),
747
+ title: `Daily AI News VoiceScript - ${plan.def.stem}`,
748
+ sourceUri: `file://${resolve(scriptPath)}#${plan.def.stem}`,
749
+ body: Bun.YAML.stringify(secScript),
750
+ mediaType: 'application/yaml',
751
+ metadata: { voiceProfile },
752
+ };
753
+ writeJsonText(secDocIn, [doc]);
754
+ try {
755
+ const status = kk(voiceExecutorArgs(voiceGenerator, secDocIn, plan.jsonPath, pluginsPath), {
756
+ env: { VOICE_GEN_MP3: transcodeMp3 },
757
+ soft: true,
758
+ });
759
+ if (status !== 0) {
760
+ failWith(status, `generate: section synthesis failed for ${plan.def.stem}`);
761
+ }
762
+ } finally {
763
+ rmSync(secDocIn, { force: true });
764
+ }
765
+ try {
766
+ const sidecar = readJson(plan.jsonPath) as Record<string, unknown>;
767
+ sidecar.hash = plan.hash;
768
+ if (sidecar.metadata && typeof sidecar.metadata === 'object') {
769
+ (sidecar.metadata as Record<string, unknown>).hash = plan.hash;
770
+ }
771
+ writeJsonText(plan.jsonPath, sidecar);
772
+ } catch {}
773
+ }
774
+
775
+ // Join sections with ffmpeg
776
+ const probeHelper = (file: string, entries: string): string =>
777
+ spawnSync('ffprobe', [
778
+ '-v',
779
+ 'error',
780
+ '-select_streams',
781
+ 'a:0',
782
+ '-show_entries',
783
+ entries,
784
+ '-of',
785
+ 'csv=p=0',
786
+ file,
787
+ ])
788
+ .stdout?.toString()
789
+ .trim() ?? '';
790
+
791
+ const sampleRate = probeHelper(plans[0].wavPath, 'stream=sample_rate') || '24000';
792
+ const ffmpegArgs = ['-y', '-hide_banner', '-loglevel', 'error'];
793
+ const filterParts: string[] = [];
794
+ const concatInputs: string[] = [];
795
+
796
+ plans.forEach((plan, i) => {
797
+ ffmpegArgs.push('-i', plan.wavPath);
798
+ filterParts.push(`[${i}:a]aresample=${sampleRate},aformat=sample_fmts=s16:channel_layouts=mono[a${i}]`);
799
+ concatInputs.push(`[a${i}]`);
800
+ if (i < plans.length - 1) {
801
+ const gapMs = plan.def.gap_ms;
802
+ const gapS = gapMs > 0 ? (gapMs / 1000).toFixed(4) : '0';
803
+ filterParts.push(`anullsrc=r=${sampleRate}:cl=mono,atrim=duration=${gapS}[sil${i}]`);
804
+ concatInputs.push(`[sil${i}]`);
805
+ }
806
+ });
807
+
808
+ const filterComplex = `${filterParts.join(';')};${concatInputs.join('')}concat=n=${concatInputs.length}:v=0:a=1[out]`;
809
+ ffmpegArgs.push('-filter_complex', filterComplex, '-map', '[out]', '-c:a', 'pcm_s16le', joinedWavPath);
810
+
811
+ const conv = spawnSync('ffmpeg', ffmpegArgs);
812
+ if (conv.status !== 0 || !existsSync(joinedWavPath)) {
813
+ failWith(1, `generate: ffmpeg join failed: ${conv.stderr?.toString().trim()}`);
814
+ }
815
+
816
+ const joinedDuration = Number.parseFloat(probeHelper(joinedWavPath, 'format=duration'));
817
+
818
+ let contentObj: Record<string, unknown> = {};
819
+ if (existsSync(joinedContentPath)) {
820
+ try {
821
+ contentObj = readJson(joinedContentPath) as Record<string, unknown>;
822
+ } catch {}
823
+ }
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;
831
+ writeJsonText(joinedContentPath, contentObj);
453
832
  });
454
833
  }
455
834
 
@@ -573,16 +952,21 @@ export function dailyIntro(args: string[]): void {
573
952
  }
574
953
 
575
954
  export function dailyPlan(args: string[]): void {
576
- const [workDir = '', runDate = '', pluginsPath = '', planMaxItems = ''] = args;
955
+ const [workDir = '', runDate = '', pluginsPath = '', planMaxItems = '', deferPlanDocs = ''] = args;
577
956
  const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
578
957
  const planDocs = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
579
958
  // voice2's extract node writes 10-collect/pool.json; voice1's fan-in still writes the blended
580
959
  // file. Prefer the pool when it exists so both DAGs share this stage (0212 dogfood finding).
581
960
  const pool = join(workDir, '10-collect', 'pool.json');
582
961
  const blended = isFile(pool) ? pool : join(workDir, '10-ingest', `${runDate}_02_collect_blended.json`);
962
+ // voice2 passes plan_translate_enabled. When it is true, plan-translate owns plan.json: its
963
+ // `! test -s` guard is also the first-run gate, so writing the English docs here skips the
964
+ // translation. Voice1 omits the arg and still materializes the file (its translate node has no
965
+ // skip guard and overwrites it).
966
+ const deferDocs = deferPlanDocs === 'true';
583
967
  timed(workDir, 'plan', () => {
584
- if (isFile(planDocs)) {
585
- console.log(`plan cached: ${planDocs}`);
968
+ if (deferDocs ? isFile(planContent) : isFile(planDocs)) {
969
+ console.log(`plan cached: ${deferDocs ? planContent : planDocs}`);
586
970
  return;
587
971
  }
588
972
  kk(
@@ -593,6 +977,7 @@ export function dailyPlan(args: string[]): void {
593
977
  );
594
978
  const docs = metadataOf(readJson(planContent)).docs;
595
979
  if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
980
+ if (deferDocs) return;
596
981
  sted('write', planDocs, () => writeJsonText(planDocs, docs));
597
982
  if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
598
983
  });
@@ -783,18 +1168,30 @@ export function dailyPublishWechat(args: string[]): void {
783
1168
  return;
784
1169
  }
785
1170
  const packaged = requireJson(article, 'publish-wechat');
786
- // MP draft enrichment (operator preference 2026-09-29): 原创+作者, 赞赏, 合集, 原文链接
787
- // (the surfdash post), AI 生成声明, cover. The brand strings are this daily show's constants.
1171
+ // MP draft enrichment (operator preferences 2026-09-29, completed 2026-10-10): 原创, 赞赏,
1172
+ // 作者, 合集, 原文链接 (the surfdash post), 创作来源, cover, audio material. The brand strings
1173
+ // are this daily show's constants; every field here is consumed by `wechatmp-pub`'s flow.
788
1174
  const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
789
1175
  const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
1176
+ const surfdashResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1177
+ const originalUrl = isFile(surfdashResult) ? firstTargetUrl(readJson(surfdashResult), 'surfdash-pub') : '';
790
1178
  const wechatmp: Record<string, unknown> = {
791
1179
  original: true,
792
- aiGenerated: true,
1180
+ // 赞赏 is enabled per episode; the plugin fills the 赞赏账户 only when the platform
1181
+ // leaves it empty (it defaults from 作者, operator walk 2026-10-10).
1182
+ reward: true,
793
1183
  author: '冰原奔狼',
794
- collection: collectionArg || 'AI 乱炖',
1184
+ collection: collectionArg || '每日AI资讯',
1185
+ // 创作来源 is the column's own stance, not an AI-declaration label — the operator owns
1186
+ // the wording (2026-10-10).
1187
+ claimSource: '个人观点,仅供参考',
1188
+ // 封面 comes from 图片库: the episode cover is uploaded there as the audio material's
1189
+ // cover, so the library's newest entry is the intended image.
1190
+ coverSource: 'library',
1191
+ ...(originalUrl !== '' ? { originalUrl } : {}),
795
1192
  ...(isFile(cover) ? { coverPath: cover } : {}),
796
- // Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
797
- // the 素材库 under the article title; the plugin reuses an existing same-title material.
1193
+ // Audio block at the article top (operator request 2026-09-29; the plugin UPLOADS the
1194
+ // material when the 素材库 has no same-title entry, 2026-10-10).
798
1195
  ...(isFile(audio)
799
1196
  ? {
800
1197
  audioPath: audio,
@@ -842,10 +1239,15 @@ export function dailyPublishWechat(args: string[]): void {
842
1239
  * its own). Non-blocking by operator decision: a missing/expired XHS session or a changed console
843
1240
  * logs a loud failure and leaves the run alive, and the outcome never enters `publish_status`
844
1241
  * (surfdash + podcast remain the episode's publication contract).
1242
+ *
1243
+ * 0236: the optional trailing `<podcast>` arg (`'true'` from voice2 only) attaches the episode's
1244
+ * 发播客 note to the same visit as `metadata.followUps` — one console visit, two notes, one cap entry
1245
+ * (ADR-025 amendment 2026-10-09).
845
1246
  */
846
1247
  export function dailyPublishXhs(args: string[]): void {
847
- const [workDir = '', runDate = '', pluginsPath = '', live = 'false'] = args;
1248
+ const [workDir = '', runDate = '', pluginsPath = '', live = 'false', podcast = ''] = args;
848
1249
  const content = join(workDir, '25-xhs', 'content.json');
1250
+ const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
849
1251
  const input = join(workDir, '31-publish', `${runDate}_19_publish-xhs_content.json`);
850
1252
  const result = join(workDir, '31-publish', `${runDate}_19_publish-xhs_result.json`);
851
1253
  const want = live === 'true' ? 'public' : 'draft';
@@ -854,6 +1256,17 @@ export function dailyPublishXhs(args: string[]): void {
854
1256
  // xhs_live=true re-run (that would report a live post that never happened).
855
1257
  if (resultPublishStatus(result) === want) {
856
1258
  console.log(`publish-xhs cached: ${want} already recorded in ${result}`);
1259
+ // 0236: the cache keys on the visit's primary note, so a 发播客 follow-up that failed in
1260
+ // that visit is named here instead of hidden. It is not retried: a re-visit would post
1261
+ // a second 图文 draft (the console has no idempotency key).
1262
+ const followUps = metadataOf(readJson(result)).followUps;
1263
+ for (const entry of Array.isArray(followUps) ? followUps : []) {
1264
+ const step = (entry ?? {}) as Record<string, unknown>;
1265
+ if (step.ok === true) continue;
1266
+ console.log(
1267
+ `publish-xhs cached: follow-up ${String(metadataOf(step).operation ?? '?')} did not complete: ${String(step.error ?? 'no error recorded')}`,
1268
+ );
1269
+ }
857
1270
  return;
858
1271
  }
859
1272
  if (!isFile(content)) {
@@ -861,8 +1274,16 @@ export function dailyPublishXhs(args: string[]): void {
861
1274
  return;
862
1275
  }
863
1276
  const packaged = requireJson(content, 'publish-xhs');
1277
+ const followUp = podcast === 'true' ? xhsPodcastNote(prep, want) : undefined;
864
1278
  sted('write', input, () =>
865
- writeJsonText(input, { ...packaged, metadata: { ...metadataOf(packaged), publishStatus: want } }),
1279
+ writeJsonText(input, {
1280
+ ...packaged,
1281
+ metadata: {
1282
+ ...metadataOf(packaged),
1283
+ publishStatus: want,
1284
+ ...(followUp === undefined ? {} : { followUps: [followUp] }),
1285
+ },
1286
+ }),
866
1287
  );
867
1288
  // `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
868
1289
  // and prints the failing step (login gate, upload, body, submit control), which is what the
@@ -878,6 +1299,34 @@ export function dailyPublishXhs(args: string[]): void {
878
1299
  });
879
1300
  }
880
1301
 
1302
+ /**
1303
+ * 0236 R1: the 发播客 Content for the same episode — the run's publish-prep Content (the final mp3
1304
+ * with the intro, `23-cover`'s PNG, title and body) plus `metadata.operation` and the run's own
1305
+ * `publishStatus`. No artifact is produced here: both files already exist.
1306
+ *
1307
+ * `metadata.audio` repeats the prep's `metadata.audioFile` under the key the xhs channel's 发播客 mode
1308
+ * reads (`xhs-pub/src/modes.ts`); `metadata.coverImage` is already the shared spelling. A run with no
1309
+ * publish-prep output names the missing episode drafts and carries the 图文 note alone — the 发播客
1310
+ * note is never invented.
1311
+ */
1312
+ function xhsPodcastNote(prepPath: string, publishStatus: 'draft' | 'public'): Record<string, unknown> | undefined {
1313
+ const prep = readJson(prepPath);
1314
+ if (prep === null) {
1315
+ console.log(`publish-xhs: no publish-prep Content at ${prepPath} — the 发播客 note is skipped`);
1316
+ return undefined;
1317
+ }
1318
+ const metadata = metadataOf(prep);
1319
+ return {
1320
+ ...prep,
1321
+ metadata: {
1322
+ ...metadata,
1323
+ operation: '发播客',
1324
+ audio: metadata.audioFile,
1325
+ publishStatus,
1326
+ },
1327
+ };
1328
+ }
1329
+
881
1330
  /**
882
1331
  * WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
883
1332
  * draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
@@ -1167,8 +1616,141 @@ export function dailySafetyRevise(args: string[]): void {
1167
1616
  });
1168
1617
  }
1169
1618
 
1619
+ /** Block YAML for the fields a fitted VoiceScript actually carries. Strings use JSON quoting. */
1620
+ function voicescriptToBlockYaml(script: Record<string, unknown>): string {
1621
+ const lines: string[] = [];
1622
+ for (const key of [
1623
+ 'title',
1624
+ 'language',
1625
+ 'default_profile',
1626
+ 'default_profile_id',
1627
+ 'default_engine',
1628
+ 'default_emotion',
1629
+ 'default_effect_preset',
1630
+ ]) {
1631
+ const value = script[key];
1632
+ if (typeof value === 'string' && value !== '') lines.push(`${key}: ${JSON.stringify(value)}`);
1633
+ }
1634
+ const segments = script.segments;
1635
+ if (!Array.isArray(segments)) return lines.join('\n');
1636
+ lines.push('segments:');
1637
+ for (const entry of segments) {
1638
+ const segment = entry as Record<string, unknown>;
1639
+ lines.push(` - text: ${JSON.stringify(segment.text ?? '')}`);
1640
+ for (const key of ['speaker', 'profile', 'profile_id', 'emotion', 'instruct', 'effect_preset']) {
1641
+ if (typeof segment[key] === 'string') lines.push(` ${key}: ${JSON.stringify(segment[key])}`);
1642
+ }
1643
+ for (const key of ['gap_ms', 'speed', 'seed', 'personality']) {
1644
+ if (typeof segment[key] === 'number') lines.push(` ${key}: ${segment[key]}`);
1645
+ }
1646
+ }
1647
+ return lines.join('\n');
1648
+ }
1649
+
1650
+ /** Measured driver rates from dailyGenerate. Unknown drivers use the voicebox rate. */
1651
+ function spokenCharRate(vdriver: string): number {
1652
+ return vdriver === 'gemini' ? 5.4 : 5.6;
1653
+ }
1654
+
1655
+ /**
1656
+ * Shorten one fact/insight at 。!? so the cut removes at least `removeAtLeast` characters when a
1657
+ * boundary allows it, otherwise drop the last sentence. Returns null when the text is one sentence.
1658
+ */
1659
+ function shrinkFactText(text: string, removeAtLeast: number): string | null {
1660
+ const ends: number[] = [];
1661
+ for (let i = 0; i < text.length; i++) {
1662
+ const ch = text[i];
1663
+ if (ch === '。' || ch === '!' || ch === '?') ends.push(i + 1);
1664
+ }
1665
+ if (ends.length === 0) return null;
1666
+ const limit = text.length - Math.max(removeAtLeast, 1);
1667
+ let chosen: number | null = null;
1668
+ for (const end of ends) {
1669
+ if (end <= limit && end < text.length && end > 0) chosen = end;
1670
+ }
1671
+ if (chosen !== null) return text.slice(0, chosen);
1672
+ const shorter = ends.filter((end) => end < text.length && end > 0);
1673
+ const tail = shorter[shorter.length - 1];
1674
+ if (tail === undefined) return null;
1675
+ return text.slice(0, tail);
1676
+ }
1677
+
1678
+ /**
1679
+ * The pre-spend gate in dailyGenerate refuses before any TTS when the spoken character estimate
1680
+ * exceeds `episode_max_minutes`. Optional args (voice2 only) fit the fresh VoiceScript down to that
1681
+ * budget by shortening the longest fact segments, then insights. Absent args leave the file untouched.
1682
+ */
1683
+ function fitVoicescriptToBudget(yamlOut: string, contentOut: string, episodeMaxMinutes: string, vdriver: string): void {
1684
+ if (episodeMaxMinutes === '') return;
1685
+ const maxMinutes = Number(episodeMaxMinutes);
1686
+ if (!Number.isFinite(maxMinutes) || maxMinutes <= 0) return;
1687
+ // Two minutes under the hard duration gate. The character rate is a past measurement; fitting
1688
+ // flush to the ceiling renders an episode the gate then rejects.
1689
+ const maxChars = Math.floor((maxMinutes * 60 - 8 - 120) * spokenCharRate(vdriver));
1690
+ const original = readFileSync(yamlOut, 'utf8');
1691
+ const script = Bun.YAML.parse(original) as { segments?: Array<Record<string, unknown>> };
1692
+ const segments = script.segments;
1693
+ if (!Array.isArray(segments) || segments.length < 3) return;
1694
+ const totalOf = (): number => segments.reduce((sum, seg) => sum + [...String(seg.text ?? '')].length, 0);
1695
+ const before = totalOf();
1696
+ if (before <= maxChars) return;
1697
+ const unsplittable = new Set<number>();
1698
+ for (const factsOnly of [true, false]) {
1699
+ while (totalOf() > maxChars) {
1700
+ let best = -1;
1701
+ let bestLen = 0;
1702
+ for (let i = 1; i < segments.length - 1; i++) {
1703
+ if (unsplittable.has(i)) continue;
1704
+ if (factsOnly && segments[i]?.speed === 1.12) continue;
1705
+ const len = [...String(segments[i]?.text ?? '')].length;
1706
+ if (len > bestLen) {
1707
+ best = i;
1708
+ bestLen = len;
1709
+ }
1710
+ }
1711
+ if (best < 0) break;
1712
+ const text = String(segments[best]?.text ?? '');
1713
+ const next = shrinkFactText(text, totalOf() - maxChars);
1714
+ if (next === null || next.length >= text.length) {
1715
+ unsplittable.add(best);
1716
+ continue;
1717
+ }
1718
+ const segment = segments[best];
1719
+ if (segment) segment.text = next;
1720
+ }
1721
+ }
1722
+ const after = totalOf();
1723
+ if (after === before) return;
1724
+ // Block YAML, same shape as dailynews-gen's formatter. Bun.YAML.stringify emits a `{...}` flow
1725
+ // document, and the VoiceScript checker JSON-parses anything that starts with `{`.
1726
+ const fitted = `${voicescriptToBlockYaml(script)}\n`;
1727
+ sted('write', yamlOut, () => writeRaw(yamlOut, fitted));
1728
+ if (isFile(contentOut)) {
1729
+ const content = readJson(contentOut);
1730
+ if (content && typeof content.body === 'string') {
1731
+ content.body = fitted;
1732
+ sted('write', contentOut, () => writeJsonText(contentOut, content));
1733
+ }
1734
+ }
1735
+ console.log(`script: fitted voicescript ${before} -> ${after} chars (budget ${maxChars})`);
1736
+ if (after > maxChars) {
1737
+ console.error(
1738
+ `script: voicescript still ${after - maxChars} chars over the ${maxChars} budget after sentence cuts`,
1739
+ );
1740
+ }
1741
+ }
1742
+
1170
1743
  export function dailyScript(args: string[]): void {
1171
- const [workDir = '', runDate = '', pluginsPath = '', language = '', title = '', voiceProfile = ''] = args;
1744
+ const [
1745
+ workDir = '',
1746
+ runDate = '',
1747
+ pluginsPath = '',
1748
+ language = '',
1749
+ title = '',
1750
+ voiceProfile = '',
1751
+ episodeMaxMinutes = '',
1752
+ vdriver = '',
1753
+ ] = args;
1172
1754
  const yamlOut = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
1173
1755
  const contentOut = join(workDir, '24-script', `${runDate}_08_script_content.json`);
1174
1756
  const planIn = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
@@ -1195,6 +1777,7 @@ export function dailyScript(args: string[]): void {
1195
1777
  const body = readJson(contentOut)?.body;
1196
1778
  if (typeof body !== 'string') failWith(1, `script: ${contentOut} has no string body`);
1197
1779
  sted('write', yamlOut, () => writeRaw(yamlOut, body));
1780
+ fitVoicescriptToBudget(yamlOut, contentOut, episodeMaxMinutes, vdriver);
1198
1781
  // R3 (task 0139): fresh writes are validated here; the script cache path is covered by
1199
1782
  // wrap-docs, which runs on every path that reaches audio.
1200
1783
  try {
@@ -1249,8 +1832,30 @@ export function dailyShowNotes(args: string[]): void {
1249
1832
  // Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
1250
1833
  // grammar lives in daily-article-gen's excludedIdsFromNote.
1251
1834
  const postPath = firstPostPath(readJson(result), 'surfdash-pub');
1252
- const articleBody = readJson(article)?.body;
1835
+ const articleContent = readJson(article);
1836
+ const articleBody = articleContent?.body;
1253
1837
  const excluded = new Set<string>();
1838
+ // Article Content carries the dropped ids as `metadata.excludedIds` (2026-10-10): the boundary
1839
+ // note is stripped from the published body, so re-parsing the body alone would silently stop
1840
+ // filtering and leak excluded docs into the show notes. The note parse below stays as the
1841
+ // fallback for Content produced before the field existed.
1842
+ const metaExcluded = metadataOf(articleContent)?.excludedIds;
1843
+ if (Array.isArray(metaExcluded)) {
1844
+ for (const id of metaExcluded) {
1845
+ if (typeof id === 'string' && id !== '') excluded.add(id);
1846
+ }
1847
+ }
1848
+ // Script-plan exclusions are the prompt-layer topic boundary (foreign public affairs the
1849
+ // keyword floor deliberately leaves in the plan). Show notes must match what was spoken.
1850
+ const scriptPlan = readJson(join(workDir, '24-script', `${runDate}_08_script-write_plan.json`));
1851
+ const scriptExcluded = scriptPlan?.excluded;
1852
+ if (Array.isArray(scriptExcluded)) {
1853
+ for (const entry of scriptExcluded) {
1854
+ if (entry !== null && typeof entry === 'object' && typeof (entry as { id?: unknown }).id === 'string') {
1855
+ excluded.add((entry as { id: string }).id);
1856
+ }
1857
+ }
1858
+ }
1254
1859
  if (typeof articleBody === 'string') {
1255
1860
  for (const note of articleBody.matchAll(/未写入[::](.*)/g)) {
1256
1861
  for (const id of note[1].matchAll(/(?:[a-zA-Z0-9_-]+:)?[0-9a-f]{16,64}/g)) {
@@ -1290,9 +1895,8 @@ export function dailyShowNotes(args: string[]): void {
1290
1895
  PODCAST_PUB_RUN_DATE: runDate,
1291
1896
  PODCAST_PUB_BRIEF_MODE: 'true',
1292
1897
  PODCAST_PUB_ARTICLE_POST_PATH: postPath,
1293
- // Task 0189 R1 / Apple §1.11: AI-voice disclosure in the episode metadata, not only
1294
- // in the audio sign-off and the article footer.
1295
- PODCAST_PUB_FOOTER_NOTE: '本期文字由 AI 辅助整理,音频为 AI 合成配音。',
1898
+ // 2026-10-08: the column settings carry the AI-production note. Do not append the
1899
+ // stock closer to show notes (or the article, or the spoken sign-off).
1296
1900
  ...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
1297
1901
  },
1298
1902
  });
@@ -1310,7 +1914,10 @@ export function dailyShowNotes(args: string[]): void {
1310
1914
  */
1311
1915
  export function dailyTranslateLocalize(args: string[]): void {
1312
1916
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
1313
- const result = join(workDir, '31-publish', `${runDate}_17_publish_result.json`);
1917
+ // Same seam as show-notes (task 0137): `_17_publish_result.json` is written later by
1918
+ // publish-classify, which depends on translate-sync. Reading it here always saw no zh
1919
+ // path, so en/ja were never scaffolded (2026-10-08 dogfood).
1920
+ const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1314
1921
  const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
1315
1922
  timed(workDir, 'translate-localize', () => {
1316
1923
  const postPath = firstPostPath(readJson(result), 'surfdash-pub');
@@ -1408,12 +2015,99 @@ export function dailyWrapDocs(args: string[]): void {
1408
2015
  });
1409
2016
  }
1410
2017
 
2018
+ /** 0235 R4: `note_scope`'s one opt-in value. Anything else — absent included — is today's path. */
2019
+ const XHS_NOTE_SCOPE_EPISODE = 'episode';
2020
+
2021
+ /**
2022
+ * 0235 R2: `①` — the circled digits ①…⑳ are contiguous from here, and the episode note attaches
2023
+ * `XHS_MAX_CARDS` cards plus at most one cover, so no bare number can ever be needed.
2024
+ */
2025
+ const CIRCLED_DIGIT_ONE = 0x2460;
2026
+
2027
+ /** The attach-order marker of line `position` (0-based). */
2028
+ const markerFor = (position: number): string => String.fromCodePoint(CIRCLED_DIGIT_ONE + position);
2029
+
2030
+ /**
2031
+ * 0235 R2: split the agent's note body into its lead paragraph and its trailing hashtag run. The
2032
+ * prompt asks for 3-5 hashtags at the end, so the run is read off the end of the body; a body
2033
+ * without hashtags is entirely lead.
2034
+ */
2035
+ function splitNoteBody(body: string): { lead: string; hashtags: string } {
2036
+ const trimmed = body.trim();
2037
+ const match = /(?:^|\s)(#\S+(?:\s+#\S+)*)\s*$/.exec(trimmed);
2038
+ if (match === null) return { lead: trimmed, hashtags: '' };
2039
+ return { lead: trimmed.slice(0, match.index).trimEnd(), hashtags: match[1] ?? '' };
2040
+ }
2041
+
2042
+ /**
2043
+ * 0235 R2: the episode note body — the agent's lead paragraph, then one numbered line per attached
2044
+ * image in attach order, then the plan's hashtags. `cap` is the console's own note limit
2045
+ * (`XHS_NOTE_BODY_MAX`): only the lead is trimmed to fit, so an image never loses its line. A body
2046
+ * that overflows even without a lead fails loud rather than shipping a note the editor would cut.
2047
+ */
2048
+ function episodeNoteBody(body: string, lines: string[], cap: number): string {
2049
+ const { lead, hashtags } = splitNoteBody(body);
2050
+ const tail = [lines.join('\n'), hashtags].filter((part) => part !== '').join('\n\n');
2051
+ // `\n\n` joins the lead to the tail; a lead trimmed away drops the separator with it.
2052
+ const budget = cap - tail.length - (lead === '' ? 0 : 2);
2053
+ const head = budget >= lead.length ? lead : lead.slice(0, Math.max(budget, 0)).trimEnd();
2054
+ const note = head === '' ? tail : `${head}\n\n${tail}`;
2055
+ if (note.length > cap) {
2056
+ failWith(
2057
+ 1,
2058
+ `xhs render: episode note body is ${note.length} chars for ${lines.length} image lines — the cap is ${cap} and a line is never dropped`,
2059
+ );
2060
+ }
2061
+ return note;
2062
+ }
2063
+
2064
+ /**
2065
+ * 0251 R1: parse the composer's safe-area overflow sentence
2066
+ * (`text overflows the safe area for field(s): <fields> (safe area: …)`, chromium.ts) into the
2067
+ * named `points[n]` indexes — or null when the failure is anything else: no marker, a non-points
2068
+ * field (e.g. `headline`), or an index outside `card.points`. Null means no retry.
2069
+ */
2070
+ export function xhsOverflowPoints(stderr: string, card: XhsCard): number[] | null {
2071
+ const marker = 'text overflows the safe area for field(s):';
2072
+ const at = stderr.indexOf(marker);
2073
+ if (at < 0) return null;
2074
+ const list = stderr.slice(at + marker.length).split('(')[0] ?? '';
2075
+ const fields = list.split(',').map((name) => /^points\[(\d+)\]$/.exec(name.trim())?.[1]);
2076
+ if (fields.length === 0 || fields.some((field) => field === undefined)) return null;
2077
+ const indexes = fields.map((field) => Number(field));
2078
+ if (indexes.some((n) => n >= card.points.length)) return null;
2079
+ return indexes;
2080
+ }
2081
+
2082
+ /** The sentence-ending marks the shorten rule cuts at. */
2083
+ const POINT_SENTENCE_MARKS = new Set(['。', '!', '?']);
2084
+
2085
+ /**
2086
+ * 0251 R1: shorten one overflowing card point — drop its last sentence (cut at the last
2087
+ * 。/!/? that is not the final character, inclusive); a one-sentence point longer than 36
2088
+ * code points keeps its first 36 plus `…`. Returns the input unchanged when neither rule
2089
+ * shortens it, which the caller reads as "nothing got shorter — do not retry".
2090
+ */
2091
+ export function shortenOverflowedPoint(point: string): string {
2092
+ const chars = [...point];
2093
+ for (let i = chars.length - 2; i >= 0; i--) {
2094
+ if (POINT_SENTENCE_MARKS.has(chars[i] ?? '')) return chars.slice(0, i + 1).join('');
2095
+ }
2096
+ if (chars.length > 36) return `${chars.slice(0, 36).join('')}…`;
2097
+ return point;
2098
+ }
2099
+
1411
2100
  /**
1412
2101
  * E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
1413
2102
  * ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
1414
2103
  * are pinned here (never model-authored), so the series cannot drift from its preset. Card copy
1415
2104
  * is typeset by image-gen's composer; the provider prompt stays text-free (0181). Resume-safe:
1416
2105
  * a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
2106
+ *
2107
+ * `note_scope` (`episode`, 0235) opts into the episode note for voice2: the packaged set then also
2108
+ * attaches the episode cover last, and the body is rebuilt as the lead paragraph plus one numbered
2109
+ * line per attached image. Absent or any other value: the lead-item series and the agent's body are
2110
+ * packaged verbatim (voice1's contract).
1417
2111
  */
1418
2112
  export function dailyXhsRender(args: string[]): void {
1419
2113
  const [
@@ -1425,9 +2119,12 @@ export function dailyXhsRender(args: string[]): void {
1425
2119
  provider = '',
1426
2120
  model = '',
1427
2121
  fallback = '',
2122
+ noteScope = '',
1428
2123
  ] = args;
1429
2124
  const count = Number.parseInt(countArg, 10);
2125
+ const episode = noteScope === XHS_NOTE_SCOPE_EPISODE;
1430
2126
  const planPath = join(workDir, '25-xhs', `${runDate}_18_xhs_plan.json`);
2127
+ const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1431
2128
  const cardsDir = join(workDir, '25-xhs', 'cards');
1432
2129
  const manifestPath = join(workDir, '25-xhs', 'cards.json');
1433
2130
  const contentPath = join(workDir, '25-xhs', 'content.json');
@@ -1439,6 +2136,13 @@ export function dailyXhsRender(args: string[]): void {
1439
2136
  sted('mkdir', cardsDir, () => mkdirSync(cardsDir, { recursive: true }));
1440
2137
  const images: Record<string, unknown>[] = [];
1441
2138
  const cards: Record<string, unknown>[] = [];
2139
+ // 0235 R2: the numbered lines are a pure function of the plan and the cover, in attach order
2140
+ // (cards in plan order, the cover last), so the note — and its cap — is settled before the
2141
+ // first card is billed: an over-cap tail fails here with nothing rendered.
2142
+ const withCover = episode && isFile(coverPath);
2143
+ const lines = plan.cards.map((card, i) => `${markerFor(i)} ${card.copy}:${card.points[0] ?? ''}`);
2144
+ if (withCover) lines.push(`${markerFor(lines.length)} 每日AI乱炖 ${runDate} 本期封面`);
2145
+ const body = episode ? episodeNoteBody(plan.body, lines, XHS_BODY_CAP) : plan.body;
1442
2146
  for (const card of plan.cards) {
1443
2147
  const out = join(cardsDir, `card-${card.index}.json`);
1444
2148
  const promptPath = join(cardsDir, `card-${card.index}.prompt.txt`);
@@ -1471,17 +2175,45 @@ export function dailyXhsRender(args: string[]): void {
1471
2175
  // Sequential on purpose: one provider call per card (illustration only —
1472
2176
  // card copy is typeset by image-gen's composer, never prompted), and the
1473
2177
  // first failure stops the spend — later cards stay unrendered for a resume
1474
- // run to pick up.
1475
- const status = kk(
1476
- ['executor', 'run', 'image-gen', '--in', envPath, '--out', out, ...pluginsArgs(pluginsPath)],
1477
- { soft: true },
1478
- );
2178
+ // run to pick up. 0251: the one captured call — the overflow retry branches
2179
+ // on the composer's stderr, which kk()'s inherit stdio cannot return.
2180
+ const imageGenArgs = [
2181
+ 'executor',
2182
+ 'run',
2183
+ 'image-gen',
2184
+ '--in',
2185
+ envPath,
2186
+ '--out',
2187
+ out,
2188
+ ...pluginsArgs(pluginsPath),
2189
+ ];
1479
2190
  // 0219 R2: name the failed card so a hand re-run resumes without re-billing.
1480
- if (status !== 0) {
1481
- failWith(
1482
- status,
1483
- `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}`,
1484
- );
2191
+ const resumeLine = (status: number): string =>
2192
+ `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
+ 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
+ ]),
2213
+ );
2214
+ result = kkCaptured(imageGenArgs, { soft: true });
2215
+ }
2216
+ if (result.status !== 0) failWith(result.status, resumeLine(result.status));
1485
2217
  }
1486
2218
  artifact = firstArtifact(requireJson(out, 'xhs render'));
1487
2219
  }
@@ -1514,19 +2246,34 @@ export function dailyXhsRender(args: string[]): void {
1514
2246
  promptPath,
1515
2247
  imagePath,
1516
2248
  illustrationPath,
2249
+ ...(card.itemId === undefined ? {} : { itemId: card.itemId }),
1517
2250
  status: 'rendered',
1518
2251
  });
1519
2252
  }
2253
+ // 0235 R1: the episode cover closes the set — it is ultrawide, and image 1 (card 01) is what
2254
+ // sets the note's grid ratio. It is `daily-cover`'s own artifact (no new image kind), and
2255
+ // the set is ≤ XHS_MAX_CARDS + 1 = 11, inside `xhs-pub`'s XHS_IMAGE_MAX (18).
2256
+ if (withCover) {
2257
+ images.push({ path: resolve(coverPath) });
2258
+ } else if (episode) {
2259
+ console.log(
2260
+ `xhs render: no episode cover at ${coverPath} — the note carries the ${cards.length} cards only`,
2261
+ );
2262
+ }
1520
2263
  sted('write', manifestPath, () => writeJsonText(manifestPath, { source: planPath, cards }));
1521
2264
  sted('write', contentPath, () =>
1522
2265
  writeJsonText(contentPath, {
1523
2266
  title: plan.title,
1524
- body: plan.body,
2267
+ body,
1525
2268
  format: 'text',
1526
2269
  metadata: { images, publishStatus: 'draft' },
1527
2270
  }),
1528
2271
  );
1529
- console.log(`xhs render: ${cards.length} cards packaged in card order (${contentPath})`);
2272
+ console.log(
2273
+ episode
2274
+ ? `xhs render: ${images.length} images packaged in image order (${contentPath})`
2275
+ : `xhs render: ${cards.length} cards packaged in card order (${contentPath})`,
2276
+ );
1530
2277
  });
1531
2278
  }
1532
2279