@gobing-ai/knowledge-kit 0.0.18 → 0.0.21
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/dist/index.js +62 -7
- package/package.json +1 -1
- package/plugins/generations/dailynews-gen/dist/index.js +9 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +19 -1
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +13 -5
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +23 -15
- package/plugins/generations/voice-gen/dist/index.js +60 -3
- package/plugins/generations/voice-gen/src/index.ts +8 -0
- package/plugins/generations/voice-gen/src/voicebox-server.ts +91 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +1 -1
- package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
- package/plugins/ingestions/aihot-ingest/src/rss.ts +12 -2
- package/plugins/kk/commands/workflow-run.md +30 -12
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/itc-stages.ts +563 -0
- package/plugins/kk/scripts/kk-workflow-stages.ts +150 -23
- package/plugins/kk/skills/article-adapt/SKILL.md +85 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +67 -145
- package/plugins/kk/workflows/kk-itc.yaml +461 -12
package/dist/index.js
CHANGED
|
@@ -26420,24 +26420,38 @@ function spawnPluginEntry(opts) {
|
|
|
26420
26420
|
stderr: proc.stderr.toString().trim()
|
|
26421
26421
|
};
|
|
26422
26422
|
}
|
|
26423
|
-
|
|
26423
|
+
var KILL_TREE_SETTLE_MS = 100;
|
|
26424
|
+
function listChildren(pid) {
|
|
26424
26425
|
let stdout;
|
|
26425
26426
|
try {
|
|
26426
26427
|
stdout = Bun.spawnSync(["pgrep", "-P", String(pid)]).stdout.toString();
|
|
26427
26428
|
} catch {
|
|
26428
26429
|
stdout = "";
|
|
26429
26430
|
}
|
|
26430
|
-
|
|
26431
|
-
`))
|
|
26432
|
-
|
|
26433
|
-
|
|
26434
|
-
|
|
26435
|
-
|
|
26431
|
+
return stdout.trim().split(`
|
|
26432
|
+
`).map(Number).filter((child) => Number.isInteger(child) && child > 0);
|
|
26433
|
+
}
|
|
26434
|
+
function killSubtree(pid) {
|
|
26435
|
+
for (const child of listChildren(pid)) {
|
|
26436
|
+
killSubtree(child);
|
|
26436
26437
|
}
|
|
26437
26438
|
try {
|
|
26438
26439
|
process.kill(pid, "SIGKILL");
|
|
26439
26440
|
} catch {}
|
|
26440
26441
|
}
|
|
26442
|
+
async function killProcessTree(pid) {
|
|
26443
|
+
const sweep = () => {
|
|
26444
|
+
for (const child of listChildren(pid)) {
|
|
26445
|
+
killSubtree(child);
|
|
26446
|
+
}
|
|
26447
|
+
};
|
|
26448
|
+
sweep();
|
|
26449
|
+
await Bun.sleep(KILL_TREE_SETTLE_MS);
|
|
26450
|
+
sweep();
|
|
26451
|
+
try {
|
|
26452
|
+
process.kill(pid, "SIGKILL");
|
|
26453
|
+
} catch {}
|
|
26454
|
+
}
|
|
26441
26455
|
async function spawnPluginEntryAsync(opts) {
|
|
26442
26456
|
let timedOut = false;
|
|
26443
26457
|
let timer;
|
|
@@ -27445,6 +27459,46 @@ function registerPluginCommand(program2) {
|
|
|
27445
27459
|
registerStatusVerb(plugins, "doctor", "Alias for `plugin status` (deprecated)", true);
|
|
27446
27460
|
}
|
|
27447
27461
|
|
|
27462
|
+
// src/stage.ts
|
|
27463
|
+
import { spawnSync } from "child_process";
|
|
27464
|
+
import { existsSync as existsSync5, realpathSync as realpathSync3 } from "fs";
|
|
27465
|
+
import { dirname as dirname3, join as join5, resolve as resolve6 } from "path";
|
|
27466
|
+
var STAGES_REL = "plugins/kk/scripts/kk-workflow-stages.ts";
|
|
27467
|
+
function resolveStagesScript(argv1 = process.argv[1]) {
|
|
27468
|
+
let fromPkg = "";
|
|
27469
|
+
if (argv1) {
|
|
27470
|
+
try {
|
|
27471
|
+
fromPkg = join5(dirname3(dirname3(realpathSync3(argv1))), STAGES_REL);
|
|
27472
|
+
} catch {
|
|
27473
|
+
fromPkg = "";
|
|
27474
|
+
}
|
|
27475
|
+
}
|
|
27476
|
+
if (fromPkg && existsSync5(fromPkg))
|
|
27477
|
+
return fromPkg;
|
|
27478
|
+
const fromCwd = resolve6(STAGES_REL);
|
|
27479
|
+
if (existsSync5(fromCwd))
|
|
27480
|
+
return fromCwd;
|
|
27481
|
+
throw new Error(`kk-workflow-stages.ts not found at ${fromPkg || STAGES_REL} \u2014 install or refresh the kk package (bun link in a checkout)`);
|
|
27482
|
+
}
|
|
27483
|
+
function runStage(args, execPath = process.execPath, argv1) {
|
|
27484
|
+
const script = resolveStagesScript(argv1);
|
|
27485
|
+
const res = spawnSync(execPath, [script, ...args], { stdio: "inherit" });
|
|
27486
|
+
if (res.error) {
|
|
27487
|
+
throw new Error(`failed to spawn stage runner at ${script}: ${res.error.message}`);
|
|
27488
|
+
}
|
|
27489
|
+
return res.status ?? 1;
|
|
27490
|
+
}
|
|
27491
|
+
|
|
27492
|
+
// src/commands/stage.ts
|
|
27493
|
+
function registerStageCommand(program2) {
|
|
27494
|
+
program2.command("stage").description("Run a kk-workflow-stages.ts stage (workflow orchestration steps)").argument("<args...>", "Stage name followed by its positional arguments").allowUnknownOption(true).action((args) => {
|
|
27495
|
+
const code = runStage(args);
|
|
27496
|
+
if (code !== 0) {
|
|
27497
|
+
throw new CommanderError(code, "kk.stage.failed", `stage failed with exit code ${code}`);
|
|
27498
|
+
}
|
|
27499
|
+
});
|
|
27500
|
+
}
|
|
27501
|
+
|
|
27448
27502
|
// src/config.ts
|
|
27449
27503
|
import { readFileSync as readFileSync4 } from "fs";
|
|
27450
27504
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
@@ -27460,6 +27514,7 @@ function createProgram() {
|
|
|
27460
27514
|
const program2 = new Command().name(CLI_CONFIG.binaryName).description("knowledge-kit CLI").version(CLI_CONFIG.binaryVersion).option("-v, --verbose", "enable verbose diagnostics");
|
|
27461
27515
|
registerExecutorCommand(program2);
|
|
27462
27516
|
registerPluginCommand(program2);
|
|
27517
|
+
registerStageCommand(program2);
|
|
27463
27518
|
return program2;
|
|
27464
27519
|
}
|
|
27465
27520
|
|
package/package.json
CHANGED
|
@@ -22010,6 +22010,13 @@ function stripLeadingTitleEcho(body, title) {
|
|
|
22010
22010
|
return body;
|
|
22011
22011
|
return body.slice(end).replace(/^[\uFF0C\u3002\u3001\uFF1A:\uFF1B;!\uFF01?\uFF1F\u2026\s\u300C\u300D\u300E\u300F\u3010\u3011]+/, "");
|
|
22012
22012
|
}
|
|
22013
|
+
var TAG_METADATA_LINE_RE = /^\s*\**\s*\u6807\u7B7E\s*\**\s*[\uFF1A:\uFF0C,]/;
|
|
22014
|
+
function stripTagMetadataLines(body) {
|
|
22015
|
+
const kept = body.split(`
|
|
22016
|
+
`).filter((line) => !TAG_METADATA_LINE_RE.test(line)).join(`
|
|
22017
|
+
`);
|
|
22018
|
+
return kept === body ? body : kept.trim();
|
|
22019
|
+
}
|
|
22013
22020
|
function splitFactText(text, maxChars = MAX_FACT_SEGMENT_CHARS) {
|
|
22014
22021
|
if (text.length <= maxChars)
|
|
22015
22022
|
return [text];
|
|
@@ -22091,7 +22098,7 @@ function buildNewsVoiceScript(docs, options) {
|
|
|
22091
22098
|
docs.forEach((doc2, idx) => {
|
|
22092
22099
|
const isLast = idx === docs.length - 1 && docs.length > 1;
|
|
22093
22100
|
const cleanTitle = (doc2.title ?? "").trim();
|
|
22094
|
-
const cleanBody = stripLeadingTitleEcho((doc2.body ?? "").trim(), cleanTitle);
|
|
22101
|
+
const cleanBody = stripLeadingTitleEcho(stripTagMetadataLines((doc2.body ?? "").trim()), cleanTitle);
|
|
22095
22102
|
const lead = isZh ? idx === 0 ? `\u9996\u5148\u6765\u804A\u804A\u5927\u5BB6\u975E\u5E38\u5173\u6CE8\u7684\u3010${cleanTitle}\u3011\u3002` : isLast ? `\u6700\u540E\uFF0C\u6765\u770B\u770B\u4ECA\u5929\u7684\u6700\u540E\u4E00\u6761\u8D44\u8BAF\uFF0C\u3010${cleanTitle}\u3011\u3002` : `\u63A5\u7740\u6211\u4EEC\u628A\u76EE\u5149\u8F6C\u5411\u53E6\u4E00\u6761\u91CD\u8981\u8FDB\u5C55\uFF0C\u3010${cleanTitle}\u3011\u3002` : idx === 0 ? `First up today, let's look at ${cleanTitle}.` : isLast ? `And finally today, let's wrap up with ${cleanTitle}.` : `Next, turning our attention to ${cleanTitle}.`;
|
|
22096
22103
|
const normalizedLead = normalizeBroadcastText(lead, isZh);
|
|
22097
22104
|
const normalizedBody = cleanBody === "" ? "" : normalizeBroadcastText(cleanBody, isZh);
|
|
@@ -22341,6 +22348,7 @@ if (import.meta.main) {
|
|
|
22341
22348
|
}
|
|
22342
22349
|
export {
|
|
22343
22350
|
synthesizePersonalUnderstanding,
|
|
22351
|
+
stripTagMetadataLines,
|
|
22344
22352
|
stripLeadingTitleEcho,
|
|
22345
22353
|
splitFactText,
|
|
22346
22354
|
processGeneratorIO,
|
|
@@ -245,6 +245,24 @@ export function stripLeadingTitleEcho(body: string, title: string): string {
|
|
|
245
245
|
return body.slice(end).replace(/^[,。、::;;!!??…\s「」『』【】]+/, '');
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Drop `**标签**: …` metadata lines from a plan doc body (0139 R3).
|
|
250
|
+
*
|
|
251
|
+
* `plan-translate` keeps the research note's trailing tag line in the body it hands over; spoken
|
|
252
|
+
* aloud it reads as a `标签,…` tail the script validator rejects. The line is article metadata,
|
|
253
|
+
* not copy — remove whole lines that open with the 标签 label + separator; anything else is left
|
|
254
|
+
* for the validator to fail loud on.
|
|
255
|
+
*/
|
|
256
|
+
const TAG_METADATA_LINE_RE = /^\s*\**\s*标签\s*\**\s*[::,,]/;
|
|
257
|
+
|
|
258
|
+
export function stripTagMetadataLines(body: string): string {
|
|
259
|
+
const kept = body
|
|
260
|
+
.split('\n')
|
|
261
|
+
.filter((line) => !TAG_METADATA_LINE_RE.test(line))
|
|
262
|
+
.join('\n');
|
|
263
|
+
return kept === body ? body : kept.trim();
|
|
264
|
+
}
|
|
265
|
+
|
|
248
266
|
/**
|
|
249
267
|
* Split a fact text into chunks of at most `maxChars` (task 0139 R4).
|
|
250
268
|
*
|
|
@@ -360,7 +378,7 @@ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions
|
|
|
360
378
|
const cleanTitle = (doc.title ?? '').trim();
|
|
361
379
|
// R1: drop the title restatement plan-translate prepends to the body, so the lead's
|
|
362
380
|
// 【title】 is the only place the title is spoken for this item.
|
|
363
|
-
const cleanBody = stripLeadingTitleEcho((doc.body ?? '').trim(), cleanTitle);
|
|
381
|
+
const cleanBody = stripLeadingTitleEcho(stripTagMetadataLines((doc.body ?? '').trim()), cleanTitle);
|
|
364
382
|
|
|
365
383
|
// Conversational lead
|
|
366
384
|
const lead = isZh
|
|
@@ -133,8 +133,8 @@ def _transcribe(backend: Backend, wav: bytes, language: str) -> str | None:
|
|
|
133
133
|
return None
|
|
134
134
|
|
|
135
135
|
|
|
136
|
-
def _render_verified(backend: Backend, spec: SegmentSpec) -> tuple[bytes, float, int, str | None, bool]:
|
|
137
|
-
"""Spec §5 verify-retry -> (wav, duration, verify_retries, transcription, loudness_dip)."""
|
|
136
|
+
def _render_verified(backend: Backend, spec: SegmentSpec) -> tuple[bytes, float, int, str | None, bool, float | None]:
|
|
137
|
+
"""Spec §5 verify-retry -> (wav, duration, verify_retries, transcription, loudness_dip, dip_at)."""
|
|
138
138
|
for attempt in range(MAX_VERIFY_RETRIES + 1):
|
|
139
139
|
final = attempt == MAX_VERIFY_RETRIES # the final attempt is always accepted
|
|
140
140
|
if attempt:
|
|
@@ -154,7 +154,7 @@ def _render_verified(backend: Backend, spec: SegmentSpec) -> tuple[bytes, float,
|
|
|
154
154
|
if dip and not final:
|
|
155
155
|
_log(f"loudness dip detected at {dip_at}s — regenerating segment")
|
|
156
156
|
continue
|
|
157
|
-
return wav, duration, attempt, transcription, dip
|
|
157
|
+
return wav, duration, attempt, transcription, dip, dip_at
|
|
158
158
|
raise AssertionError("unreachable: the final verify attempt always returns") # pragma: no cover
|
|
159
159
|
|
|
160
160
|
|
|
@@ -212,7 +212,7 @@ def _render(
|
|
|
212
212
|
"seed": None if segment.seed is None else int(segment.seed),
|
|
213
213
|
"speed": None if segment.speed is None else float(segment.speed),
|
|
214
214
|
}
|
|
215
|
-
wav, duration, retries, transcription, dip = _render_verified(backend, spec)
|
|
215
|
+
wav, duration, retries, transcription, dip, dip_at = _render_verified(backend, spec)
|
|
216
216
|
|
|
217
217
|
gap_ms = segment.gap_ms or 0
|
|
218
218
|
wavs.append(wav)
|
|
@@ -233,7 +233,15 @@ def _render(
|
|
|
233
233
|
}
|
|
234
234
|
)
|
|
235
235
|
)
|
|
236
|
-
audit_meta.append(
|
|
236
|
+
audit_meta.append(
|
|
237
|
+
{
|
|
238
|
+
"duration": duration,
|
|
239
|
+
"transcription": transcription,
|
|
240
|
+
"loudness_dip": dip,
|
|
241
|
+
"dip_at": dip_at,
|
|
242
|
+
"verify_retries": retries,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
237
245
|
retry_note = f" ({retries} verify {'retry' if retries == 1 else 'retries'})" if retries else ""
|
|
238
246
|
_log(f"segment {index + 1}/{total} done in {monotonic() - segment_started:.1f}s{retry_note}")
|
|
239
247
|
|
|
@@ -48,6 +48,7 @@ class SegmentQualityAudit:
|
|
|
48
48
|
repetition_detected: bool
|
|
49
49
|
duration_anomaly: bool
|
|
50
50
|
fidelity_score: float | None
|
|
51
|
+
verify_retries: int = 0
|
|
51
52
|
issues: list[str] = field(default_factory=list)
|
|
52
53
|
|
|
53
54
|
|
|
@@ -158,21 +159,6 @@ def audit_voice_segments(
|
|
|
158
159
|
if fidelity < TRANSCRIPTION_FIDELITY_FLOOR:
|
|
159
160
|
issues.append(f"transcription_fidelity_low:{fidelity:.2f}")
|
|
160
161
|
|
|
161
|
-
# Repetition
|
|
162
|
-
repetition = detect_repetitions(text) or (
|
|
163
|
-
detect_repetitions(transcription) if transcription is not None else False
|
|
164
|
-
)
|
|
165
|
-
if repetition:
|
|
166
|
-
issues.append("repetition_detected")
|
|
167
|
-
|
|
168
|
-
# Loudness dip: reuse the verify-phase result when present; only run the windowed
|
|
169
|
-
# scan when the verify phase did not (meta.loudness_dip absent).
|
|
170
|
-
dip, dip_at = (
|
|
171
|
-
(bool(meta["loudness_dip"]), None) if "loudness_dip" in meta else detect_loudness_dip(wav)
|
|
172
|
-
)
|
|
173
|
-
if dip:
|
|
174
|
-
issues.append(f"loudness_dip:{f'{dip_at}s' if dip_at is not None else 'unknown'}")
|
|
175
|
-
|
|
176
162
|
# Duration anomaly
|
|
177
163
|
ratio_anomaly = duration > MAX_DURATION_RATIO * max_sec if max_sec > 0 else False
|
|
178
164
|
abs_anomaly = duration > max_sec + MAX_ABS_DIFF_SEC if max_sec > 0 else False
|
|
@@ -187,6 +173,27 @@ def audit_voice_segments(
|
|
|
187
173
|
f"duration_anomaly: expected {min_sec:.2f}s-{max_sec:.2f}s, got {duration:.2f}s"
|
|
188
174
|
)
|
|
189
175
|
|
|
176
|
+
# Repetition — transcription channel only, corroborated by duration anomaly (voice-gen
|
|
177
|
+
# qc.ts parity): the battery is an ASR/TTS-artifact detector, not a source-text lint.
|
|
178
|
+
# Source strings like version numbers ("1.1.1.") are legitimate copy and speak fine
|
|
179
|
+
# (dogfood 2026-09-18: a version string in the source tripped the word-run regex and
|
|
180
|
+
# condemned a 70-minute render that no regeneration could fix). A real TTS loop always
|
|
181
|
+
# inflates duration; a Whisper phantom loop over trailing silence does not.
|
|
182
|
+
repetition = detect_repetitions(transcription) if transcription is not None else False
|
|
183
|
+
if repetition and duration_anomaly:
|
|
184
|
+
# Bare token: pipeline.py's R5a gate matches issues by exact membership.
|
|
185
|
+
issues.append("repetition_detected")
|
|
186
|
+
|
|
187
|
+
# Loudness dip: reuse the verify-phase result when present; only run the windowed
|
|
188
|
+
# scan when the verify phase did not (meta.loudness_dip absent).
|
|
189
|
+
dip, dip_at = (
|
|
190
|
+
(bool(meta["loudness_dip"]), meta.get("dip_at"))
|
|
191
|
+
if "loudness_dip" in meta
|
|
192
|
+
else detect_loudness_dip(wav)
|
|
193
|
+
)
|
|
194
|
+
if dip:
|
|
195
|
+
issues.append(f"loudness_dip:{f'{dip_at}s' if dip_at is not None else 'unknown'}")
|
|
196
|
+
|
|
190
197
|
audits.append(
|
|
191
198
|
SegmentQualityAudit(
|
|
192
199
|
segment_index=index,
|
|
@@ -198,6 +205,7 @@ def audit_voice_segments(
|
|
|
198
205
|
repetition_detected=repetition,
|
|
199
206
|
duration_anomaly=duration_anomaly,
|
|
200
207
|
fidelity_score=fidelity,
|
|
208
|
+
verify_retries=int(meta.get("verify_retries", 0) or 0),
|
|
201
209
|
issues=issues,
|
|
202
210
|
)
|
|
203
211
|
)
|
|
@@ -21744,7 +21744,7 @@ function findProjectRoot(startDir) {
|
|
|
21744
21744
|
var init_file_system_node = () => {};
|
|
21745
21745
|
|
|
21746
21746
|
// ../../plugins/generations/voice-gen/src/index.ts
|
|
21747
|
-
import { basename, dirname as dirname2, extname, join, resolve } from "path";
|
|
21747
|
+
import { basename, dirname as dirname2, extname, join as join2, resolve } from "path";
|
|
21748
21748
|
import { parseArgs } from "util";
|
|
21749
21749
|
|
|
21750
21750
|
// ../../packages/kk-core/src/kinds.ts
|
|
@@ -22413,6 +22413,60 @@ function createVoiceboxClient(options = {}) {
|
|
|
22413
22413
|
};
|
|
22414
22414
|
}
|
|
22415
22415
|
|
|
22416
|
+
// ../../plugins/generations/voice-gen/src/voicebox-server.ts
|
|
22417
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
22418
|
+
import { existsSync as existsSync2, openSync } from "fs";
|
|
22419
|
+
import { homedir } from "os";
|
|
22420
|
+
import { join } from "path";
|
|
22421
|
+
var BOOT_WAIT_MS = 30000;
|
|
22422
|
+
var POLL_MS = 500;
|
|
22423
|
+
var LOG_PATH = "/tmp/voicebox-server.log";
|
|
22424
|
+
async function probeVoiceboxHealth(url2, customFetch) {
|
|
22425
|
+
try {
|
|
22426
|
+
return (await customFetch(`${url2}/health`)).ok;
|
|
22427
|
+
} catch {
|
|
22428
|
+
return false;
|
|
22429
|
+
}
|
|
22430
|
+
}
|
|
22431
|
+
async function ensureVoiceboxRunning(options = {}) {
|
|
22432
|
+
const rawUrl = options.url ?? process.env.VOICEBOX_URL ?? "http://127.0.0.1:17493";
|
|
22433
|
+
const customFetch = options.fetch ?? fetch;
|
|
22434
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
22435
|
+
const customSpawn = options.spawn ?? nodeSpawn;
|
|
22436
|
+
if (await probeVoiceboxHealth(rawUrl, customFetch)) {
|
|
22437
|
+
return;
|
|
22438
|
+
}
|
|
22439
|
+
const url2 = new URL(rawUrl);
|
|
22440
|
+
const local = ["127.0.0.1", "localhost", "::1"].includes(url2.hostname);
|
|
22441
|
+
if (!local) {
|
|
22442
|
+
throw new Error(`voice-gen: Voicebox not reachable at ${rawUrl} \u2014 auto-start only applies to local URLs; is the remote server up? (fix VOICEBOX_URL)`);
|
|
22443
|
+
}
|
|
22444
|
+
const serverBin = options.serverBin ?? process.env.VOICEBOX_SERVER_BIN ?? "/Applications/Voicebox.app/Contents/MacOS/voicebox-server";
|
|
22445
|
+
if (!existsSync2(serverBin)) {
|
|
22446
|
+
throw new Error(`voice-gen: Voicebox not reachable at ${rawUrl} and VOICEBOX_SERVER_BIN is missing (${serverBin}) \u2014 start Voicebox.app or set VOICEBOX_SERVER_BIN`);
|
|
22447
|
+
}
|
|
22448
|
+
const dataDir = options.dataDir ?? process.env.VOICEBOX_DATA_DIR ?? join(homedir(), "Library/Application Support/sh.voicebox.app");
|
|
22449
|
+
const logPath = options.logPath ?? LOG_PATH;
|
|
22450
|
+
const port = url2.port === "" ? 17493 : Number(url2.port);
|
|
22451
|
+
echoError(`voice-gen: Voicebox not reachable at ${rawUrl} \u2014 auto-starting ${serverBin} (log: ${logPath})`);
|
|
22452
|
+
const child = customSpawn(serverBin, ["--port", String(port), "--data-dir", dataDir], {
|
|
22453
|
+
detached: true,
|
|
22454
|
+
stdio: ["ignore", "ignore", openSync(logPath, "a")]
|
|
22455
|
+
});
|
|
22456
|
+
child.unref?.();
|
|
22457
|
+
const bootWaitMs = options.bootWaitMs ?? BOOT_WAIT_MS;
|
|
22458
|
+
const pollMs = options.pollMs ?? POLL_MS;
|
|
22459
|
+
const deadline = Date.now() + bootWaitMs;
|
|
22460
|
+
while (Date.now() < deadline) {
|
|
22461
|
+
await sleep(pollMs);
|
|
22462
|
+
if (await probeVoiceboxHealth(rawUrl, customFetch)) {
|
|
22463
|
+
echoError(`voice-gen: Voicebox ready at ${rawUrl}`);
|
|
22464
|
+
return;
|
|
22465
|
+
}
|
|
22466
|
+
}
|
|
22467
|
+
throw new Error(`voice-gen: Voicebox did not become healthy at ${rawUrl} within ${bootWaitMs}ms of auto-start \u2014 check ${logPath}`);
|
|
22468
|
+
}
|
|
22469
|
+
|
|
22416
22470
|
// ../../plugins/generations/voice-gen/src/voicescript.ts
|
|
22417
22471
|
var VOICEBOX_TEXT_MAX = 50000;
|
|
22418
22472
|
var VOICEBOX_INSTRUCT_MAX = 500;
|
|
@@ -22821,8 +22875,8 @@ async function processGeneratorIO(inputPath, outputPath, clientOverride, transco
|
|
|
22821
22875
|
const fs = createNodeFileSystem();
|
|
22822
22876
|
const outDir = dirname2(outputPath);
|
|
22823
22877
|
const outStem = basename(outputPath, extname(outputPath));
|
|
22824
|
-
const audioPath = resolve(
|
|
22825
|
-
const mp3Path = resolve(
|
|
22878
|
+
const audioPath = resolve(join2(outDir, `${outStem}.wav`));
|
|
22879
|
+
const mp3Path = resolve(join2(outDir, `${outStem}.mp3`));
|
|
22826
22880
|
const wantMp3 = isMp3Requested();
|
|
22827
22881
|
await fs.deleteFile(outputPath);
|
|
22828
22882
|
await fs.deleteFile(audioPath);
|
|
@@ -22840,6 +22894,9 @@ async function processGeneratorIO(inputPath, outputPath, clientOverride, transco
|
|
|
22840
22894
|
return;
|
|
22841
22895
|
}
|
|
22842
22896
|
const client = clientOverride ?? createVoiceboxClient();
|
|
22897
|
+
if (!clientOverride) {
|
|
22898
|
+
await ensureVoiceboxRunning();
|
|
22899
|
+
}
|
|
22843
22900
|
try {
|
|
22844
22901
|
await client.health();
|
|
22845
22902
|
const envDefaultProfile = process.env.VOICEBOX_DEFAULT_PROFILE;
|
|
@@ -7,6 +7,7 @@ import { applySpeed, concatWavs } from './concat';
|
|
|
7
7
|
import { isMp3Requested, type Mp3Transcoder, transcodeWavToMp3 } from './mp3';
|
|
8
8
|
import { auditVoiceSegments, detectLoudnessDip, TRANSCRIPTION_FIDELITY_FLOOR, transcriptionFidelity } from './qc';
|
|
9
9
|
import { createVoiceboxClient, type VoiceboxClient, type VoiceboxGenerateBody } from './voicebox-client';
|
|
10
|
+
import { ensureVoiceboxRunning } from './voicebox-server';
|
|
10
11
|
import {
|
|
11
12
|
mergeVoiceScripts,
|
|
12
13
|
parseDocToVoiceScript,
|
|
@@ -71,6 +72,13 @@ export async function processGeneratorIO(
|
|
|
71
72
|
|
|
72
73
|
const client = clientOverride ?? createVoiceboxClient();
|
|
73
74
|
|
|
75
|
+
// Auto-start the local server when it is down — only on the default client path;
|
|
76
|
+
// tests inject clientOverride and skip the spawn entirely. With vdriver=ominivoice
|
|
77
|
+
// (the default) this plugin never runs, so no server is needed at all.
|
|
78
|
+
if (!clientOverride) {
|
|
79
|
+
await ensureVoiceboxRunning();
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
try {
|
|
75
83
|
// 1. Health check
|
|
76
84
|
await client.health();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type ChildProcess, spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, openSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { echoError } from '@gobing-ai/ts-utils';
|
|
6
|
+
|
|
7
|
+
/** Boot wait / poll cadence for a freshly spawned local server (Voicebox cold start ≈ 2-5s). */
|
|
8
|
+
const BOOT_WAIT_MS = 30_000;
|
|
9
|
+
const POLL_MS = 500;
|
|
10
|
+
const LOG_PATH = '/tmp/voicebox-server.log';
|
|
11
|
+
|
|
12
|
+
export interface VoiceboxServerOptions {
|
|
13
|
+
url?: string;
|
|
14
|
+
serverBin?: string;
|
|
15
|
+
dataDir?: string;
|
|
16
|
+
logPath?: string;
|
|
17
|
+
bootWaitMs?: number;
|
|
18
|
+
pollMs?: number;
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
sleep?: (ms: number) => Promise<void>;
|
|
21
|
+
spawn?: typeof nodeSpawn;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function probeVoiceboxHealth(url: string, customFetch: typeof fetch): Promise<boolean> {
|
|
25
|
+
try {
|
|
26
|
+
return (await customFetch(`${url}/health`)).ok;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ensure a local Voicebox server is reachable: probe `/health`, and when it is down and the
|
|
34
|
+
* URL points at this machine, spawn the app's server binary detached (stderr appended to the
|
|
35
|
+
* log file, survives plugin exit) and poll until healthy — fail loud otherwise. Remote URLs
|
|
36
|
+
* and a missing binary are configuration errors, not auto-start candidates.
|
|
37
|
+
*/
|
|
38
|
+
export async function ensureVoiceboxRunning(options: VoiceboxServerOptions = {}): Promise<void> {
|
|
39
|
+
const rawUrl = options.url ?? process.env.VOICEBOX_URL ?? 'http://127.0.0.1:17493';
|
|
40
|
+
const customFetch = options.fetch ?? fetch;
|
|
41
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
42
|
+
const customSpawn = options.spawn ?? nodeSpawn;
|
|
43
|
+
|
|
44
|
+
if (await probeVoiceboxHealth(rawUrl, customFetch)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const url = new URL(rawUrl);
|
|
49
|
+
const local = ['127.0.0.1', 'localhost', '::1'].includes(url.hostname);
|
|
50
|
+
if (!local) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`voice-gen: Voicebox not reachable at ${rawUrl} — auto-start only applies to local URLs; is the remote server up? (fix VOICEBOX_URL)`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const serverBin =
|
|
56
|
+
options.serverBin ??
|
|
57
|
+
process.env.VOICEBOX_SERVER_BIN ??
|
|
58
|
+
'/Applications/Voicebox.app/Contents/MacOS/voicebox-server';
|
|
59
|
+
if (!existsSync(serverBin)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`voice-gen: Voicebox not reachable at ${rawUrl} and VOICEBOX_SERVER_BIN is missing (${serverBin}) — start Voicebox.app or set VOICEBOX_SERVER_BIN`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const dataDir =
|
|
65
|
+
options.dataDir ??
|
|
66
|
+
process.env.VOICEBOX_DATA_DIR ??
|
|
67
|
+
join(homedir(), 'Library/Application Support/sh.voicebox.app');
|
|
68
|
+
const logPath = options.logPath ?? LOG_PATH;
|
|
69
|
+
const port = url.port === '' ? 17493 : Number(url.port);
|
|
70
|
+
|
|
71
|
+
echoError(`voice-gen: Voicebox not reachable at ${rawUrl} — auto-starting ${serverBin} (log: ${logPath})`);
|
|
72
|
+
const child: ChildProcess = customSpawn(serverBin, ['--port', String(port), '--data-dir', dataDir], {
|
|
73
|
+
detached: true,
|
|
74
|
+
stdio: ['ignore', 'ignore', openSync(logPath, 'a')],
|
|
75
|
+
});
|
|
76
|
+
child.unref?.();
|
|
77
|
+
|
|
78
|
+
const bootWaitMs = options.bootWaitMs ?? BOOT_WAIT_MS;
|
|
79
|
+
const pollMs = options.pollMs ?? POLL_MS;
|
|
80
|
+
const deadline = Date.now() + bootWaitMs;
|
|
81
|
+
while (Date.now() < deadline) {
|
|
82
|
+
await sleep(pollMs);
|
|
83
|
+
if (await probeVoiceboxHealth(rawUrl, customFetch)) {
|
|
84
|
+
echoError(`voice-gen: Voicebox ready at ${rawUrl}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw new Error(
|
|
89
|
+
`voice-gen: Voicebox did not become healthy at ${rawUrl} within ${bootWaitMs}ms of auto-start — check ${logPath}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -22086,7 +22086,7 @@ function mapAihotItemsToDocs(items) {
|
|
|
22086
22086
|
|
|
22087
22087
|
// ../../plugins/ingestions/aihot-ingest/src/rss.ts
|
|
22088
22088
|
import { createHash as createHash2 } from "crypto";
|
|
22089
|
-
var AIHOT_FEED_URL_DEFAULT = "https://
|
|
22089
|
+
var AIHOT_FEED_URL_DEFAULT = "https://arstechnica.com/ai/feed/";
|
|
22090
22090
|
function stripMarkup(raw) {
|
|
22091
22091
|
return raw.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'").replace(/\s+/g, " ").trim();
|
|
22092
22092
|
}
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
"kind": "ingestion",
|
|
4
4
|
"entry": "./dist/index.js",
|
|
5
5
|
"version": "1.0.0",
|
|
6
|
-
"description": "Ingests fresh AI news from an RSS feed (default
|
|
6
|
+
"description": "Ingests fresh AI news from an RSS feed (default Ars Technica AI, full text) into Doc[]; legacy aihot.virxact.com API behind AIHOT_SOURCE=api"
|
|
7
7
|
}
|
|
@@ -15,8 +15,18 @@ import type { Doc } from '@gobing-ai/kk-core';
|
|
|
15
15
|
* still applies to feed items.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
/** Default fresh AI-news feed (RSS 2.0,
|
|
19
|
-
|
|
18
|
+
/** Default fresh AI-news feed (RSS 2.0, full text, reachable without auth).
|
|
19
|
+
*
|
|
20
|
+
* Why Ars Technica (2026-09-20, dogfood): the previous default (TechCrunch AI
|
|
21
|
+
* category feed) stopped shipping `content:encoded` — items now carry only
|
|
22
|
+
* 86–236-char `description` summaries. Every such body lands below the planner's
|
|
23
|
+
* STUB_BODY_FLOOR (300 chars), scores quality 1, and is auto-rejected at
|
|
24
|
+
* `qc_min_quality=2`, so the aihot leg contributed ~0 usable items (the
|
|
25
|
+
* 2026-09-20 episode collapsed to 3 items / 5m33s). The Ars Technica AI feed
|
|
26
|
+
* ships full `content:encoded` (≈900–1800 chars) on every item. The parser
|
|
27
|
+
* already prefers `content:encoded`, so only the URL changes.
|
|
28
|
+
*/
|
|
29
|
+
export const AIHOT_FEED_URL_DEFAULT = 'https://arstechnica.com/ai/feed/';
|
|
20
30
|
|
|
21
31
|
export type RssFetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
22
32
|
|
|
@@ -35,7 +35,10 @@ Parse per the frozen rule:
|
|
|
35
35
|
`--fixture` → force `fixture: "true"`.
|
|
36
36
|
- Profile `kk-itc`: `--dir <path>` (default `$works_dir/kk-itc/<kebab>`), `--playbook generic|english|wechat` (default `generic`),
|
|
37
37
|
`--research` (`true`|`false`, default `false`), `--judge` (`true`|`false`, default `false`),
|
|
38
|
-
`--outline <a|b|c>` (default empty), `--writer itc-generating|topic` (default `itc-generating`)
|
|
38
|
+
`--outline <a|b|c>` (default empty), `--writer itc-generating|topic` (default `itc-generating`),
|
|
39
|
+
`--auto`, `--unslop true|false` (default `false`), `--max-revisions <N>` (default `2`),
|
|
40
|
+
`--cover`, `--inline-images`, `--targets <a,b>` (default empty), `--live`,
|
|
41
|
+
`--source-locale en|zh|ja` (default `en`; `--playbook wechat` without it implies `zh`).
|
|
39
42
|
`--fixture` passed with `kk-itc` or `kk-solo-podcast` → exit 1 (`fixture is storm-only`).
|
|
40
43
|
- Profile `kk-solo-podcast`: `--dir <path>` (default `$works_dir/kk-solo-podcast/<date>`), `--outline <a|b|c>`
|
|
41
44
|
(default empty), `--duration <min>` (default `8`), `--language <code>` (default `en`),
|
|
@@ -48,7 +51,9 @@ Parse per the frozen rule:
|
|
|
48
51
|
|
|
49
52
|
Resolve these before step 2: `NAME`, `TOPIC`, `INPUT_FILE` (empty in sentence mode), `FIXTURE`
|
|
50
53
|
(`true`|`false`), `FORCE` (`true`|`false`), `DIR`, `PLAYBOOK`, `RESEARCH`, `JUDGE`, `OUTLINE`,
|
|
51
|
-
`WRITER`, `
|
|
54
|
+
`WRITER`, `AUTO` (`true`|`false`), `UNSLOP` (`true`|`false`), `MAX_REVISIONS` (`2`), `COVER`
|
|
55
|
+
(`true`|`false`), `INLINE_IMAGES` (`true`|`false`), `TARGETS`, `LIVE` (`true`|`false`),
|
|
56
|
+
`SOURCE_LOCALE` (`en`), `DURATION` (`8`), `LANGUAGE` (`en`), `SCRIPT_APPROVED` (`true`|`false`),
|
|
52
57
|
`VOICE_PROFILE` (from `VOICEBOX_DEFAULT_PROFILE`, default empty).
|
|
53
58
|
|
|
54
59
|
## 2. Resolve the config
|
|
@@ -185,6 +190,14 @@ mkdir -p "$work_dir"
|
|
|
185
190
|
`work_dir="${DIR:-$works_dir/kk-itc/$kebab}"`. Empty derived `kebab` → exit 1. An explicit
|
|
186
191
|
`--dir` still wins, and the default never writes into the caller's cwd.
|
|
187
192
|
|
|
193
|
+
Binding (0153): `--auto` binds `auto=true` and defaults `research`, `judge` and `unslop` to
|
|
194
|
+
`true` unless the operator passed them explicitly. `--live` is never implied — `publish_live`
|
|
195
|
+
stays `false` unless `--live` is passed explicitly (ADR-022). `--playbook wechat` without
|
|
196
|
+
`--source-locale` binds `source_locale=zh`; an explicit `--source-locale` always wins.
|
|
197
|
+
`--targets <a,b>` binds `publish_targets`; `--cover` / `--inline-images` bind
|
|
198
|
+
`cover_enabled` / `inline_images` (`false` unless passed); `--max-revisions` binds
|
|
199
|
+
`max_revisions` (default `2`).
|
|
200
|
+
|
|
188
201
|
### Profile `kk-solo-podcast`
|
|
189
202
|
|
|
190
203
|
`date` = today's date in `YYYY-MM-DD` (`$(date +%Y-%m-%d)`).
|
|
@@ -218,13 +231,14 @@ spur workflow run "$dest" --vars \
|
|
|
218
231
|
|
|
219
232
|
### Profile `kk-itc`
|
|
220
233
|
|
|
221
|
-
Bind vars: `topic`, `input_file`, `work_dir`, `writer`, `playbook`, `research`, `judge`, `
|
|
222
|
-
`force`, `rubric` (`tech-accuracy`), `agent
|
|
223
|
-
|
|
234
|
+
Bind vars: `topic`, `input_file`, `work_dir`, `writer`, `playbook`, `research`, `judge`, `auto`,
|
|
235
|
+
`unslop`, `max_revisions`, `outline`, `force`, `rubric` (`tech-accuracy`), `agent`,
|
|
236
|
+
`cover_enabled`, `inline_images`, `publish_targets`, `publish_live`, `source_locale`. The judge
|
|
237
|
+
verdict lands at `$work_dir/.itc-verdict.json` (fixed path inside the workflow).
|
|
224
238
|
|
|
225
239
|
```bash
|
|
226
240
|
spur workflow run "$dest" --vars \
|
|
227
|
-
"{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"writer\":\"$WRITER\",\"playbook\":\"$PLAYBOOK\",\"research\":\"$RESEARCH\",\"judge\":\"$JUDGE\",\"outline\":\"$OUTLINE\",\"force\":\"$FORCE\",\"rubric\":\"tech-accuracy\",\"agent\":\"$AGENT\"}"
|
|
241
|
+
"{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"writer\":\"$WRITER\",\"playbook\":\"$PLAYBOOK\",\"research\":\"$RESEARCH\",\"judge\":\"$JUDGE\",\"auto\":\"$AUTO\",\"unslop\":\"$UNSLOP\",\"max_revisions\":\"$MAX_REVISIONS\",\"outline\":\"$OUTLINE\",\"force\":\"$FORCE\",\"rubric\":\"tech-accuracy\",\"agent\":\"$AGENT\",\"cover_enabled\":\"$COVER\",\"inline_images\":\"$INLINE_IMAGES\",\"publish_targets\":\"$TARGETS\",\"publish_live\":\"$LIVE\",\"source_locale\":\"$SOURCE_LOCALE\"}"
|
|
228
242
|
```
|
|
229
243
|
|
|
230
244
|
### Profile `kk-solo-podcast`
|
|
@@ -246,15 +260,19 @@ spur workflow run "$dest" --vars \
|
|
|
246
260
|
|
|
247
261
|
### Profile `kk-daily-ai-voice`
|
|
248
262
|
|
|
249
|
-
Bind vars: `work_dir`, `publish_enabled`, `last30days_enabled`, `skip_review`,
|
|
250
|
-
(empty → ADR-012 default discovery), `agent`. `$dest` resolves like solo-podcast.
|
|
251
|
-
topicless — no `topic`/`input_file`/`fixture` vars. `run_date` is
|
|
252
|
-
|
|
253
|
-
|
|
263
|
+
Bind vars: `work_dir`, `run_date`, `publish_enabled`, `last30days_enabled`, `skip_review`,
|
|
264
|
+
`plugins_path` (empty → ADR-012 default discovery), `agent`. `$dest` resolves like solo-podcast.
|
|
265
|
+
The run is topicless — no `topic`/`input_file`/`fixture` vars. `run_date` is optional: empty →
|
|
266
|
+
today (manual path), or a dashed/digits date for backfills (headless cron path). `daily-prepare`
|
|
267
|
+
derives the digits-only stamp from it and composes `work_dir` when empty:
|
|
268
|
+
`$works_dir/kk-daily-ai-voice/<yyyymmdd>`; an explicit `work_dir` wins. Either way the effective
|
|
269
|
+
workspace lands in the anchor `.spur/runs/kk-daily-ai-voice/work-dir.txt` and the stamp in
|
|
270
|
+
`$work_dir/run-date.txt`. The `review` state pauses the run for operator approval of the
|
|
271
|
+
VoiceScript; resume with `spur workflow continue [run-id]`.
|
|
254
272
|
|
|
255
273
|
```bash
|
|
256
274
|
spur workflow run "$dest" --vars \
|
|
257
|
-
"{\"work_dir\":\"$work_dir\",\"publish_enabled\":\"$PUBLISH\",\"last30days_enabled\":\"$L30D\",\"skip_review\":\"$SKIP_REVIEW\",\"plugins_path\":\"\",\"agent\":\"$AGENT\"}"
|
|
275
|
+
"{\"work_dir\":\"$work_dir\",\"run_date\":\"\",\"publish_enabled\":\"$PUBLISH\",\"last30days_enabled\":\"$L30D\",\"skip_review\":\"$SKIP_REVIEW\",\"plugins_path\":\"\",\"agent\":\"$AGENT\"}"
|
|
258
276
|
```
|
|
259
277
|
|
|
260
278
|
## 6. Report
|
package/plugins/kk/plugin.json
CHANGED