@gobing-ai/knowledge-kit 0.0.11 → 0.0.12

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 (42) hide show
  1. package/dist/index.js +363 -138
  2. package/package.json +1 -1
  3. package/plugins/generations/content-gen/src/storm.ts +99 -50
  4. package/plugins/generations/core-facts-gen/package.json +17 -0
  5. package/plugins/generations/core-facts-gen/plugin.json +7 -0
  6. package/plugins/generations/core-facts-gen/src/index.ts +116 -0
  7. package/plugins/generations/core-facts-gen/tsconfig.json +4 -0
  8. package/plugins/generations/daily-article-gen/package.json +17 -0
  9. package/plugins/generations/daily-article-gen/plugin.json +7 -0
  10. package/plugins/generations/daily-article-gen/src/index.ts +91 -0
  11. package/plugins/generations/daily-article-gen/tsconfig.json +4 -0
  12. package/plugins/generations/dailynews-gen/src/index.ts +11 -0
  13. package/plugins/generations/dailynews-gen/src/script-builder.ts +1 -1
  14. package/plugins/generations/episode-plan-gen/package.json +17 -0
  15. package/plugins/generations/episode-plan-gen/plugin.json +7 -0
  16. package/plugins/generations/episode-plan-gen/src/index.ts +726 -0
  17. package/plugins/generations/episode-plan-gen/tsconfig.json +4 -0
  18. package/plugins/generations/voice-gen/src/index.ts +102 -11
  19. package/plugins/generations/voice-gen/src/qc.ts +154 -9
  20. package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
  21. package/plugins/ingestions/aihot-ingest/src/index.ts +72 -13
  22. package/plugins/ingestions/aihot-ingest/src/mapper.ts +1 -0
  23. package/plugins/ingestions/aihot-ingest/src/rss.ts +151 -0
  24. package/plugins/ingestions/horizon-ingest/package.json +17 -0
  25. package/plugins/ingestions/horizon-ingest/plugin.json +7 -0
  26. package/plugins/ingestions/horizon-ingest/src/index.ts +205 -0
  27. package/plugins/ingestions/horizon-ingest/tsconfig.json +4 -0
  28. package/plugins/ingestions/last30days-ingest/package.json +17 -0
  29. package/plugins/ingestions/last30days-ingest/plugin.json +7 -0
  30. package/plugins/ingestions/last30days-ingest/src/index.ts +148 -0
  31. package/plugins/ingestions/last30days-ingest/tsconfig.json +4 -0
  32. package/plugins/kk/skills/taste-unslop/SKILL.md +12 -6
  33. package/plugins/kk/skills/taste-unslop/references/pattern-guide.md +128 -48
  34. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +432 -19
  35. package/plugins/publishings/podcast-pub/package.json +17 -0
  36. package/plugins/publishings/podcast-pub/plugin.json +7 -0
  37. package/plugins/publishings/podcast-pub/src/index.ts +538 -0
  38. package/plugins/publishings/podcast-pub/src/map.ts +165 -0
  39. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +196 -0
  40. package/plugins/publishings/podcast-pub/src/show-notes.ts +132 -0
  41. package/plugins/publishings/podcast-pub/tsconfig.json +4 -0
  42. package/plugins/publishings/surfdash-pub/src/index.ts +328 -62
@@ -3,10 +3,13 @@
3
3
  name: kk-daily-ai-voice
4
4
  kind: state-machine
5
5
  description: >-
6
- Daily AI news broadcast pipeline orchestrating aihot-ingest, dailynews-gen, and voice-gen
6
+ Daily AI news end-to-end pipeline (F61 destination): collect (fan-in --source: aihot + last30days
7
+ + Horizon) → core facts → plan → content QC (filter, done-no-content terminal) → article + cover
8
+ + script + review + voice → publish (surfdash + podcast, full|partial|none routing) → locales
7
9
  initialState: prepare
8
10
  terminalStates:
9
11
  - done
12
+ - done-no-content
10
13
  - failed
11
14
  failureStates:
12
15
  - failed
@@ -15,46 +18,152 @@ vars:
15
18
  work_dir: ""
16
19
  limit: "10"
17
20
  language: "zh"
18
- voice_profile: "Robin"
21
+ voice_profile: "robin-news"
19
22
  title: ""
20
23
  cursor: ""
21
24
  state_file: ""
22
25
  skip_review: "false"
23
26
  transcode_mp3: "false"
27
+ cover_enabled: "true"
28
+ plan_max_items: "20"
29
+ plan_translate_enabled: "true"
24
30
  plugins_path: ""
31
+ last30days_topic: "AI news"
32
+ horizon_hours: "24"
33
+ publish_enabled: "false"
34
+ last30days_enabled: "false"
35
+ run_date: ""
36
+ agent: "auto"
37
+ surfdash_post_path: ""
38
+ surfdash_en_post_path: ""
39
+ surfdash_ja_post_path: ""
40
+ qc_categories: "ai-news,ai-industry"
41
+ # 0098 dogfood calibration (r7, owner-approved 0099): quality 3→2, impact 2→1 — the shipped
42
+ # defaults rejected 20/20 live candidates (strongest ai-news items died solely on impact:1<2).
43
+ # These pins must move in lockstep with episode-plan-gen's DEFAULT_FILTER_CONFIG (env wins).
44
+ qc_min_quality: "2"
45
+ qc_min_importance: "3"
46
+ qc_min_urgency: "2"
47
+ qc_min_impact: "1"
25
48
  __hitlAnswer: ""
26
49
 
27
50
  states:
28
51
  - id: prepare
29
- description: "Create workspace subdirectories and validate parameters"
52
+ description: "Create workspace subdirectories and validate parameters; write source configs"
30
53
  onEnter:
31
54
  - kind: shell
32
55
  options:
33
56
  command: >-
34
57
  test -n "${vars.work_dir}" || { echo "missing required variable: work_dir" >&2 && exit 1; };
35
- mkdir -p "${vars.work_dir}" "${vars.work_dir}/1-ingest" "${vars.work_dir}/2-script" "${vars.work_dir}/3-audio";
36
- bun -e 'await Bun.write(process.argv[1], JSON.stringify({ limit: Number(process.argv[2]), cursor: process.argv[3] || undefined, stateFile: process.argv[4] || undefined }, null, 2))' "${vars.work_dir}/1-ingest/source.json" "${vars.limit}" "${vars.cursor}" "${vars.state_file}"
58
+ mkdir -p "${vars.work_dir}/1-ingest" "${vars.work_dir}/2-facts" "${vars.work_dir}/2-plan" "${vars.work_dir}/2-article" "${vars.work_dir}/2-cover" "${vars.work_dir}/2-script" "${vars.work_dir}/3-audio" "${vars.work_dir}/3-publish";
59
+ date +%Y%m%d > "${vars.work_dir}/run-date.txt" &&
60
+ bun -e 'await Bun.write(process.argv[1], JSON.stringify({ limit: Number(process.argv[2]), cursor: process.argv[3] || undefined, stateFile: process.argv[4] || undefined }, null, 2))' "${vars.work_dir}/1-ingest/source.json" "${vars.limit}" "${vars.cursor}" "${vars.state_file}" &&
61
+ if [ "${vars.last30days_enabled}" != "true" ]; then printf '[]' > "${vars.work_dir}/1-ingest/last30days-docs.json"; fi &&
62
+ bun -e 'await Bun.write(process.argv[1], JSON.stringify({ topic: process.argv[2] }, null, 2))' "${vars.work_dir}/1-ingest/last30days.json" "${vars.last30days_topic}" &&
63
+ bun -e 'await Bun.write(process.argv[1], JSON.stringify({ hours: Number(process.argv[2]) }, null, 2))' "${vars.work_dir}/1-ingest/horizon.json" "${vars.horizon_hours}"
64
+ - kind: file.read.into-var
65
+ options:
66
+ path: ${vars.work_dir}/run-date.txt
67
+ var: run_date
68
+
69
+ - id: collect
70
+ description: "Concurrently ingest all sources via kk executor fan-in --source, then render the dated core-facts markdown"
71
+ onEnter:
72
+ - kind: shell
73
+ options:
74
+ # 0098 dogfood: horizon-ingest latency grew 20m40s (r4) → 28m39s (r7), straddling the
75
+ # fan-in 30m per-source default and making empty episodes stochastic — horizon gets a
76
+ # 45m leash; aihot/last30days keep the default.
77
+ command: >-
78
+ if [ -f "${vars.work_dir}/1-ingest/blended.json" ]; then echo "collect cached: ${vars.work_dir}/1-ingest/blended.json"; exit 0; fi;
79
+ kk executor fan-in --source "aihot-ingest:${vars.work_dir}/1-ingest/source.json" --source "last30days-ingest:${vars.work_dir}/1-ingest/last30days.json" --source "horizon-ingest:${vars.work_dir}/1-ingest/horizon.json" --source-timeout-override "horizon-ingest:2700000" --out "${vars.work_dir}/1-ingest/blended.json"
80
+ - kind: shell
81
+ options:
82
+ command: >-
83
+ set --;
84
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
85
+ if [ -f "${vars.work_dir}/2-facts/content.json" ] && [ -f "${vars.work_dir}/2-facts/core-facts.md" ]; then echo "core facts cached: ${vars.work_dir}/2-facts/core-facts.md"; exit 0; fi;
86
+ kk executor run core-facts-gen --in "${vars.work_dir}/1-ingest/blended.json" --out "${vars.work_dir}/2-facts/content.json" "$@" &&
87
+ bun -e 'await Bun.write(process.argv[2], (await Bun.file(process.argv[1]).json()).body)' "${vars.work_dir}/2-facts/content.json" "${vars.work_dir}/2-facts/core-facts.md"
88
+
89
+ - id: plan
90
+ description: "Score/dedup/select + annotate the blended Doc[] into the episode plan (episode-plan-gen; annotate rides inside planEpisode)"
91
+ onEnter:
92
+ - kind: shell
93
+ options:
94
+ command: >-
95
+ set --;
96
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
97
+ PLAN_CONTENT="${vars.work_dir}/2-plan/content.json"; PLAN_DOCS="${vars.work_dir}/2-plan/plan.json";
98
+ if [ -f "$PLAN_DOCS" ]; then echo "plan cached: $PLAN_DOCS"; exit 0; fi;
99
+ EPISODE_PLAN_MAX_ITEMS="${vars.plan_max_items}" EPISODE_PLAN_DATE="${vars.run_date}" kk executor run episode-plan-gen --in "${vars.work_dir}/1-ingest/blended.json" --out "$PLAN_CONTENT" "$@" &&
100
+ bun -e 'const content = JSON.parse(await Bun.file(process.argv[1]).text()); const docs = content.metadata?.docs; if (!Array.isArray(docs) || docs.length === 0) { console.error("plan: content.metadata.docs missing or empty"); process.exit(1); } await Bun.write(process.argv[2], JSON.stringify(docs, null, 2))' "$PLAN_CONTENT" "$PLAN_DOCS" &&
101
+ test -f "$PLAN_DOCS" || { echo "plan: $PLAN_DOCS not written after episode-plan-gen"; exit 1; }
102
+
103
+ - id: quality-control-content
104
+ description: "Content QC: filter the plan's annotated candidates by category allowlist + score thresholds (episode-plan-gen --mode filter); zero candidates route to done-no-content"
105
+ onEnter:
106
+ - kind: shell
107
+ options:
108
+ command: >-
109
+ set --;
110
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
111
+ if [ -f "${vars.work_dir}/2-plan/candidates.json" ]; then echo "candidates cached: ${vars.work_dir}/2-plan/candidates.json"; exit 0; fi;
112
+ jq '.metadata.docs' "${vars.work_dir}/2-plan/content.json" > "${vars.work_dir}/2-plan/annotated.json" &&
113
+ EPISODE_PLAN_MODE=filter QC_CATEGORIES="${vars.qc_categories}" QC_MIN_QUALITY="${vars.qc_min_quality}" QC_MIN_IMPORTANCE="${vars.qc_min_importance}" QC_MIN_URGENCY="${vars.qc_min_urgency}" QC_MIN_IMPACT="${vars.qc_min_impact}" kk executor run episode-plan-gen --in "${vars.work_dir}/2-plan/annotated.json" --out "${vars.work_dir}/2-plan/candidates.json" "$@"
114
+
115
+ - id: plan-translate
116
+ description: "Translate needsTranslation plan items into the output plan.json via agent.run"
117
+ onEnter:
118
+ - kind: agent.run
119
+ options:
120
+ role: scribe
121
+ agent: ${vars.agent}
122
+ input: |
123
+ Read the JSON file at ${vars.work_dir}/2-plan/candidates.json. Its metadata.docs is a JSON array of news items (Doc[]) — this is the episode plan to translate. Do not edit candidates.json.
124
+ For every item with needsTranslation: translate its "title" and "body" fields into natural, concise Chinese (simplified).
125
+ Titles must read as a news event, not a bare label: derive the title from the item's body (first sentence carries the event) so "Sora 2" becomes e.g. "OpenAI 发布 Sora 应用,集成 Sora 2 模型" — subject + action + object. Keep product/model/company names verbatim.
126
+ Keep everything else untouched: metadata (including needsTranslation, planScore, mergedFrom, urls), sourceUri, id, mediaType.
127
+ Preserve any URLs inside the body text. Keep technical terms (product/model/company names) in their original form where that is clearer.
128
+ The output must be strictly valid JSON: escape every double quote inside string values as \", never use raw ASCII double quotes inside Chinese text (use 「」 or \"), and produce no trailing commas or comments.
129
+ Write the translated array — a valid JSON array of the same length, same item order, same ids — to ${vars.work_dir}/2-plan/plan.json (create the file).
130
+ expectFile: ${vars.work_dir}/2-plan/plan.json
131
+ allowAppend: false
37
132
 
38
- - id: fetch
39
- description: "Ingest latest curated AI news from aihot.virxact.com"
133
+ - id: article
134
+ description: "Generate the dated daily article from the episode plan (daily-article-gen template)"
40
135
  onEnter:
41
136
  - kind: shell
42
137
  options:
43
138
  command: >-
44
139
  set --;
45
140
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
46
- kk executor run aihot-ingest --in "${vars.work_dir}/1-ingest/source.json" --out "${vars.work_dir}/1-ingest/docs.json" "$@"
141
+ CONTENT_OUT="${vars.work_dir}/2-article/content-${vars.run_date}.json"; ARTICLE_MD="${vars.work_dir}/2-article/article-${vars.run_date}.md";
142
+ if [ -f "$ARTICLE_MD" ]; then echo "article cached: $ARTICLE_MD"; else PLAN_IN="${vars.work_dir}/2-plan/plan.json"; PLAN_OK=1; bun -e 'try { JSON.parse(await Bun.file(process.argv[1]).text()); process.exit(0); } catch (e) { console.warn("article: plan.json invalid JSON — replacing with untranslated plan (downstream states read this file): " + e.message); const docs = JSON.parse(await Bun.file(process.argv[2]).text()).metadata?.docs; if (!Array.isArray(docs) || docs.length === 0) { console.error("article: candidates.json metadata.docs missing"); process.exit(2); } await Bun.write(process.argv[1], JSON.stringify(docs, null, 2)); process.exit(1); }' "$PLAN_IN" "${vars.work_dir}/2-plan/candidates.json"; case $? in 0|1) PLAN_OK=0;; esac; test "$PLAN_OK" -eq 0 || { echo "article: no usable plan input"; exit 1; }; ARTICLE_DATE="${vars.run_date}" kk executor run daily-article-gen --in "$PLAN_IN" --out "$CONTENT_OUT" "$@" && bun -e 'await Bun.write(process.argv[2], (await Bun.file(process.argv[1]).json()).body)' "$CONTENT_OUT" "$ARTICLE_MD"; fi
143
+
144
+ - id: cover
145
+ description: "Generate the article cover image via the wt-image-cover skill (agent.run)"
146
+ onEnter:
147
+ - kind: agent.run
148
+ options:
149
+ role: scribe
150
+ agent: ${vars.agent}
151
+ input: |
152
+ Invoke the wt-image-cover skill.
153
+ Generate a 21:9 cover image for the article at ${vars.work_dir}/2-article/article-${vars.run_date}.md and save it to ${vars.work_dir}/2-cover/cover-${vars.run_date}.png.
154
+ Use style "news". If no image-generation backend is available, a procedurally rendered cover (e.g. PIL: news palette, headline typography) is an acceptable, expected deliverable — not a fake; generate it and write the file. Do not wait for operator confirmation.
155
+ expectFile: ${vars.work_dir}/2-cover/cover-${vars.run_date}.png
47
156
 
48
157
  - id: script
49
- description: "Compile news Doc[] into structured broadcast VoiceScript YAML Content"
158
+ description: "Compile the episode plan Doc[] into structured broadcast VoiceScript YAML Content"
50
159
  onEnter:
51
160
  - kind: shell
52
161
  options:
53
162
  command: >-
54
163
  set --;
55
164
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
56
- DAILYNEWS_LANGUAGE="${vars.language}" DAILYNEWS_TITLE="${vars.title}" VOICEBOX_DEFAULT_PROFILE="${vars.voice_profile}" kk executor run dailynews-gen --in "${vars.work_dir}/1-ingest/docs.json" --out "${vars.work_dir}/2-script/content.json" "$@" &&
57
- bun -e 'await Bun.write(process.argv[2], (await Bun.file(process.argv[1]).json()).body)' "${vars.work_dir}/2-script/content.json" "${vars.work_dir}/2-script/voicescript.yaml"
165
+ SCRIPT_CONTENT_OUT="${vars.work_dir}/2-script/content-${vars.run_date}.json"; VOICESCRIPT_YAML="${vars.work_dir}/2-script/voicescript-${vars.run_date}.yaml";
166
+ if [ -f "$VOICESCRIPT_YAML" ]; then echo "voicescript capped by plan: $VOICESCRIPT_YAML"; else DAILYNEWS_LANGUAGE="${vars.language}" DAILYNEWS_TITLE="${vars.title}" VOICEBOX_DEFAULT_PROFILE="${vars.voice_profile}" kk executor run dailynews-gen --in "${vars.work_dir}/2-plan/plan.json" --out "$SCRIPT_CONTENT_OUT" "$@" && bun -e 'await Bun.write(process.argv[2], (await Bun.file(process.argv[1]).json()).body)' "$SCRIPT_CONTENT_OUT" "$VOICESCRIPT_YAML"; fi
58
167
 
59
168
  - id: review
60
169
  description: "Operator reviews the generated news script before speech synthesis"
@@ -62,7 +171,7 @@ states:
62
171
  onEnter:
63
172
  - kind: hitl.confirm
64
173
  options:
65
- prompt: "Review daily AI news VoiceScript at ${vars.work_dir}/2-script/voicescript.yaml. Proceed to voice generation?"
174
+ prompt: "Review daily AI news VoiceScript at ${vars.work_dir}/2-script/voicescript-${vars.run_date}.yaml. Proceed to voice generation?"
66
175
  var: __hitlAnswer
67
176
 
68
177
  - id: wrap-docs
@@ -71,7 +180,7 @@ states:
71
180
  - kind: shell
72
181
  options:
73
182
  command: >-
74
- bun -e 'const scriptUrl = new URL(process.argv[1], "file://" + process.cwd() + "/"); const scriptPath = decodeURIComponent(scriptUrl.pathname); const doc = { id: new Bun.CryptoHasher("sha256").update(scriptUrl.href).digest("hex").slice(0, 16), title: "Daily AI News VoiceScript", sourceUri: scriptUrl.href, body: await Bun.file(scriptPath).text(), mediaType: "application/yaml", metadata: { voiceProfile: process.argv[3] || process.env.VOICEBOX_DEFAULT_PROFILE || "Robin" } }; await Bun.write(process.argv[2], JSON.stringify([doc], null, 2))' "${vars.work_dir}/2-script/voicescript.yaml" "${vars.work_dir}/3-audio/docs.json" "${vars.voice_profile}"
183
+ bun -e 'const scriptUrl = new URL(process.argv[1], "file://" + process.cwd() + "/"); const scriptPath = decodeURIComponent(scriptUrl.pathname); const doc = { id: new Bun.CryptoHasher("sha256").update(scriptUrl.href).digest("hex").slice(0, 16), title: "Daily AI News VoiceScript", sourceUri: scriptUrl.href, body: await Bun.file(scriptPath).text(), mediaType: "application/yaml", metadata: { voiceProfile: process.argv[3] || process.env.VOICEBOX_DEFAULT_PROFILE || "robin-news" } }; await Bun.write(process.argv[2], JSON.stringify([doc], null, 2))' "${vars.work_dir}/2-script/voicescript-${vars.run_date}.yaml" "${vars.work_dir}/3-audio/docs.json" "${vars.voice_profile}"
75
184
 
76
185
  - id: generate
77
186
  description: "Invoke voice-gen generator plugin to generate audio"
@@ -81,6 +190,7 @@ states:
81
190
  command: >-
82
191
  set --;
83
192
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
193
+ if [ -f "${vars.work_dir}/3-audio/content.json" ] && jq -e '.metadata.qc.passed == true' "${vars.work_dir}/3-audio/content.json" > /dev/null 2>&1; then echo "audio cached: ${vars.work_dir}/3-audio/content.json (QC passed)"; exit 0; fi;
84
194
  VOICE_GEN_MP3="${vars.transcode_mp3}" kk executor run voice-gen --in "${vars.work_dir}/3-audio/docs.json" --out "${vars.work_dir}/3-audio/content.json" "$@"
85
195
 
86
196
  - id: quality-control
@@ -91,21 +201,220 @@ states:
91
201
  command: >-
92
202
  bun -e 'const content = JSON.parse(await Bun.file(process.argv[1]).text()); const qc = content.metadata?.qc; console.log("=== Voice Quality Control Report ==="); console.log("Overall Score: " + (qc ? qc.overallScore : 100) + "/100 | Passed: " + (qc ? qc.passed : true)); if (qc && qc.criticalIssues && qc.criticalIssues.length) { console.warn("QC Issues:\n" + qc.criticalIssues.map(function(i) { return " - " + i; }).join("\n")); } else { console.log("All audio segments passed quality control checks cleanly."); }' "${vars.work_dir}/3-audio/content.json"
93
203
 
204
+ - id: show-notes
205
+ description: "Render uniform show notes from the episode plan (podcast-pub show-notes operation; PODCAST_PUB_TITLE carries the effective title vars.title || article.title)"
206
+ onEnter:
207
+ - kind: shell
208
+ options:
209
+ command: >-
210
+ set --;
211
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
212
+ TITLE_ARG="${vars.title}";
213
+ EFFECTIVE_TITLE="$(jq -r '.title // ""' "${vars.work_dir}/2-article/content-${vars.run_date}.json")";
214
+ if [ -z "$TITLE_ARG" ]; then TITLE_ARG="$EFFECTIVE_TITLE"; fi;
215
+ PODCAST_PUB_TITLE="$TITLE_ARG" PODCAST_PUB_RUN_DATE="${vars.run_date}" kk executor run podcast-pub --in "${vars.work_dir}/2-plan/plan.json" --out "${vars.work_dir}/3-publish/show-notes.md" "$@"
216
+
217
+ - id: publish-prep
218
+ description: "Merge article Content with audio (podcast assembly facts ride the content options channel for podcast-pub's absorbed assembly; runDate kept for surfdash slug)"
219
+ onEnter:
220
+ - kind: shell
221
+ options:
222
+ command: >-
223
+ bun -e 'const article = JSON.parse(await Bun.file(process.argv[1]).text()); const audio = JSON.parse(await Bun.file(process.argv[2]).text()); const audioMeta = audio.metadata ?? {}; const rawFile = audioMeta.mp3Path ?? audioMeta.audioPath; if (!rawFile) { console.error("publish-prep: voice content carries no mp3Path/audioPath"); process.exit(1); } const { resolve } = await import("node:path"); const { existsSync } = await import("node:fs"); const audioFile = resolve(rawFile); const coverPath = resolve(process.argv[4]); const merged = { ...article, metadata: { ...(article.metadata ?? {}), audioFile, runDate: process.argv[5] }, options: { audioFile, durationSec: audioMeta.duration, coverPath: existsSync(coverPath) ? coverPath : undefined } }; await Bun.write(process.argv[3], JSON.stringify(merged, null, 2))' "${vars.work_dir}/2-article/content-${vars.run_date}.json" "${vars.work_dir}/3-audio/content.json" "${vars.work_dir}/3-publish/content.json" "${vars.work_dir}/2-cover/cover-${vars.run_date}.png" "${vars.run_date}"
224
+
225
+ - id: publish
226
+ description: "Fan out the merged Content to surfdash-pub and podcast-pub; classify the outcome full|partial|none into publish-status.txt (exit 0 in all three — routing is transition-owned)"
227
+ onEnter:
228
+ - kind: shell
229
+ options:
230
+ command: >-
231
+ set --;
232
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
233
+ RESULT="${vars.work_dir}/3-publish/result.json"; STATUS_FILE="${vars.work_dir}/3-publish/publish-status.txt";
234
+ if jq -e '.ok == true and ([.targets[]? | select(.ok == true and .status == "published")] | length) == 2' "$RESULT" > /dev/null 2>&1; then echo "publish cached: $RESULT (2 targets ok)"; printf 'full' > "$STATUS_FILE"; exit 0; fi;
235
+ OK_COUNT=0; TOTAL=0; STATUS="none";
236
+ env -u SPUR_WORKFLOW_RUN_ACTIVE kk executor fan-out --content "${vars.work_dir}/3-publish/content.json" --target surfdash-pub --target podcast-pub --out "$RESULT" "$@" || true;
237
+ if [ -f "$RESULT" ]; then OK_COUNT="$(jq '[.targets[]? | select(.ok == true and .status == "published")] | length' "$RESULT")"; TOTAL="$(jq '[.targets[]?] | length' "$RESULT")"; if [ "$OK_COUNT" -gt 0 ] && [ "$OK_COUNT" -eq "$TOTAL" ]; then STATUS="full"; elif [ "$OK_COUNT" -gt 0 ]; then STATUS="partial"; fi; else echo "publish: fan-out wrote no result.json — routing to none" >&2; fi;
238
+ printf '%s' "$STATUS" > "$STATUS_FILE";
239
+ echo "publish: status=$STATUS ($OK_COUNT/$TOTAL targets published); routing is transition-owned"
240
+
241
+ - id: publish-partial
242
+ description: "Partial publish — at least one target published, at least one failed; echo warning, write a .spur/run marker, preserve result.json for --retry-from, continue to done"
243
+ onEnter:
244
+ - kind: shell
245
+ options:
246
+ command: >-
247
+ FAILED="$(jq -r '[.targets[]? | select(.ok != true or .status != "published") | .target] | join(",")' "${vars.work_dir}/3-publish/result.json")";
248
+ if [ -z "$FAILED" ]; then FAILED="(unparsed)"; fi;
249
+ echo "WARNING: partial publish — failed targets: $FAILED" >&2;
250
+ mkdir -p .spur/run &&
251
+ printf '%s partial publish: failed targets: %s (result.json preserved for kk executor fan-out --retry-from)\n' "${vars.run_date}" "$FAILED" > ".spur/run/publish-partial-${vars.run_date}.txt"
252
+
253
+ - id: translate-en
254
+ description: "Scaffold en draft from the zh article via surfdash-pub scaffold-locale (postPath from publish result; done-no-content/none-publish runs carry no zh path and skip)"
255
+ onEnter:
256
+ - kind: shell
257
+ options:
258
+ command: >-
259
+ RESULT="${vars.work_dir}/3-publish/result.json";
260
+ ZH="$(jq -r '[(.targets // [])[] | select(.target == "surfdash-pub" and .ok == true) | .metadata.postPath // empty] | first // ""' "$RESULT")";
261
+ case "$ZH" in *index.md) ;; *) ZH="";; esac;
262
+ printf '%s' "$ZH" > "${vars.work_dir}/3-publish/zh-post-path.txt";
263
+ EN="$(printf '%s' "$ZH" | sed 's|/zh/|/en/|')";
264
+ printf '%s' "$EN" > "${vars.work_dir}/3-publish/en-post-path.txt";
265
+ test -n "$ZH" || { echo "no zh post path — skip translate-en"; exit 0; };
266
+ if [ -f "$EN" ]; then echo "en draft cached: $EN"; exit 0; fi;
267
+ set --;
268
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
269
+ jq -n --arg postPath "$ZH" --arg target en '{body: "", options: {operation: "scaffold-locale", postPath: $postPath, target: $target}}' > "${vars.work_dir}/3-publish/scaffold-en.json" &&
270
+ kk executor run surfdash-pub --in "${vars.work_dir}/3-publish/scaffold-en.json" --out "${vars.work_dir}/3-publish/scaffold-en-result.json" "$@" &&
271
+ jq -r '.metadata.draftPath // ""' "${vars.work_dir}/3-publish/scaffold-en-result.json" > "${vars.work_dir}/3-publish/en-post-path.txt"
272
+ - kind: file.read.into-var
273
+ options:
274
+ path: ${vars.work_dir}/3-publish/zh-post-path.txt
275
+ var: surfdash_post_path
276
+ - kind: file.read.into-var
277
+ options:
278
+ path: ${vars.work_dir}/3-publish/en-post-path.txt
279
+ var: surfdash_en_post_path
280
+
281
+ - id: translate-en-agent
282
+ description: "Translate the en draft in place via agent.run"
283
+ onEnter:
284
+ - kind: agent.run
285
+ options:
286
+ role: scribe
287
+ agent: ${vars.agent}
288
+ input: |
289
+ Translate the English draft post at ${vars.surfdash_en_post_path} from Chinese to English.
290
+ Edit the file in place: translate the title in frontmatter and the whole markdown body to natural English. Keep frontmatter keys, dates, URLs, code, and the assets/ image reference untouched. Keep the draft: true flag.
291
+ Read the surfdash translator glossary if present at the surfdash checkout root under .agents/skills/surfdash-translator/glossary.md and apply its terminology.
292
+ Do not wait for operator confirmation. The file must end fully in English.
293
+ expectFile: ${vars.surfdash_en_post_path}
294
+
295
+ - id: translate-ja
296
+ description: "Scaffold ja draft from the zh article via surfdash-pub scaffold-locale (sed-derived path as cache pre-check; plugin's draftPath is authoritative once scaffolded)"
297
+ onEnter:
298
+ - kind: shell
299
+ options:
300
+ command: >-
301
+ ZH="$(cat "${vars.work_dir}/3-publish/zh-post-path.txt" 2>/dev/null || printf '')";
302
+ JA="$(printf '%s' "$ZH" | sed 's|/zh/|/ja/|')";
303
+ printf '%s' "$JA" > "${vars.work_dir}/3-publish/ja-post-path.txt";
304
+ test -n "$ZH" || { echo "no zh post path — skip translate-ja"; exit 0; };
305
+ if [ -f "$JA" ]; then echo "ja draft cached: $JA"; exit 0; fi;
306
+ set --;
307
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
308
+ jq -n --arg postPath "$ZH" --arg target ja '{body: "", options: {operation: "scaffold-locale", postPath: $postPath, target: $target}}' > "${vars.work_dir}/3-publish/scaffold-ja.json" &&
309
+ kk executor run surfdash-pub --in "${vars.work_dir}/3-publish/scaffold-ja.json" --out "${vars.work_dir}/3-publish/scaffold-ja-result.json" "$@" &&
310
+ JA="$(jq -r '.metadata.draftPath // ""' "${vars.work_dir}/3-publish/scaffold-ja-result.json")" &&
311
+ printf '%s' "$JA" > "${vars.work_dir}/3-publish/ja-post-path.txt"
312
+ - kind: file.read.into-var
313
+ options:
314
+ path: ${vars.work_dir}/3-publish/ja-post-path.txt
315
+ var: surfdash_ja_post_path
316
+
317
+ - id: translate-ja-agent
318
+ description: "Translate the ja draft in place via agent.run"
319
+ onEnter:
320
+ - kind: agent.run
321
+ options:
322
+ role: scribe
323
+ agent: ${vars.agent}
324
+ input: |
325
+ Translate the Japanese draft post at ${vars.surfdash_ja_post_path} from Chinese to Japanese.
326
+ Edit the file in place: translate the title in frontmatter and the whole markdown body to natural Japanese. Keep frontmatter keys, dates, URLs, code, and the assets/ image reference untouched. Keep the draft: true flag.
327
+ Read the surfdash translator glossary if present at the surfdash checkout root under .agents/skills/surfdash-translator/glossary.md and apply its terminology.
328
+ Do not wait for operator confirmation. The file must end fully in Japanese.
329
+ expectFile: ${vars.surfdash_ja_post_path}
330
+
331
+ - id: translate-sync
332
+ description: "Sync the zh post and its en/ja locale variants into the surfdash CMS (surfdash-pub sync operation)"
333
+ onEnter:
334
+ - kind: shell
335
+ options:
336
+ command: >-
337
+ test -n "${vars.surfdash_post_path}" || { echo "no zh post path — skip translate-sync"; exit 0; };
338
+ set --;
339
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
340
+ jq -n --arg postPath "${vars.surfdash_post_path}" '{body: "", options: {operation: "sync", postPath: $postPath}}' > "${vars.work_dir}/3-publish/sync.json" &&
341
+ kk executor run surfdash-pub --in "${vars.work_dir}/3-publish/sync.json" --out "${vars.work_dir}/3-publish/sync-result.json" "$@"
342
+
94
343
  - id: done
95
- description: "Terminal — Daily AI news voice broadcast generated successfully"
344
+ description: "Terminal — Daily AI news pipeline completed (collect → QC'd plan → article/cover/script/voice → publish/locales)"
345
+
346
+ - id: done-no-content
347
+ description: "Terminal — quality-control-content admitted zero candidates under the QC thresholds; no article/script/audio/publish artifacts were produced"
348
+
96
349
  - id: failed
97
350
  description: "Terminal — Pipeline failed"
98
351
 
99
352
  transitions:
100
353
  - from: prepare
101
- to: fetch
102
- description: "Workspace prepared -> fetch news items"
354
+ to: collect
355
+ description: "Workspace prepared -> concurrent source collection"
103
356
  guard:
104
357
  kind: always
105
358
 
106
- - from: fetch
359
+ - from: collect
360
+ to: plan
361
+ description: "Sources merged into blended.json -> plan the episode (score/dedup/select/annotate)"
362
+ guard:
363
+ kind: always
364
+
365
+ - from: plan
366
+ to: quality-control-content
367
+ description: "Plan annotated -> content QC filter over the plan's candidates"
368
+ guard:
369
+ kind: always
370
+
371
+ - from: quality-control-content
372
+ to: done-no-content
373
+ description: "QC filter admitted 0 candidates -> done-no-content (an empty episode is not a failure)"
374
+ guard:
375
+ kind: shell
376
+ options:
377
+ command: 'test "$(jq ''.metadata.docs | length == 0'' "${vars.work_dir}/2-plan/candidates.json")" = "true"'
378
+
379
+ - from: quality-control-content
380
+ to: plan-translate
381
+ description: "Candidates passed QC and plan_translate_enabled -> translate flagged items in place"
382
+ guard:
383
+ kind: shell
384
+ options:
385
+ command: 'test "${vars.plan_translate_enabled}" = "true"'
386
+
387
+ - from: quality-control-content
388
+ to: article
389
+ description: "Candidates passed QC, plan-translate disabled -> straight to article"
390
+ guard:
391
+ kind: shell
392
+ options:
393
+ command: 'test "${vars.plan_translate_enabled}" != "true"'
394
+
395
+ - from: plan-translate
396
+ to: article
397
+ description: "Plan translated -> generate article from plan"
398
+ guard:
399
+ kind: always
400
+
401
+ - from: article
402
+ to: cover
403
+ description: "cover_enabled and today's cover missing -> generate cover image"
404
+ guard:
405
+ kind: shell
406
+ options:
407
+ command: 'test "${vars.cover_enabled}" = "true" && test ! -f "${vars.work_dir}/2-cover/cover-${vars.run_date}.png"'
408
+
409
+ - from: article
107
410
  to: script
108
- description: "News ingested -> build broadcast script"
411
+ description: "cover disabled or today's cover cached -> skip to script"
412
+ guard:
413
+ kind: always
414
+
415
+ - from: cover
416
+ to: script
417
+ description: "Cover generated -> build broadcast script"
109
418
  guard:
110
419
  kind: always
111
420
 
@@ -152,7 +461,111 @@ transitions:
152
461
  kind: always
153
462
 
154
463
  - from: quality-control
464
+ to: show-notes
465
+ description: "publish enabled AND QC passed -> render show notes, then publish"
466
+ guard:
467
+ kind: shell
468
+ options:
469
+ command: 'test "${vars.publish_enabled}" = "true" && test "$(jq -r ''.metadata.qc.passed // true'' "${vars.work_dir}/3-audio/content.json")" = "true"'
470
+
471
+ - from: quality-control
472
+ to: done
473
+ description: "publish disabled AND QC passed -> audio episode complete (no publish)"
474
+ guard:
475
+ kind: shell
476
+ options:
477
+ command: 'test "${vars.publish_enabled}" != "true" && test "$(jq -r ''.metadata.qc.passed // true'' "${vars.work_dir}/3-audio/content.json")" = "true"'
478
+
479
+ - from: quality-control
480
+ to: failed
481
+ description: "Audio QC failed -> pipeline failed"
482
+ guard:
483
+ kind: always
484
+
485
+ - from: show-notes
486
+ to: publish-prep
487
+ description: "Show notes rendered -> merge article + audio for the publish seam"
488
+ guard:
489
+ kind: always
490
+
491
+ - from: publish-prep
492
+ to: publish
493
+ description: "Merged content ready -> fan out to surfdash-pub + podcast-pub"
494
+ guard:
495
+ kind: always
496
+
497
+ - from: publish
498
+ to: translate-en
499
+ description: "Publish full (all targets published) -> scaffold + translate en locale"
500
+ guard:
501
+ kind: shell
502
+ options:
503
+ command: 'test "$(cat "${vars.work_dir}/3-publish/publish-status.txt")" = "full"'
504
+
505
+ - from: publish
506
+ to: publish-partial
507
+ description: "Publish partial (>=1 target published, >=1 failed) -> record warning + marker, preserve result.json, continue to done"
508
+ guard:
509
+ kind: shell
510
+ options:
511
+ command: 'test "$(cat "${vars.work_dir}/3-publish/publish-status.txt")" = "partial"'
512
+
513
+ - from: publish
514
+ to: failed
515
+ description: "Publish none (all targets failed or fan-out crash) -> pipeline failed"
516
+ guard:
517
+ kind: shell
518
+ options:
519
+ command: 'test "$(cat "${vars.work_dir}/3-publish/publish-status.txt")" = "none"'
520
+
521
+ - from: publish-partial
522
+ to: done
523
+ description: "Partial publish recorded (warning + .spur/run marker) -> done; retry failed targets later via kk executor fan-out --retry-from"
524
+ guard:
525
+ kind: always
526
+
527
+ - from: translate-en
528
+ to: translate-en-agent
529
+ description: "zh post published and en draft scaffolded -> agent translates en draft in place"
530
+ guard:
531
+ kind: shell
532
+ options:
533
+ command: 'test -n "${vars.surfdash_post_path}" && test -f "${vars.surfdash_en_post_path}"'
534
+
535
+ - from: translate-en
536
+ to: translate-ja
537
+ description: "no zh post path -> skip en locale"
538
+ guard:
539
+ kind: always
540
+
541
+ - from: translate-en-agent
542
+ to: translate-ja
543
+ description: "en draft translated -> scaffold ja locale"
544
+ guard:
545
+ kind: always
546
+
547
+ - from: translate-ja
548
+ to: translate-ja-agent
549
+ description: "zh post published and ja draft scaffolded -> agent translates ja draft in place"
550
+ guard:
551
+ kind: shell
552
+ options:
553
+ command: 'test -n "${vars.surfdash_post_path}" && test -f "${vars.surfdash_ja_post_path}"'
554
+
555
+ - from: translate-ja
556
+ to: translate-sync
557
+ description: "no zh post path -> skip ja locale"
558
+ guard:
559
+ kind: always
560
+
561
+ - from: translate-ja-agent
562
+ to: translate-sync
563
+ description: "ja draft translated -> sync all locales into the CMS"
564
+ guard:
565
+ kind: always
566
+
567
+ - from: translate-sync
155
568
  to: done
156
- description: "Quality control checks complete -> done"
569
+ description: "zh post + locales synced into surfdash CMS -> episode complete"
157
570
  guard:
158
571
  kind: always
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@gobing-ai/podcast-pub",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "typecheck": "tsc --noEmit"
7
+ },
8
+ "dependencies": {
9
+ "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/publish-harness": "workspace:*",
11
+ "@gobing-ai/ts-runtime": "catalog:",
12
+ "@gobing-ai/ts-utils": "catalog:"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "1.3.14"
16
+ }
17
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "podcast-pub",
3
+ "kind": "publish",
4
+ "entry": "./src/index.ts",
5
+ "version": "1.0.0",
6
+ "description": "Publish plugin for a self-hosted microfeed instance: podcast episodes via the Bearer-authenticated /api/v1/ REST API plus item management operations"
7
+ }