@gobing-ai/knowledge-kit 0.1.8 → 0.1.10

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 (78) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.js +725 -1101
  3. package/dist/board/daily-voice/index.js +722 -101
  4. package/dist/board/itc/index.js +13 -0
  5. package/dist/index.js +1796 -102
  6. package/package.json +1 -1
  7. package/plugins/generations/content-gen/dist/index.js +4 -0
  8. package/plugins/generations/core-facts-gen/dist/index.js +4 -0
  9. package/plugins/generations/daily-article-gen/dist/index.js +4 -0
  10. package/plugins/generations/dailynews-gen/dist/index.js +4 -0
  11. package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
  12. package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
  13. package/plugins/generations/gemini-tts/dist/index.js +4 -0
  14. package/plugins/generations/image-gen/dist/index.js +4 -0
  15. package/plugins/generations/news-report-gen/dist/index.js +4 -0
  16. package/plugins/generations/voice-gen/dist/index.js +4 -0
  17. package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
  18. package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
  19. package/plugins/ingestions/horizon-ingest/dist/index.js +28 -1
  20. package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
  21. package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
  22. package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
  23. package/plugins/ingestions/web-search/dist/index.js +4 -0
  24. package/plugins/kk/config.example.yaml +8 -0
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
  27. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
  28. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
  29. package/plugins/kk/scripts/daily-stages.ts +665 -78
  30. package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
  31. package/plugins/kk/scripts/sentences.ts +134 -0
  32. package/plugins/kk/scripts/series-names.ts +124 -0
  33. package/plugins/kk/scripts/topic-boundary.json +18 -83
  34. package/plugins/kk/workflows/kk-collect.yaml +2 -0
  35. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +112 -9
  36. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
  37. package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
  38. package/plugins/kk/workflows/kk-daily-safety.yaml +50 -5
  39. package/plugins/publishings/bilibili-pub/dist/index.js +4 -0
  40. package/plugins/publishings/cnblogs-pub/dist/index.js +4 -0
  41. package/plugins/publishings/csdn-pub/dist/index.js +4 -0
  42. package/plugins/publishings/csdn-pub/dist/profile-check.js +4 -0
  43. package/plugins/publishings/emdash-pub/dist/index.js +4 -0
  44. package/plugins/publishings/infoq-pub/dist/index.js +4 -0
  45. package/plugins/publishings/infoq-pub/dist/profile-check.js +4 -0
  46. package/plugins/publishings/jike-pub/dist/index.js +4 -0
  47. package/plugins/publishings/jike-pub/dist/profile-check.js +4 -0
  48. package/plugins/publishings/juejin-pub/dist/index.js +4 -0
  49. package/plugins/publishings/juejin-pub/dist/profile-check.js +4 -0
  50. package/plugins/publishings/medium-pub/dist/index.js +4 -0
  51. package/plugins/publishings/podcast-pub/README.md +65 -0
  52. package/plugins/publishings/podcast-pub/dist/index.js +1906 -374
  53. package/plugins/publishings/podcast-pub/plugin.json +1 -1
  54. package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
  55. package/plugins/publishings/podcast-pub/src/index.ts +211 -34
  56. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
  57. package/plugins/publishings/podcast-pub/src/seo.ts +1079 -0
  58. package/plugins/publishings/podcast-pub/src/show-notes.ts +74 -14
  59. package/plugins/publishings/qiita-pub/dist/index.js +4 -0
  60. package/plugins/publishings/shipinhao-pub/dist/index.js +4 -0
  61. package/plugins/publishings/sspai-pub/dist/index.js +4 -0
  62. package/plugins/publishings/substack-pub/dist/index.js +4 -0
  63. package/plugins/publishings/substack-pub/dist/profile-check.js +4 -0
  64. package/plugins/publishings/surfdash-pub/dist/index.js +313 -32
  65. package/plugins/publishings/surfdash-pub/src/digest-check.ts +218 -0
  66. package/plugins/publishings/surfdash-pub/src/index.ts +170 -39
  67. package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
  68. package/plugins/publishings/threads-pub/dist/index.js +4 -0
  69. package/plugins/publishings/wechatmp-pub/dist/index.js +4 -0
  70. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +4 -0
  71. package/plugins/publishings/weibo-pub/dist/index.js +4 -0
  72. package/plugins/publishings/weibo-pub/dist/profile-check.js +4 -0
  73. package/plugins/publishings/x-pub/dist/index.js +4 -0
  74. package/plugins/publishings/x-pub/dist/profile-check.js +4 -0
  75. package/plugins/publishings/xhs-pub/dist/index.js +4 -0
  76. package/plugins/publishings/xhs-pub/dist/profile-check.js +4 -0
  77. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +4 -0
  78. package/plugins/publishings/zenn-pub/dist/index.js +4 -0
@@ -1426,7 +1426,12 @@ function dailyPatchSurfdash(args: string[]): void {
1426
1426
  const pod = targetOf(podcastResult);
1427
1427
  const postPath = (sd.metadata as Record<string, unknown> | undefined)?.postPath;
1428
1428
  if (typeof postPath !== 'string' || postPath === '') {
1429
- failWith(1, 'patch-surfdash: surfdash result carries no postPath');
1429
+ // A surfdash publish that never landed (2026-10-21: its `git push` was rejected with
1430
+ // `fetch first`) leaves no postPath to patch. `publish-classify` has already recorded the
1431
+ // run as `partial`, and failing here turned a published podcast episode into a `failed`
1432
+ // run — the folded tail must not escalate what the classifier already routed.
1433
+ console.error('patch-surfdash: surfdash result carries no postPath — nothing to patch, skip');
1434
+ return;
1430
1435
  }
1431
1436
  const resultPath = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
1432
1437
  if (process.env.KK_SURFDASH_PATCH_OFFLINE === '1') {
@@ -1742,6 +1747,11 @@ if (import.meta.main)
1742
1747
  dailyPublishWechat(rest);
1743
1748
  break;
1744
1749
  }
1750
+ case 'daily-bundle-variants': {
1751
+ const { dailyBundleVariants } = await loadDailyStages();
1752
+ dailyBundleVariants(rest);
1753
+ break;
1754
+ }
1745
1755
  case 'daily-publish-classify':
1746
1756
  dailyPublishClassify(rest);
1747
1757
  break;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Quote-aware sentence splitting (2026-10-24).
3
+ *
4
+ * NOTE: This file mirrors `packages/kk-core/src/sentences.ts`. It exists as a separate script file
5
+ * because `plugins/kk/scripts/daily-stages.ts` runs from the installed package or from standalone
6
+ * runner invocations with Node/Bun builtins only and cannot import `@gobing-ai/kk-core`.
7
+ * Keep the algorithm and behaviors in sync between both files.
8
+ *
9
+ * Rules:
10
+ * - splits after 。!? (CJK) always, and after `.`/`!`/`?` only when followed by whitespace or end of text
11
+ * (so Character.AI, llama.cpp, LWN.net, whatmodel.sh, claude.dev, v1.2, 3.6% never split);
12
+ * - keeps any run of closing quotes/brackets ( ” ’ 」 』 ) ) " ' ) attached to the preceding sentence;
13
+ * - never splits while inside an open 「」, 『』, “”, () pair (track depth; unbalanced → treat the rest as one sentence);
14
+ * - trims each sentence, drops empties; joining the results with '' (zh) loses no non-whitespace characters.
15
+ * - `{ cjkOnly: true }` splits only on 。!? — the zh/ja briefing paths, where an English body must
16
+ * stay one unit (its first-two-sentences cut would otherwise glue "vtables.It" and split "U.S.").
17
+ */
18
+
19
+ const CJK_TERMINATORS = new Set(['。', '!', '?']);
20
+ const LATIN_TERMINATORS = new Set(['.', '!', '?']);
21
+ const ATTACHED_CLOSERS = new Set(['”', '’', '」', '』', ')', ')', '"', "'"]);
22
+
23
+ const OPEN_PAIRS: Record<string, string> = {
24
+ '「': '」',
25
+ '『': '』',
26
+ '“': '”',
27
+ '(': ')',
28
+ '(': ')',
29
+ };
30
+
31
+ /** Splitter options; `cjkOnly` drops the Latin `.!?` terminators (zh/ja split only on 。!?). */
32
+ export interface SplitSentencesOptions {
33
+ cjkOnly?: boolean;
34
+ }
35
+
36
+ const CLOSE_PAIRS: Record<string, string> = {
37
+ '」': '「',
38
+ '』': '『',
39
+ '”': '“',
40
+ ')': '(',
41
+ ')': '(',
42
+ };
43
+
44
+ /**
45
+ * Splits text into complete sentences while respecting CJK/Latin terminators,
46
+ * attached closing quotes, and nested quote/bracket pairs.
47
+ */
48
+ export function splitSentences(text: string, options: SplitSentencesOptions = {}): string[] {
49
+ if (!text || typeof text !== 'string') return [];
50
+ const results: string[] = [];
51
+ let sentenceStart = 0;
52
+ let stack: string[] = [];
53
+ let i = 0;
54
+ const isTerminator = (c: string): boolean =>
55
+ CJK_TERMINATORS.has(c) || (options.cjkOnly !== true && LATIN_TERMINATORS.has(c));
56
+
57
+ while (i < text.length) {
58
+ const ch = text[i] as string;
59
+
60
+ if (isTerminator(ch)) {
61
+ let termEnd = i;
62
+ let hasCjkTerm = false;
63
+ let hasLatinTerm = false;
64
+ while (termEnd < text.length) {
65
+ const termChar = text[termEnd];
66
+ if (!termChar || !isTerminator(termChar)) {
67
+ break;
68
+ }
69
+ if (CJK_TERMINATORS.has(termChar)) hasCjkTerm = true;
70
+ if (LATIN_TERMINATORS.has(termChar)) hasLatinTerm = true;
71
+ termEnd++;
72
+ }
73
+
74
+ let closerEnd = termEnd;
75
+ const tempStack = [...stack];
76
+ while (closerEnd < text.length) {
77
+ const closer = text[closerEnd];
78
+ if (!closer || !ATTACHED_CLOSERS.has(closer)) {
79
+ break;
80
+ }
81
+ const top = tempStack[tempStack.length - 1];
82
+ if (top !== undefined && top === closer) {
83
+ tempStack.pop();
84
+ } else if (CLOSE_PAIRS[closer]) {
85
+ const idx = tempStack.lastIndexOf(closer);
86
+ if (idx >= 0) tempStack.splice(idx, 1);
87
+ }
88
+ closerEnd++;
89
+ }
90
+
91
+ let isBoundary = false;
92
+ if (tempStack.length === 0) {
93
+ if (hasCjkTerm) {
94
+ isBoundary = true;
95
+ } else if (hasLatinTerm) {
96
+ const nextChar = text[closerEnd];
97
+ if (closerEnd === text.length || (nextChar !== undefined && /\s/.test(nextChar))) {
98
+ isBoundary = true;
99
+ }
100
+ }
101
+ }
102
+
103
+ if (isBoundary) {
104
+ const sentence = text.slice(sentenceStart, closerEnd).trim();
105
+ if (sentence.length > 0) {
106
+ results.push(sentence);
107
+ }
108
+ sentenceStart = closerEnd;
109
+ stack = tempStack;
110
+ i = closerEnd;
111
+ continue;
112
+ }
113
+ }
114
+
115
+ const openMatch = OPEN_PAIRS[ch];
116
+ const closeMatch = CLOSE_PAIRS[ch];
117
+ if (openMatch) {
118
+ stack.push(openMatch);
119
+ } else if (closeMatch) {
120
+ const idx = stack.lastIndexOf(ch);
121
+ if (idx >= 0) stack.splice(idx, 1);
122
+ }
123
+ i++;
124
+ }
125
+
126
+ if (sentenceStart < text.length) {
127
+ const tail = text.slice(sentenceStart).trim();
128
+ if (tail.length > 0) {
129
+ results.push(tail);
130
+ }
131
+ }
132
+
133
+ return results;
134
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Canonical daily-series naming for the kk-daily-* pipelines (2026-10-23 regression).
3
+ *
4
+ * The 2026-10-23 surfdash run: the translate agent invented locale series names (ja 毎日AIブレンド,
5
+ * en "Daily AI Medley"/"hodgepodge" phrasing, zh-name listen links inside en/ja posts). Series
6
+ * names are product identity, not translation — this module is the pipeline's single definition of
7
+ * the per-locale series/show names plus the deterministic rewrite that repairs the known bad set
8
+ * before the CMS sync (plugins/kk/scripts/daily-stages.ts translate-sync gate).
9
+ *
10
+ * Pure string work only, no I/O. URL segments (http/https) are never touched — a slug or
11
+ * percent-encoded path may legitimately embed any of these tokens. surfdash-pub's cross-link table
12
+ * (plugins/publishings/surfdash-pub/src/patch.ts) cannot import this file (the kk scripts resolve
13
+ * from the installed package with node builtins only), so it repeats the names; tests assert the
14
+ * two tables agree.
15
+ */
16
+
17
+ /** The locales a daily post is published in (mirrors surfdash-pub's PATCH_LOCALES). */
18
+ export type DailyLocale = 'zh' | 'en' | 'ja';
19
+
20
+ /** One locale's daily-series naming: the series (episode-column) name and the show it runs under. */
21
+ export interface DailySeriesNames {
22
+ series: string;
23
+ show: string;
24
+ }
25
+
26
+ /** The single per-locale table; en/ja names are fixed identity, the zh column name is the source. */
27
+ export const DAILY_SERIES_NAMES: Record<DailyLocale, DailySeriesNames> = {
28
+ zh: { series: '每日AI乱炖', show: '咸话咸说' },
29
+ en: { series: 'Daily AI Stew', show: 'Salty Talk' },
30
+ ja: { series: 'デイリーAIシチュー', show: '咸話咸説' },
31
+ };
32
+
33
+ /**
34
+ * Retired series spellings that must never be emitted in any locale. ごった煮 is deliberately absent:
35
+ * it is ordinary ja vocabulary, so rewriting or failing on it would butcher legitimate prose.
36
+ */
37
+ const RETIRED_SERIES_NAMES = ['毎日AIブレンド', 'Daily AI Medley', 'Daily AI Briefing'] as const;
38
+
39
+ /** All canonical series tokens — each locale's own name is correct there, wrong-locale elsewhere. */
40
+ const SERIES_TOKENS = ['每日AI乱炖', 'Daily AI Stew', 'デイリーAIシチュー'] as const;
41
+
42
+ const escapeRe = (token: string): string => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
+
44
+ /** Latin tokens match case-insensitively with word boundaries; CJK tokens match exactly. */
45
+ const tokenRe = (token: string): RegExp =>
46
+ /^[\x20-\x7E]+$/.test(token) ? new RegExp(`\\b${escapeRe(token)}\\b`, 'gi') : new RegExp(escapeRe(token), 'g');
47
+
48
+ /** Splits markdown into alternating non-URL / URL segments (`split` keeps the capture group). */
49
+ const URL_SPLIT = /(https?:\/\/[^\s<>"')\]]+)/g;
50
+
51
+ function mapOutsideUrls(markdown: string, fn: (text: string) => string): string {
52
+ return markdown
53
+ .split(URL_SPLIT)
54
+ .map((part, index) => (index % 2 === 1 ? part : fn(part)))
55
+ .join('');
56
+ }
57
+
58
+ /** The short/spacing zh form (`AI 乱炖`/`AI乱炖`) as a series name; `每日` already prefixing it is correct zh. */
59
+ const SHORT_ZH_FORM = /(?<!每日)AI ?乱炖/g;
60
+
61
+ /** Every retired or wrong-locale series reference in `text`, as written, deduplicated in order. */
62
+ function retiredIn(text: string, locale: DailyLocale): string[] {
63
+ const canonical = DAILY_SERIES_NAMES[locale].series;
64
+ const patterns: RegExp[] = [
65
+ ...RETIRED_SERIES_NAMES.map(tokenRe),
66
+ ...SERIES_TOKENS.filter((token) => token !== canonical).map(tokenRe),
67
+ SHORT_ZH_FORM,
68
+ /\bhodgepodge\b/gi,
69
+ ];
70
+ const found: string[] = [];
71
+ for (const pattern of patterns) {
72
+ for (const match of text.matchAll(pattern)) {
73
+ if (!found.includes(match[0])) found.push(match[0]);
74
+ }
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /**
80
+ * Retired / wrong-locale series references in `markdown`, as written (URL segments excluded).
81
+ * Detection only — the repair side is {@link normalizeSeriesNames}.
82
+ */
83
+ export function findRetiredSeriesNames(markdown: string, locale: DailyLocale): string[] {
84
+ const found: string[] = [];
85
+ mapOutsideUrls(markdown, (text) => {
86
+ for (const hit of retiredIn(text, locale)) {
87
+ if (!found.includes(hit)) found.push(hit);
88
+ }
89
+ return text;
90
+ });
91
+ return found;
92
+ }
93
+
94
+ /**
95
+ * Rewrite every known retired / wrong-locale series reference to the locale's canonical series
96
+ * name and list what could not be rewritten safely: a bare `hodgepodge` is vocabulary, not a name
97
+ * — rewriting it would butcher prose, so it is reported for hand repair instead. Idempotent: the
98
+ * rewrite only ever produces the locale's own canonical name, which no rule matches.
99
+ */
100
+ export function normalizeSeriesNames(
101
+ markdown: string,
102
+ locale: DailyLocale,
103
+ ): {
104
+ /** The input with every known bad series token rewritten (URL segments untouched). */
105
+ markdown: string;
106
+ /** Retired series references left over that the caller must report or repair by hand. */
107
+ remaining: string[];
108
+ } {
109
+ const canonical = DAILY_SERIES_NAMES[locale].series;
110
+ const next = mapOutsideUrls(markdown, (text) => {
111
+ let out = text;
112
+ if (locale === 'en') {
113
+ // The observed en intro phrase — rewritten as a phrase first so the bare word never
114
+ // produces "daily AI Daily AI Stew".
115
+ out = out.replace(/\byour daily AI hodgepodge\b/gi, 'Your Daily AI Stew');
116
+ out = out.replace(/\bdaily AI hodgepodge\b/gi, 'Daily AI Stew');
117
+ }
118
+ for (const token of [...RETIRED_SERIES_NAMES, ...SERIES_TOKENS.filter((name) => name !== canonical)]) {
119
+ out = out.replace(tokenRe(token), () => canonical);
120
+ }
121
+ return out.replace(SHORT_ZH_FORM, canonical);
122
+ });
123
+ return { markdown: next, remaining: findRetiredSeriesNames(next, locale) };
124
+ }
@@ -1,92 +1,27 @@
1
1
  {
2
- "version": 1,
3
- "note": "Task 0189 R2 topic admission for the AI 乱炖 column, with the 2026-10-02 operator scope correction: the political-diplomatic rule is China-only (中国政治/外交主线). Foreign public-affairs items are NOT floor-excluded — they flow to the plan and are handled by the article-write/script-write/cover 选题边界 prompts (skip + note / plan `excluded`) and the content-safety review. Excluded items are recorded with id, rule, reason and the matched keyword. Sources are never deleted or modified. Scan covers the title plus the first scanChars chars of the body. Precision first: these keywords are a floor, not a sieve — a rare false negative is caught by the prompt-layer boundaries and the content-safety skill review, and a suspected false positive is restored by hand with a recorded reason (manual review), never silently.",
2
+ "version": 2,
4
3
  "scanChars": 200,
5
4
  "rules": [
6
5
  {
7
- "id": "political-diplomatic",
8
- "reason": "中国政治/外交主线:涉及中国党政机构、领导人、外交部门或台海/中美关系的条目,不属于 AI 乱炖的技术分享范围(0189 R2;2026-10-02 操作者裁定本规则仅限中国,外国政治条目由选题边界提示与内容安全评审处理)。",
6
+ "id": "prc-leadership",
7
+ "reason": "中国最高领导人敏感线:现任及历任国家最高领导人属平台审核红线,不论条目是否与 AI 相关一律排除(2026-10-22 操作者裁定,取代 0189 R2 的五条规则——诉讼、出口管制、监管博弈、军事技术、社会事件本身都是 AI 产业新闻的主线,整类排除过严)。",
9
8
  "keywords": [
10
- "领导人",
11
- "主席访",
9
+ "习近平",
10
+ "毛泽东",
11
+ "邓小平",
12
+ "江泽民",
13
+ "胡锦涛",
14
+ "华国锋",
15
+ "赵紫阳",
16
+ "xi jinping",
17
+ "jiang zemin",
18
+ "deng xiaoping",
19
+ "hu jintao",
20
+ "mao zedong",
21
+ "国家主席",
12
22
  "总书记",
13
- "国务院总理",
14
- "外交部",
15
- "大使馆",
16
- "国事访问",
17
- "政治局",
18
- "中南海",
19
- "台海",
20
- "两岸关系",
21
- "中美关系"
22
- ]
23
- },
24
- {
25
- "id": "trade-controls",
26
- "reason": "外交/贸易管制主线:制裁、关税、出口管制、实体清单等政府间管制动作属于公共事务,不是技术实践(0189 R2;涉管制的技术影响可在人工复审后改写为纯技术内容)。",
27
- "keywords": [
28
- "制裁",
29
- "关税",
30
- "出口管制",
31
- "实体清单",
32
- "贸易战",
33
- "禁令",
34
- "制裁清单",
35
- "sanctions",
36
- "tariff",
37
- "export control",
38
- "trade war",
39
- "entity list"
40
- ]
41
- },
42
- {
43
- "id": "military",
44
- "reason": "军事主线:军队、武器、作战相关的条目一律排除(0189 R2)。",
45
- "keywords": [
46
- "军队",
47
- "军事",
48
- "解放军",
49
- "武器",
50
- "导弹",
51
- "无人机袭击",
52
- "战场",
53
- "military",
54
- "pentagon",
55
- "missile"
56
- ]
57
- },
58
- {
59
- "id": "policy-dispute",
60
- "reason": "公共政策争议主线:立法、听证、反垄断等政策博弈是公共事务主线,不是技术内容(0189 R2;纯技术子话题需人工复审并重写后才可使用)。",
61
- "keywords": [
62
- "立法",
63
- "听证",
64
- "政策争议",
65
- "反垄断",
66
- "监管机构",
67
- "监管新规",
68
- "法案",
69
- "regulation",
70
- "antitrust",
71
- "legislation",
72
- "lawsuit"
73
- ]
74
- },
75
- {
76
- "id": "social-event",
77
- "reason": "社会公共事件主线:事故、灾害、冲突现场等社会事件的报道不属于技术分享(0189 R2)。",
78
- "keywords": [
79
- "社会事件",
80
- "踩踏",
81
- "骚乱",
82
- "抗议",
83
- "示威",
84
- "罢工",
85
- "遇难",
86
- "伤亡",
87
- "protest",
88
- "riot",
89
- "casualties"
23
+ "政治局常委",
24
+ "中南海"
90
25
  ]
91
26
  }
92
27
  ],
@@ -110,6 +110,8 @@ states:
110
110
  # the same 45m leash the daily workflow pins. Writes <work_dir>/10-ingest/<stamp>_02_collect_blended.json.
111
111
  # A single failed source degrades to an error entry in the merge (exit 0, surviving docs kept);
112
112
  # only an all-sources failure exits 1 and leaves the file unwritten.
113
+ # Empty 6h windows exit 0 with no digest; the plugin falls back to the newest digest ≤26h old
114
+ # (2026-10-23: Oct 21 contributed zero docs when every tick hit an empty window).
113
115
  command: >-
114
116
  kk executor fan-in
115
117
  --source "aihot-ingest:${vars.work_dir}/10-ingest/source.json"
@@ -80,7 +80,12 @@ vars:
80
80
  # siblings declare `role: reviewer` (capable-1), two rungs above the primary's `scribe`, so the
81
81
  # default `inline` resolves the retry above any rung the primary reaches by climbing past an
82
82
  # unusable cheap executor (ac5-live-1). The operator may still pin a known-good executor here.
83
- agent_retry: "inline"
83
+ # 2026-10-24 pin: `inline` resolved to agy-gemini, whose headless write path is dead (terminal
84
+ # sandbox denies workspace writes), so the retry failed the same way twice — the whole day closed
85
+ # with no plan. `claude` carries `--permission-mode acceptEdits --allowedTools Write Edit` and is
86
+ # the tier that actually delivered both producers on this date. Re-point it at whatever
87
+ # `spur agent doctor <name>` reports usable; an empty retry tier fails the run loudly.
88
+ agent_retry: "claude"
84
89
  # QC pins — lockstep with episode-plan-gen's DEFAULT_FILTER_CONFIG (env wins), same as voice1.
85
90
  qc_categories: "ai-news,ai-technical,ai-research,ai-industry"
86
91
  qc_min_quality: "2"
@@ -91,9 +96,35 @@ vars:
91
96
  episode_min_minutes: "15"
92
97
  # 2026-10-09 operator decision: ingest bodies are spoken in full and today's pools render 22-46
93
98
  # minutes at the current item counts, so the 20-minute ceiling refused real episodes before any
94
- # TTS request. The pair drives BOTH the pre-spend estimate in `daily-generate` and the measured
95
- # gate in `daily-episode-duration`.
99
+ # TTS request. The pair drives the pre-spend estimates (`daily-qc-content`'s pool gate, and
100
+ # `daily-generate`'s script gate, which measures the authored VoiceScript) and the measured gate
101
+ # in `daily-episode-duration`.
96
102
  episode_max_minutes: "25"
103
+ # podcast-pub per-episode SEO (D9). Defaults match the plugin. Legacy publish output:
104
+ # podcast_seo=false, podcast_summary_paragraph=false, podcast_timeline_inline_style=before,
105
+ # podcast_auto_episode_number=false, podcast_link_field=url, podcast_slug=auto,
106
+ # podcast_round_duration=false. Title keywords stay in podcast-pub; ID3 keeps the date-only title.
107
+ podcast_seo: "true"
108
+ podcast_episode_title_mode: "as-is"
109
+ podcast_episode_title_pattern: "{series} · {date}:{keywords}"
110
+ podcast_series_name: "每日AI乱炖"
111
+ podcast_show_name: "咸话咸说"
112
+ podcast_title_max: "55"
113
+ podcast_title_max_keywords: "2"
114
+ podcast_seo_title_suffix: "|咸话咸说"
115
+ podcast_seo_title_max: "60"
116
+ podcast_seo_description_min: "120"
117
+ podcast_seo_description_max: "155"
118
+ podcast_seo_description_source: "plan"
119
+ podcast_summary_paragraph: "true"
120
+ podcast_timeline_inline_style: "after"
121
+ podcast_auto_episode_number: "true"
122
+ podcast_title_sanitize: "normalize"
123
+ podcast_link_field: "none"
124
+ podcast_slug: "pattern"
125
+ podcast_slug_pattern: "daily-ai-{date}"
126
+ podcast_social_image: "false"
127
+ podcast_round_duration: "true"
97
128
  # Run id, injected by spur at launch (0674 R5/R8) — the selections / ledger / mark-published
98
129
  # identity. A shell command may only reference declared vars.
99
130
  __runId: ""
@@ -200,7 +231,8 @@ nodes:
200
231
  store's `selections` ledger, written by the `select` node below). Writes
201
232
  <work_dir>/10-collect/pool.json (the fan-in blended.json shape) and prints {count}. An empty
202
233
  pool writes [] and exits 0 — the run's no-content path, not a failure. The `daily-extract`
203
- stage widens the window (×2, ×3, capped at 72h) only when the configured window's pool cannot
234
+ stage widens the window (×2, ×3, ×7, capped at 168h — extended 2026-10-23 when the 72h
235
+ ceiling closed a supply-collapsed day as no-content) only when the configured window's pool cannot
204
236
  reach `episode_min_minutes` (2026-10-19): a published episode consumes more stories than a day
205
237
  of collection supplies, so the tight window alone closed 2026-10-18/19 as no-content while
206
238
  older unselected items sat in the store.
@@ -580,7 +612,7 @@ nodes:
580
612
  options:
581
613
  onError: continue
582
614
  command: >-
583
- OMNIVOICE_SPEED=1.06 kk stage daily-generate "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.transcode_mp3}" "${vars.vdriver}" "1" "${vars.episode_max_minutes}"
615
+ OMNIVOICE_SPEED=1.06 kk stage daily-generate "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.transcode_mp3}" "${vars.vdriver}" "1" "${vars.episode_max_minutes}" "${vars.episode_min_minutes}"
584
616
 
585
617
  # Driver fallback chain (2026-10-15, operator decision): voxcpm -> gemini -> omni. Each sibling
586
618
  # runs the same stage with a pinned driver; its condition re-checks the pool AND that NO upstream
@@ -601,7 +633,7 @@ nodes:
601
633
  options:
602
634
  onError: continue
603
635
  command: >-
604
- kk stage daily-generate "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.transcode_mp3}" "gemini" "1" "${vars.episode_max_minutes}"
636
+ kk stage daily-generate "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.transcode_mp3}" "gemini" "1" "${vars.episode_max_minutes}" "${vars.episode_min_minutes}"
605
637
 
606
638
  - id: generate-fallback2
607
639
  description: "Voice tier fallback 2 (last, loud): omni-voice-gen when neither voxcpm nor gemini delivered"
@@ -616,7 +648,7 @@ nodes:
616
648
  kind: shell
617
649
  options:
618
650
  command: >-
619
- OMNIVOICE_SPEED=1.06 kk stage daily-generate "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.transcode_mp3}" "ominivoice" "1" "${vars.episode_max_minutes}"
651
+ OMNIVOICE_SPEED=1.06 kk stage daily-generate "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.transcode_mp3}" "ominivoice" "1" "${vars.episode_max_minutes}" "${vars.episode_min_minutes}"
620
652
 
621
653
  - id: intro
622
654
  description: "Prepend the 8s intro sting (intro_music var; empty = skip), update duration/audioPath"
@@ -718,6 +750,27 @@ nodes:
718
750
  kind: shell
719
751
  options:
720
752
  command: >-
753
+ PODCAST_PUB_SEO="${vars.podcast_seo}"
754
+ PODCAST_PUB_EPISODE_TITLE_MODE="${vars.podcast_episode_title_mode}"
755
+ PODCAST_PUB_EPISODE_TITLE_PATTERN="${vars.podcast_episode_title_pattern}"
756
+ PODCAST_PUB_SERIES_NAME="${vars.podcast_series_name}"
757
+ PODCAST_PUB_SHOW_NAME="${vars.podcast_show_name}"
758
+ PODCAST_PUB_TITLE_MAX="${vars.podcast_title_max}"
759
+ PODCAST_PUB_TITLE_MAX_KEYWORDS="${vars.podcast_title_max_keywords}"
760
+ PODCAST_PUB_SEO_TITLE_SUFFIX="${vars.podcast_seo_title_suffix}"
761
+ PODCAST_PUB_SEO_TITLE_MAX="${vars.podcast_seo_title_max}"
762
+ PODCAST_PUB_SEO_DESCRIPTION_MIN="${vars.podcast_seo_description_min}"
763
+ PODCAST_PUB_SEO_DESCRIPTION_MAX="${vars.podcast_seo_description_max}"
764
+ PODCAST_PUB_SEO_DESCRIPTION_SOURCE="${vars.podcast_seo_description_source}"
765
+ PODCAST_PUB_SUMMARY_PARAGRAPH="${vars.podcast_summary_paragraph}"
766
+ PODCAST_PUB_TIMELINE_INLINE_STYLE="${vars.podcast_timeline_inline_style}"
767
+ PODCAST_PUB_AUTO_EPISODE_NUMBER="${vars.podcast_auto_episode_number}"
768
+ PODCAST_PUB_TITLE_SANITIZE="${vars.podcast_title_sanitize}"
769
+ PODCAST_PUB_LINK_FIELD="${vars.podcast_link_field}"
770
+ PODCAST_PUB_SLUG="${vars.podcast_slug}"
771
+ PODCAST_PUB_SLUG_PATTERN="${vars.podcast_slug_pattern}"
772
+ PODCAST_PUB_SOCIAL_IMAGE="${vars.podcast_social_image}"
773
+ PODCAST_PUB_ROUND_DURATION="${vars.podcast_round_duration}"
721
774
  kk stage daily-show-notes "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.title}"
722
775
 
723
776
  - id: publish-podcast
@@ -731,6 +784,27 @@ nodes:
731
784
  kind: shell
732
785
  options:
733
786
  command: >-
787
+ PODCAST_PUB_SEO="${vars.podcast_seo}"
788
+ PODCAST_PUB_EPISODE_TITLE_MODE="${vars.podcast_episode_title_mode}"
789
+ PODCAST_PUB_EPISODE_TITLE_PATTERN="${vars.podcast_episode_title_pattern}"
790
+ PODCAST_PUB_SERIES_NAME="${vars.podcast_series_name}"
791
+ PODCAST_PUB_SHOW_NAME="${vars.podcast_show_name}"
792
+ PODCAST_PUB_TITLE_MAX="${vars.podcast_title_max}"
793
+ PODCAST_PUB_TITLE_MAX_KEYWORDS="${vars.podcast_title_max_keywords}"
794
+ PODCAST_PUB_SEO_TITLE_SUFFIX="${vars.podcast_seo_title_suffix}"
795
+ PODCAST_PUB_SEO_TITLE_MAX="${vars.podcast_seo_title_max}"
796
+ PODCAST_PUB_SEO_DESCRIPTION_MIN="${vars.podcast_seo_description_min}"
797
+ PODCAST_PUB_SEO_DESCRIPTION_MAX="${vars.podcast_seo_description_max}"
798
+ PODCAST_PUB_SEO_DESCRIPTION_SOURCE="${vars.podcast_seo_description_source}"
799
+ PODCAST_PUB_SUMMARY_PARAGRAPH="${vars.podcast_summary_paragraph}"
800
+ PODCAST_PUB_TIMELINE_INLINE_STYLE="${vars.podcast_timeline_inline_style}"
801
+ PODCAST_PUB_AUTO_EPISODE_NUMBER="${vars.podcast_auto_episode_number}"
802
+ PODCAST_PUB_TITLE_SANITIZE="${vars.podcast_title_sanitize}"
803
+ PODCAST_PUB_LINK_FIELD="${vars.podcast_link_field}"
804
+ PODCAST_PUB_SLUG="${vars.podcast_slug}"
805
+ PODCAST_PUB_SLUG_PATTERN="${vars.podcast_slug_pattern}"
806
+ PODCAST_PUB_SOCIAL_IMAGE="${vars.podcast_social_image}"
807
+ PODCAST_PUB_ROUND_DURATION="${vars.podcast_round_duration}"
734
808
  kk stage daily-publish-podcast "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}"
735
809
 
736
810
  # ---- independent soft channels: xhs, wechat, locales (exit 0 on soft failure; fail-fast DAG) ----
@@ -844,17 +918,46 @@ nodes:
844
918
  onError: continue
845
919
  role: scribe
846
920
  agent: ${vars.agent}
847
- input: |
921
+ # 2026-10-24: `onError: continue` documents the locales as non-blocking, but a failure here
922
+ # landed AFTER surfdash/podcast and left `translate-sync` to sync nothing while
923
+ # `publish-classify` still reported the day `full` — the en/ja posts never existed. The
924
+ # retry sibling below carries the capable tier; `daily-publish-completeness` is what makes
925
+ # the residue visible instead of a silent skip.
926
+ input: &translate-input |
848
927
  Translate the en/ja draft posts of the zh article. Read the two marker files at these fixed paths — each holds one draft path, or is empty when that locale was not scaffolded:
849
928
  English: ${vars.work_dir}/31-publish/${vars.run_date}_20_translate-localize_en-post-path.txt
850
929
  Japanese: ${vars.work_dir}/31-publish/${vars.run_date}_20_translate-localize_ja-post-path.txt
851
930
  For each: if the marker is empty or unreadable, skip that locale. Otherwise edit the named file in place — translate the frontmatter title and the whole markdown body to natural English/Japanese respectively. Keep frontmatter keys, dates, URLs, code, and the assets/ image reference untouched. Set draft: false (the translated post publishes to the site) and remove any canonical: frontmatter line pointing at a different locale's URL.
931
+ Also translate these frontmatter values into the target locale, keeping all other keys untouched: description, seo_title, seo_description, and each key_points item. Each stays one coherent standalone sentence that summarizes the same story as the zh source — never splice fragments from different items or stories into one, and key_points must keep exactly the same number of items as the zh post. The 2026-10-23 run failed this: spliced description/key_points had to be hand-repaired before sync.
932
+ Series names are fixed product identity, never invented or freely translated: the en series name is "Daily AI Stew" (show "Salty Talk"); the ja series name is "デイリーAIシチュー" (show "咸話咸説"). Never emit 毎日AIブレンド, ごった煮, "Daily AI Medley", "hodgepodge", "Daily AI Briefing", or the zh name 每日AI乱炖 in en/ja prose or link text (a URL keeps whatever it contains). Openers: en "Your Daily AI Stew from Robin, on Salty Talk."; ja "咸話咸説のデイリーAIシチュー、Robin がお届けします。" Listen links: en "[listen to Daily AI Stew · <date>](<url>)", ja "[デイリーAIシチュー · <date> を聴く](<url>)". In each key_points item keep the "Label — sentence" shape: the label before " — " stays a short phrase of at most 14 characters (ja) / 28 (en), never truncated with "…". daily-translate-sync rewrites known retired names deterministically and fails the sync on leftovers.
852
933
  Keep frontmatter valid YAML: double-quote any value containing ': '. Read the surfdash translator glossary if present at the surfdash checkout root under .agents/skills/surfdash-translator/glossary.md and apply its terminology.
853
934
  Do not wait for operator confirmation. Each edited file must end fully in its target language.
854
935
 
936
+ # 2026-10-24: the locale translation carried `onError: continue` with NO retry sibling, so a
937
+ # failed executor left the en/ja drafts untranslated and `translate-sync` synced nothing while the
938
+ # day still classified `full`. Same shape as the two hard-fail producers above: the primary
939
+ # tolerates, this sibling is loud-tolerant (a locale is an independent channel, so it continues
940
+ # rather than failing the DAG) and runs only when a scaffolded draft is still untranslated.
941
+ - id: translate-retry
942
+ description: "Retry the en/ja locale translation on the capable tier when the primary left a scaffolded draft untranslated (2026-10-24)"
943
+ dependsOn: [translate]
944
+ condition:
945
+ kind: shell
946
+ options:
947
+ command: >-
948
+ test "$(cat "${vars.work_dir}/31-publish/${vars.run_date}_20_translate-localize_zh-post-path.txt" 2>/dev/null)" != ""
949
+ && { for L in en ja; do M="${vars.work_dir}/31-publish/${vars.run_date}_20_translate-localize_$L-post-path.txt"; P="$(cat "$M" 2>/dev/null)"; [ -z "$P" ] && continue; grep -q '^draft: *false' "$P" || { echo "untranslated: $L ($P)"; exit 0; }; done; exit 1; }
950
+ action:
951
+ kind: agent.run
952
+ options:
953
+ onError: continue
954
+ role: reviewer
955
+ agent: ${vars.agent_retry}
956
+ input: *translate-input
957
+
855
958
  - id: translate-sync
856
959
  description: "Sync the translated en/ja drafts into the surfdash CMS (the zh post path rides the marker file)"
857
- dependsOn: [translate]
960
+ dependsOn: [translate, translate-retry]
858
961
  action:
859
962
  kind: shell
860
963
  options: