@gobing-ai/knowledge-kit 0.0.33 → 0.0.34

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 (47) 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 +14 -20
  7. package/plugins/generations/daily-article-gen/src/index.ts +7 -11
  8. package/plugins/generations/dailynews-gen/dist/index.js +22 -21
  9. package/plugins/generations/dailynews-gen/src/script-builder.ts +25 -26
  10. package/plugins/generations/episode-plan-gen/dist/index.js +12 -12
  11. package/plugins/generations/gemini-tts/dist/index.js +9 -9
  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/voice-gen/dist/index.js +9 -9
  18. package/plugins/ingestions/aihot-ingest/dist/index.js +12 -12
  19. package/plugins/ingestions/horizon-ingest/dist/index.js +12 -12
  20. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  21. package/plugins/ingestions/last30days-ingest/dist/index.js +12 -12
  22. package/plugins/ingestions/web-search/dist/index.js +10 -10
  23. package/plugins/kk/decisions/extract/ai-news.yaml +6 -0
  24. package/plugins/kk/decisions/kk-items.yaml +69 -3
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/scripts/daily-stages.ts +539 -49
  27. package/plugins/kk/scripts/daily-voice-style.zh.md +15 -10
  28. package/plugins/kk/scripts/kk-workflow-stages.ts +6 -3
  29. package/plugins/kk/skills/content-safety/SKILL.md +1 -1
  30. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +2 -2
  31. package/plugins/kk/workflows/kk-collect.yaml +7 -9
  32. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +163 -44
  33. package/plugins/kk/workflows/kk-daily-safety.yaml +8 -11
  34. package/plugins/publishings/emdash-pub/dist/index.js +9 -9
  35. package/plugins/publishings/infoq-pub/dist/index.js +22 -22
  36. package/plugins/publishings/juejin-pub/dist/index.js +22 -22
  37. package/plugins/publishings/medium-pub/dist/index.js +9 -9
  38. package/plugins/publishings/podcast-pub/dist/index.js +9 -9
  39. package/plugins/publishings/qiita-pub/dist/index.js +9 -9
  40. package/plugins/publishings/substack-pub/dist/index.js +22 -22
  41. package/plugins/publishings/surfdash-pub/dist/index.js +9 -9
  42. package/plugins/publishings/wechatmp-pub/dist/index.js +22 -22
  43. package/plugins/publishings/x-pub/dist/index.js +22 -22
  44. package/plugins/publishings/xhs-pub/dist/index.js +76 -27
  45. package/plugins/publishings/xhs-pub/src/flow.ts +9 -3
  46. package/plugins/publishings/xhs-pub/src/index.ts +111 -5
  47. package/plugins/publishings/zenn-pub/dist/index.js +9 -9
@@ -104,6 +104,7 @@ import {
104
104
  writeJqText,
105
105
  writeJsonText,
106
106
  writeRaw,
107
+ XHS_BODY_CAP,
107
108
  XHS_FORMAT,
108
109
  XHS_MAX_CARDS,
109
110
  xhsPlan,
@@ -197,12 +198,31 @@ export function dailyArticle(args: string[]): void {
197
198
  timed(workDir, 'article', () => {
198
199
  let planUsable = true;
199
200
  const raw = isFile(planIn) ? readFileSync(planIn, 'utf-8') : '';
201
+ // The plan file is canonically the plan `Doc[]` — `dailyPlan` writes the generator's
202
+ // `metadata.docs` — and every downstream stage reads it as one. `plan-translate`'s executor
203
+ // has been observed writing the generator's Content ENVELOPE back instead: valid JSON of the
204
+ // wrong shape, which the article generator then refuses with `expected array, received
205
+ // object`. Unwrap that envelope rather than discard the translation; anything else unusable
206
+ // still falls back to the untranslated candidates.
207
+ let planShape: unknown;
200
208
  try {
201
- JSON.parse(raw);
209
+ planShape = JSON.parse(raw);
202
210
  } catch (error) {
203
211
  console.error(
204
212
  `article: plan.json invalid JSON — replacing with untranslated plan (downstream states read this file): ${(error as Error).message}`,
205
213
  );
214
+ }
215
+ if (planShape !== undefined && !Array.isArray(planShape)) {
216
+ const enveloped = metadataOf(planShape as Record<string, unknown>).docs;
217
+ if (Array.isArray(enveloped) && enveloped.length > 0) {
218
+ console.error(
219
+ `article: plan.json carries the Content envelope, not the Doc[] — unwrapping metadata.docs (${enveloped.length} doc(s))`,
220
+ );
221
+ sted('write', planIn, () => writeJsonText(planIn, enveloped));
222
+ planShape = enveloped;
223
+ }
224
+ }
225
+ if (!Array.isArray(planShape)) {
206
226
  const docs = metadataOf(readJson(candidates)).docs;
207
227
  if (!Array.isArray(docs) || docs.length === 0) {
208
228
  console.error('article: candidates.json metadata.docs missing');
@@ -386,8 +406,51 @@ export function dailyEpisodeDuration(args: string[]): void {
386
406
  console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
387
407
  }
388
408
 
409
+ /** Ceiling for the pre-spend estimate when the caller passes no `episode_max_minutes`. */
410
+ const DEFAULT_EPISODE_MAX_MINUTES = 20;
411
+
412
+ /** `kk executor run` argv shared by the quota probe, the voice1 one-shot, and each section. */
413
+ function voiceExecutorArgs(generator: string, input: string, output: string, pluginsPath: string): string[] {
414
+ return ['executor', 'run', generator, '--in', input, '--out', output, ...pluginsArgs(pluginsPath)];
415
+ }
416
+
417
+ /**
418
+ * One tiny gemini-tts render before any episode speech. A non-zero exit (quota, key, network)
419
+ * means this run should use voicebox. Probe files are removed either way.
420
+ * Returns false when the caller should fall back.
421
+ */
422
+ function geminiQuotaProbeOk(workDir: string, pluginsPath: string, transcodeMp3: string, probeDocs: unknown): boolean {
423
+ const probeDir = join(workDir, '30-audio');
424
+ const probeIn = join(probeDir, '.gemini-probe-in.json');
425
+ const probeOut = join(probeDir, '.gemini-probe-out.json');
426
+ writeJsonText(probeIn, probeDocs);
427
+ const probe = spawnSync('kk', voiceExecutorArgs('gemini-tts', probeIn, probeOut, pluginsPath), {
428
+ encoding: 'utf8',
429
+ timeout: 120_000,
430
+ env: { ...process.env, VOICE_GEN_MP3: transcodeMp3 },
431
+ });
432
+ for (const stale of [probeIn, probeOut, probeOut.replace(/\.json$/, '.wav'), probeOut.replace(/\.json$/, '.mp3')]) {
433
+ rmSync(stale, { force: true });
434
+ }
435
+ if (probe.status !== 0) {
436
+ const reason = (probe.stderr ?? '').trim().split('\n').slice(-1)[0] || `exit ${probe.status}`;
437
+ console.log(`generate: gemini quota probe failed (${reason}) — falling back to voicebox for this run`);
438
+ return false;
439
+ }
440
+ console.log('generate: gemini quota probe ok');
441
+ return true;
442
+ }
443
+
389
444
  export function dailyGenerate(args: string[]): void {
390
- const [workDir = '', runDate = '', pluginsPath = '', transcodeMp3 = '', vdriver = ''] = args;
445
+ const [
446
+ workDir = '',
447
+ runDate = '',
448
+ pluginsPath = '',
449
+ transcodeMp3 = '',
450
+ vdriver = '',
451
+ sectionConcurrency = '',
452
+ episodeMaxMinutes = '',
453
+ ] = args;
391
454
  // 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
392
455
  // passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
393
456
  const VOICE_DRIVERS: Record<string, string> = {
@@ -402,54 +465,351 @@ export function dailyGenerate(args: string[]): void {
402
465
  `generate: unknown vdriver '${vdriver}' (expected one of: ${Object.keys(VOICE_DRIVERS).join(', ')})`,
403
466
  );
404
467
  }
468
+ // Strict `Number`, not `parseFloat`: a typo'd var ('2O') must refuse, not cap the episode at 2 min.
469
+ const maxMinutes = episodeMaxMinutes === '' ? DEFAULT_EPISODE_MAX_MINUTES : Number(episodeMaxMinutes);
470
+ if (!Number.isFinite(maxMinutes) || maxMinutes <= 0) {
471
+ failWith(1, `generate: invalid episode_max_minutes: ${episodeMaxMinutes}`);
472
+ }
405
473
  const docs = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
406
474
  const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
475
+
476
+ if (!sectionConcurrency) {
477
+ timed(workDir, 'generate', () => {
478
+ if (isFile(content) && metadataOf(readJson(content)).qc !== undefined) {
479
+ const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
480
+ if (qc?.passed === true) {
481
+ console.log(`audio cached: ${content} (QC passed)`);
482
+ return;
483
+ }
484
+ }
485
+ // Gemini quota probe (20261006): the 06:00 cron hit gemini-3.8-flash-tts 429 with a
486
+ // multi-hour retry-after after the shared key's window drained overnight. One tiny
487
+ // render (~1 request) before the real episode; ANY probe failure (quota, key, network)
488
+ // flips this run to the local voicebox driver instead of dying mid-episode — voicebox
489
+ // needs no key. Operator decision 20261006: keep the gemini default + auto-fallback.
490
+ if (vdriver === 'gemini') {
491
+ const docsList = readJson(docs) as Array<Record<string, unknown>>;
492
+ if (
493
+ docsList.length > 0 &&
494
+ !geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [{ ...docsList[0], body: 'Hello.' }])
495
+ ) {
496
+ voiceGenerator = VOICE_DRIVERS.voicebox;
497
+ }
498
+ }
499
+ kk(voiceExecutorArgs(voiceGenerator, docs, content, pluginsPath), {
500
+ env: { VOICE_GEN_MP3: transcodeMp3 },
501
+ });
502
+ });
503
+ return;
504
+ }
505
+
506
+ if (sectionConcurrency !== '1') {
507
+ failWith(1, `generate: section_concurrency '${sectionConcurrency}' not supported (must be 1)`);
508
+ }
509
+
407
510
  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;
511
+ const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
512
+ if (!existsSync(scriptPath)) {
513
+ failWith(1, `generate: voicescript not found: ${scriptPath}`);
514
+ }
515
+ const scriptRaw = readFileSync(scriptPath, 'utf8');
516
+ const script = Bun.YAML.parse(scriptRaw) as Record<string, unknown>;
517
+ const segments = (script.segments ?? []) as Array<Record<string, unknown>>;
518
+ if (!Array.isArray(segments) || segments.length < 2) {
519
+ failWith(1, 'generate: invalid voicescript (expected at least opening and closing segments)');
520
+ }
521
+
522
+ // Grouping: first is opening, last is closing, middle segments split after gap_ms: 1200
523
+ const middleSegments = segments.slice(1, -1);
524
+ const itemSections: Array<Array<Record<string, unknown>>> = [];
525
+ let currentItem: Array<Record<string, unknown>> = [];
526
+ for (const seg of middleSegments) {
527
+ currentItem.push(seg);
528
+ if (seg.gap_ms === 1200) {
529
+ itemSections.push(currentItem);
530
+ currentItem = [];
413
531
  }
414
532
  }
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`,
533
+ if (currentItem.length > 0) {
534
+ itemSections.push(currentItem);
535
+ }
536
+
537
+ const planPath = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
538
+ if (existsSync(planPath)) {
539
+ try {
540
+ const plan = readJson(planPath) as { items?: unknown[] };
541
+ if (Array.isArray(plan.items) && plan.items.length !== itemSections.length) {
542
+ failWith(
543
+ 1,
544
+ `generate: plan item count (${plan.items.length}) does not match script item sections (${itemSections.length})`,
443
545
  );
444
- voiceGenerator = VOICE_DRIVERS.voicebox;
445
- } else {
446
- console.log('generate: gemini quota probe ok');
447
546
  }
547
+ } catch (e: unknown) {
548
+ if (e instanceof StageFailure) throw e;
448
549
  }
449
550
  }
450
- kk(['executor', 'run', voiceGenerator, '--in', docs, '--out', content, ...pluginsArgs(pluginsPath)], {
451
- env: { VOICE_GEN_MP3: transcodeMp3 },
551
+
552
+ interface SectionDef {
553
+ index: number;
554
+ role: string;
555
+ stem: string;
556
+ name: string;
557
+ segments: Array<Record<string, unknown>>;
558
+ characters: number;
559
+ gap_ms: number;
560
+ text: string;
561
+ }
562
+
563
+ const sectionDefs: SectionDef[] = [];
564
+ // 00-opening
565
+ const openSegs = [segments[0]];
566
+ const openText = openSegs.map((s) => String(s.text ?? '')).join('');
567
+ sectionDefs.push({
568
+ index: 0,
569
+ role: 'opening',
570
+ stem: '00-opening',
571
+ name: '00-opening',
572
+ segments: openSegs,
573
+ characters: [...openText].length,
574
+ gap_ms:
575
+ typeof openSegs[openSegs.length - 1].gap_ms === 'number'
576
+ ? (openSegs[openSegs.length - 1].gap_ms as number)
577
+ : 1200,
578
+ text: openText,
579
+ });
580
+
581
+ // 01-item ...
582
+ itemSections.forEach((segs, idx) => {
583
+ const itemIdx = idx + 1;
584
+ const pad = String(itemIdx).padStart(2, '0');
585
+ const stem = `${pad}-item`;
586
+ const itemText = segs.map((s) => String(s.text ?? '')).join('');
587
+ const lastSeg = segs[segs.length - 1];
588
+ sectionDefs.push({
589
+ index: itemIdx,
590
+ role: 'item',
591
+ stem,
592
+ name: stem,
593
+ segments: segs,
594
+ characters: [...itemText].length,
595
+ gap_ms: typeof lastSeg?.gap_ms === 'number' ? (lastSeg.gap_ms as number) : 1200,
596
+ text: itemText,
597
+ });
452
598
  });
599
+
600
+ // closing
601
+ const closeIdx = sectionDefs.length;
602
+ const closePad = String(closeIdx).padStart(2, '0');
603
+ const closeSegs = [segments[segments.length - 1]];
604
+ const closeText = closeSegs.map((s) => String(s.text ?? '')).join('');
605
+ sectionDefs.push({
606
+ index: closeIdx,
607
+ role: 'closing',
608
+ stem: `${closePad}-closing`,
609
+ name: `${closePad}-closing`,
610
+ segments: closeSegs,
611
+ characters: [...closeText].length,
612
+ gap_ms:
613
+ typeof closeSegs[closeSegs.length - 1].gap_ms === 'number'
614
+ ? (closeSegs[closeSegs.length - 1].gap_ms as number)
615
+ : 300,
616
+ text: closeText,
617
+ });
618
+
619
+ // Character-rate estimate: section characters / driver rate + 8 s sting > the workflow's
620
+ // ceiling (`episode_max_minutes`, arg 8; default 20 for a bare five/six-argument call).
621
+ // The rates are MEASURED from shipped renders, not the style contract's prose. gemini-tts:
622
+ // 20261004 5466 chars / 1026 s = 5.33, 20261007 5970 / 1096 = 5.45, and the 0237 live dry run
623
+ // 4645 / 789.9 s of section speech = 5.88 — a 5.3–5.9 range, so 5.4 deliberately errs low (a
624
+ // slightly early refusal is cheaper than a rendered episode the duration gate then rejects).
625
+ // voice-gen: 20261006 6376 / 1135 = 5.62. A 20-minute ceiling is therefore ~6400 characters.
626
+ // The style sidecar's 3.3 / 6.3 figures are stale and mutually inconsistent with its own
627
+ // 4300–5800 字 budget (which is 13–29 min at 3.3); using them refused a script that really
628
+ // renders in 17 min. Recalibrate from a fresh pair of `metadata.duration` readings when a
629
+ // driver changes model or speed.
630
+ const charRate = voiceGenerator === 'gemini-tts' ? 5.4 : 5.6;
631
+ const totalChars = sectionDefs.reduce((acc, s) => acc + s.characters, 0);
632
+ const estimatedSeconds = 8 + totalChars / charRate;
633
+ if (estimatedSeconds > maxMinutes * 60) {
634
+ const longest3 = [...sectionDefs].sort((a, b) => b.characters - a.characters).slice(0, 3);
635
+ failWith(
636
+ 1,
637
+ `generate: character-rate estimate ${Math.round(estimatedSeconds / 60)} min exceeds ${maxMinutes} min limit (longest sections: ${longest3.map((s) => s.name).join(', ')})`,
638
+ );
639
+ }
640
+
641
+ const voiceProfile =
642
+ (typeof script.default_profile_id === 'string' && script.default_profile_id) ||
643
+ (typeof script.default_profile === 'string' && script.default_profile) ||
644
+ process.env.VOICEBOX_DEFAULT_PROFILE ||
645
+ 'robin-news';
646
+
647
+ const secDir = join(workDir, '30-audio', 'sections', runDate);
648
+ mkdirSync(secDir, { recursive: true });
649
+
650
+ const joinedWavPath = join(workDir, '30-audio', `${runDate}_11_generate_content.wav`);
651
+ const joinedContentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
652
+
653
+ interface SectionPlan {
654
+ def: SectionDef;
655
+ hash: string;
656
+ wavPath: string;
657
+ jsonPath: string;
658
+ needsSynthesis: boolean;
659
+ }
660
+
661
+ const computePlans = (driver: string): SectionPlan[] => {
662
+ return sectionDefs.map((def) => {
663
+ const hash = createHash('sha256').update(`${def.text}\0${voiceProfile}\0${driver}`).digest('hex');
664
+ const wavPath = join(secDir, `${def.stem}.wav`);
665
+ const jsonPath = join(secDir, `${def.stem}.json`);
666
+ let cached = false;
667
+ if (existsSync(wavPath) && existsSync(jsonPath)) {
668
+ try {
669
+ const sidecar = readJson(jsonPath) as Record<string, unknown>;
670
+ const sidecarMeta = metadataOf(sidecar) as Record<string, unknown>;
671
+ if (sidecar.hash === hash || sidecarMeta.hash === hash) {
672
+ cached = true;
673
+ }
674
+ } catch {}
675
+ }
676
+ return {
677
+ def,
678
+ hash,
679
+ wavPath,
680
+ jsonPath,
681
+ needsSynthesis: !cached,
682
+ };
683
+ });
684
+ };
685
+
686
+ let plans = computePlans(voiceGenerator);
687
+ const anySynthesisNeeded = plans.some((p) => p.needsSynthesis);
688
+
689
+ if (!anySynthesisNeeded && existsSync(joinedWavPath) && existsSync(joinedContentPath)) {
690
+ console.log('generate: all sections cached, joined speech reused');
691
+ return;
692
+ }
693
+
694
+ // Same quota probe as the one-shot path, before any section request. A fallback changes
695
+ // the cache key, so plans are recomputed and no Gemini file is played as voicebox speech.
696
+ if (
697
+ anySynthesisNeeded &&
698
+ vdriver === 'gemini' &&
699
+ !geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [
700
+ {
701
+ id: 'probe',
702
+ title: 'Probe',
703
+ body: Bun.YAML.stringify({
704
+ title: 'Probe',
705
+ segments: [{ text: 'Hello.' }],
706
+ }),
707
+ sourceUri: 'file://probe',
708
+ mediaType: 'application/yaml',
709
+ metadata: { voiceProfile },
710
+ },
711
+ ])
712
+ ) {
713
+ voiceGenerator = VOICE_DRIVERS.voicebox;
714
+ plans = computePlans(voiceGenerator);
715
+ }
716
+
717
+ // Synthesize sections sequentially (concurrency 1)
718
+ for (const plan of plans) {
719
+ if (!plan.needsSynthesis) {
720
+ console.log(`generate: section cached: ${plan.def.stem}`);
721
+ continue;
722
+ }
723
+ console.log(`generate: synthesizing section: ${plan.def.stem}`);
724
+ const secScript = { ...script, segments: plan.def.segments };
725
+ const secDocIn = join(secDir, `.${plan.def.stem}_in.json`);
726
+ const doc = {
727
+ id: createHash('sha256').update(`section-${plan.def.stem}`).digest('hex').slice(0, 16),
728
+ title: `Daily AI News VoiceScript - ${plan.def.stem}`,
729
+ sourceUri: `file://${resolve(scriptPath)}#${plan.def.stem}`,
730
+ body: Bun.YAML.stringify(secScript),
731
+ mediaType: 'application/yaml',
732
+ metadata: { voiceProfile },
733
+ };
734
+ writeJsonText(secDocIn, [doc]);
735
+ try {
736
+ const status = kk(voiceExecutorArgs(voiceGenerator, secDocIn, plan.jsonPath, pluginsPath), {
737
+ env: { VOICE_GEN_MP3: transcodeMp3 },
738
+ soft: true,
739
+ });
740
+ if (status !== 0) {
741
+ failWith(status, `generate: section synthesis failed for ${plan.def.stem}`);
742
+ }
743
+ } finally {
744
+ rmSync(secDocIn, { force: true });
745
+ }
746
+ try {
747
+ const sidecar = readJson(plan.jsonPath) as Record<string, unknown>;
748
+ sidecar.hash = plan.hash;
749
+ if (sidecar.metadata && typeof sidecar.metadata === 'object') {
750
+ (sidecar.metadata as Record<string, unknown>).hash = plan.hash;
751
+ }
752
+ writeJsonText(plan.jsonPath, sidecar);
753
+ } catch {}
754
+ }
755
+
756
+ // Join sections with ffmpeg
757
+ const probeHelper = (file: string, entries: string): string =>
758
+ spawnSync('ffprobe', [
759
+ '-v',
760
+ 'error',
761
+ '-select_streams',
762
+ 'a:0',
763
+ '-show_entries',
764
+ entries,
765
+ '-of',
766
+ 'csv=p=0',
767
+ file,
768
+ ])
769
+ .stdout?.toString()
770
+ .trim() ?? '';
771
+
772
+ const sampleRate = probeHelper(plans[0].wavPath, 'stream=sample_rate') || '24000';
773
+ const ffmpegArgs = ['-y', '-hide_banner', '-loglevel', 'error'];
774
+ const filterParts: string[] = [];
775
+ const concatInputs: string[] = [];
776
+
777
+ plans.forEach((plan, i) => {
778
+ ffmpegArgs.push('-i', plan.wavPath);
779
+ filterParts.push(`[${i}:a]aresample=${sampleRate},aformat=sample_fmts=s16:channel_layouts=mono[a${i}]`);
780
+ concatInputs.push(`[a${i}]`);
781
+ if (i < plans.length - 1) {
782
+ const gapMs = plan.def.gap_ms;
783
+ const gapS = gapMs > 0 ? (gapMs / 1000).toFixed(4) : '0';
784
+ filterParts.push(`anullsrc=r=${sampleRate}:cl=mono,atrim=duration=${gapS}[sil${i}]`);
785
+ concatInputs.push(`[sil${i}]`);
786
+ }
787
+ });
788
+
789
+ const filterComplex = `${filterParts.join(';')};${concatInputs.join('')}concat=n=${concatInputs.length}:v=0:a=1[out]`;
790
+ ffmpegArgs.push('-filter_complex', filterComplex, '-map', '[out]', '-c:a', 'pcm_s16le', joinedWavPath);
791
+
792
+ const conv = spawnSync('ffmpeg', ffmpegArgs);
793
+ if (conv.status !== 0 || !existsSync(joinedWavPath)) {
794
+ failWith(1, `generate: ffmpeg join failed: ${conv.stderr?.toString().trim()}`);
795
+ }
796
+
797
+ const joinedDuration = Number.parseFloat(probeHelper(joinedWavPath, 'format=duration'));
798
+
799
+ let contentObj: Record<string, unknown> = {};
800
+ if (existsSync(joinedContentPath)) {
801
+ try {
802
+ contentObj = readJson(joinedContentPath) as Record<string, unknown>;
803
+ } catch {}
804
+ }
805
+ const meta = (contentObj.metadata ?? {}) as Record<string, unknown>;
806
+ delete meta.introMerged;
807
+ meta.audioPath = joinedWavPath;
808
+ meta.duration = joinedDuration;
809
+ contentObj.audioPath = joinedWavPath;
810
+ contentObj.mediaType = 'audio/wav';
811
+ contentObj.metadata = meta;
812
+ writeJsonText(joinedContentPath, contentObj);
453
813
  });
454
814
  }
455
815
 
@@ -791,7 +1151,7 @@ export function dailyPublishWechat(args: string[]): void {
791
1151
  original: true,
792
1152
  aiGenerated: true,
793
1153
  author: '冰原奔狼',
794
- collection: collectionArg || 'AI 乱炖',
1154
+ collection: collectionArg || '每日AI乱炖',
795
1155
  ...(isFile(cover) ? { coverPath: cover } : {}),
796
1156
  // Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
797
1157
  // the 素材库 under the article title; the plugin reuses an existing same-title material.
@@ -842,10 +1202,15 @@ export function dailyPublishWechat(args: string[]): void {
842
1202
  * its own). Non-blocking by operator decision: a missing/expired XHS session or a changed console
843
1203
  * logs a loud failure and leaves the run alive, and the outcome never enters `publish_status`
844
1204
  * (surfdash + podcast remain the episode's publication contract).
1205
+ *
1206
+ * 0236: the optional trailing `<podcast>` arg (`'true'` from voice2 only) attaches the episode's
1207
+ * 发播客 note to the same visit as `metadata.followUps` — one console visit, two notes, one cap entry
1208
+ * (ADR-025 amendment 2026-10-09).
845
1209
  */
846
1210
  export function dailyPublishXhs(args: string[]): void {
847
- const [workDir = '', runDate = '', pluginsPath = '', live = 'false'] = args;
1211
+ const [workDir = '', runDate = '', pluginsPath = '', live = 'false', podcast = ''] = args;
848
1212
  const content = join(workDir, '25-xhs', 'content.json');
1213
+ const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
849
1214
  const input = join(workDir, '31-publish', `${runDate}_19_publish-xhs_content.json`);
850
1215
  const result = join(workDir, '31-publish', `${runDate}_19_publish-xhs_result.json`);
851
1216
  const want = live === 'true' ? 'public' : 'draft';
@@ -854,6 +1219,17 @@ export function dailyPublishXhs(args: string[]): void {
854
1219
  // xhs_live=true re-run (that would report a live post that never happened).
855
1220
  if (resultPublishStatus(result) === want) {
856
1221
  console.log(`publish-xhs cached: ${want} already recorded in ${result}`);
1222
+ // 0236: the cache keys on the visit's primary note, so a 发播客 follow-up that failed in
1223
+ // that visit is named here instead of hidden. It is not retried: a re-visit would post
1224
+ // a second 图文 draft (the console has no idempotency key).
1225
+ const followUps = metadataOf(readJson(result)).followUps;
1226
+ for (const entry of Array.isArray(followUps) ? followUps : []) {
1227
+ const step = (entry ?? {}) as Record<string, unknown>;
1228
+ if (step.ok === true) continue;
1229
+ console.log(
1230
+ `publish-xhs cached: follow-up ${String(metadataOf(step).operation ?? '?')} did not complete: ${String(step.error ?? 'no error recorded')}`,
1231
+ );
1232
+ }
857
1233
  return;
858
1234
  }
859
1235
  if (!isFile(content)) {
@@ -861,8 +1237,16 @@ export function dailyPublishXhs(args: string[]): void {
861
1237
  return;
862
1238
  }
863
1239
  const packaged = requireJson(content, 'publish-xhs');
1240
+ const followUp = podcast === 'true' ? xhsPodcastNote(prep, want) : undefined;
864
1241
  sted('write', input, () =>
865
- writeJsonText(input, { ...packaged, metadata: { ...metadataOf(packaged), publishStatus: want } }),
1242
+ writeJsonText(input, {
1243
+ ...packaged,
1244
+ metadata: {
1245
+ ...metadataOf(packaged),
1246
+ publishStatus: want,
1247
+ ...(followUp === undefined ? {} : { followUps: [followUp] }),
1248
+ },
1249
+ }),
866
1250
  );
867
1251
  // `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
868
1252
  // and prints the failing step (login gate, upload, body, submit control), which is what the
@@ -878,6 +1262,34 @@ export function dailyPublishXhs(args: string[]): void {
878
1262
  });
879
1263
  }
880
1264
 
1265
+ /**
1266
+ * 0236 R1: the 发播客 Content for the same episode — the run's publish-prep Content (the final mp3
1267
+ * with the intro, `23-cover`'s PNG, title and body) plus `metadata.operation` and the run's own
1268
+ * `publishStatus`. No artifact is produced here: both files already exist.
1269
+ *
1270
+ * `metadata.audio` repeats the prep's `metadata.audioFile` under the key the xhs channel's 发播客 mode
1271
+ * reads (`xhs-pub/src/modes.ts`); `metadata.coverImage` is already the shared spelling. A run with no
1272
+ * publish-prep output names the missing episode drafts and carries the 图文 note alone — the 发播客
1273
+ * note is never invented.
1274
+ */
1275
+ function xhsPodcastNote(prepPath: string, publishStatus: 'draft' | 'public'): Record<string, unknown> | undefined {
1276
+ const prep = readJson(prepPath);
1277
+ if (prep === null) {
1278
+ console.log(`publish-xhs: no publish-prep Content at ${prepPath} — the 发播客 note is skipped`);
1279
+ return undefined;
1280
+ }
1281
+ const metadata = metadataOf(prep);
1282
+ return {
1283
+ ...prep,
1284
+ metadata: {
1285
+ ...metadata,
1286
+ operation: '发播客',
1287
+ audio: metadata.audioFile,
1288
+ publishStatus,
1289
+ },
1290
+ };
1291
+ }
1292
+
881
1293
  /**
882
1294
  * WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
883
1295
  * draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
@@ -1290,9 +1702,8 @@ export function dailyShowNotes(args: string[]): void {
1290
1702
  PODCAST_PUB_RUN_DATE: runDate,
1291
1703
  PODCAST_PUB_BRIEF_MODE: 'true',
1292
1704
  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 合成配音。',
1705
+ // 2026-10-08: the column settings carry the AI-production note. Do not append the
1706
+ // stock closer to show notes (or the article, or the spoken sign-off).
1296
1707
  ...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
1297
1708
  },
1298
1709
  });
@@ -1310,7 +1721,10 @@ export function dailyShowNotes(args: string[]): void {
1310
1721
  */
1311
1722
  export function dailyTranslateLocalize(args: string[]): void {
1312
1723
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
1313
- const result = join(workDir, '31-publish', `${runDate}_17_publish_result.json`);
1724
+ // Same seam as show-notes (task 0137): `_17_publish_result.json` is written later by
1725
+ // publish-classify, which depends on translate-sync. Reading it here always saw no zh
1726
+ // path, so en/ja were never scaffolded (2026-10-08 dogfood).
1727
+ const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1314
1728
  const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
1315
1729
  timed(workDir, 'translate-localize', () => {
1316
1730
  const postPath = firstPostPath(readJson(result), 'surfdash-pub');
@@ -1408,12 +1822,63 @@ export function dailyWrapDocs(args: string[]): void {
1408
1822
  });
1409
1823
  }
1410
1824
 
1825
+ /** 0235 R4: `note_scope`'s one opt-in value. Anything else — absent included — is today's path. */
1826
+ const XHS_NOTE_SCOPE_EPISODE = 'episode';
1827
+
1828
+ /**
1829
+ * 0235 R2: `①` — the circled digits ①…⑳ are contiguous from here, and the episode note attaches
1830
+ * `XHS_MAX_CARDS` cards plus at most one cover, so no bare number can ever be needed.
1831
+ */
1832
+ const CIRCLED_DIGIT_ONE = 0x2460;
1833
+
1834
+ /** The attach-order marker of line `position` (0-based). */
1835
+ const markerFor = (position: number): string => String.fromCodePoint(CIRCLED_DIGIT_ONE + position);
1836
+
1837
+ /**
1838
+ * 0235 R2: split the agent's note body into its lead paragraph and its trailing hashtag run. The
1839
+ * prompt asks for 3-5 hashtags at the end, so the run is read off the end of the body; a body
1840
+ * without hashtags is entirely lead.
1841
+ */
1842
+ function splitNoteBody(body: string): { lead: string; hashtags: string } {
1843
+ const trimmed = body.trim();
1844
+ const match = /(?:^|\s)(#\S+(?:\s+#\S+)*)\s*$/.exec(trimmed);
1845
+ if (match === null) return { lead: trimmed, hashtags: '' };
1846
+ return { lead: trimmed.slice(0, match.index).trimEnd(), hashtags: match[1] ?? '' };
1847
+ }
1848
+
1849
+ /**
1850
+ * 0235 R2: the episode note body — the agent's lead paragraph, then one numbered line per attached
1851
+ * image in attach order, then the plan's hashtags. `cap` is the console's own note limit
1852
+ * (`XHS_NOTE_BODY_MAX`): only the lead is trimmed to fit, so an image never loses its line. A body
1853
+ * that overflows even without a lead fails loud rather than shipping a note the editor would cut.
1854
+ */
1855
+ function episodeNoteBody(body: string, lines: string[], cap: number): string {
1856
+ const { lead, hashtags } = splitNoteBody(body);
1857
+ const tail = [lines.join('\n'), hashtags].filter((part) => part !== '').join('\n\n');
1858
+ // `\n\n` joins the lead to the tail; a lead trimmed away drops the separator with it.
1859
+ const budget = cap - tail.length - (lead === '' ? 0 : 2);
1860
+ const head = budget >= lead.length ? lead : lead.slice(0, Math.max(budget, 0)).trimEnd();
1861
+ const note = head === '' ? tail : `${head}\n\n${tail}`;
1862
+ if (note.length > cap) {
1863
+ failWith(
1864
+ 1,
1865
+ `xhs render: episode note body is ${note.length} chars for ${lines.length} image lines — the cap is ${cap} and a line is never dropped`,
1866
+ );
1867
+ }
1868
+ return note;
1869
+ }
1870
+
1411
1871
  /**
1412
1872
  * E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
1413
1873
  * ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
1414
1874
  * are pinned here (never model-authored), so the series cannot drift from its preset. Card copy
1415
1875
  * is typeset by image-gen's composer; the provider prompt stays text-free (0181). Resume-safe:
1416
1876
  * a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
1877
+ *
1878
+ * `note_scope` (`episode`, 0235) opts into the episode note for voice2: the packaged set then also
1879
+ * attaches the episode cover last, and the body is rebuilt as the lead paragraph plus one numbered
1880
+ * line per attached image. Absent or any other value: the lead-item series and the agent's body are
1881
+ * packaged verbatim (voice1's contract).
1417
1882
  */
1418
1883
  export function dailyXhsRender(args: string[]): void {
1419
1884
  const [
@@ -1425,9 +1890,12 @@ export function dailyXhsRender(args: string[]): void {
1425
1890
  provider = '',
1426
1891
  model = '',
1427
1892
  fallback = '',
1893
+ noteScope = '',
1428
1894
  ] = args;
1429
1895
  const count = Number.parseInt(countArg, 10);
1896
+ const episode = noteScope === XHS_NOTE_SCOPE_EPISODE;
1430
1897
  const planPath = join(workDir, '25-xhs', `${runDate}_18_xhs_plan.json`);
1898
+ const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1431
1899
  const cardsDir = join(workDir, '25-xhs', 'cards');
1432
1900
  const manifestPath = join(workDir, '25-xhs', 'cards.json');
1433
1901
  const contentPath = join(workDir, '25-xhs', 'content.json');
@@ -1439,6 +1907,13 @@ export function dailyXhsRender(args: string[]): void {
1439
1907
  sted('mkdir', cardsDir, () => mkdirSync(cardsDir, { recursive: true }));
1440
1908
  const images: Record<string, unknown>[] = [];
1441
1909
  const cards: Record<string, unknown>[] = [];
1910
+ // 0235 R2: the numbered lines are a pure function of the plan and the cover, in attach order
1911
+ // (cards in plan order, the cover last), so the note — and its cap — is settled before the
1912
+ // first card is billed: an over-cap tail fails here with nothing rendered.
1913
+ const withCover = episode && isFile(coverPath);
1914
+ const lines = plan.cards.map((card, i) => `${markerFor(i)} ${card.copy}:${card.points[0] ?? ''}`);
1915
+ if (withCover) lines.push(`${markerFor(lines.length)} 每日AI乱炖 ${runDate} 本期封面`);
1916
+ const body = episode ? episodeNoteBody(plan.body, lines, XHS_BODY_CAP) : plan.body;
1442
1917
  for (const card of plan.cards) {
1443
1918
  const out = join(cardsDir, `card-${card.index}.json`);
1444
1919
  const promptPath = join(cardsDir, `card-${card.index}.prompt.txt`);
@@ -1514,19 +1989,34 @@ export function dailyXhsRender(args: string[]): void {
1514
1989
  promptPath,
1515
1990
  imagePath,
1516
1991
  illustrationPath,
1992
+ ...(card.itemId === undefined ? {} : { itemId: card.itemId }),
1517
1993
  status: 'rendered',
1518
1994
  });
1519
1995
  }
1996
+ // 0235 R1: the episode cover closes the set — it is ultrawide, and image 1 (card 01) is what
1997
+ // sets the note's grid ratio. It is `daily-cover`'s own artifact (no new image kind), and
1998
+ // the set is ≤ XHS_MAX_CARDS + 1 = 11, inside `xhs-pub`'s XHS_IMAGE_MAX (18).
1999
+ if (withCover) {
2000
+ images.push({ path: resolve(coverPath) });
2001
+ } else if (episode) {
2002
+ console.log(
2003
+ `xhs render: no episode cover at ${coverPath} — the note carries the ${cards.length} cards only`,
2004
+ );
2005
+ }
1520
2006
  sted('write', manifestPath, () => writeJsonText(manifestPath, { source: planPath, cards }));
1521
2007
  sted('write', contentPath, () =>
1522
2008
  writeJsonText(contentPath, {
1523
2009
  title: plan.title,
1524
- body: plan.body,
2010
+ body,
1525
2011
  format: 'text',
1526
2012
  metadata: { images, publishStatus: 'draft' },
1527
2013
  }),
1528
2014
  );
1529
- console.log(`xhs render: ${cards.length} cards packaged in card order (${contentPath})`);
2015
+ console.log(
2016
+ episode
2017
+ ? `xhs render: ${images.length} images packaged in image order (${contentPath})`
2018
+ : `xhs render: ${cards.length} cards packaged in card order (${contentPath})`,
2019
+ );
1530
2020
  });
1531
2021
  }
1532
2022