@gobing-ai/knowledge-kit 0.0.35 → 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 +11768 -9249
- 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 +1004 -10803
- package/plugins/generations/daily-article-gen/package.json +1 -1
- package/plugins/generations/dailynews-gen/dist/index.js +1092 -10891
- package/plugins/generations/dailynews-gen/package.json +1 -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 +1104 -10903
- 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/voice-gen/dist/index.js +1062 -10861
- package/plugins/generations/voice-gen/package.json +1 -1
- 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 +428 -46
- package/plugins/kk/scripts/kk-workflow-stages.ts +76 -146
- 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 +4 -2
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +86 -15
- package/plugins/kk/workflows/kk-daily-safety.yaml +15 -2
- 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 +1677 -11199
- package/plugins/publishings/infoq-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/plugin.json +5 -1
- package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
- package/plugins/publishings/juejin-pub/dist/profile-check.js +1677 -11199
- package/plugins/publishings/juejin-pub/package.json +1 -1
- package/plugins/publishings/juejin-pub/plugin.json +5 -1
- 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 +1698 -11200
- package/plugins/publishings/substack-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/plugin.json +4 -1
- 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 +2508 -11695
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +1656 -11188
- package/plugins/publishings/wechatmp-pub/package.json +1 -1
- package/plugins/publishings/wechatmp-pub/plugin.json +5 -1
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +244 -71
- package/plugins/publishings/wechatmp-pub/src/flow.ts +412 -96
- package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
- package/plugins/publishings/wechatmp-pub/src/page.ts +16 -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 +1677 -11199
- package/plugins/publishings/x-pub/package.json +1 -1
- package/plugins/publishings/x-pub/plugin.json +5 -1
- package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
- package/plugins/publishings/xhs-pub/dist/profile-check.js +1697 -11212
- package/plugins/publishings/xhs-pub/package.json +1 -1
- package/plugins/publishings/xhs-pub/plugin.json +5 -1
- 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
|
@@ -124,6 +124,8 @@ export function dailyQcContent(args: string[]): void {
|
|
|
124
124
|
minUrgency = '',
|
|
125
125
|
minImpact = '',
|
|
126
126
|
minEpisodeItems = '',
|
|
127
|
+
minMinutes = '',
|
|
128
|
+
vdriver = '',
|
|
127
129
|
] = args;
|
|
128
130
|
const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
|
|
129
131
|
const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
|
|
@@ -158,6 +160,7 @@ export function dailyQcContent(args: string[]): void {
|
|
|
158
160
|
},
|
|
159
161
|
},
|
|
160
162
|
);
|
|
163
|
+
applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
|
|
161
164
|
});
|
|
162
165
|
// Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
|
|
163
166
|
// candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
|
|
@@ -208,6 +211,79 @@ export function dailyQcContent(args: string[]): void {
|
|
|
208
211
|
* episode-plan-gen's `titleSimilarity`. Duplicated on purpose: this file is node-builtins-only
|
|
209
212
|
* (no workspace imports), so it cannot reach that module.
|
|
210
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
|
+
|
|
211
287
|
export function dailyArticle(args: string[]): void {
|
|
212
288
|
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
213
289
|
const articleMd = join(workDir, '22-article', `${runDate}_06_article_article.md`);
|
|
@@ -383,9 +459,16 @@ export function dailyCoverNormalize(args: string[]): void {
|
|
|
383
459
|
* content's `metadata.duration` — after the intro sting merged, this is the true episode length —
|
|
384
460
|
* and fails loud outside `[min, max]` seconds-worth of minutes. A missing duration is a failure,
|
|
385
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.
|
|
386
469
|
*/
|
|
387
470
|
export function dailyEpisodeDuration(args: string[]): void {
|
|
388
|
-
const [workDir = '', runDate = '', minArg = '', maxArg = ''] = args;
|
|
471
|
+
const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = ''] = args;
|
|
389
472
|
if (!workDir) fail('missing work_dir');
|
|
390
473
|
if (!runDate) fail('missing run_date');
|
|
391
474
|
const minMinutes = Number(minArg);
|
|
@@ -411,10 +494,43 @@ export function dailyEpisodeDuration(args: string[]): void {
|
|
|
411
494
|
const minutes = seconds / 60;
|
|
412
495
|
const shown = minutes.toFixed(1);
|
|
413
496
|
if (minutes < minMinutes) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
}),
|
|
417
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})`,
|
|
532
|
+
);
|
|
533
|
+
return;
|
|
418
534
|
}
|
|
419
535
|
if (minutes > maxMinutes) {
|
|
420
536
|
failWith(
|
|
@@ -460,6 +576,158 @@ function geminiQuotaProbeOk(workDir: string, pluginsPath: string, transcodeMp3:
|
|
|
460
576
|
return true;
|
|
461
577
|
}
|
|
462
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
|
+
|
|
463
731
|
export function dailyGenerate(args: string[]): void {
|
|
464
732
|
const [
|
|
465
733
|
workDir = '',
|
|
@@ -476,6 +744,7 @@ export function dailyGenerate(args: string[]): void {
|
|
|
476
744
|
ominivoice: 'omni-voice-gen',
|
|
477
745
|
voicebox: 'voice-gen',
|
|
478
746
|
gemini: 'gemini-tts',
|
|
747
|
+
voxcpm: 'voxcpm-tts',
|
|
479
748
|
};
|
|
480
749
|
let voiceGenerator = VOICE_DRIVERS[vdriver];
|
|
481
750
|
if (!voiceGenerator) {
|
|
@@ -494,12 +763,9 @@ export function dailyGenerate(args: string[]): void {
|
|
|
494
763
|
|
|
495
764
|
if (!sectionConcurrency) {
|
|
496
765
|
timed(workDir, 'generate', () => {
|
|
497
|
-
if (
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
console.log(`audio cached: ${content} (QC passed)`);
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
766
|
+
if (wholeDocCache(content, voiceGenerator, transcodeMp3)) {
|
|
767
|
+
console.log(`audio cached: ${content} (QC passed)`);
|
|
768
|
+
return;
|
|
503
769
|
}
|
|
504
770
|
// Gemini quota probe (20261006): the 06:00 cron hit gemini-3.8-flash-tts 429 with a
|
|
505
771
|
// multi-hour retry-after after the shared key's window drained overnight. One tiny
|
|
@@ -513,11 +779,19 @@ export function dailyGenerate(args: string[]): void {
|
|
|
513
779
|
!geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [{ ...docsList[0], body: 'Hello.' }])
|
|
514
780
|
) {
|
|
515
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
|
+
}
|
|
516
789
|
}
|
|
517
790
|
}
|
|
518
791
|
kk(voiceExecutorArgs(voiceGenerator, docs, content, pluginsPath), {
|
|
519
792
|
env: { VOICE_GEN_MP3: transcodeMp3 },
|
|
520
793
|
});
|
|
794
|
+
ensurePublishMp3(content, transcodeMp3);
|
|
521
795
|
});
|
|
522
796
|
return;
|
|
523
797
|
}
|
|
@@ -642,6 +916,8 @@ export function dailyGenerate(args: string[]): void {
|
|
|
642
916
|
// 4645 / 789.9 s of section speech = 5.88 — a 5.3–5.9 range, so 5.4 deliberately errs low (a
|
|
643
917
|
// slightly early refusal is cheaper than a rendered episode the duration gate then rejects).
|
|
644
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.
|
|
645
921
|
// The style sidecar's 3.3 / 6.3 figures are stale and mutually inconsistent with its own
|
|
646
922
|
// 4300–5800 字 budget (which is 13–29 min at 3.3); using them refused a script that really
|
|
647
923
|
// renders in 17 min. Recalibrate from a fresh pair of `metadata.duration` readings when a
|
|
@@ -687,7 +963,16 @@ export function dailyGenerate(args: string[]): void {
|
|
|
687
963
|
try {
|
|
688
964
|
const sidecar = readJson(jsonPath) as Record<string, unknown>;
|
|
689
965
|
const sidecarMeta = metadataOf(sidecar) as Record<string, unknown>;
|
|
690
|
-
|
|
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
|
+
) {
|
|
691
976
|
cached = true;
|
|
692
977
|
}
|
|
693
978
|
} catch {}
|
|
@@ -705,7 +990,18 @@ export function dailyGenerate(args: string[]): void {
|
|
|
705
990
|
let plans = computePlans(voiceGenerator);
|
|
706
991
|
const anySynthesisNeeded = plans.some((p) => p.needsSynthesis);
|
|
707
992
|
|
|
708
|
-
|
|
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
|
+
) {
|
|
709
1005
|
console.log('generate: all sections cached, joined speech reused');
|
|
710
1006
|
return;
|
|
711
1007
|
}
|
|
@@ -752,10 +1048,25 @@ export function dailyGenerate(args: string[]): void {
|
|
|
752
1048
|
};
|
|
753
1049
|
writeJsonText(secDocIn, [doc]);
|
|
754
1050
|
try {
|
|
755
|
-
|
|
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), {
|
|
756
1058
|
env: { VOICE_GEN_MP3: transcodeMp3 },
|
|
757
1059
|
soft: true,
|
|
758
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
|
+
}
|
|
759
1070
|
if (status !== 0) {
|
|
760
1071
|
failWith(status, `generate: section synthesis failed for ${plan.def.stem}`);
|
|
761
1072
|
}
|
|
@@ -814,21 +1125,52 @@ export function dailyGenerate(args: string[]): void {
|
|
|
814
1125
|
}
|
|
815
1126
|
|
|
816
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
|
+
}
|
|
817
1131
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
+
}
|
|
823
1143
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
+
};
|
|
831
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);
|
|
832
1174
|
});
|
|
833
1175
|
}
|
|
834
1176
|
|
|
@@ -1647,7 +1989,7 @@ function voicescriptToBlockYaml(script: Record<string, unknown>): string {
|
|
|
1647
1989
|
return lines.join('\n');
|
|
1648
1990
|
}
|
|
1649
1991
|
|
|
1650
|
-
/** Measured driver rates from dailyGenerate. Unknown drivers use the voicebox rate. */
|
|
1992
|
+
/** Measured driver rates from dailyGenerate. Unknown drivers (incl. voxcpm, provisionally) use the voicebox rate. */
|
|
1651
1993
|
function spokenCharRate(vdriver: string): number {
|
|
1652
1994
|
return vdriver === 'gemini' ? 5.4 : 5.6;
|
|
1653
1995
|
}
|
|
@@ -2082,6 +2424,37 @@ export function xhsOverflowPoints(stderr: string, card: XhsCard): number[] | nul
|
|
|
2082
2424
|
/** The sentence-ending marks the shorten rule cuts at. */
|
|
2083
2425
|
const POINT_SENTENCE_MARKS = new Set(['。', '!', '?']);
|
|
2084
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
|
+
|
|
2085
2458
|
/**
|
|
2086
2459
|
* 0251 R1: shorten one overflowing card point — drop its last sentence (cut at the last
|
|
2087
2460
|
* 。/!/? that is not the final character, inclusive); a one-sentence point longer than 36
|
|
@@ -2191,30 +2564,39 @@ export function dailyXhsRender(args: string[]): void {
|
|
|
2191
2564
|
const resumeLine = (status: number): string =>
|
|
2192
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}`;
|
|
2193
2566
|
let result = kkCaptured(imageGenArgs, { soft: true });
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
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})`,
|
|
2213
2586
|
);
|
|
2214
2587
|
result = kkCaptured(imageGenArgs, { soft: true });
|
|
2588
|
+
continue;
|
|
2215
2589
|
}
|
|
2216
|
-
if (
|
|
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
|
+
]),
|
|
2596
|
+
);
|
|
2597
|
+
result = kkCaptured(imageGenArgs, { soft: true });
|
|
2217
2598
|
}
|
|
2599
|
+
if (result.status !== 0) failWith(result.status, resumeLine(result.status));
|
|
2218
2600
|
artifact = firstArtifact(requireJson(out, 'xhs render'));
|
|
2219
2601
|
}
|
|
2220
2602
|
if (artifact === null) failWith(1, `xhs render: card ${card.index} recorded no image artifact (${out})`);
|