@gobing-ai/knowledge-kit 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/index.js +21643 -11933
  2. package/package.json +4 -1
  3. package/plugins/generations/content-gen/package.json +2 -1
  4. package/plugins/generations/content-gen/src/index.ts +9 -8
  5. package/plugins/generations/content-gen/src/storm.ts +12 -4
  6. package/plugins/generations/voice-gen/package.json +17 -0
  7. package/plugins/generations/voice-gen/plugin.json +6 -0
  8. package/plugins/generations/voice-gen/src/concat.ts +218 -0
  9. package/plugins/generations/voice-gen/src/index.ts +213 -0
  10. package/plugins/generations/voice-gen/src/voicebox-client.ts +223 -0
  11. package/plugins/generations/voice-gen/src/voicescript.ts +365 -0
  12. package/plugins/generations/voice-gen/tsconfig.json +8 -0
  13. package/plugins/ingestions/karakeep-local/package.json +17 -0
  14. package/plugins/ingestions/karakeep-local/src/index.ts +31 -26
  15. package/plugins/ingestions/karakeep-local/tsconfig.json +4 -0
  16. package/plugins/ingestions/web-search/package.json +4 -1
  17. package/plugins/ingestions/web-search/src/index.ts +299 -17
  18. package/plugins/kk/README.md +12 -1
  19. package/plugins/kk/commands/workflow-run.md +202 -0
  20. package/plugins/kk/config.example.yaml +34 -0
  21. package/plugins/kk/scripts/render-md.ts +150 -0
  22. package/plugins/kk/skills/{kk-judge → content-judge}/SKILL.md +13 -14
  23. package/plugins/kk/skills/{kk-judge → content-judge}/references/rubrics.md +2 -2
  24. package/plugins/kk/skills/{kk-judge → content-judge}/references/workflow-integration.md +17 -15
  25. package/plugins/kk/skills/itc-generating/SKILL.md +147 -0
  26. package/plugins/kk/skills/itc-generating/references/generic-craft.md +80 -0
  27. package/plugins/kk/skills/itc-generating/references/platform-english.md +72 -0
  28. package/plugins/kk/skills/itc-generating/references/platform-wechat.md +60 -0
  29. package/plugins/kk/skills/itc-generating/references/skill-authoring.md +62 -0
  30. package/plugins/kk/skills/storm-research/SKILL.md +173 -0
  31. package/plugins/kk/skills/{kk-topic → topic}/SKILL.md +4 -4
  32. package/plugins/kk/workflows/judge-gated-publish-example.yaml +101 -0
  33. package/plugins/kk/workflows/kk-ingest-generate-publish.yaml +72 -0
  34. package/plugins/kk/workflows/kk-itc.yaml +285 -0
  35. package/plugins/kk/workflows/kk-solo-podcast.yaml +374 -0
  36. package/plugins/kk/workflows/kk-storm-research.yaml +184 -0
  37. package/plugins/kk/workflows/validate-voicescript.ts +226 -0
  38. package/plugins/publishings/emdash-pub/package.json +17 -0
  39. package/plugins/publishings/emdash-pub/plugin.json +7 -0
  40. package/plugins/publishings/emdash-pub/src/index.ts +450 -0
  41. package/plugins/publishings/emdash-pub/tsconfig.json +4 -0
  42. package/plugins/publishings/qiita-pub/package.json +2 -1
  43. package/plugins/publishings/qiita-pub/src/index.ts +9 -9
  44. package/plugins/publishings/surfdash-pub/package.json +2 -1
  45. package/plugins/publishings/surfdash-pub/src/index.ts +16 -11
  46. package/plugins/publishings/zenn-pub/package.json +2 -1
  47. package/plugins/publishings/zenn-pub/src/index.ts +11 -11
  48. package/plugins/kk/agents/kk-judge-compliance.md +0 -37
  49. package/plugins/kk/agents/kk-judge-tech.md +0 -35
  50. package/plugins/kk/agents/kk-judge-tone.md +0 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,6 +28,9 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "dependencies": {
31
+ "@gobing-ai/ts-infra": "catalog:",
32
+ "@gobing-ai/ts-runtime": "catalog:",
33
+ "@gobing-ai/ts-utils": "catalog:",
31
34
  "commander": "15.0.0"
32
35
  },
33
36
  "devDependencies": {
@@ -7,7 +7,8 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@gobing-ai/kk-core": "workspace:*",
10
- "@gobing-ai/utils": "workspace:*",
10
+ "@gobing-ai/ts-runtime": "catalog:",
11
+ "@gobing-ai/ts-utils": "catalog:",
11
12
  "zod": "4.4.3"
12
13
  },
13
14
  "devDependencies": {
@@ -1,7 +1,7 @@
1
- import { readFile, rm, writeFile } from 'node:fs/promises';
2
1
  import { parseArgs } from 'node:util';
3
2
  import { type Content, ContentSchema, type Doc, DocListSchema } from '@gobing-ai/kk-core';
4
- import { logger } from '@gobing-ai/utils';
3
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
4
+ import { echoError } from '@gobing-ai/ts-utils';
5
5
  import { generateWithStormPipeline } from './storm';
6
6
 
7
7
  type ContentGenerator = (docs: Doc[], inputPath: string) => Promise<Content>;
@@ -19,10 +19,11 @@ export async function processGeneratorIO(
19
19
  outputPath: string,
20
20
  generate: ContentGenerator = generateWithStormPipeline,
21
21
  ): Promise<void> {
22
- await rm(outputPath, { force: true });
23
- const docs = DocListSchema.parse(JSON.parse(await readFile(inputPath, 'utf-8')));
22
+ const fs = createNodeFileSystem();
23
+ await fs.deleteFile(outputPath);
24
+ const docs = DocListSchema.parse(JSON.parse(await fs.readFile(inputPath)));
24
25
  const content = docs.length === 0 ? EMPTY_CONTENT : ContentSchema.parse(await generate(docs, inputPath));
25
- await writeFile(outputPath, JSON.stringify(content, null, 2), 'utf-8');
26
+ await fs.writeFile(outputPath, JSON.stringify(content, null, 2));
26
27
  }
27
28
 
28
29
  export async function main(): Promise<number> {
@@ -33,7 +34,7 @@ export async function main(): Promise<number> {
33
34
  },
34
35
  });
35
36
  if (!values.in || !values.out) {
36
- logger.error('Missing required arguments: --in and --out');
37
+ echoError('Missing required arguments: --in and --out');
37
38
  return 1;
38
39
  }
39
40
 
@@ -41,8 +42,8 @@ export async function main(): Promise<number> {
41
42
  await processGeneratorIO(values.in, values.out);
42
43
  return 0;
43
44
  } catch (error: unknown) {
44
- logger.error(`content-gen failed: ${error instanceof Error ? error.message : String(error)}`);
45
- await rm(values.out, { force: true });
45
+ echoError(`content-gen failed: ${error instanceof Error ? error.message : String(error)}`);
46
+ await createNodeFileSystem().deleteFile(values.out);
46
47
  return 1;
47
48
  }
48
49
  }
@@ -113,8 +113,13 @@ function stageError(stage: string, detail: string): Error {
113
113
  return new Error(`${stage} stage failed: ${detail}`);
114
114
  }
115
115
 
116
+ /** Strip markdown footnote markers agents prepend to citation ids (`^doc-1` → `doc-1`). */
117
+ function bareCitationId(id: string): string {
118
+ return id.replace(/^\^+/, '');
119
+ }
120
+
116
121
  function assertKnownEvidence(stage: string, ids: string[], allowed: Set<string>): void {
117
- const unknown = ids.filter((id) => !allowed.has(id));
122
+ const unknown = ids.map(bareCitationId).filter((id) => !allowed.has(id));
118
123
  if (unknown.length > 0) {
119
124
  throw stageError(stage, `cites unknown document ID(s): ${[...new Set(unknown)].join(', ')}`);
120
125
  }
@@ -237,6 +242,7 @@ export async function articleStage(
237
242
  `The validated curation result for the article is: ${JSON.stringify(curation)}`,
238
243
  `The validated outline for the article is: ${JSON.stringify(outline)}`,
239
244
  'Write the complete article as markdown, one section per outline heading. Every citation must reference one of the allowed document IDs.',
245
+ 'Synthesize: the body must draw findings from across the supplied documents, compare them, and state conclusions. Never list sources one per line or produce an index of references — references are evidence, not the deliverable.',
240
246
  'Return only a JSON object matching {"title":string,"body":string,"citations":[string]} where body is the full markdown draft and every citations entry is one of the allowed document IDs.',
241
247
  ].join('\n'),
242
248
  );
@@ -289,9 +295,11 @@ export async function polishStage(
289
295
  // Machine-verify the preservation mandate: every heading and citation in the draft must
290
296
  // survive, and polish must not introduce citations to unknown document IDs.
291
297
  const polishedHeadings = bodyHeadings(result.body);
292
- const polishedCitations = bodyCitations(result.body);
298
+ const polishedCitations = bodyCitations(result.body).map(bareCitationId);
293
299
  const droppedHeadings = bodyHeadings(article.body).filter((heading) => !polishedHeadings.includes(heading));
294
- const droppedCitations = bodyCitations(article.body).filter((citation) => !polishedCitations.includes(citation));
300
+ const droppedCitations = bodyCitations(article.body)
301
+ .map(bareCitationId)
302
+ .filter((citation) => !polishedCitations.includes(citation));
295
303
  if (droppedHeadings.length > 0 || droppedCitations.length > 0) {
296
304
  const dropped: string[] = [];
297
305
  if (droppedHeadings.length > 0) dropped.push(`dropped heading(s): ${droppedHeadings.join(', ')}`);
@@ -320,7 +328,7 @@ export async function generateWithStormPipeline(
320
328
  return ContentSchema.parse({
321
329
  title:
322
330
  polished.title ?? article.title ?? outline.title ?? docs[0]?.title ?? 'Generated Knowledge Kit Synthesis',
323
- body: polished.body,
331
+ body: polished.body.replace(/\[\^([^\]()\s]+)\](?!\()/g, '[$1]'),
324
332
  format: 'markdown',
325
333
  outline: polished.outline ?? outline.outline,
326
334
  references: docs.map((doc) => ({ url: doc.sourceUri, title: doc.title, cite: doc.id })),
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@gobing-ai/voice-gen",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "typecheck": "tsc --noEmit"
7
+ },
8
+ "dependencies": {
9
+ "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/ts-runtime": "catalog:",
11
+ "@gobing-ai/ts-utils": "catalog:",
12
+ "zod": "4.4.3"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "1.3.14"
16
+ }
17
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "voice-gen",
3
+ "kind": "generator",
4
+ "entry": "./src/index.ts",
5
+ "version": "1.0.0"
6
+ }
@@ -0,0 +1,218 @@
1
+ export interface ParsedWav {
2
+ audioFormat: number;
3
+ numChannels: number;
4
+ sampleRate: number;
5
+ byteRate: number;
6
+ blockAlign: number;
7
+ bitsPerSample: number;
8
+ dataBytes: Uint8Array;
9
+ }
10
+
11
+ /**
12
+ * Parse a RIFF WAVE buffer into its format fields and raw audio data.
13
+ */
14
+ export function parseWav(buffer: Uint8Array): ParsedWav {
15
+ if (buffer.length < 44) {
16
+ throw new Error('Invalid WAV buffer: buffer too short');
17
+ }
18
+
19
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
20
+
21
+ const b0 = buffer[0];
22
+ const b1 = buffer[1];
23
+ const b2 = buffer[2];
24
+ const b3 = buffer[3];
25
+ const b8 = buffer[8];
26
+ const b9 = buffer[9];
27
+ const b10 = buffer[10];
28
+ const b11 = buffer[11];
29
+
30
+ if (
31
+ b0 === undefined ||
32
+ b1 === undefined ||
33
+ b2 === undefined ||
34
+ b3 === undefined ||
35
+ b8 === undefined ||
36
+ b9 === undefined ||
37
+ b10 === undefined ||
38
+ b11 === undefined
39
+ ) {
40
+ throw new Error('Invalid WAV buffer: buffer too short');
41
+ }
42
+
43
+ const riffTag = String.fromCharCode(b0, b1, b2, b3);
44
+ const waveTag = String.fromCharCode(b8, b9, b10, b11);
45
+
46
+ if (riffTag !== 'RIFF' || waveTag !== 'WAVE') {
47
+ throw new Error(`Invalid WAV buffer: expected RIFF/WAVE header, got ${riffTag}/${waveTag}`);
48
+ }
49
+
50
+ let offset = 12;
51
+ let formatInfo:
52
+ | {
53
+ audioFormat: number;
54
+ numChannels: number;
55
+ sampleRate: number;
56
+ byteRate: number;
57
+ blockAlign: number;
58
+ bitsPerSample: number;
59
+ }
60
+ | undefined;
61
+ let dataBytes: Uint8Array | undefined;
62
+
63
+ while (offset + 8 <= buffer.length) {
64
+ const c0 = buffer[offset];
65
+ const c1 = buffer[offset + 1];
66
+ const c2 = buffer[offset + 2];
67
+ const c3 = buffer[offset + 3];
68
+ if (c0 === undefined || c1 === undefined || c2 === undefined || c3 === undefined) {
69
+ break;
70
+ }
71
+
72
+ const chunkId = String.fromCharCode(c0, c1, c2, c3);
73
+ const chunkSize = view.getUint32(offset + 4, true);
74
+ const chunkDataOffset = offset + 8;
75
+
76
+ if (chunkId === 'fmt ') {
77
+ if (chunkSize < 16) {
78
+ throw new Error('Invalid WAV fmt chunk size');
79
+ }
80
+ const audioFormat = view.getUint16(chunkDataOffset, true);
81
+ const numChannels = view.getUint16(chunkDataOffset + 2, true);
82
+ const sampleRate = view.getUint32(chunkDataOffset + 4, true);
83
+ const byteRate = view.getUint32(chunkDataOffset + 8, true);
84
+ const blockAlign = view.getUint16(chunkDataOffset + 12, true);
85
+ const bitsPerSample = view.getUint16(chunkDataOffset + 14, true);
86
+
87
+ formatInfo = {
88
+ audioFormat,
89
+ numChannels,
90
+ sampleRate,
91
+ byteRate,
92
+ blockAlign,
93
+ bitsPerSample,
94
+ };
95
+ } else if (chunkId === 'data') {
96
+ const end = Math.min(chunkDataOffset + chunkSize, buffer.length);
97
+ dataBytes = buffer.subarray(chunkDataOffset, end);
98
+ }
99
+
100
+ // Advance to next chunk (aligned to 2 bytes)
101
+ offset = chunkDataOffset + chunkSize + (chunkSize % 2 === 1 ? 1 : 0);
102
+ }
103
+
104
+ if (!formatInfo) {
105
+ throw new Error('Invalid WAV: missing "fmt " chunk');
106
+ }
107
+ if (!dataBytes) {
108
+ throw new Error('Invalid WAV: missing "data" chunk');
109
+ }
110
+
111
+ return {
112
+ ...formatInfo,
113
+ dataBytes,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Concatenate multiple WAV audio buffers in order, inserting gap_ms of silence before clips.
119
+ */
120
+ export function concatWavs(wavBuffers: Uint8Array[], gapsMs: number[] = []): Uint8Array {
121
+ if (wavBuffers.length === 0) {
122
+ return new Uint8Array(0);
123
+ }
124
+
125
+ const parsedWavs = wavBuffers.map((buf) => parseWav(buf));
126
+ const first = parsedWavs[0];
127
+ if (!first) {
128
+ return new Uint8Array(0);
129
+ }
130
+
131
+ // Verify format compatibility
132
+ for (let i = 1; i < parsedWavs.length; i++) {
133
+ const current = parsedWavs[i];
134
+ if (!current) {
135
+ continue;
136
+ }
137
+ if (current.sampleRate !== first.sampleRate) {
138
+ throw new Error(`WAV sample rate mismatch: expected ${first.sampleRate} Hz, got ${current.sampleRate} Hz`);
139
+ }
140
+ if (
141
+ current.audioFormat !== first.audioFormat ||
142
+ current.numChannels !== first.numChannels ||
143
+ current.bitsPerSample !== first.bitsPerSample
144
+ ) {
145
+ throw new Error(
146
+ `WAV format mismatch between segment 0 and segment ${i}: format ${first.audioFormat} vs ${current.audioFormat}, channels ${first.numChannels} vs ${current.numChannels}, bits ${first.bitsPerSample} vs ${current.bitsPerSample}`,
147
+ );
148
+ }
149
+ }
150
+
151
+ // Compute total combined data length
152
+ let totalDataLength = 0;
153
+ const pieces: Uint8Array[] = [];
154
+
155
+ for (let i = 0; i < parsedWavs.length; i++) {
156
+ const wav = parsedWavs[i];
157
+ if (!wav) {
158
+ continue;
159
+ }
160
+ const gap = gapsMs[i] ?? 0;
161
+ if (gap > 0) {
162
+ const numGapBytes = Math.floor(first.sampleRate * (gap / 1000)) * first.blockAlign;
163
+ if (numGapBytes > 0) {
164
+ const silence = new Uint8Array(numGapBytes);
165
+ pieces.push(silence);
166
+ totalDataLength += numGapBytes;
167
+ }
168
+ }
169
+ pieces.push(wav.dataBytes);
170
+ totalDataLength += wav.dataBytes.length;
171
+ }
172
+
173
+ // Build the consolidated WAV buffer
174
+ const headerSize = 44;
175
+ const totalFileSize = headerSize + totalDataLength;
176
+ const output = new Uint8Array(totalFileSize);
177
+ const view = new DataView(output.buffer, output.byteOffset, output.byteLength);
178
+
179
+ // RIFF chunk descriptor
180
+ output[0] = 'R'.charCodeAt(0);
181
+ output[1] = 'I'.charCodeAt(0);
182
+ output[2] = 'F'.charCodeAt(0);
183
+ output[3] = 'F'.charCodeAt(0);
184
+ view.setUint32(4, 36 + totalDataLength, true);
185
+ output[8] = 'W'.charCodeAt(0);
186
+ output[9] = 'A'.charCodeAt(0);
187
+ output[10] = 'V'.charCodeAt(0);
188
+ output[11] = 'E'.charCodeAt(0);
189
+
190
+ // fmt sub-chunk
191
+ output[12] = 'f'.charCodeAt(0);
192
+ output[13] = 'm'.charCodeAt(0);
193
+ output[14] = 't'.charCodeAt(0);
194
+ output[15] = ' '.charCodeAt(0);
195
+ view.setUint32(16, 16, true);
196
+ view.setUint16(20, first.audioFormat, true);
197
+ view.setUint16(22, first.numChannels, true);
198
+ view.setUint32(24, first.sampleRate, true);
199
+ view.setUint32(28, first.byteRate, true);
200
+ view.setUint16(32, first.blockAlign, true);
201
+ view.setUint16(34, first.bitsPerSample, true);
202
+
203
+ // data sub-chunk
204
+ output[36] = 'd'.charCodeAt(0);
205
+ output[37] = 'a'.charCodeAt(0);
206
+ output[38] = 't'.charCodeAt(0);
207
+ output[39] = 'a'.charCodeAt(0);
208
+ view.setUint32(40, totalDataLength, true);
209
+
210
+ // Copy audio data pieces
211
+ let currentOffset = headerSize;
212
+ for (const piece of pieces) {
213
+ output.set(piece, currentOffset);
214
+ currentOffset += piece.length;
215
+ }
216
+
217
+ return output;
218
+ }
@@ -0,0 +1,213 @@
1
+ import { basename, dirname, extname, join, resolve } from 'node:path';
2
+ import { parseArgs } from 'node:util';
3
+ import { type Content, ContentSchema, type Doc, DocListSchema } from '@gobing-ai/kk-core';
4
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
5
+ import { echoError } from '@gobing-ai/ts-utils';
6
+ import { concatWavs } from './concat';
7
+ import { createVoiceboxClient, type VoiceboxClient, type VoiceboxGenerateBody } from './voicebox-client';
8
+ import {
9
+ mergeVoiceScripts,
10
+ parseDocToVoiceScript,
11
+ VOICEBOX_CHUNK_CHARS_DEFAULT,
12
+ VOICEBOX_CHUNK_CHARS_MAX,
13
+ VOICEBOX_CHUNK_CHARS_MIN,
14
+ VOICEBOX_CROSSFADE_MS_DEFAULT,
15
+ VOICEBOX_CROSSFADE_MS_MAX,
16
+ VOICEBOX_CROSSFADE_MS_MIN,
17
+ validateVoiceScript,
18
+ } from './voicescript';
19
+
20
+ export * from './concat';
21
+ export * from './voicebox-client';
22
+ export * from './voicescript';
23
+
24
+ const EMPTY_CONTENT: Content = {
25
+ title: 'Notice',
26
+ body: '# Notice\nNo documents provided for generation.',
27
+ format: 'markdown',
28
+ references: [],
29
+ };
30
+
31
+ /**
32
+ * Read validated Doc[] input, generate validated audio Content via Voicebox, and write output files.
33
+ */
34
+ export async function processGeneratorIO(
35
+ inputPath: string,
36
+ outputPath: string,
37
+ clientOverride?: VoiceboxClient,
38
+ ): Promise<void> {
39
+ const fs = createNodeFileSystem();
40
+ const outDir = dirname(outputPath);
41
+ const outStem = basename(outputPath, extname(outputPath));
42
+ const audioPath = resolve(join(outDir, `${outStem}.wav`));
43
+
44
+ // Delete existing output and sibling WAV at start
45
+ await fs.deleteFile(outputPath);
46
+ await fs.deleteFile(audioPath);
47
+
48
+ let docs: Doc[];
49
+ try {
50
+ const raw = await fs.readFile(inputPath);
51
+ docs = DocListSchema.parse(JSON.parse(raw));
52
+ } catch (err: unknown) {
53
+ throw new Error(`Invalid DocList input: ${err instanceof Error ? err.message : String(err)}`);
54
+ }
55
+
56
+ if (docs.length === 0) {
57
+ await fs.ensureDir(outDir);
58
+ await fs.writeFile(outputPath, JSON.stringify(ContentSchema.parse(EMPTY_CONTENT), null, 2));
59
+ return;
60
+ }
61
+
62
+ const client = clientOverride ?? createVoiceboxClient();
63
+
64
+ try {
65
+ // 1. Health check
66
+ await client.health();
67
+
68
+ // 2. Parse docs to VoiceScript
69
+ const envDefaultProfile = process.env.VOICEBOX_DEFAULT_PROFILE;
70
+ const scripts = docs.map((doc) => parseDocToVoiceScript(doc, envDefaultProfile));
71
+ const combinedScript = mergeVoiceScripts(scripts, docs, envDefaultProfile);
72
+
73
+ // 3. Validate VoiceScript
74
+ validateVoiceScript(combinedScript);
75
+
76
+ // 4. Generate each segment
77
+ const segmentWavs: Uint8Array[] = [];
78
+ const segmentGaps: number[] = [];
79
+ const segmentMetadata: Array<{ generationId: string; profile: string; duration: number }> = [];
80
+ let totalDuration = 0;
81
+
82
+ for (const segment of combinedScript.segments) {
83
+ const speakerConfig = segment.speaker ? combinedScript.speakers?.[segment.speaker] : undefined;
84
+ const profileTarget =
85
+ segment.profile ??
86
+ speakerConfig?.profile ??
87
+ combinedScript.default_profile ??
88
+ envDefaultProfile ??
89
+ (typeof docs[0]?.metadata?.voiceProfile === 'string' ? docs[0].metadata.voiceProfile : undefined);
90
+
91
+ if (!profileTarget) {
92
+ throw new Error(
93
+ 'No voice profile specified for segment (set profile, speaker profile, default_profile, or VOICEBOX_DEFAULT_PROFILE)',
94
+ );
95
+ }
96
+
97
+ const resolvedProfile = await client.resolveProfile(profileTarget);
98
+
99
+ const engine = segment.engine ?? speakerConfig?.engine ?? combinedScript.default_engine;
100
+ const language = segment.language ?? speakerConfig?.language ?? combinedScript.language ?? 'en';
101
+ const instruct = segment.instruct ?? speakerConfig?.instruct;
102
+
103
+ const envChunk = process.env.VOICEBOX_MAX_CHUNK_CHARS
104
+ ? parseInt(process.env.VOICEBOX_MAX_CHUNK_CHARS, 10)
105
+ : undefined;
106
+ const envCrossfade = process.env.VOICEBOX_CROSSFADE_MS
107
+ ? parseInt(process.env.VOICEBOX_CROSSFADE_MS, 10)
108
+ : undefined;
109
+
110
+ const max_chunk_chars =
111
+ segment.max_chunk_chars ?? combinedScript.max_chunk_chars ?? envChunk ?? VOICEBOX_CHUNK_CHARS_DEFAULT;
112
+ const crossfade_ms =
113
+ segment.crossfade_ms ?? combinedScript.crossfade_ms ?? envCrossfade ?? VOICEBOX_CROSSFADE_MS_DEFAULT;
114
+
115
+ if (max_chunk_chars < VOICEBOX_CHUNK_CHARS_MIN || max_chunk_chars > VOICEBOX_CHUNK_CHARS_MAX) {
116
+ throw new Error(
117
+ `max_chunk_chars must be between ${VOICEBOX_CHUNK_CHARS_MIN} and ${VOICEBOX_CHUNK_CHARS_MAX}`,
118
+ );
119
+ }
120
+ if (crossfade_ms < VOICEBOX_CROSSFADE_MS_MIN || crossfade_ms > VOICEBOX_CROSSFADE_MS_MAX) {
121
+ throw new Error(
122
+ `crossfade_ms must be between ${VOICEBOX_CROSSFADE_MS_MIN} and ${VOICEBOX_CROSSFADE_MS_MAX}`,
123
+ );
124
+ }
125
+
126
+ const generateBody: VoiceboxGenerateBody = {
127
+ profile_id: resolvedProfile.id,
128
+ text: segment.text,
129
+ language,
130
+ engine,
131
+ instruct,
132
+ max_chunk_chars,
133
+ crossfade_ms,
134
+ personality: false,
135
+ normalize: true,
136
+ };
137
+
138
+ const { id } = await client.generate(generateBody);
139
+ const history = await client.waitUntilDone(id);
140
+ if (history.status === 'failed') {
141
+ throw new Error(
142
+ `Voicebox generation failed for profile "${resolvedProfile.name}": ${history.error || 'status failed'}`,
143
+ );
144
+ }
145
+
146
+ const wavBytes = await client.downloadAudio(id);
147
+ const gap = segment.gap_ms ?? 0;
148
+
149
+ segmentWavs.push(wavBytes);
150
+ segmentGaps.push(gap);
151
+
152
+ const segDuration = history.duration ?? 0;
153
+ totalDuration += segDuration + gap / 1000;
154
+ segmentMetadata.push({
155
+ generationId: id,
156
+ profile: resolvedProfile.name,
157
+ duration: segDuration,
158
+ });
159
+ }
160
+
161
+ // 5. Concatenate audio
162
+ const concatenatedWav = concatWavs(segmentWavs, segmentGaps);
163
+ await fs.ensureDir(outDir);
164
+ await Bun.write(audioPath, concatenatedWav);
165
+
166
+ // 6. Build Content
167
+ const content: Content = {
168
+ title: combinedScript.title || docs[0]?.title || 'Generated voice',
169
+ body: Bun.YAML.stringify(combinedScript),
170
+ format: 'audio',
171
+ references: [],
172
+ metadata: {
173
+ generator: 'kk:voice-gen',
174
+ audioPath,
175
+ duration: totalDuration,
176
+ segments: segmentMetadata,
177
+ },
178
+ };
179
+
180
+ const validatedContent = ContentSchema.parse(content);
181
+ await fs.writeFile(outputPath, JSON.stringify(validatedContent, null, 2));
182
+ } catch (err: unknown) {
183
+ await fs.deleteFile(outputPath);
184
+ await fs.deleteFile(audioPath);
185
+ throw err;
186
+ }
187
+ }
188
+
189
+ export async function main(): Promise<number> {
190
+ const { values } = parseArgs({
191
+ options: {
192
+ in: { type: 'string' },
193
+ out: { type: 'string' },
194
+ },
195
+ });
196
+
197
+ if (!values.in || !values.out) {
198
+ echoError('voice-gen failed: Missing required arguments: --in and --out');
199
+ return 1;
200
+ }
201
+
202
+ try {
203
+ await processGeneratorIO(values.in, values.out);
204
+ return 0;
205
+ } catch (err: unknown) {
206
+ echoError(`voice-gen failed: ${err instanceof Error ? err.message : String(err)}`);
207
+ return 1;
208
+ }
209
+ }
210
+
211
+ if (import.meta.main) {
212
+ process.exit(await main());
213
+ }