@gobing-ai/knowledge-kit 0.0.34 → 0.0.36
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/dist/board/collect/index.css +42 -0
- package/dist/board/collect/index.js +787 -0
- package/dist/board/daily-voice/index.css +13 -0
- package/dist/board/daily-voice/index.js +902 -0
- package/dist/board/itc/index.css +14 -0
- package/dist/board/itc/index.js +1026 -0
- package/dist/index.js +11769 -9250
- package/package.json +10 -9
- package/plugins/generations/content-gen/dist/index.js +1015 -10814
- package/plugins/generations/content-gen/package.json +1 -1
- package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
- package/plugins/generations/core-facts-gen/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +1019 -10804
- package/plugins/generations/daily-article-gen/package.json +1 -1
- package/plugins/generations/daily-article-gen/src/index.ts +32 -3
- package/plugins/generations/dailynews-gen/dist/index.js +1094 -10892
- package/plugins/generations/dailynews-gen/package.json +1 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +6 -1
- package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
- package/plugins/generations/episode-plan-gen/package.json +1 -1
- package/plugins/generations/gemini-tts/dist/index.js +1108 -10906
- package/plugins/generations/gemini-tts/package.json +1 -1
- package/plugins/generations/image-gen/dist/index.js +1239 -11035
- package/plugins/generations/image-gen/package.json +1 -1
- package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
- package/plugins/generations/news-report-gen/package.json +1 -1
- package/plugins/generations/omni-voice-gen/plugin.json +1 -1
- package/plugins/generations/voice-gen/dist/index.js +1066 -10864
- package/plugins/generations/voice-gen/package.json +1 -1
- package/plugins/generations/voice-gen/src/index.ts +9 -3
- package/plugins/generations/voxcpm-tts/README.md +72 -0
- package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
- package/plugins/generations/voxcpm-tts/package.json +16 -0
- package/plugins/generations/voxcpm-tts/plugin.json +6 -0
- package/plugins/generations/voxcpm-tts/profiles.json +10 -0
- package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
- package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
- package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
- package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
- package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
- package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
- package/plugins/ingestions/aihot-ingest/package.json +1 -1
- package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
- package/plugins/ingestions/horizon-ingest/package.json +1 -1
- package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
- package/plugins/ingestions/karakeep-local/package.json +1 -1
- package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
- package/plugins/ingestions/last30days-ingest/package.json +1 -1
- package/plugins/ingestions/web-search/dist/index.js +1250 -11033
- package/plugins/ingestions/web-search/package.json +1 -1
- package/plugins/kk/README.md +29 -2
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/schedules/cron.tmpl +9 -1
- package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
- package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
- package/plugins/kk/scripts/daily-stages.ts +702 -63
- package/plugins/kk/scripts/kk-workflow-stages.ts +116 -146
- package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
- package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
- package/plugins/kk/skills/publish/SKILL.md +8 -4
- package/plugins/kk/workflows/feature-verification.yaml +93 -0
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
- package/plugins/kk/workflows/kk-collect.yaml +43 -11
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +36 -9
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +92 -18
- package/plugins/kk/workflows/kk-daily-safety.yaml +16 -3
- package/plugins/kk/workflows/kk-itc.yaml +8 -1
- package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
- package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
- package/plugins/publishings/emdash-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
- package/plugins/publishings/infoq-pub/dist/profile-check.js +20814 -0
- package/plugins/publishings/infoq-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/plugin.json +6 -2
- package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
- package/plugins/publishings/juejin-pub/dist/profile-check.js +20800 -0
- package/plugins/publishings/juejin-pub/package.json +1 -1
- package/plugins/publishings/juejin-pub/plugin.json +6 -2
- package/plugins/publishings/medium-pub/dist/index.js +978 -10805
- package/plugins/publishings/medium-pub/package.json +1 -1
- package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
- package/plugins/publishings/podcast-pub/package.json +1 -1
- package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
- package/plugins/publishings/qiita-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
- package/plugins/publishings/substack-pub/dist/profile-check.js +20890 -0
- package/plugins/publishings/substack-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/plugin.json +5 -2
- package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
- package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
- package/plugins/publishings/surfdash-pub/package.json +1 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
- package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
- package/plugins/publishings/threads-pub/dist/index.js +12394 -0
- package/plugins/publishings/threads-pub/package.json +17 -0
- package/plugins/publishings/threads-pub/plugin.json +7 -0
- package/plugins/publishings/threads-pub/src/index.ts +340 -0
- package/plugins/publishings/threads-pub/tsconfig.json +4 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +2638 -11650
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +20739 -0
- package/plugins/publishings/wechatmp-pub/package.json +1 -1
- package/plugins/publishings/wechatmp-pub/plugin.json +6 -2
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +297 -163
- package/plugins/publishings/wechatmp-pub/src/flow.ts +611 -54
- package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
- package/plugins/publishings/wechatmp-pub/src/page.ts +36 -0
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
- package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
- package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
- package/plugins/publishings/weibo-pub/package.json +19 -0
- package/plugins/publishings/weibo-pub/plugin.json +12 -0
- package/plugins/publishings/weibo-pub/src/index.ts +868 -0
- package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
- package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
- package/plugins/publishings/x-pub/dist/index.js +1948 -11440
- package/plugins/publishings/x-pub/dist/profile-check.js +20806 -0
- package/plugins/publishings/x-pub/package.json +1 -1
- package/plugins/publishings/x-pub/plugin.json +6 -2
- package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
- package/plugins/publishings/xhs-pub/dist/profile-check.js +21023 -0
- package/plugins/publishings/xhs-pub/package.json +1 -1
- package/plugins/publishings/xhs-pub/plugin.json +6 -2
- package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
- package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
- package/plugins/publishings/zenn-pub/package.json +1 -1
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
|
@@ -77,8 +77,10 @@ import {
|
|
|
77
77
|
failWith,
|
|
78
78
|
firstArtifact,
|
|
79
79
|
firstPostPath,
|
|
80
|
+
firstTargetUrl,
|
|
80
81
|
isFile,
|
|
81
82
|
kk,
|
|
83
|
+
kkCaptured,
|
|
82
84
|
metadataOf,
|
|
83
85
|
NESTED_SPUR_UNSET,
|
|
84
86
|
PII_FINDING_CATEGORY,
|
|
@@ -107,6 +109,7 @@ import {
|
|
|
107
109
|
XHS_BODY_CAP,
|
|
108
110
|
XHS_FORMAT,
|
|
109
111
|
XHS_MAX_CARDS,
|
|
112
|
+
type XhsCard,
|
|
110
113
|
xhsPlan,
|
|
111
114
|
} from './kk-workflow-stages.ts';
|
|
112
115
|
|
|
@@ -121,6 +124,8 @@ export function dailyQcContent(args: string[]): void {
|
|
|
121
124
|
minUrgency = '',
|
|
122
125
|
minImpact = '',
|
|
123
126
|
minEpisodeItems = '',
|
|
127
|
+
minMinutes = '',
|
|
128
|
+
vdriver = '',
|
|
124
129
|
] = args;
|
|
125
130
|
const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
|
|
126
131
|
const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
|
|
@@ -155,6 +160,7 @@ export function dailyQcContent(args: string[]): void {
|
|
|
155
160
|
},
|
|
156
161
|
},
|
|
157
162
|
);
|
|
163
|
+
applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
|
|
158
164
|
});
|
|
159
165
|
// Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
|
|
160
166
|
// candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
|
|
@@ -163,25 +169,41 @@ export function dailyQcContent(args: string[]): void {
|
|
|
163
169
|
const boundaryDocs = metadataOf(readJson(candidates)).docs;
|
|
164
170
|
if (!Array.isArray(boundaryDocs)) return;
|
|
165
171
|
const { kept, excluded } = applyTopicBoundary(boundaryDocs, runDate, readTopicBoundary());
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
172
|
+
// A no-op boundary must not rewrite the file: the idempotent-rerun test compares bytes.
|
|
173
|
+
if (excluded.length > 0) {
|
|
174
|
+
sted('write', exclusions, () =>
|
|
175
|
+
writeJsonText(exclusions, {
|
|
176
|
+
version: 1,
|
|
177
|
+
runDate,
|
|
178
|
+
excluded,
|
|
179
|
+
kept: kept.map((doc) => (doc as { id: string }).id),
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
const content = requireJson(candidates, 'topic-boundary');
|
|
183
|
+
const meta = content.metadata;
|
|
184
|
+
if (meta === null || typeof meta !== 'object') {
|
|
185
|
+
failWith(1, 'topic-boundary: candidates content carries no metadata object');
|
|
186
|
+
}
|
|
187
|
+
(meta as Record<string, unknown>).docs = kept;
|
|
188
|
+
sted('write', candidates, () => writeJsonText(candidates, content));
|
|
189
|
+
console.log(
|
|
190
|
+
`topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
|
|
191
|
+
);
|
|
179
192
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
);
|
|
193
|
+
// Title dedup before plan-translate / script-write. dailyArticle repeats this on plan.json, but
|
|
194
|
+
// that runs in parallel with script-write: an id dropped after the script plan is authored is
|
|
195
|
+
// `unknown-id`, the whole plan is rejected, and generate then sees a template with fewer sections.
|
|
196
|
+
const currentDocs = excluded.length > 0 ? kept : boundaryDocs;
|
|
197
|
+
const { kept: deduped, dropped } = dedupPlanByTitle(currentDocs as Array<{ id?: unknown; title?: unknown }>);
|
|
198
|
+
if (dropped.length === 0) return;
|
|
199
|
+
const dedupedContent = requireJson(candidates, 'qc-dedup');
|
|
200
|
+
const dedupedMeta = dedupedContent.metadata;
|
|
201
|
+
if (dedupedMeta === null || typeof dedupedMeta !== 'object') {
|
|
202
|
+
failWith(1, 'qc-dedup: candidates content carries no metadata object');
|
|
203
|
+
}
|
|
204
|
+
(dedupedMeta as Record<string, unknown>).docs = deduped;
|
|
205
|
+
sted('write', candidates, () => writeJsonText(candidates, dedupedContent));
|
|
206
|
+
console.error(`qc: dropped ${dropped.length} near-duplicate candidate(s): ${dropped.join(', ')}`);
|
|
185
207
|
}
|
|
186
208
|
|
|
187
209
|
/**
|
|
@@ -189,6 +211,79 @@ export function dailyQcContent(args: string[]): void {
|
|
|
189
211
|
* episode-plan-gen's `titleSimilarity`. Duplicated on purpose: this file is node-builtins-only
|
|
190
212
|
* (no workspace imports), so it cannot reach that module.
|
|
191
213
|
*/
|
|
214
|
+
/**
|
|
215
|
+
* The factor between a candidate's raw body characters and the characters the episode actually
|
|
216
|
+
* speaks, calibrated on the two 2026-10-11 runs: 3 thin candidates (1.8k chars) scripted to 1270
|
|
217
|
+
* spoken characters (4.0 min, failed the 15-min gate) and 7 candidates (4.3k chars) scripted to 5925
|
|
218
|
+
* (16.8 min, shipped). The agent expands the news register, so the raw count under-predicts by ~1.4x;
|
|
219
|
+
* with it, both runs land on the right side of the floor.
|
|
220
|
+
*/
|
|
221
|
+
const SPEAK_CHAR_EXPANSION = 1.4;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The calibrated pre-spend estimate over a kept pool (title+body characters × the 1.4 expansion,
|
|
225
|
+
* over the driver's measured char rate, plus the 8 s intro sting) — shared by the pre-spend floor
|
|
226
|
+
* gate (applyFloorGate) and the post-spend duration refusal (dailyEpisodeDuration, 0266), so both
|
|
227
|
+
* artifacts report the same arithmetic. `seconds` is unrounded: the floor gate compares seconds.
|
|
228
|
+
*/
|
|
229
|
+
function estimateSpokenMinutes(docs: unknown[], vdriver: string): { minutes: number; seconds: number; chars: number } {
|
|
230
|
+
const chars = docs.reduce((sum, doc) => {
|
|
231
|
+
const entry = (doc ?? {}) as Record<string, unknown>;
|
|
232
|
+
return sum + `${String(entry.title ?? '')}`.length + String(entry.body ?? '').length;
|
|
233
|
+
}, 0);
|
|
234
|
+
const seconds = 8 + (chars * SPEAK_CHAR_EXPANSION) / spokenCharRate(vdriver);
|
|
235
|
+
return { minutes: Math.round((seconds / 60) * 10) / 10, seconds, chars };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Pre-spend floor gate (2026-10-12). `daily-episode-duration` also refuses a final episode below
|
|
240
|
+
* the floor, but it runs after the TTS spend — a thin pool would still pay for a full synthesis
|
|
241
|
+
* pass (the 04:30 tick of 2026-10-11: 3 candidates → 4.0 min episode, nothing published; since
|
|
242
|
+
* 0266 that post-spend refusal closes as no-content instead of aborting, but the spend is gone).
|
|
243
|
+
* This estimates the spoken length from the kept
|
|
244
|
+
* candidates and, when it cannot reach the floor, empties the candidate list: every downstream
|
|
245
|
+
* `pool-count --min 1` guard then skips the content, audio, publish and XHS subtrees, and the run
|
|
246
|
+
* closes through run-report and the ledger exactly like an empty pool. The estimate and its inputs
|
|
247
|
+
* land in a refusal artifact next to the candidates.
|
|
248
|
+
*/
|
|
249
|
+
function applyFloorGate(
|
|
250
|
+
candidates: string,
|
|
251
|
+
workDir: string,
|
|
252
|
+
runDate: string,
|
|
253
|
+
minMinutes: string,
|
|
254
|
+
vdriver: string,
|
|
255
|
+
): void {
|
|
256
|
+
const floor = Number(minMinutes);
|
|
257
|
+
if (minMinutes === '' || !Number.isFinite(floor) || floor <= 0) return;
|
|
258
|
+
const content = readJson(candidates);
|
|
259
|
+
const docs = metadataOf(content).docs;
|
|
260
|
+
if (!Array.isArray(docs) || docs.length === 0) return;
|
|
261
|
+
const { minutes: estimatedMinutes, seconds: estimatedSeconds, chars } = estimateSpokenMinutes(docs, vdriver);
|
|
262
|
+
if (estimatedSeconds >= floor * 60) {
|
|
263
|
+
console.log(`floor gate: estimated ${estimatedMinutes} min from ${chars} chars — above the ${floor} min floor`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
|
|
267
|
+
const ids = docs.map((doc) => String((doc as Record<string, unknown> | null)?.id ?? ''));
|
|
268
|
+
sted('write', refusal, () =>
|
|
269
|
+
writeJqText(refusal, {
|
|
270
|
+
estimatedMinutes,
|
|
271
|
+
minMinutes: floor,
|
|
272
|
+
candidateChars: chars,
|
|
273
|
+
expansion: SPEAK_CHAR_EXPANSION,
|
|
274
|
+
charRate: spokenCharRate(vdriver),
|
|
275
|
+
ids,
|
|
276
|
+
reason: `a ${docs.length}-item pool estimates ${estimatedMinutes} min of speech, below the ${floor} min floor — refusing before any TTS spend`,
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
sted('write', candidates, () =>
|
|
280
|
+
writeJsonText(candidates, { ...(content ?? {}), metadata: { ...metadataOf(content), docs: [] } }),
|
|
281
|
+
);
|
|
282
|
+
console.error(
|
|
283
|
+
`floor gate: ${docs.length} candidates estimate ${estimatedMinutes} min < ${floor} min — candidates emptied, the run closes as no-content (see ${refusal})`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
192
287
|
export function dailyArticle(args: string[]): void {
|
|
193
288
|
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
194
289
|
const articleMd = join(workDir, '22-article', `${runDate}_06_article_article.md`);
|
|
@@ -364,9 +459,16 @@ export function dailyCoverNormalize(args: string[]): void {
|
|
|
364
459
|
* content's `metadata.duration` — after the intro sting merged, this is the true episode length —
|
|
365
460
|
* and fails loud outside `[min, max]` seconds-worth of minutes. A missing duration is a failure,
|
|
366
461
|
* not a pass: the gate must never silently let an under-length episode through again.
|
|
462
|
+
*
|
|
463
|
+
* 0266 (policy (c), 2026-10-13): an under-minutes episode is a thin day, not a broken run — the
|
|
464
|
+
* gate refuses it like the pre-spend floor gate does instead of aborting before run-report/ledger:
|
|
465
|
+
* write `_11_generate_content_duration-refusal.json` beside the content, empty the candidates
|
|
466
|
+
* file (every pool-count-gated node downstream then skips) and exit 0, so the run closes as
|
|
467
|
+
* no-content with the measured vs estimated minutes recorded. Over-max still fails loud — trimming
|
|
468
|
+
* is a different problem.
|
|
367
469
|
*/
|
|
368
470
|
export function dailyEpisodeDuration(args: string[]): void {
|
|
369
|
-
const [workDir = '', runDate = '', minArg = '', maxArg = ''] = args;
|
|
471
|
+
const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = ''] = args;
|
|
370
472
|
if (!workDir) fail('missing work_dir');
|
|
371
473
|
if (!runDate) fail('missing run_date');
|
|
372
474
|
const minMinutes = Number(minArg);
|
|
@@ -392,10 +494,43 @@ export function dailyEpisodeDuration(args: string[]): void {
|
|
|
392
494
|
const minutes = seconds / 60;
|
|
393
495
|
const shown = minutes.toFixed(1);
|
|
394
496
|
if (minutes < minMinutes) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
497
|
+
const refusal = join(workDir, '30-audio', `${runDate}_11_generate_content_duration-refusal.json`);
|
|
498
|
+
const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
|
|
499
|
+
const content = readJson(candidates);
|
|
500
|
+
const docs = Array.isArray(metadataOf(content).docs) ? (metadataOf(content).docs as unknown[]) : [];
|
|
501
|
+
// Idempotency first: a rerun must not recompute an estimate from the already-emptied candidates.
|
|
502
|
+
// A kill between the two writes below leaves the refusal beside non-empty candidates, which would
|
|
503
|
+
// let the pool-gated publish fan package a sub-floor episode — so finish the emptying here.
|
|
504
|
+
if (isFile(refusal)) {
|
|
505
|
+
if (content !== null && docs.length > 0) {
|
|
506
|
+
sted('write', candidates, () =>
|
|
507
|
+
writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
console.log(`episode-duration: duration refusal already recorded (${refusal}) — nothing to do`);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const estimated = estimateSpokenMinutes(docs, vdriver);
|
|
514
|
+
const measuredMinutes = Math.round(minutes * 10) / 10;
|
|
515
|
+
sted('write', refusal, () =>
|
|
516
|
+
writeJqText(refusal, {
|
|
517
|
+
measuredMinutes,
|
|
518
|
+
estimatedMinutes: estimated.minutes,
|
|
519
|
+
minMinutes,
|
|
520
|
+
keptCount: docs.length,
|
|
521
|
+
candidateChars: estimated.chars,
|
|
522
|
+
reason: `the episode ran ${shown} min of speech, below the ${minMinutes} min floor (pre-spend estimate ${estimated.minutes} min) — refusing after synthesis`,
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
if (content !== null) {
|
|
526
|
+
sted('write', candidates, () =>
|
|
527
|
+
writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
console.error(
|
|
531
|
+
`episode-duration: episode ran ${shown} min (estimate ${estimated.minutes} min), below the ${minMinutes}–${maxMinutes} min requirement — candidates emptied, the run closes as no-content (see ${refusal})`,
|
|
398
532
|
);
|
|
533
|
+
return;
|
|
399
534
|
}
|
|
400
535
|
if (minutes > maxMinutes) {
|
|
401
536
|
failWith(
|
|
@@ -441,6 +576,158 @@ function geminiQuotaProbeOk(workDir: string, pluginsPath: string, transcodeMp3:
|
|
|
441
576
|
return true;
|
|
442
577
|
}
|
|
443
578
|
|
|
579
|
+
/** `VOICE_GEN_MP3` truthiness, matching voice-gen's own parse (`plugins/generations/voice-gen/src/mp3.ts`). */
|
|
580
|
+
const MP3_TRUTHY = new Set(['1', 'true', 'yes', 'on']);
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* `transcode_mp3=true` is a workflow contract, not a driver hint: the run publishes an MP3
|
|
584
|
+
* enclosure and `daily-publish-prep` refuses a WAV (0171 R2). Only voice-gen honours
|
|
585
|
+
* `VOICE_GEN_MP3`; gemini-tts and omni-voice-gen write WAV only, so `metadata.mp3Path` stayed
|
|
586
|
+
* unset for them and every publishing run died at publish-prep. Derive the sibling MP3 here —
|
|
587
|
+
* the one place that knows both the requested transcode and what the driver actually wrote —
|
|
588
|
+
* so `daily-intro` re-transcodes the intro-merged audio to the same recorded path.
|
|
589
|
+
*/
|
|
590
|
+
function ensurePublishMp3(contentPath: string, transcodeMp3: string): void {
|
|
591
|
+
if (!MP3_TRUTHY.has(transcodeMp3.trim().toLowerCase())) return;
|
|
592
|
+
const content = readJson(contentPath);
|
|
593
|
+
if (content === null) return;
|
|
594
|
+
const meta = (content.metadata ?? {}) as Record<string, unknown>;
|
|
595
|
+
const recorded = meta.mp3Path;
|
|
596
|
+
if (typeof recorded === 'string' && recorded !== '' && existsSync(resolve(recorded))) return;
|
|
597
|
+
const wav = [meta.audioPath, content.audioPath].find(
|
|
598
|
+
(candidate): candidate is string =>
|
|
599
|
+
typeof candidate === 'string' && candidate.endsWith('.wav') && existsSync(resolve(candidate)),
|
|
600
|
+
);
|
|
601
|
+
if (wav === undefined) return;
|
|
602
|
+
const wavPath = resolve(wav);
|
|
603
|
+
const mp3Path = wavPath.replace(/\.wav$/i, '.mp3');
|
|
604
|
+
const proc = spawnSync('ffmpeg', [
|
|
605
|
+
'-y',
|
|
606
|
+
'-hide_banner',
|
|
607
|
+
'-loglevel',
|
|
608
|
+
'error',
|
|
609
|
+
'-i',
|
|
610
|
+
wavPath,
|
|
611
|
+
'-codec:a',
|
|
612
|
+
'libmp3lame',
|
|
613
|
+
'-b:a',
|
|
614
|
+
'64k',
|
|
615
|
+
mp3Path,
|
|
616
|
+
]);
|
|
617
|
+
if (proc.status !== 0 || !existsSync(mp3Path)) {
|
|
618
|
+
const detail = (proc.stderr?.toString() ?? '').trim() || `exit ${String(proc.status)}`;
|
|
619
|
+
failWith(1, `generate: publish MP3 transcode failed: ${detail}`);
|
|
620
|
+
}
|
|
621
|
+
meta.mp3Path = mp3Path;
|
|
622
|
+
content.metadata = meta;
|
|
623
|
+
sted('write', contentPath, () => writeJsonText(contentPath, content));
|
|
624
|
+
console.log(`generate: wrote publish MP3 ${mp3Path}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** ffprobe one audio attribute; empty string when the file is absent/unreadable. */
|
|
628
|
+
function probeAudio(file: string, entries: string): string {
|
|
629
|
+
return (
|
|
630
|
+
spawnSync('ffprobe', [
|
|
631
|
+
'-v',
|
|
632
|
+
'error',
|
|
633
|
+
'-select_streams',
|
|
634
|
+
'a:0',
|
|
635
|
+
'-show_entries',
|
|
636
|
+
entries,
|
|
637
|
+
'-of',
|
|
638
|
+
'csv=p=0',
|
|
639
|
+
file,
|
|
640
|
+
])
|
|
641
|
+
.stdout?.toString()
|
|
642
|
+
.trim() ?? ''
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** A playable local audio artifact: present, positive native sample rate and positive duration. */
|
|
647
|
+
function validNativeAudio(path: unknown): boolean {
|
|
648
|
+
if (typeof path !== 'string' || path === '' || !existsSync(resolve(path))) return false;
|
|
649
|
+
const rate = Number.parseFloat(probeAudio(resolve(path), 'stream=sample_rate'));
|
|
650
|
+
const duration = Number.parseFloat(probeAudio(resolve(path), 'format=duration'));
|
|
651
|
+
return Number.isFinite(rate) && rate > 0 && Number.isFinite(duration) && duration > 0;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Whole-document speech cache hit (0105 R2 / 0257 R2): the recorded Content must carry THIS run's
|
|
656
|
+
* generator identity (`kk:<plugin>`), a passing QC report and a playable native-rate WAV. Foreign,
|
|
657
|
+
* legacy or corrupt output is a miss — never relabel another driver's audio. A valid cache missing
|
|
658
|
+
* only the requested MP3 is repaired in place from the already-rendered speech (no resynthesis).
|
|
659
|
+
*/
|
|
660
|
+
function wholeDocCache(contentPath: string, plugin: string, transcodeMp3: string): boolean {
|
|
661
|
+
const content = readJson(contentPath);
|
|
662
|
+
if (content === null) return false;
|
|
663
|
+
const meta = metadataOf(content);
|
|
664
|
+
const qc = meta.qc as Record<string, unknown> | undefined;
|
|
665
|
+
if (meta.generator !== `kk:${plugin}`) return false;
|
|
666
|
+
if (qc?.passed === true && validNativeAudio(meta.audioPath)) {
|
|
667
|
+
ensurePublishMp3(contentPath, transcodeMp3);
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Joined-final cache identity (0257 R3): exact ordered `metadata.sectionHashes` equality, matching
|
|
675
|
+
* generator, passing QC and a playable WAV. A stale or legacy final is a miss even when every
|
|
676
|
+
* section still hits — the caller rejoins the cached sections without model calls. The requested
|
|
677
|
+
* MP3 is repaired from the valid joined WAV; a failed derivative fails loud.
|
|
678
|
+
*/
|
|
679
|
+
function joinedFinalFresh(contentPath: string, plugin: string, hashes: string[], transcodeMp3: string): boolean {
|
|
680
|
+
const content = readJson(contentPath);
|
|
681
|
+
if (content === null) return false;
|
|
682
|
+
const meta = metadataOf(content);
|
|
683
|
+
if (meta.generator !== `kk:${plugin}`) return false;
|
|
684
|
+
const frozen = meta.sectionHashes;
|
|
685
|
+
if (!Array.isArray(frozen) || frozen.length !== hashes.length || !frozen.every((h, i) => h === hashes[i])) {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
const qc = meta.qc as Record<string, unknown> | undefined;
|
|
689
|
+
if (qc?.passed !== true || !validNativeAudio(meta.audioPath)) return false;
|
|
690
|
+
ensurePublishMp3(contentPath, transcodeMp3);
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Aggregate the current sections' QC into the shared VoiceQualityReport names (0257 R3): passed
|
|
696
|
+
* iff every section passed; overallScore is the minimum when every section reports a finite one
|
|
697
|
+
* (absent evidence stays unavailable, never an invented zero); segmentAudits are concatenated with
|
|
698
|
+
* globally ordered segmentIndex; criticalIssues are flattened with section labels.
|
|
699
|
+
*/
|
|
700
|
+
function aggregateSectionQc(
|
|
701
|
+
plans: Array<{ def: { name: string }; jsonPath: string }>,
|
|
702
|
+
totalDuration: number,
|
|
703
|
+
): Record<string, unknown> {
|
|
704
|
+
const segmentAudits: Array<Record<string, unknown>> = [];
|
|
705
|
+
const criticalIssues: string[] = [];
|
|
706
|
+
const scores: number[] = [];
|
|
707
|
+
let allPassed = true;
|
|
708
|
+
let allScored = true;
|
|
709
|
+
for (const plan of plans) {
|
|
710
|
+
const report = metadataOf(readJson(plan.jsonPath)).qc;
|
|
711
|
+
const qc = report !== null && typeof report === 'object' ? (report as Record<string, unknown>) : {};
|
|
712
|
+
if (qc.passed !== true) allPassed = false;
|
|
713
|
+
if (typeof qc.overallScore === 'number' && Number.isFinite(qc.overallScore)) scores.push(qc.overallScore);
|
|
714
|
+
else allScored = false;
|
|
715
|
+
if (Array.isArray(qc.segmentAudits)) {
|
|
716
|
+
for (const audit of qc.segmentAudits) {
|
|
717
|
+
if (audit !== null && typeof audit === 'object') {
|
|
718
|
+
segmentAudits.push({ ...(audit as Record<string, unknown>), segmentIndex: segmentAudits.length });
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (Array.isArray(qc.criticalIssues)) {
|
|
723
|
+
for (const issue of qc.criticalIssues) criticalIssues.push(`${plan.def.name}: ${String(issue)}`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const aggregate: Record<string, unknown> = { passed: allPassed, totalDuration, segmentAudits, criticalIssues };
|
|
727
|
+
if (allScored && scores.length > 0) aggregate.overallScore = Math.min(...scores);
|
|
728
|
+
return aggregate;
|
|
729
|
+
}
|
|
730
|
+
|
|
444
731
|
export function dailyGenerate(args: string[]): void {
|
|
445
732
|
const [
|
|
446
733
|
workDir = '',
|
|
@@ -457,6 +744,7 @@ export function dailyGenerate(args: string[]): void {
|
|
|
457
744
|
ominivoice: 'omni-voice-gen',
|
|
458
745
|
voicebox: 'voice-gen',
|
|
459
746
|
gemini: 'gemini-tts',
|
|
747
|
+
voxcpm: 'voxcpm-tts',
|
|
460
748
|
};
|
|
461
749
|
let voiceGenerator = VOICE_DRIVERS[vdriver];
|
|
462
750
|
if (!voiceGenerator) {
|
|
@@ -475,12 +763,9 @@ export function dailyGenerate(args: string[]): void {
|
|
|
475
763
|
|
|
476
764
|
if (!sectionConcurrency) {
|
|
477
765
|
timed(workDir, 'generate', () => {
|
|
478
|
-
if (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
console.log(`audio cached: ${content} (QC passed)`);
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
766
|
+
if (wholeDocCache(content, voiceGenerator, transcodeMp3)) {
|
|
767
|
+
console.log(`audio cached: ${content} (QC passed)`);
|
|
768
|
+
return;
|
|
484
769
|
}
|
|
485
770
|
// Gemini quota probe (20261006): the 06:00 cron hit gemini-3.8-flash-tts 429 with a
|
|
486
771
|
// multi-hour retry-after after the shared key's window drained overnight. One tiny
|
|
@@ -494,11 +779,19 @@ export function dailyGenerate(args: string[]): void {
|
|
|
494
779
|
!geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [{ ...docsList[0], body: 'Hello.' }])
|
|
495
780
|
) {
|
|
496
781
|
voiceGenerator = VOICE_DRIVERS.voicebox;
|
|
782
|
+
// The fallback driver's own cache may already hold this episode (0257 R2) —
|
|
783
|
+
// reuse it instead of paying for a second local render. VoxCPM never probes
|
|
784
|
+
// Gemini and never falls back, so this branch is Gemini-only.
|
|
785
|
+
if (wholeDocCache(content, voiceGenerator, transcodeMp3)) {
|
|
786
|
+
console.log(`audio cached: ${content} (QC passed)`);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
497
789
|
}
|
|
498
790
|
}
|
|
499
791
|
kk(voiceExecutorArgs(voiceGenerator, docs, content, pluginsPath), {
|
|
500
792
|
env: { VOICE_GEN_MP3: transcodeMp3 },
|
|
501
793
|
});
|
|
794
|
+
ensurePublishMp3(content, transcodeMp3);
|
|
502
795
|
});
|
|
503
796
|
return;
|
|
504
797
|
}
|
|
@@ -623,6 +916,8 @@ export function dailyGenerate(args: string[]): void {
|
|
|
623
916
|
// 4645 / 789.9 s of section speech = 5.88 — a 5.3–5.9 range, so 5.4 deliberately errs low (a
|
|
624
917
|
// slightly early refusal is cheaper than a rendered episode the duration gate then rejects).
|
|
625
918
|
// voice-gen: 20261006 6376 / 1135 = 5.62. A 20-minute ceiling is therefore ~6400 characters.
|
|
919
|
+
// VoxCPM (voxcpm-tts) shares the 5.6 voicebox estimate — PROVISIONAL: no measured VoxCPM
|
|
920
|
+
// episode exists yet (0258 supplies the evidence); the measured duration stays authoritative.
|
|
626
921
|
// The style sidecar's 3.3 / 6.3 figures are stale and mutually inconsistent with its own
|
|
627
922
|
// 4300–5800 字 budget (which is 13–29 min at 3.3); using them refused a script that really
|
|
628
923
|
// renders in 17 min. Recalibrate from a fresh pair of `metadata.duration` readings when a
|
|
@@ -668,7 +963,16 @@ export function dailyGenerate(args: string[]): void {
|
|
|
668
963
|
try {
|
|
669
964
|
const sidecar = readJson(jsonPath) as Record<string, unknown>;
|
|
670
965
|
const sidecarMeta = metadataOf(sidecar) as Record<string, unknown>;
|
|
671
|
-
|
|
966
|
+
const sidecarQc = sidecarMeta.qc as Record<string, unknown> | undefined;
|
|
967
|
+
// A completed section is reusable only when its provenance, QC and audio
|
|
968
|
+
// all belong to this driver/text/profile — a foreign or half-written
|
|
969
|
+
// sidecar re-renders (0257 R2). The hash already folds in the driver.
|
|
970
|
+
if (
|
|
971
|
+
(sidecar.hash === hash || sidecarMeta.hash === hash) &&
|
|
972
|
+
sidecarMeta.generator === `kk:${driver}` &&
|
|
973
|
+
sidecarQc?.passed === true &&
|
|
974
|
+
validNativeAudio(wavPath)
|
|
975
|
+
) {
|
|
672
976
|
cached = true;
|
|
673
977
|
}
|
|
674
978
|
} catch {}
|
|
@@ -686,7 +990,18 @@ export function dailyGenerate(args: string[]): void {
|
|
|
686
990
|
let plans = computePlans(voiceGenerator);
|
|
687
991
|
const anySynthesisNeeded = plans.some((p) => p.needsSynthesis);
|
|
688
992
|
|
|
689
|
-
|
|
993
|
+
// All sections hit, but the joined final is reusable only when its frozen identity matches
|
|
994
|
+
// this run's ordered section hashes (0257 R3). A stale or legacy final — e.g. a previous
|
|
995
|
+
// join failed — falls through and rejoins the cached sections below, with no model calls.
|
|
996
|
+
if (
|
|
997
|
+
!anySynthesisNeeded &&
|
|
998
|
+
joinedFinalFresh(
|
|
999
|
+
joinedContentPath,
|
|
1000
|
+
voiceGenerator,
|
|
1001
|
+
plans.map((p) => p.hash),
|
|
1002
|
+
transcodeMp3,
|
|
1003
|
+
)
|
|
1004
|
+
) {
|
|
690
1005
|
console.log('generate: all sections cached, joined speech reused');
|
|
691
1006
|
return;
|
|
692
1007
|
}
|
|
@@ -733,10 +1048,25 @@ export function dailyGenerate(args: string[]): void {
|
|
|
733
1048
|
};
|
|
734
1049
|
writeJsonText(secDocIn, [doc]);
|
|
735
1050
|
try {
|
|
736
|
-
|
|
1051
|
+
// 0269-shaped hardening (20261012): one retry for a failed section. The design already
|
|
1052
|
+
// bounds the episode's risk with the pre-flight quota probe and the per-section cache,
|
|
1053
|
+
// but a failure that arrives MID-render (a 429 once the window drains, a network blip)
|
|
1054
|
+
// still cost the whole episode — the one step nothing can publish without. The retry is
|
|
1055
|
+
// cheap and bounded: the successful sections are cache hits, so at most one extra
|
|
1056
|
+
// request per failed section is issued, and a persistent failure still fails loud.
|
|
1057
|
+
let status = kk(voiceExecutorArgs(voiceGenerator, secDocIn, plan.jsonPath, pluginsPath), {
|
|
737
1058
|
env: { VOICE_GEN_MP3: transcodeMp3 },
|
|
738
1059
|
soft: true,
|
|
739
1060
|
});
|
|
1061
|
+
if (status !== 0) {
|
|
1062
|
+
console.log(
|
|
1063
|
+
`generate: section ${plan.def.stem} synthesis failed (exit ${status}) — retrying it once`,
|
|
1064
|
+
);
|
|
1065
|
+
status = kk(voiceExecutorArgs(voiceGenerator, secDocIn, plan.jsonPath, pluginsPath), {
|
|
1066
|
+
env: { VOICE_GEN_MP3: transcodeMp3 },
|
|
1067
|
+
soft: true,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
740
1070
|
if (status !== 0) {
|
|
741
1071
|
failWith(status, `generate: section synthesis failed for ${plan.def.stem}`);
|
|
742
1072
|
}
|
|
@@ -795,21 +1125,52 @@ export function dailyGenerate(args: string[]): void {
|
|
|
795
1125
|
}
|
|
796
1126
|
|
|
797
1127
|
const joinedDuration = Number.parseFloat(probeHelper(joinedWavPath, 'format=duration'));
|
|
1128
|
+
if (!validNativeAudio(joinedWavPath) || !Number.isFinite(joinedDuration)) {
|
|
1129
|
+
failWith(1, `generate: joined audio is not a playable native-rate WAV: ${joinedWavPath}`);
|
|
1130
|
+
}
|
|
798
1131
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
1132
|
+
// podcast-pub renders the show-notes timeline from `metadata.segments[]` (per-segment
|
|
1133
|
+
// duration + ASR transcription). The one-shot path receives them from the voice plugin, but
|
|
1134
|
+
// this sectioned path synthesizes one sidecar per section and dropped them at the join — every
|
|
1135
|
+
// timeline row rendered 0:00 (2026-10-11). Rebuild the list in section order from the CURRENT
|
|
1136
|
+
// sidecars, never inherit the previous final's list (0257 R3).
|
|
1137
|
+
const joinedSegments: Array<Record<string, unknown>> = [];
|
|
1138
|
+
for (const plan of plans) {
|
|
1139
|
+
const sectionSegments = metadataOf(readJson(plan.jsonPath)).segments;
|
|
1140
|
+
if (Array.isArray(sectionSegments)) {
|
|
1141
|
+
joinedSegments.push(...(sectionSegments as Array<Record<string, unknown>>));
|
|
1142
|
+
}
|
|
804
1143
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1144
|
+
// Rebuild the Content envelope from the current script/sections. The previous final's
|
|
1145
|
+
// generator/QC/mp3Path/sectionHashes must never survive a fresh join, and mp3Path is
|
|
1146
|
+
// deliberately absent so ensurePublishMp3 regenerates the requested derivative (0257 R3).
|
|
1147
|
+
const baseMeta: Record<string, unknown> = {
|
|
1148
|
+
generator: `kk:${voiceGenerator}`,
|
|
1149
|
+
audioPath: joinedWavPath,
|
|
1150
|
+
duration: joinedDuration,
|
|
1151
|
+
};
|
|
1152
|
+
if (joinedSegments.length > 0) baseMeta.segments = joinedSegments;
|
|
1153
|
+
const contentObj: Record<string, unknown> = {
|
|
1154
|
+
title: typeof script.title === 'string' && script.title !== '' ? script.title : 'Daily AI News',
|
|
1155
|
+
body: scriptRaw,
|
|
1156
|
+
format: 'audio',
|
|
1157
|
+
references: [],
|
|
1158
|
+
audioPath: joinedWavPath,
|
|
1159
|
+
mediaType: 'audio/wav',
|
|
1160
|
+
metadata: baseMeta,
|
|
1161
|
+
};
|
|
812
1162
|
writeJsonText(joinedContentPath, contentObj);
|
|
1163
|
+
// Transcode the requested MP3 before any success claim is written: a failed conversion
|
|
1164
|
+
// fails loud with no qc.passed on disk, so the next run rejoins the cached sections rather
|
|
1165
|
+
// than trusting a broken final (0257 R3).
|
|
1166
|
+
ensurePublishMp3(joinedContentPath, transcodeMp3);
|
|
1167
|
+
// Join and derivative are proven; only now record the frozen identity and aggregated QC.
|
|
1168
|
+
const finalized = readJson(joinedContentPath) ?? contentObj;
|
|
1169
|
+
const finalMeta = metadataOf(finalized);
|
|
1170
|
+
finalMeta.qc = aggregateSectionQc(plans, joinedDuration);
|
|
1171
|
+
finalMeta.sectionHashes = plans.map((p) => p.hash);
|
|
1172
|
+
finalized.metadata = finalMeta;
|
|
1173
|
+
writeJsonText(joinedContentPath, finalized);
|
|
813
1174
|
});
|
|
814
1175
|
}
|
|
815
1176
|
|
|
@@ -933,16 +1294,21 @@ export function dailyIntro(args: string[]): void {
|
|
|
933
1294
|
}
|
|
934
1295
|
|
|
935
1296
|
export function dailyPlan(args: string[]): void {
|
|
936
|
-
const [workDir = '', runDate = '', pluginsPath = '', planMaxItems = ''] = args;
|
|
1297
|
+
const [workDir = '', runDate = '', pluginsPath = '', planMaxItems = '', deferPlanDocs = ''] = args;
|
|
937
1298
|
const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
|
|
938
1299
|
const planDocs = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
|
|
939
1300
|
// voice2's extract node writes 10-collect/pool.json; voice1's fan-in still writes the blended
|
|
940
1301
|
// file. Prefer the pool when it exists so both DAGs share this stage (0212 dogfood finding).
|
|
941
1302
|
const pool = join(workDir, '10-collect', 'pool.json');
|
|
942
1303
|
const blended = isFile(pool) ? pool : join(workDir, '10-ingest', `${runDate}_02_collect_blended.json`);
|
|
1304
|
+
// voice2 passes plan_translate_enabled. When it is true, plan-translate owns plan.json: its
|
|
1305
|
+
// `! test -s` guard is also the first-run gate, so writing the English docs here skips the
|
|
1306
|
+
// translation. Voice1 omits the arg and still materializes the file (its translate node has no
|
|
1307
|
+
// skip guard and overwrites it).
|
|
1308
|
+
const deferDocs = deferPlanDocs === 'true';
|
|
943
1309
|
timed(workDir, 'plan', () => {
|
|
944
|
-
if (isFile(planDocs)) {
|
|
945
|
-
console.log(`plan cached: ${planDocs}`);
|
|
1310
|
+
if (deferDocs ? isFile(planContent) : isFile(planDocs)) {
|
|
1311
|
+
console.log(`plan cached: ${deferDocs ? planContent : planDocs}`);
|
|
946
1312
|
return;
|
|
947
1313
|
}
|
|
948
1314
|
kk(
|
|
@@ -953,6 +1319,7 @@ export function dailyPlan(args: string[]): void {
|
|
|
953
1319
|
);
|
|
954
1320
|
const docs = metadataOf(readJson(planContent)).docs;
|
|
955
1321
|
if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
|
|
1322
|
+
if (deferDocs) return;
|
|
956
1323
|
sted('write', planDocs, () => writeJsonText(planDocs, docs));
|
|
957
1324
|
if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
|
|
958
1325
|
});
|
|
@@ -1143,18 +1510,30 @@ export function dailyPublishWechat(args: string[]): void {
|
|
|
1143
1510
|
return;
|
|
1144
1511
|
}
|
|
1145
1512
|
const packaged = requireJson(article, 'publish-wechat');
|
|
1146
|
-
// MP draft enrichment (operator
|
|
1147
|
-
// (the surfdash post),
|
|
1513
|
+
// MP draft enrichment (operator preferences 2026-09-29, completed 2026-10-10): 原创, 赞赏,
|
|
1514
|
+
// 作者, 合集, 原文链接 (the surfdash post), 创作来源, cover, audio material. The brand strings
|
|
1515
|
+
// are this daily show's constants; every field here is consumed by `wechatmp-pub`'s flow.
|
|
1148
1516
|
const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
1149
1517
|
const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
|
|
1518
|
+
const surfdashResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
1519
|
+
const originalUrl = isFile(surfdashResult) ? firstTargetUrl(readJson(surfdashResult), 'surfdash-pub') : '';
|
|
1150
1520
|
const wechatmp: Record<string, unknown> = {
|
|
1151
1521
|
original: true,
|
|
1152
|
-
|
|
1522
|
+
// 赞赏 is enabled per episode; the plugin fills the 赞赏账户 only when the platform
|
|
1523
|
+
// leaves it empty (it defaults from 作者, operator walk 2026-10-10).
|
|
1524
|
+
reward: true,
|
|
1153
1525
|
author: '冰原奔狼',
|
|
1154
|
-
collection: collectionArg || '每日AI
|
|
1526
|
+
collection: collectionArg || '每日AI资讯',
|
|
1527
|
+
// 创作来源 is the column's own stance, not an AI-declaration label — the operator owns
|
|
1528
|
+
// the wording (2026-10-10).
|
|
1529
|
+
claimSource: '个人观点,仅供参考',
|
|
1530
|
+
// 封面 comes from 图片库: the episode cover is uploaded there as the audio material's
|
|
1531
|
+
// cover, so the library's newest entry is the intended image.
|
|
1532
|
+
coverSource: 'library',
|
|
1533
|
+
...(originalUrl !== '' ? { originalUrl } : {}),
|
|
1155
1534
|
...(isFile(cover) ? { coverPath: cover } : {}),
|
|
1156
|
-
// Audio block at the article top (operator request 2026-09-29
|
|
1157
|
-
//
|
|
1535
|
+
// Audio block at the article top (operator request 2026-09-29; the plugin UPLOADS the
|
|
1536
|
+
// material when the 素材库 has no same-title entry, 2026-10-10).
|
|
1158
1537
|
...(isFile(audio)
|
|
1159
1538
|
? {
|
|
1160
1539
|
audioPath: audio,
|
|
@@ -1579,8 +1958,141 @@ export function dailySafetyRevise(args: string[]): void {
|
|
|
1579
1958
|
});
|
|
1580
1959
|
}
|
|
1581
1960
|
|
|
1961
|
+
/** Block YAML for the fields a fitted VoiceScript actually carries. Strings use JSON quoting. */
|
|
1962
|
+
function voicescriptToBlockYaml(script: Record<string, unknown>): string {
|
|
1963
|
+
const lines: string[] = [];
|
|
1964
|
+
for (const key of [
|
|
1965
|
+
'title',
|
|
1966
|
+
'language',
|
|
1967
|
+
'default_profile',
|
|
1968
|
+
'default_profile_id',
|
|
1969
|
+
'default_engine',
|
|
1970
|
+
'default_emotion',
|
|
1971
|
+
'default_effect_preset',
|
|
1972
|
+
]) {
|
|
1973
|
+
const value = script[key];
|
|
1974
|
+
if (typeof value === 'string' && value !== '') lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
1975
|
+
}
|
|
1976
|
+
const segments = script.segments;
|
|
1977
|
+
if (!Array.isArray(segments)) return lines.join('\n');
|
|
1978
|
+
lines.push('segments:');
|
|
1979
|
+
for (const entry of segments) {
|
|
1980
|
+
const segment = entry as Record<string, unknown>;
|
|
1981
|
+
lines.push(` - text: ${JSON.stringify(segment.text ?? '')}`);
|
|
1982
|
+
for (const key of ['speaker', 'profile', 'profile_id', 'emotion', 'instruct', 'effect_preset']) {
|
|
1983
|
+
if (typeof segment[key] === 'string') lines.push(` ${key}: ${JSON.stringify(segment[key])}`);
|
|
1984
|
+
}
|
|
1985
|
+
for (const key of ['gap_ms', 'speed', 'seed', 'personality']) {
|
|
1986
|
+
if (typeof segment[key] === 'number') lines.push(` ${key}: ${segment[key]}`);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
return lines.join('\n');
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/** Measured driver rates from dailyGenerate. Unknown drivers (incl. voxcpm, provisionally) use the voicebox rate. */
|
|
1993
|
+
function spokenCharRate(vdriver: string): number {
|
|
1994
|
+
return vdriver === 'gemini' ? 5.4 : 5.6;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* Shorten one fact/insight at 。!? so the cut removes at least `removeAtLeast` characters when a
|
|
1999
|
+
* boundary allows it, otherwise drop the last sentence. Returns null when the text is one sentence.
|
|
2000
|
+
*/
|
|
2001
|
+
function shrinkFactText(text: string, removeAtLeast: number): string | null {
|
|
2002
|
+
const ends: number[] = [];
|
|
2003
|
+
for (let i = 0; i < text.length; i++) {
|
|
2004
|
+
const ch = text[i];
|
|
2005
|
+
if (ch === '。' || ch === '!' || ch === '?') ends.push(i + 1);
|
|
2006
|
+
}
|
|
2007
|
+
if (ends.length === 0) return null;
|
|
2008
|
+
const limit = text.length - Math.max(removeAtLeast, 1);
|
|
2009
|
+
let chosen: number | null = null;
|
|
2010
|
+
for (const end of ends) {
|
|
2011
|
+
if (end <= limit && end < text.length && end > 0) chosen = end;
|
|
2012
|
+
}
|
|
2013
|
+
if (chosen !== null) return text.slice(0, chosen);
|
|
2014
|
+
const shorter = ends.filter((end) => end < text.length && end > 0);
|
|
2015
|
+
const tail = shorter[shorter.length - 1];
|
|
2016
|
+
if (tail === undefined) return null;
|
|
2017
|
+
return text.slice(0, tail);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* The pre-spend gate in dailyGenerate refuses before any TTS when the spoken character estimate
|
|
2022
|
+
* exceeds `episode_max_minutes`. Optional args (voice2 only) fit the fresh VoiceScript down to that
|
|
2023
|
+
* budget by shortening the longest fact segments, then insights. Absent args leave the file untouched.
|
|
2024
|
+
*/
|
|
2025
|
+
function fitVoicescriptToBudget(yamlOut: string, contentOut: string, episodeMaxMinutes: string, vdriver: string): void {
|
|
2026
|
+
if (episodeMaxMinutes === '') return;
|
|
2027
|
+
const maxMinutes = Number(episodeMaxMinutes);
|
|
2028
|
+
if (!Number.isFinite(maxMinutes) || maxMinutes <= 0) return;
|
|
2029
|
+
// Two minutes under the hard duration gate. The character rate is a past measurement; fitting
|
|
2030
|
+
// flush to the ceiling renders an episode the gate then rejects.
|
|
2031
|
+
const maxChars = Math.floor((maxMinutes * 60 - 8 - 120) * spokenCharRate(vdriver));
|
|
2032
|
+
const original = readFileSync(yamlOut, 'utf8');
|
|
2033
|
+
const script = Bun.YAML.parse(original) as { segments?: Array<Record<string, unknown>> };
|
|
2034
|
+
const segments = script.segments;
|
|
2035
|
+
if (!Array.isArray(segments) || segments.length < 3) return;
|
|
2036
|
+
const totalOf = (): number => segments.reduce((sum, seg) => sum + [...String(seg.text ?? '')].length, 0);
|
|
2037
|
+
const before = totalOf();
|
|
2038
|
+
if (before <= maxChars) return;
|
|
2039
|
+
const unsplittable = new Set<number>();
|
|
2040
|
+
for (const factsOnly of [true, false]) {
|
|
2041
|
+
while (totalOf() > maxChars) {
|
|
2042
|
+
let best = -1;
|
|
2043
|
+
let bestLen = 0;
|
|
2044
|
+
for (let i = 1; i < segments.length - 1; i++) {
|
|
2045
|
+
if (unsplittable.has(i)) continue;
|
|
2046
|
+
if (factsOnly && segments[i]?.speed === 1.12) continue;
|
|
2047
|
+
const len = [...String(segments[i]?.text ?? '')].length;
|
|
2048
|
+
if (len > bestLen) {
|
|
2049
|
+
best = i;
|
|
2050
|
+
bestLen = len;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
if (best < 0) break;
|
|
2054
|
+
const text = String(segments[best]?.text ?? '');
|
|
2055
|
+
const next = shrinkFactText(text, totalOf() - maxChars);
|
|
2056
|
+
if (next === null || next.length >= text.length) {
|
|
2057
|
+
unsplittable.add(best);
|
|
2058
|
+
continue;
|
|
2059
|
+
}
|
|
2060
|
+
const segment = segments[best];
|
|
2061
|
+
if (segment) segment.text = next;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
const after = totalOf();
|
|
2065
|
+
if (after === before) return;
|
|
2066
|
+
// Block YAML, same shape as dailynews-gen's formatter. Bun.YAML.stringify emits a `{...}` flow
|
|
2067
|
+
// document, and the VoiceScript checker JSON-parses anything that starts with `{`.
|
|
2068
|
+
const fitted = `${voicescriptToBlockYaml(script)}\n`;
|
|
2069
|
+
sted('write', yamlOut, () => writeRaw(yamlOut, fitted));
|
|
2070
|
+
if (isFile(contentOut)) {
|
|
2071
|
+
const content = readJson(contentOut);
|
|
2072
|
+
if (content && typeof content.body === 'string') {
|
|
2073
|
+
content.body = fitted;
|
|
2074
|
+
sted('write', contentOut, () => writeJsonText(contentOut, content));
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
console.log(`script: fitted voicescript ${before} -> ${after} chars (budget ${maxChars})`);
|
|
2078
|
+
if (after > maxChars) {
|
|
2079
|
+
console.error(
|
|
2080
|
+
`script: voicescript still ${after - maxChars} chars over the ${maxChars} budget after sentence cuts`,
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
1582
2085
|
export function dailyScript(args: string[]): void {
|
|
1583
|
-
const [
|
|
2086
|
+
const [
|
|
2087
|
+
workDir = '',
|
|
2088
|
+
runDate = '',
|
|
2089
|
+
pluginsPath = '',
|
|
2090
|
+
language = '',
|
|
2091
|
+
title = '',
|
|
2092
|
+
voiceProfile = '',
|
|
2093
|
+
episodeMaxMinutes = '',
|
|
2094
|
+
vdriver = '',
|
|
2095
|
+
] = args;
|
|
1584
2096
|
const yamlOut = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
|
|
1585
2097
|
const contentOut = join(workDir, '24-script', `${runDate}_08_script_content.json`);
|
|
1586
2098
|
const planIn = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
|
|
@@ -1607,6 +2119,7 @@ export function dailyScript(args: string[]): void {
|
|
|
1607
2119
|
const body = readJson(contentOut)?.body;
|
|
1608
2120
|
if (typeof body !== 'string') failWith(1, `script: ${contentOut} has no string body`);
|
|
1609
2121
|
sted('write', yamlOut, () => writeRaw(yamlOut, body));
|
|
2122
|
+
fitVoicescriptToBudget(yamlOut, contentOut, episodeMaxMinutes, vdriver);
|
|
1610
2123
|
// R3 (task 0139): fresh writes are validated here; the script cache path is covered by
|
|
1611
2124
|
// wrap-docs, which runs on every path that reaches audio.
|
|
1612
2125
|
try {
|
|
@@ -1661,8 +2174,30 @@ export function dailyShowNotes(args: string[]): void {
|
|
|
1661
2174
|
// Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
|
|
1662
2175
|
// grammar lives in daily-article-gen's excludedIdsFromNote.
|
|
1663
2176
|
const postPath = firstPostPath(readJson(result), 'surfdash-pub');
|
|
1664
|
-
const
|
|
2177
|
+
const articleContent = readJson(article);
|
|
2178
|
+
const articleBody = articleContent?.body;
|
|
1665
2179
|
const excluded = new Set<string>();
|
|
2180
|
+
// Article Content carries the dropped ids as `metadata.excludedIds` (2026-10-10): the boundary
|
|
2181
|
+
// note is stripped from the published body, so re-parsing the body alone would silently stop
|
|
2182
|
+
// filtering and leak excluded docs into the show notes. The note parse below stays as the
|
|
2183
|
+
// fallback for Content produced before the field existed.
|
|
2184
|
+
const metaExcluded = metadataOf(articleContent)?.excludedIds;
|
|
2185
|
+
if (Array.isArray(metaExcluded)) {
|
|
2186
|
+
for (const id of metaExcluded) {
|
|
2187
|
+
if (typeof id === 'string' && id !== '') excluded.add(id);
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
// Script-plan exclusions are the prompt-layer topic boundary (foreign public affairs the
|
|
2191
|
+
// keyword floor deliberately leaves in the plan). Show notes must match what was spoken.
|
|
2192
|
+
const scriptPlan = readJson(join(workDir, '24-script', `${runDate}_08_script-write_plan.json`));
|
|
2193
|
+
const scriptExcluded = scriptPlan?.excluded;
|
|
2194
|
+
if (Array.isArray(scriptExcluded)) {
|
|
2195
|
+
for (const entry of scriptExcluded) {
|
|
2196
|
+
if (entry !== null && typeof entry === 'object' && typeof (entry as { id?: unknown }).id === 'string') {
|
|
2197
|
+
excluded.add((entry as { id: string }).id);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
1666
2201
|
if (typeof articleBody === 'string') {
|
|
1667
2202
|
for (const note of articleBody.matchAll(/未写入[::](.*)/g)) {
|
|
1668
2203
|
for (const id of note[1].matchAll(/(?:[a-zA-Z0-9_-]+:)?[0-9a-f]{16,64}/g)) {
|
|
@@ -1868,6 +2403,73 @@ function episodeNoteBody(body: string, lines: string[], cap: number): string {
|
|
|
1868
2403
|
return note;
|
|
1869
2404
|
}
|
|
1870
2405
|
|
|
2406
|
+
/**
|
|
2407
|
+
* 0251 R1: parse the composer's safe-area overflow sentence
|
|
2408
|
+
* (`text overflows the safe area for field(s): <fields> (safe area: …)`, chromium.ts) into the
|
|
2409
|
+
* named `points[n]` indexes — or null when the failure is anything else: no marker, a non-points
|
|
2410
|
+
* field (e.g. `headline`), or an index outside `card.points`. Null means no retry.
|
|
2411
|
+
*/
|
|
2412
|
+
export function xhsOverflowPoints(stderr: string, card: XhsCard): number[] | null {
|
|
2413
|
+
const marker = 'text overflows the safe area for field(s):';
|
|
2414
|
+
const at = stderr.indexOf(marker);
|
|
2415
|
+
if (at < 0) return null;
|
|
2416
|
+
const list = stderr.slice(at + marker.length).split('(')[0] ?? '';
|
|
2417
|
+
const fields = list.split(',').map((name) => /^points\[(\d+)\]$/.exec(name.trim())?.[1]);
|
|
2418
|
+
if (fields.length === 0 || fields.some((field) => field === undefined)) return null;
|
|
2419
|
+
const indexes = fields.map((field) => Number(field));
|
|
2420
|
+
if (indexes.some((n) => n >= card.points.length)) return null;
|
|
2421
|
+
return indexes;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/** The sentence-ending marks the shorten rule cuts at. */
|
|
2425
|
+
const POINT_SENTENCE_MARKS = new Set(['。', '!', '?']);
|
|
2426
|
+
|
|
2427
|
+
/** Fewest bullets a card keeps: below two, the block stops reading as a list. */
|
|
2428
|
+
const XHS_MIN_POINTS = 2;
|
|
2429
|
+
|
|
2430
|
+
/** How many rewrites a refused card gets; each attempt re-bills one illustration. */
|
|
2431
|
+
const XHS_OVERFLOW_ATTEMPTS = 3;
|
|
2432
|
+
|
|
2433
|
+
/**
|
|
2434
|
+
* 0251 R1 + 2026-10-12: reduce the points of a card the composer refused, so the next compose pass
|
|
2435
|
+
* can fit them. Rule 1 shortens each point the composer named ({@link shortenOverflowedPoint}). When
|
|
2436
|
+
* no point can get shorter the block itself is over budget — measured on 20261011-ai-2026-10-11, whose
|
|
2437
|
+
* cover points are 12–16 code-point single sentences that both shortening rules leave untouched, so
|
|
2438
|
+
* the old single-shot retry never fired and the whole series was lost — so rule 2 drops the trailing
|
|
2439
|
+
* point. Returns whether the card changed, the caller's signal to retry.
|
|
2440
|
+
*/
|
|
2441
|
+
export function reduceOverflowingPoints(points: string[], named: readonly number[]): boolean {
|
|
2442
|
+
let changed = false;
|
|
2443
|
+
for (const n of named) {
|
|
2444
|
+
const point = points[n] ?? '';
|
|
2445
|
+
const next = shortenOverflowedPoint(point);
|
|
2446
|
+
if (next !== point) {
|
|
2447
|
+
points[n] = next;
|
|
2448
|
+
changed = true;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
if (!changed && points.length > XHS_MIN_POINTS) {
|
|
2452
|
+
points.pop();
|
|
2453
|
+
changed = true;
|
|
2454
|
+
}
|
|
2455
|
+
return changed;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
/**
|
|
2459
|
+
* 0251 R1: shorten one overflowing card point — drop its last sentence (cut at the last
|
|
2460
|
+
* 。/!/? that is not the final character, inclusive); a one-sentence point longer than 36
|
|
2461
|
+
* code points keeps its first 36 plus `…`. Returns the input unchanged when neither rule
|
|
2462
|
+
* shortens it, which the caller reads as "nothing got shorter — do not retry".
|
|
2463
|
+
*/
|
|
2464
|
+
export function shortenOverflowedPoint(point: string): string {
|
|
2465
|
+
const chars = [...point];
|
|
2466
|
+
for (let i = chars.length - 2; i >= 0; i--) {
|
|
2467
|
+
if (POINT_SENTENCE_MARKS.has(chars[i] ?? '')) return chars.slice(0, i + 1).join('');
|
|
2468
|
+
}
|
|
2469
|
+
if (chars.length > 36) return `${chars.slice(0, 36).join('')}…`;
|
|
2470
|
+
return point;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
1871
2473
|
/**
|
|
1872
2474
|
* E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
|
|
1873
2475
|
* ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
|
|
@@ -1946,18 +2548,55 @@ export function dailyXhsRender(args: string[]): void {
|
|
|
1946
2548
|
// Sequential on purpose: one provider call per card (illustration only —
|
|
1947
2549
|
// card copy is typeset by image-gen's composer, never prompted), and the
|
|
1948
2550
|
// first failure stops the spend — later cards stay unrendered for a resume
|
|
1949
|
-
// run to pick up.
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
2551
|
+
// run to pick up. 0251: the one captured call — the overflow retry branches
|
|
2552
|
+
// on the composer's stderr, which kk()'s inherit stdio cannot return.
|
|
2553
|
+
const imageGenArgs = [
|
|
2554
|
+
'executor',
|
|
2555
|
+
'run',
|
|
2556
|
+
'image-gen',
|
|
2557
|
+
'--in',
|
|
2558
|
+
envPath,
|
|
2559
|
+
'--out',
|
|
2560
|
+
out,
|
|
2561
|
+
...pluginsArgs(pluginsPath),
|
|
2562
|
+
];
|
|
1954
2563
|
// 0219 R2: name the failed card so a hand re-run resumes without re-billing.
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2564
|
+
const resumeLine = (status: number): string =>
|
|
2565
|
+
`xhs render: card ${card.index} failed (image-gen exit ${status}); cards before it are kept — re-run daily-xhs-render to resume from card ${card.index}`;
|
|
2566
|
+
let result = kkCaptured(imageGenArgs, { soft: true });
|
|
2567
|
+
// 0269: a failure the composer's overflow sentence does NOT explain is retried once with
|
|
2568
|
+
// the SAME envelope, because the observed cause of those is the provider, not the input:
|
|
2569
|
+
// on 2026-10-12 card 02 was rewritten for a points[3] overflow and the second call then
|
|
2570
|
+
// failed with only a vendor-fallback warning (minimax flaked); re-running that exact
|
|
2571
|
+
// saved envelope by hand rendered on the first try. 0251 R2 read "not an overflow ⇒ do not
|
|
2572
|
+
// retry" as fail-fast, which is what lost the whole series that day (and every day since
|
|
2573
|
+
// 10-08). The budget stays bounded: overflow rewrites keep their 3, a provider failure
|
|
2574
|
+
// gets exactly 1, so a permanently broken card costs one extra illustration and not four.
|
|
2575
|
+
let transientRetryUsed = false;
|
|
2576
|
+
for (let attempt = 0; result.status !== 0 && attempt < XHS_OVERFLOW_ATTEMPTS; attempt += 1) {
|
|
2577
|
+
// 0251 R1 (+ the block-level rule 2): a points[n] safe-area overflow is rewritable —
|
|
2578
|
+
// shorten the named points, then drop the trailing one. The envelope is rewritten in
|
|
2579
|
+
// the same shape and image-gen is called again.
|
|
2580
|
+
const named = xhsOverflowPoints(result.stderr, card);
|
|
2581
|
+
if (named === null) {
|
|
2582
|
+
if (transientRetryUsed) break;
|
|
2583
|
+
transientRetryUsed = true;
|
|
2584
|
+
console.log(
|
|
2585
|
+
`xhs render: card ${card.index} failed without a safe-area overflow — retrying the same envelope once (exit ${result.status})`,
|
|
2586
|
+
);
|
|
2587
|
+
result = kkCaptured(imageGenArgs, { soft: true });
|
|
2588
|
+
continue;
|
|
2589
|
+
}
|
|
2590
|
+
if (!reduceOverflowingPoints(card.points, named)) break;
|
|
2591
|
+
(imageGen.vars as Record<string, unknown>).points = card.points.join('\n');
|
|
2592
|
+
sted('write', envPath, () =>
|
|
2593
|
+
writeJsonText(envPath, [
|
|
2594
|
+
{ id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
|
|
2595
|
+
]),
|
|
1959
2596
|
);
|
|
2597
|
+
result = kkCaptured(imageGenArgs, { soft: true });
|
|
1960
2598
|
}
|
|
2599
|
+
if (result.status !== 0) failWith(result.status, resumeLine(result.status));
|
|
1961
2600
|
artifact = firstArtifact(requireJson(out, 'xhs render'));
|
|
1962
2601
|
}
|
|
1963
2602
|
if (artifact === null) failWith(1, `xhs render: card ${card.index} recorded no image artifact (${out})`);
|