@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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "plugins": [
8
8
  {
9
- "version": "0.0.18",
9
+ "version": "0.0.21",
10
10
  "name": "kk",
11
11
  "source": "./plugins/kk",
12
12
  "description": "knowledge-kit — a set of toolkit for knowledge management"
package/dist/index.js CHANGED
@@ -26420,24 +26420,38 @@ function spawnPluginEntry(opts) {
26420
26420
  stderr: proc.stderr.toString().trim()
26421
26421
  };
26422
26422
  }
26423
- function killProcessTree(pid) {
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
- for (const line of stdout.trim().split(`
26431
- `)) {
26432
- const child = Number(line);
26433
- if (Number.isInteger(child) && child > 0) {
26434
- killProcessTree(child);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.0.18",
3
+ "version": "0.0.21",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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({"duration": duration, "transcription": transcription, "loudness_dip": dip})
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(join(outDir, `${outStem}.wav`));
22825
- const mp3Path = resolve(join(outDir, `${outStem}.mp3`));
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://techcrunch.com/category/artificial-intelligence/feed/";
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(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;|&apos;/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 TechCrunch AI) into Doc[]; legacy aihot.virxact.com API behind AIHOT_SOURCE=api"
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, high volume, reachable without auth). */
19
- export const AIHOT_FEED_URL_DEFAULT = 'https://techcrunch.com/category/artificial-intelligence/feed/';
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`, `DURATION` (`8`), `LANGUAGE` (`en`), `SCRIPT_APPROVED` (`true`|`false`),
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`, `outline`,
222
- `force`, `rubric` (`tech-accuracy`), `agent`. The judge verdict lands at `$work_dir/.itc-verdict.json`
223
- (fixed path inside the workflow).
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`, `plugins_path`
250
- (empty → ADR-012 default discovery), `agent`. `$dest` resolves like solo-podcast. The run is
251
- topicless — no `topic`/`input_file`/`fixture` vars. `run_date` is computed by the `prepare`
252
- state and written to `$work_dir/run-date.txt`. The `review` state pauses the run for operator
253
- approval of the VoiceScript; resume with `spur workflow continue [run-id]`.
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
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.0.18",
3
+ "version": "0.0.21",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }