@gobing-ai/knowledge-kit 0.0.17 → 0.0.19

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 (76) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +74 -0
  3. package/dist/index.js +108 -107
  4. package/package.json +19 -1
  5. package/plugins/generations/content-gen/dist/index.js +42 -94
  6. package/plugins/generations/core-facts-gen/dist/index.js +51 -100
  7. package/plugins/generations/daily-article-gen/dist/index.js +57 -99
  8. package/plugins/generations/daily-article-gen/src/index.ts +12 -4
  9. package/plugins/generations/dailynews-gen/dist/index.js +57 -102
  10. package/plugins/generations/dailynews-gen/src/script-builder.ts +9 -1
  11. package/plugins/generations/episode-plan-gen/dist/index.js +120 -113
  12. package/plugins/generations/episode-plan-gen/src/index.ts +102 -22
  13. package/plugins/generations/image-gen/dist/index.js +63 -115
  14. package/plugins/generations/news-report-gen/dist/index.js +117 -99
  15. package/plugins/generations/news-report-gen/src/index.ts +117 -1
  16. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +1 -1
  17. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +8 -0
  18. package/plugins/generations/voice-gen/dist/index.js +161 -120
  19. package/plugins/generations/voice-gen/src/concat.ts +34 -0
  20. package/plugins/generations/voice-gen/src/index.ts +14 -4
  21. package/plugins/generations/voice-gen/src/voicebox-server.ts +91 -0
  22. package/plugins/generations/voice-gen/src/voicescript.ts +8 -0
  23. package/plugins/ingestions/aihot-ingest/dist/index.js +47 -97
  24. package/plugins/ingestions/horizon-ingest/dist/index.js +47 -96
  25. package/plugins/ingestions/karakeep-local/dist/index.js +217 -266
  26. package/plugins/ingestions/last30days-ingest/dist/index.js +42 -91
  27. package/plugins/ingestions/web-search/dist/index.js +68 -117
  28. package/plugins/kk/commands/workflow-run.md +10 -6
  29. package/plugins/kk/plugin.json +1 -1
  30. package/plugins/kk/scripts/kk-workflow-stages.ts +376 -70
  31. package/plugins/kk/skills/publish/SKILL.md +80 -0
  32. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +136 -224
  33. package/plugins/kk/workflows/leftover-publish-fanout-example.yaml +53 -0
  34. package/plugins/publishings/emdash-pub/dist/index.js +42 -91
  35. package/plugins/publishings/infoq-pub/dist/index.js +21994 -0
  36. package/plugins/publishings/infoq-pub/package.json +17 -0
  37. package/plugins/publishings/infoq-pub/plugin.json +7 -0
  38. package/plugins/publishings/infoq-pub/src/index.ts +123 -0
  39. package/plugins/publishings/infoq-pub/tsconfig.json +4 -0
  40. package/plugins/publishings/juejin-pub/dist/index.js +21994 -0
  41. package/plugins/publishings/juejin-pub/package.json +17 -0
  42. package/plugins/publishings/juejin-pub/plugin.json +7 -0
  43. package/plugins/publishings/juejin-pub/src/index.ts +123 -0
  44. package/plugins/publishings/juejin-pub/tsconfig.json +4 -0
  45. package/plugins/publishings/medium-pub/dist/index.js +22041 -0
  46. package/plugins/publishings/medium-pub/package.json +17 -0
  47. package/plugins/publishings/medium-pub/plugin.json +7 -0
  48. package/plugins/publishings/medium-pub/src/index.ts +215 -0
  49. package/plugins/publishings/medium-pub/tsconfig.json +4 -0
  50. package/plugins/publishings/podcast-pub/dist/index.js +183 -123
  51. package/plugins/publishings/podcast-pub/src/index.ts +47 -3
  52. package/plugins/publishings/podcast-pub/src/show-notes.ts +153 -34
  53. package/plugins/publishings/qiita-pub/dist/index.js +42 -91
  54. package/plugins/publishings/substack-pub/dist/index.js +21994 -0
  55. package/plugins/publishings/substack-pub/package.json +17 -0
  56. package/plugins/publishings/substack-pub/plugin.json +7 -0
  57. package/plugins/publishings/substack-pub/src/index.ts +123 -0
  58. package/plugins/publishings/substack-pub/tsconfig.json +4 -0
  59. package/plugins/publishings/surfdash-pub/dist/index.js +172 -103
  60. package/plugins/publishings/surfdash-pub/src/index.ts +32 -2
  61. package/plugins/publishings/wechatmp-pub/dist/index.js +22119 -0
  62. package/plugins/publishings/wechatmp-pub/package.json +17 -0
  63. package/plugins/publishings/wechatmp-pub/plugin.json +7 -0
  64. package/plugins/publishings/wechatmp-pub/src/index.ts +304 -0
  65. package/plugins/publishings/wechatmp-pub/tsconfig.json +4 -0
  66. package/plugins/publishings/x-pub/dist/index.js +22110 -0
  67. package/plugins/publishings/x-pub/package.json +17 -0
  68. package/plugins/publishings/x-pub/plugin.json +7 -0
  69. package/plugins/publishings/x-pub/src/index.ts +222 -0
  70. package/plugins/publishings/x-pub/tsconfig.json +4 -0
  71. package/plugins/publishings/xhs-pub/dist/index.js +21994 -0
  72. package/plugins/publishings/xhs-pub/package.json +17 -0
  73. package/plugins/publishings/xhs-pub/plugin.json +7 -0
  74. package/plugins/publishings/xhs-pub/src/index.ts +123 -0
  75. package/plugins/publishings/xhs-pub/tsconfig.json +4 -0
  76. package/plugins/publishings/zenn-pub/dist/index.js +42 -91
@@ -23,11 +23,13 @@
23
23
  * The YAML prelude resolves this file as `$pkg/plugins/kk/scripts/kk-workflow-stages.ts` where `pkg`
24
24
  * comes from `realpath "$(command -v kk)"` (the installed package root), and falls back to the
25
25
  * cwd-relative `plugins/kk/scripts/kk-workflow-stages.ts` when that copy is absent — the checkout
26
- * tier kk-storm-research's render has always relied on, preserved on purpose.
26
+ * tier kk-storm-research's render has always relied on, preserved on purpose. The daily-voice
27
+ * workflow reaches the same script through `kk stage` (apps/cli/src/stage.ts), which applies the
28
+ * same two resolution tiers in TypeScript.
27
29
  *
28
- * SYNC: plugins/kk/workflows/{kk-solo-podcast,kk-itc,kk-storm-research,kk-daily-ai-voice}.yaml shell
29
- * this script. Keep message substrings stable — apps/cli/tests/kk-workflow-stages.test.ts and the
30
- * four workflow static tests match them.
30
+ * SYNC: plugins/kk/workflows/{kk-solo-podcast,kk-itc,kk-storm-research}.yaml shell this script
31
+ * (kk-daily-ai-voice goes through `kk stage`). Keep message substrings stable —
32
+ * apps/cli/tests/kk-workflow-stages.test.ts and the four workflow static tests match them.
31
33
  */
32
34
  import { spawnSync } from 'node:child_process';
33
35
  import { createHash } from 'node:crypto';
@@ -43,6 +45,7 @@ import {
43
45
  unlinkSync,
44
46
  writeFileSync,
45
47
  } from 'node:fs';
48
+ import { homedir } from 'node:os';
46
49
  import { basename, dirname, join, resolve } from 'node:path';
47
50
 
48
51
  const SCRIPTS_DIR = import.meta.dir;
@@ -500,6 +503,16 @@ function metadataOf(value: Record<string, unknown> | null): Record<string, unkno
500
503
  return metadata !== null && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {};
501
504
  }
502
505
 
506
+ /**
507
+ * Required stage input: a missing or unparsable file fails the stage naming the path, so a
508
+ * run-date/path mismatch surfaces as its real cause instead of a downstream "field missing" error.
509
+ */
510
+ function requireJson(path: string, label: string): Record<string, unknown> {
511
+ const parsed = readJson(path);
512
+ if (parsed === null) failWith(1, `${label}: required input not found or unparsable: ${path}`);
513
+ return parsed;
514
+ }
515
+
503
516
  /** `jq -e '.targets[]? | select(.target == t and .ok == true and .status == "published")'`. */
504
517
  function targetPublished(path: string, target: string): boolean {
505
518
  const parsed = readJson(path);
@@ -524,15 +537,29 @@ function dailyPrepare(args: string[]): void {
524
537
  last30daysEnabled = '',
525
538
  topic = '',
526
539
  horizonHours = '',
540
+ runDateArg = '',
527
541
  ] = args;
528
- const workDir = resolve(workDirArg);
529
- timed(workDirArg, 'prepare', () => {
530
- if (!workDirArg) failWith(1, 'missing required variable: work_dir');
542
+ // Empty work_dir composes the real workspace from the operator's works_dir
543
+ // (workflow-run.md §2 precedence: KK_WORKS_DIR > config works_dir > ~/.config/kk/works):
544
+ // $works_dir/kk-daily-ai-voice/<stamp>
545
+ // The stamp is digits-only (dashes stripped from the caller's run_date, else today) so a
546
+ // resumed/retried run whose persisted run_date var already carries the digits-only
547
+ // run-date.txt value re-derives the same directory.
548
+ const dirDate = runDateArg ? runDateArg.replaceAll('-', '') : formatRunDate();
549
+ const workDir = resolve(workDirArg || join(resolveWorksDir(), 'kk-daily-ai-voice', dirDate));
550
+ timed(workDir, 'prepare', () => {
531
551
  for (const dir of DAILY_DIRS)
532
552
  sted('mkdir', join(workDir, dir), () => mkdirSync(join(workDir, dir), { recursive: true }));
533
553
  sted('write', join(workDir, 'run-date.txt'), () =>
534
- writeFileSync(join(workDir, 'run-date.txt'), `${formatRunDate()}\n`),
554
+ writeFileSync(join(workDir, 'run-date.txt'), `${dirDate}\n`),
535
555
  );
556
+ // Anchor for the workflow: shell actions cannot set vars, so the YAML reads the
557
+ // effective work_dir back into vars.work_dir from this cwd-relative file.
558
+ const anchorDir = join('.spur', 'runs', 'kk-daily-ai-voice');
559
+ sted('write', join(anchorDir, 'work-dir.txt'), () => {
560
+ mkdirSync(anchorDir, { recursive: true });
561
+ writeFileSync(join(anchorDir, 'work-dir.txt'), `${workDir}\n`);
562
+ });
536
563
  sted('write', join(workDir, '1-ingest/source.json'), () =>
537
564
  writeJsonText(join(workDir, '1-ingest/source.json'), {
538
565
  limit: Number(limit),
@@ -562,6 +589,35 @@ function formatRunDate(): string {
562
589
  return `${now.getFullYear()}${month}${day}`;
563
590
  }
564
591
 
592
+ /** Expand the `~/` and `$HOME/` prefixes the config examples use. */
593
+ function expandHome(p: string): string {
594
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2));
595
+ if (p.startsWith('$HOME/')) return join(homedir(), p.slice('$HOME/'.length));
596
+ return p;
597
+ }
598
+
599
+ /**
600
+ * works_dir for composed daily workspaces — workflow-run.md §2 precedence:
601
+ * KK_WORKS_DIR env > config works_dir > compiled default ~/.config/kk/works.
602
+ * Unreadable/unparseable config falls through to the default (pluginEnvFromConfig
603
+ * tolerates the same), never fails the run.
604
+ */
605
+ function resolveWorksDir(): string {
606
+ const fromEnv = process.env.KK_WORKS_DIR;
607
+ if (fromEnv) return fromEnv;
608
+ const configPath = process.env.KK_CONFIG || join(homedir(), '.config/kk/config.yaml');
609
+ if (existsSync(configPath)) {
610
+ try {
611
+ const parsed = Bun.YAML.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown> | null;
612
+ const worksDir = parsed !== null && typeof parsed === 'object' ? parsed.works_dir : undefined;
613
+ if (typeof worksDir === 'string' && worksDir !== '') return expandHome(worksDir);
614
+ } catch {
615
+ // fall through to the compiled default
616
+ }
617
+ }
618
+ return join(homedir(), '.config/kk/works');
619
+ }
620
+
565
621
  function dailyCollectFacts(args: string[]): void {
566
622
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
567
623
  const content = join(workDir, '2-facts', `${runDate}_02_collect_content.json`);
@@ -752,6 +808,10 @@ function dailyArticle(args: string[]): void {
752
808
  console.log(`article cached: ${articleMd}`);
753
809
  return;
754
810
  }
811
+ // Agent-written prose body (workflow `article-write` state) replaces the mechanical
812
+ // per-item render when present — the 3-format split: surfdash gets an article,
813
+ // not a news list (2026-09-16 feedback).
814
+ const proseBody = join(workDir, '2-article', `${runDate}_06_article-write_body.md`);
755
815
  kk(
756
816
  [
757
817
  'executor',
@@ -764,7 +824,12 @@ function dailyArticle(args: string[]): void {
764
824
  ...pluginsArgs(pluginsPath),
765
825
  ],
766
826
  {
767
- env: { ARTICLE_DATE: runDate },
827
+ env: {
828
+ ARTICLE_DATE: runDate,
829
+ // Plugin entries run with cwd = plugin dir (invoke.ts), so relative
830
+ // paths in env must be resolved or the plugin sees ENOENT.
831
+ ...(isFile(proseBody) ? { ARTICLE_BODY_FILE: resolve(proseBody) } : {}),
832
+ },
768
833
  },
769
834
  );
770
835
  const body = readJson(articleContent)?.body;
@@ -792,7 +857,10 @@ function dailyCoverNormalize(args: string[]): void {
792
857
  function coverNormalizeBody(workDir: string, runDate: string): void {
793
858
  const png = join(workDir, '2-cover', 'content-cover-01.png');
794
859
  const jpg = join(workDir, '2-cover', 'content-cover-01.jpg');
795
- const out = join(workDir, '2-cover', `cover-${runDate}.png`);
860
+ // Must match the name dailyPublishPrep and the article->cover workflow guard read:
861
+ // `${runDate}_07_cover_cover.png` (a plain `cover-${runDate}.png` left options.coverPath
862
+ // undefined and silently dropped the cover from both podcast and surfdash — 2026-09-16 run).
863
+ const out = join(workDir, '2-cover', `${runDate}_07_cover_cover.png`);
796
864
  const source = isFile(png) ? png : isFile(jpg) ? jpg : '';
797
865
  if (source === '') failWith(1, 'cover normalize: no content-cover-01.{png,jpg} artifact');
798
866
  if (source === jpg) {
@@ -815,7 +883,7 @@ function coverNormalizeBody(workDir: string, runDate: string): void {
815
883
  // `rm -f` is silent when the file is already gone.
816
884
  }
817
885
  }
818
- console.log(`cover normalized: cover-${runDate}.png`);
886
+ console.log(`cover normalized: ${runDate}_07_cover_cover.png`);
819
887
  }
820
888
 
821
889
  function dailyScript(args: string[]): void {
@@ -864,7 +932,17 @@ function dailyWrapDocs(args: string[]): void {
864
932
  }
865
933
 
866
934
  function dailyGenerate(args: string[]): void {
867
- const [workDir = '', runDate = '', pluginsPath = '', transcodeMp3 = '', voiceGenerator = ''] = args;
935
+ const [workDir = '', runDate = '', pluginsPath = '', transcodeMp3 = '', vdriver = ''] = args;
936
+ // 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
937
+ // passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
938
+ const VOICE_DRIVERS: Record<string, string> = { ominivoice: 'omni-voice-gen', voicebox: 'voice-gen' };
939
+ const voiceGenerator = VOICE_DRIVERS[vdriver];
940
+ if (!voiceGenerator) {
941
+ failWith(
942
+ 1,
943
+ `generate: unknown vdriver '${vdriver}' (expected one of: ${Object.keys(VOICE_DRIVERS).join(', ')})`,
944
+ );
945
+ }
868
946
  const docs = join(workDir, '3-audio', `${runDate}_10_wrap-docs_docs.json`);
869
947
  const content = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
870
948
  timed(workDir, 'generate', () => {
@@ -881,6 +959,111 @@ function dailyGenerate(args: string[]): void {
881
959
  });
882
960
  }
883
961
 
962
+ /**
963
+ * 20260917 review #7: prepend the show's 8s intro sting to the generated audio.
964
+ * `intro_music` empty -> skip. Writes `<stem>.intro.wav` (original render kept), points
965
+ * `audioPath` at the merged file, extends `duration`, marks `introMerged` (idempotent).
966
+ */
967
+ function dailyIntro(args: string[]): void {
968
+ const [workDir = '', runDate = '', introSrc = ''] = args;
969
+ if (!introSrc || introSrc.trim() === '') {
970
+ console.log('intro-music: intro_music var empty — skip');
971
+ return;
972
+ }
973
+ const contentPath = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
974
+ timed(workDir, 'intro-music', () => {
975
+ const content = requireJson(contentPath, 'intro-music');
976
+ const meta = metadataOf(content);
977
+ if (meta.introMerged === true) {
978
+ console.log(`intro-music cached: ${contentPath}`);
979
+ return;
980
+ }
981
+ const rawFile = meta.mp3Path ?? meta.audioPath;
982
+ if (typeof rawFile !== 'string' || rawFile === '') {
983
+ failWith(1, 'intro-music: voice content carries no mp3Path/audioPath');
984
+ }
985
+ const introPath = resolve(introSrc);
986
+ if (!existsSync(introPath)) failWith(1, `intro-music: intro file not found: ${introPath}`);
987
+ const mainPath = resolve(rawFile);
988
+ if (!existsSync(mainPath)) failWith(1, `intro-music: generated audio not found: ${mainPath}`);
989
+ const mergedPath = mainPath.replace(/\.wav$/, '.intro.wav');
990
+ const probe = (file: string, entries: string): string =>
991
+ spawnSync('ffprobe', [
992
+ '-v',
993
+ 'error',
994
+ '-select_streams',
995
+ 'a:0',
996
+ '-show_entries',
997
+ entries,
998
+ '-of',
999
+ 'csv=p=0',
1000
+ file,
1001
+ ])
1002
+ .stdout?.toString()
1003
+ .trim() ?? '';
1004
+ const mainDur = Number.parseFloat(probe(mainPath, 'format=duration'));
1005
+ const introDur = Number.parseFloat(probe(introPath, 'format=duration'));
1006
+ if (!Number.isFinite(mainDur) || !Number.isFinite(introDur)) {
1007
+ failWith(1, `intro-music: ffprobe could not read durations (main=${mainDur} intro=${introDur})`);
1008
+ }
1009
+ const rate = probe(mainPath, 'stream=sample_rate') || '24000'; // match the generated audio's rate; mono s16 both sides
1010
+ const norm = (label: string): string =>
1011
+ `[${label}:a]aresample=${rate},aformat=sample_fmts=s16:channel_layouts=mono[${label}]`;
1012
+ const fc = `${norm('0')};${norm('1')};[0][1]concat=n=2:v=0:a=1[out]`;
1013
+ const conv = spawnSync('ffmpeg', [
1014
+ '-y',
1015
+ '-hide_banner',
1016
+ '-loglevel',
1017
+ 'error',
1018
+ '-i',
1019
+ introPath,
1020
+ '-i',
1021
+ mainPath,
1022
+ '-filter_complex',
1023
+ fc,
1024
+ '-map',
1025
+ '[out]',
1026
+ '-c:a',
1027
+ 'pcm_s16le',
1028
+ mergedPath,
1029
+ ]);
1030
+ if (conv.status !== 0 || !existsSync(mergedPath)) {
1031
+ failWith(1, `intro-music: ffmpeg concat failed: ${conv.stderr?.toString().trim()}`);
1032
+ }
1033
+ if (meta.mp3Path && typeof meta.mp3Path === 'string') {
1034
+ // transcode_mp3=true: keep the mp3 path honest by re-transcoding the merged wav.
1035
+ const mp3 = spawnSync('ffmpeg', [
1036
+ '-y',
1037
+ '-hide_banner',
1038
+ '-loglevel',
1039
+ 'error',
1040
+ '-i',
1041
+ mergedPath,
1042
+ '-codec:a',
1043
+ 'libmp3lame',
1044
+ '-qscale:a',
1045
+ '2',
1046
+ resolve(meta.mp3Path),
1047
+ ]);
1048
+ if (mp3.status !== 0) failWith(1, `intro-music: mp3 re-transcode failed: ${mp3.stderr?.toString().trim()}`);
1049
+ }
1050
+ const mergedDur = Number.parseFloat(probe(mergedPath, 'format=duration')) || mainDur + introDur;
1051
+ const updated = {
1052
+ ...content,
1053
+ metadata: {
1054
+ ...meta,
1055
+ audioPath: mergedPath,
1056
+ duration: mergedDur,
1057
+ introMerged: true,
1058
+ },
1059
+ };
1060
+ sted('write', contentPath, () => writeJsonText(contentPath, updated));
1061
+ console.log(
1062
+ `intro-music: merged ${introDur.toFixed(1)}s intro -> ${mergedPath} (${mergedDur.toFixed(1)}s total)`,
1063
+ );
1064
+ });
1065
+ }
1066
+
884
1067
  function dailyQualityReport(args: string[]): void {
885
1068
  const [workDir = '', runDate = ''] = args;
886
1069
  const content = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
@@ -904,7 +1087,7 @@ function dailyPublishPrep(args: string[]): void {
904
1087
  const prepPath = join(workDir, '3-publish', `${runDate}_13_publish-prep_content.json`);
905
1088
  const coverPath = join(workDir, '2-cover', `${runDate}_07_cover_cover.png`);
906
1089
  timed(workDir, 'publish-prep', () => {
907
- const article = readJson(articlePath) ?? {};
1090
+ const article = requireJson(articlePath, 'publish-prep');
908
1091
  const audioMeta = metadataOf(readJson(audioPath));
909
1092
  const rawFile = audioMeta.mp3Path ?? audioMeta.audioPath;
910
1093
  if (typeof rawFile !== 'string' || rawFile === '') {
@@ -912,13 +1095,21 @@ function dailyPublishPrep(args: string[]): void {
912
1095
  }
913
1096
  const audioFile = resolve(rawFile);
914
1097
  const resolvedCover = resolve(coverPath);
1098
+ const hasCover = existsSync(resolvedCover);
1099
+ // podcast-pub's documented contract reads metadata.coverImage (map.ts); options.coverPath
1100
+ // alone was silently dropped by the mapper — episodes published without a cover.
915
1101
  const merged = {
916
1102
  ...article,
917
- metadata: { ...metadataOf(article), audioFile, runDate },
1103
+ metadata: {
1104
+ ...metadataOf(article),
1105
+ audioFile,
1106
+ runDate,
1107
+ ...(hasCover ? { coverImage: resolvedCover } : {}),
1108
+ },
918
1109
  options: {
919
1110
  audioFile,
920
1111
  durationSec: audioMeta.duration,
921
- coverPath: existsSync(resolvedCover) ? resolvedCover : undefined,
1112
+ coverPath: hasCover ? resolvedCover : undefined,
922
1113
  },
923
1114
  };
924
1115
  sted('write', prepPath, () => writeJsonText(prepPath, merged));
@@ -964,6 +1155,9 @@ function dailyShowNotes(args: string[]): void {
964
1155
  const result = join(workDir, '3-publish', `${runDate}_14_publish-surfdash_result.json`);
965
1156
  const out = join(workDir, '3-publish', `${runDate}_15_show-notes_show-notes.md`);
966
1157
  const plan = join(workDir, '2-plan', `${runDate}_03_plan_plan.json`);
1158
+ // Generated-episode content carries `metadata.segments[]` — the source of real
1159
+ // audio offsets for the show-notes timeline (jump-to-item timestamps).
1160
+ const audioContent = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
967
1161
  timed(workDir, 'show-notes', () => {
968
1162
  const effective = readJson(article)?.title;
969
1163
  const titleArg = title !== '' ? title : typeof effective === 'string' ? effective : '';
@@ -974,6 +1168,7 @@ function dailyShowNotes(args: string[]): void {
974
1168
  PODCAST_PUB_RUN_DATE: runDate,
975
1169
  PODCAST_PUB_BRIEF_MODE: 'true',
976
1170
  PODCAST_PUB_ARTICLE_POST_PATH: postPath,
1171
+ ...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
977
1172
  },
978
1173
  });
979
1174
  });
@@ -1004,7 +1199,7 @@ function dailyPublishPodcast(args: string[]): void {
1004
1199
  return;
1005
1200
  }
1006
1201
  try {
1007
- const content = readJson(prep) ?? {};
1202
+ const content = requireJson(prep, 'publish-podcast');
1008
1203
  const showNotes = readFileSync(notes, 'utf-8');
1009
1204
  const merged = { ...content, metadata: { ...metadataOf(content), showNotes } };
1010
1205
  sted('write', prep, () => writeJsonText(prep, merged));
@@ -1084,6 +1279,95 @@ function dailyPublishPartial(args: string[]): void {
1084
1279
  });
1085
1280
  }
1086
1281
 
1282
+ /**
1283
+ * 20260917 review #4/#6: after both publishes land, patch the surfdash disk posts —
1284
+ * reuse the podcast-hosted cover URL in frontmatter (image/og_image; no re-upload; fallback:
1285
+ * copy the local cover into assets/) and append a podcast cross-link at the end of zh/en/ja.
1286
+ * Idempotent per post; commits + pushes the surfdash checkout (SURFDASH_AUTO_PUSH=0 keeps commit-only).
1287
+ */
1288
+ function dailyPatchSurfdash(args: string[]): void {
1289
+ const [workDir = '', runDate = '', surfdashRoot = ''] = args;
1290
+ if (!surfdashRoot || !existsSync(surfdashRoot)) {
1291
+ console.log(`patch-surfdash: surfdash root '${surfdashRoot}' not found — skip`);
1292
+ return;
1293
+ }
1294
+ timed(workDir, 'patch-surfdash', () => {
1295
+ const sdResult = join(workDir, '3-publish', `${runDate}_14_publish-surfdash_result.json`);
1296
+ const podcastResult = join(workDir, '3-publish', `${runDate}_16_publish-podcast_result.json`);
1297
+ if (!isFile(sdResult) || !isFile(podcastResult)) {
1298
+ failWith(1, 'patch-surfdash: missing surfdash/podcast publish results — run the publish states first');
1299
+ }
1300
+ const targetOf = (path: string): Record<string, unknown> => {
1301
+ const parsed = readJson(path) as { targets?: Array<Record<string, unknown>> };
1302
+ return parsed.targets?.[0] ?? {};
1303
+ };
1304
+ const sd = targetOf(sdResult);
1305
+ const pod = targetOf(podcastResult);
1306
+ const postPath = (sd.metadata as Record<string, unknown> | undefined)?.postPath;
1307
+ if (typeof postPath !== 'string' || postPath === '')
1308
+ failWith(1, 'patch-surfdash: surfdash result carries no postPath');
1309
+ const slugDir = dirname(postPath);
1310
+ const showNotesUrl = typeof pod.url === 'string' ? pod.url : '';
1311
+ const coverUrl = ((pod.metadata as Record<string, unknown> | undefined)?.imageUrl as string | undefined) ?? '';
1312
+ const localCover = join(workDir, '2-cover', `${runDate}_07_cover_cover.png`);
1313
+ const dateIso = `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`;
1314
+ const crossLink: Record<string, string> = {
1315
+ zh: `🎧 本期已同步制作为播客节目,[点击收听《每日 AI 资讯 · ${dateIso}》](${showNotesUrl})。`,
1316
+ en: `🎧 This episode is also available as a podcast: [listen to the Daily AI Briefing · ${dateIso}](${showNotesUrl}).`,
1317
+ ja: `🎧 本エピソードはポッドキャストでも配信中:[Daily AI Briefing · ${dateIso} を聴く](${showNotesUrl})。`,
1318
+ };
1319
+ let changed = 0;
1320
+ for (const [locale, dir] of [
1321
+ ['zh', slugDir],
1322
+ ['en', join(dirname(slugDir), 'en', basename(slugDir))],
1323
+ ['ja', join(dirname(slugDir), 'ja', basename(slugDir))],
1324
+ ] as const) {
1325
+ const indexMd = join(dir, 'index.md');
1326
+ if (!existsSync(indexMd)) {
1327
+ console.log(`patch-surfdash: ${locale} post not published (${indexMd}) — skip`);
1328
+ continue;
1329
+ }
1330
+ let md = readFileSync(indexMd, 'utf-8');
1331
+ const fmEnd = md.indexOf('---', 3); // frontmatter is `---\n ... \n---\n`
1332
+ if (fmEnd < 0) failWith(1, `patch-surfdash: no frontmatter in ${indexMd}`);
1333
+ if (!/^image:/m.test(md.slice(0, fmEnd))) {
1334
+ let image = coverUrl;
1335
+ if (image === '' && existsSync(localCover)) {
1336
+ // Fallback for pre-#6 runs: copy the local cover instead of a hosted URL.
1337
+ const assets = join(dir, 'assets');
1338
+ mkdirSync(assets, { recursive: true });
1339
+ copyFileSync(localCover, join(assets, 'cover.png'));
1340
+ image = './assets/cover.png';
1341
+ }
1342
+ if (image !== '') {
1343
+ md = md.replace(/^publishDate: .*$/m, (m0) => `${m0}\nimage: '${image}'\nog_image: '${image}'`);
1344
+ }
1345
+ }
1346
+ if (showNotesUrl !== '' && !md.includes(showNotesUrl)) {
1347
+ md = `${md.replace(/\s*$/, '')}\n\n${crossLink[locale]}\n`;
1348
+ }
1349
+ if (md !== readFileSync(indexMd, 'utf-8')) {
1350
+ sted('write', indexMd, () => writeFileSync(indexMd, md));
1351
+ changed++;
1352
+ }
1353
+ }
1354
+ if (changed === 0) {
1355
+ console.log('patch-surfdash: nothing to change');
1356
+ return;
1357
+ }
1358
+ const git = (...a: string[]): void => {
1359
+ const r = spawnSync('git', ['-C', surfdashRoot, ...a]);
1360
+ if (r.status !== 0) failWith(1, `patch-surfdash: git ${a[0]} failed: ${r.stderr?.toString().trim()}`);
1361
+ };
1362
+ git('add', 'content/posts/articles');
1363
+ git('commit', '-m', `feat: cover + podcast cross-link for ${basename(slugDir)} (kk daily patch)`);
1364
+ if (process.env.SURFDASH_AUTO_PUSH !== '0') git('push');
1365
+ console.log(
1366
+ `patch-surfdash: patched ${changed} post(s), committed${process.env.SURFDASH_AUTO_PUSH !== '0' ? ' + pushed' : ' (push skipped: SURFDASH_AUTO_PUSH=0)'}`,
1367
+ );
1368
+ });
1369
+ }
1370
+
1087
1371
  function dailyRunReport(args: string[]): void {
1088
1372
  const [
1089
1373
  workDir = '',
@@ -1108,6 +1392,9 @@ function dailyRunReport(args: string[]): void {
1108
1392
  return;
1109
1393
  }
1110
1394
  sted('write', cands, () => writeJqText(cands, metadataOf(readJson(candidates)).docs ?? []));
1395
+ // Plugin env paths must be absolute: spawnPluginEntry runs plugins with cwd = the
1396
+ // plugin package dir, so a relative workDir silently misses every best-effort read.
1397
+ const absWorkDir = resolve(workDir);
1111
1398
  kk(['executor', 'run', 'news-report-gen', '--in', cands, '--out', reportJson, ...pluginsArgs(pluginsPath)], {
1112
1399
  env: {
1113
1400
  QC_MIN_QUALITY: minQuality,
@@ -1115,13 +1402,19 @@ function dailyRunReport(args: string[]): void {
1115
1402
  QC_MIN_URGENCY: minUrgency,
1116
1403
  QC_MIN_IMPACT: minImpact,
1117
1404
  NEWS_REPORT_REJECTED_FILE: join(
1118
- workDir,
1405
+ absWorkDir,
1119
1406
  '2-plan',
1120
1407
  `${runDate}_04_quality-control-content_candidates.rejected.json`,
1121
1408
  ),
1122
- NEWS_REPORT_TIMING_FILE: join(workDir, 'step-timing.json'),
1409
+ NEWS_REPORT_TIMING_FILE: join(absWorkDir, 'step-timing.json'),
1123
1410
  // 0139 R5b: the generate stage's QC audit, read best-effort (absent -> section omitted).
1124
- NEWS_REPORT_QC_FILE: join(workDir, '3-audio', `${runDate}_11_generate_content.json`),
1411
+ NEWS_REPORT_QC_FILE: join(absWorkDir, '3-audio', `${runDate}_11_generate_content.json`),
1412
+ // 20260917 funnel: the plan stage's full selection trail (every fetched title +
1413
+ // scores + disposition), rendered as the 选稿漏斗 section (absent -> omitted).
1414
+ NEWS_REPORT_PLAN_AUDIT_FILE: join(absWorkDir, '2-plan', `${runDate}_03_plan_plan.json.audit.json`),
1415
+ // 20260917 funnel: the blended fan-in input, rendered as the 语料构成
1416
+ // source/category composition (absent -> omitted).
1417
+ NEWS_REPORT_BLENDED_FILE: join(absWorkDir, '1-ingest', `${runDate}_02_collect_blended.json`),
1125
1418
  NEWS_REPORT_DATE: runDate,
1126
1419
  },
1127
1420
  });
@@ -1132,68 +1425,78 @@ function dailyRunReport(args: string[]): void {
1132
1425
  });
1133
1426
  }
1134
1427
 
1135
- function dailyTranslateEn(args: string[]): void {
1428
+ /**
1429
+ * Collapsed translate scaffolding (2026-09-16 feedback): surfdash-pub owns the
1430
+ * deterministic mechanics — `scaffold-locale` for both target locales — while the
1431
+ * workflow `translate` state's agent step owns the LLM translation of the drafts.
1432
+ * Writes one marker file per locale (zh post path + en/ja draft paths); empty
1433
+ * marker = locale absent (callers skip). Reuses an existing on-disk draft as the
1434
+ * cache across run retries — re-scaffolding would erase the agent's translation.
1435
+ */
1436
+ function dailyTranslateLocalize(args: string[]): void {
1136
1437
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
1137
1438
  const result = join(workDir, '3-publish', `${runDate}_17_publish_result.json`);
1138
- const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-en_zh-post-path.txt`);
1139
- const enFile = join(workDir, '3-publish', `${runDate}_20_translate-en_en-post-path.txt`);
1140
- const scaffold = join(workDir, '3-publish', `${runDate}_20_translate-en_scaffold-en.json`);
1141
- const scaffoldResult = join(workDir, '3-publish', `${runDate}_20_translate-en_scaffold-en-result.json`);
1142
- timed(workDir, 'translate-en', () => {
1439
+ const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
1440
+ timed(workDir, 'translate-localize', () => {
1143
1441
  const postPath = firstPostPath(readJson(result), 'surfdash-pub');
1144
1442
  const zh = postPath.endsWith('index.md') ? postPath : '';
1145
1443
  sted('write', zhFile, () => writeRaw(zhFile, zh));
1146
- const en = zh.replace('/zh/', '/en/');
1147
- sted('write', enFile, () => writeRaw(enFile, en));
1148
- if (zh === '') {
1149
- console.log('no zh post path — skip translate-en');
1150
- return;
1151
- }
1152
- if (isFile(en)) {
1153
- console.log(`en draft cached: ${en}`);
1154
- return;
1155
- }
1156
- sted('write', scaffold, () =>
1157
- writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target: 'en' } }),
1158
- );
1159
- kk(['executor', 'run', 'surfdash-pub', '--in', scaffold, '--out', scaffoldResult, ...pluginsArgs(pluginsPath)]);
1160
- const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1161
- sted('write', enFile, () => writeRaw(enFile, typeof draftPath === 'string' ? draftPath : ''));
1162
- });
1163
- }
1164
-
1165
- function dailyTranslateJa(args: string[]): void {
1166
- const [workDir = '', runDate = '', pluginsPath = ''] = args;
1167
- const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-en_zh-post-path.txt`);
1168
- const jaFile = join(workDir, '3-publish', `${runDate}_22_translate-ja_ja-post-path.txt`);
1169
- const scaffold = join(workDir, '3-publish', `${runDate}_22_translate-ja_scaffold-ja.json`);
1170
- const scaffoldResult = join(workDir, '3-publish', `${runDate}_22_translate-ja_scaffold-ja-result.json`);
1171
- timed(workDir, 'translate-ja', () => {
1172
- const zh = isFile(zhFile) ? readFileSync(zhFile, 'utf-8') : '';
1173
- const ja = zh.replace('/zh/', '/ja/');
1174
- sted('write', jaFile, () => writeRaw(jaFile, ja));
1175
1444
  if (zh === '') {
1176
- console.log('no zh post path — skip translate-ja');
1445
+ for (const target of ['en', 'ja'] as const) {
1446
+ sted(
1447
+ 'write',
1448
+ join(workDir, '3-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
1449
+ () =>
1450
+ writeRaw(
1451
+ join(workDir, '3-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
1452
+ '',
1453
+ ),
1454
+ );
1455
+ }
1456
+ console.log('no zh post path — skip localize');
1177
1457
  return;
1178
1458
  }
1179
- if (isFile(ja)) {
1180
- console.log(`ja draft cached: ${ja}`);
1181
- return;
1459
+ for (const target of ['en', 'ja'] as const) {
1460
+ const markerFile = join(workDir, '3-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`);
1461
+ const existing = zh.replace('/zh/', `/${target}/`);
1462
+ if (isFile(existing)) {
1463
+ sted('write', markerFile, () => writeRaw(markerFile, existing));
1464
+ console.log(`${target} draft cached: ${existing}`);
1465
+ continue;
1466
+ }
1467
+ const scaffold = join(workDir, '3-publish', `${runDate}_20_translate-localize_scaffold-${target}.json`);
1468
+ const scaffoldResult = join(
1469
+ workDir,
1470
+ '3-publish',
1471
+ `${runDate}_20_translate-localize_scaffold-${target}-result.json`,
1472
+ );
1473
+ sted('write', scaffold, () =>
1474
+ writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target } }),
1475
+ );
1476
+ kk([
1477
+ 'executor',
1478
+ 'run',
1479
+ 'surfdash-pub',
1480
+ '--in',
1481
+ scaffold,
1482
+ '--out',
1483
+ scaffoldResult,
1484
+ ...pluginsArgs(pluginsPath),
1485
+ ]);
1486
+ const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1487
+ sted('write', markerFile, () => writeRaw(markerFile, typeof draftPath === 'string' ? draftPath : ''));
1182
1488
  }
1183
- sted('write', scaffold, () =>
1184
- writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target: 'ja' } }),
1185
- );
1186
- kk(['executor', 'run', 'surfdash-pub', '--in', scaffold, '--out', scaffoldResult, ...pluginsArgs(pluginsPath)]);
1187
- const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1188
- sted('write', jaFile, () => writeRaw(jaFile, typeof draftPath === 'string' ? draftPath : ''));
1189
1489
  });
1190
1490
  }
1191
1491
 
1192
1492
  function dailyTranslateSync(args: string[]): void {
1193
- const [workDir = '', runDate = '', pluginsPath = '', zhPostPath = ''] = args;
1493
+ const [workDir = '', runDate = '', pluginsPath = '', zhPathFile = ''] = args;
1194
1494
  const sync = join(workDir, '3-publish', `${runDate}_24_translate-sync_sync.json`);
1195
1495
  const syncResult = join(workDir, '3-publish', `${runDate}_24_translate-sync_sync-result.json`);
1196
1496
  timed(workDir, 'translate-sync', () => {
1497
+ // Reads the marker file itself — the collapsed `translate` state has no
1498
+ // cross-state var to hand the path through.
1499
+ const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8') : '';
1197
1500
  if (zhPostPath === '') {
1198
1501
  console.log('no zh post path — skip translate-sync');
1199
1502
  return;
@@ -1287,6 +1590,9 @@ if (import.meta.main)
1287
1590
  case 'daily-generate':
1288
1591
  dailyGenerate(rest);
1289
1592
  break;
1593
+ case 'daily-intro':
1594
+ dailyIntro(rest);
1595
+ break;
1290
1596
  case 'daily-quality-report':
1291
1597
  dailyQualityReport(rest);
1292
1598
  break;
@@ -1305,17 +1611,17 @@ if (import.meta.main)
1305
1611
  case 'daily-publish-classify':
1306
1612
  dailyPublishClassify(rest);
1307
1613
  break;
1614
+ case 'daily-patch-surfdash':
1615
+ dailyPatchSurfdash(rest);
1616
+ break;
1308
1617
  case 'daily-publish-partial':
1309
1618
  dailyPublishPartial(rest);
1310
1619
  break;
1311
1620
  case 'daily-run-report':
1312
1621
  dailyRunReport(rest);
1313
1622
  break;
1314
- case 'daily-translate-en':
1315
- dailyTranslateEn(rest);
1316
- break;
1317
- case 'daily-translate-ja':
1318
- dailyTranslateJa(rest);
1623
+ case 'daily-translate-localize':
1624
+ dailyTranslateLocalize(rest);
1319
1625
  break;
1320
1626
  case 'daily-translate-sync':
1321
1627
  dailyTranslateSync(rest);