@gobing-ai/knowledge-kit 0.0.34 → 0.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/board/collect/index.css +42 -0
  4. package/dist/board/collect/index.js +787 -0
  5. package/dist/board/daily-voice/index.css +13 -0
  6. package/dist/board/daily-voice/index.js +902 -0
  7. package/dist/board/itc/index.css +14 -0
  8. package/dist/board/itc/index.js +1026 -0
  9. package/dist/index.js +11769 -9250
  10. package/package.json +10 -9
  11. package/plugins/generations/content-gen/dist/index.js +1015 -10814
  12. package/plugins/generations/content-gen/package.json +1 -1
  13. package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
  14. package/plugins/generations/core-facts-gen/package.json +1 -1
  15. package/plugins/generations/daily-article-gen/dist/index.js +1019 -10804
  16. package/plugins/generations/daily-article-gen/package.json +1 -1
  17. package/plugins/generations/daily-article-gen/src/index.ts +32 -3
  18. package/plugins/generations/dailynews-gen/dist/index.js +1094 -10892
  19. package/plugins/generations/dailynews-gen/package.json +1 -1
  20. package/plugins/generations/dailynews-gen/src/script-builder.ts +6 -1
  21. package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
  22. package/plugins/generations/episode-plan-gen/package.json +1 -1
  23. package/plugins/generations/gemini-tts/dist/index.js +1108 -10906
  24. package/plugins/generations/gemini-tts/package.json +1 -1
  25. package/plugins/generations/image-gen/dist/index.js +1239 -11035
  26. package/plugins/generations/image-gen/package.json +1 -1
  27. package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
  28. package/plugins/generations/news-report-gen/package.json +1 -1
  29. package/plugins/generations/omni-voice-gen/plugin.json +1 -1
  30. package/plugins/generations/voice-gen/dist/index.js +1066 -10864
  31. package/plugins/generations/voice-gen/package.json +1 -1
  32. package/plugins/generations/voice-gen/src/index.ts +9 -3
  33. package/plugins/generations/voxcpm-tts/README.md +72 -0
  34. package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
  35. package/plugins/generations/voxcpm-tts/package.json +16 -0
  36. package/plugins/generations/voxcpm-tts/plugin.json +6 -0
  37. package/plugins/generations/voxcpm-tts/profiles.json +10 -0
  38. package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
  39. package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
  40. package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
  41. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
  42. package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
  43. package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
  44. package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
  45. package/plugins/ingestions/aihot-ingest/package.json +1 -1
  46. package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
  47. package/plugins/ingestions/horizon-ingest/package.json +1 -1
  48. package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
  49. package/plugins/ingestions/karakeep-local/package.json +1 -1
  50. package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
  51. package/plugins/ingestions/last30days-ingest/package.json +1 -1
  52. package/plugins/ingestions/web-search/dist/index.js +1250 -11033
  53. package/plugins/ingestions/web-search/package.json +1 -1
  54. package/plugins/kk/README.md +29 -2
  55. package/plugins/kk/plugin.json +1 -1
  56. package/plugins/kk/schedules/cron.tmpl +9 -1
  57. package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
  58. package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
  59. package/plugins/kk/scripts/daily-stages.ts +702 -63
  60. package/plugins/kk/scripts/kk-workflow-stages.ts +116 -146
  61. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
  62. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
  63. package/plugins/kk/skills/publish/SKILL.md +8 -4
  64. package/plugins/kk/workflows/feature-verification.yaml +93 -0
  65. package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
  66. package/plugins/kk/workflows/kk-collect.yaml +43 -11
  67. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +36 -9
  68. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +92 -18
  69. package/plugins/kk/workflows/kk-daily-safety.yaml +16 -3
  70. package/plugins/kk/workflows/kk-itc.yaml +8 -1
  71. package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
  72. package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
  73. package/plugins/publishings/emdash-pub/package.json +1 -1
  74. package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
  75. package/plugins/publishings/infoq-pub/dist/profile-check.js +20814 -0
  76. package/plugins/publishings/infoq-pub/package.json +1 -1
  77. package/plugins/publishings/infoq-pub/plugin.json +6 -2
  78. package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
  79. package/plugins/publishings/juejin-pub/dist/profile-check.js +20800 -0
  80. package/plugins/publishings/juejin-pub/package.json +1 -1
  81. package/plugins/publishings/juejin-pub/plugin.json +6 -2
  82. package/plugins/publishings/medium-pub/dist/index.js +978 -10805
  83. package/plugins/publishings/medium-pub/package.json +1 -1
  84. package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
  85. package/plugins/publishings/podcast-pub/package.json +1 -1
  86. package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
  87. package/plugins/publishings/qiita-pub/package.json +1 -1
  88. package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
  89. package/plugins/publishings/substack-pub/dist/profile-check.js +20890 -0
  90. package/plugins/publishings/substack-pub/package.json +1 -1
  91. package/plugins/publishings/substack-pub/plugin.json +5 -2
  92. package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
  93. package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
  94. package/plugins/publishings/surfdash-pub/package.json +1 -1
  95. package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
  96. package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
  97. package/plugins/publishings/threads-pub/dist/index.js +12394 -0
  98. package/plugins/publishings/threads-pub/package.json +17 -0
  99. package/plugins/publishings/threads-pub/plugin.json +7 -0
  100. package/plugins/publishings/threads-pub/src/index.ts +340 -0
  101. package/plugins/publishings/threads-pub/tsconfig.json +4 -0
  102. package/plugins/publishings/wechatmp-pub/dist/index.js +2638 -11650
  103. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +20739 -0
  104. package/plugins/publishings/wechatmp-pub/package.json +1 -1
  105. package/plugins/publishings/wechatmp-pub/plugin.json +6 -2
  106. package/plugins/publishings/wechatmp-pub/src/enrich.ts +297 -163
  107. package/plugins/publishings/wechatmp-pub/src/flow.ts +611 -54
  108. package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
  109. package/plugins/publishings/wechatmp-pub/src/page.ts +36 -0
  110. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
  111. package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
  112. package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
  113. package/plugins/publishings/weibo-pub/package.json +19 -0
  114. package/plugins/publishings/weibo-pub/plugin.json +12 -0
  115. package/plugins/publishings/weibo-pub/src/index.ts +868 -0
  116. package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
  117. package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
  118. package/plugins/publishings/x-pub/dist/index.js +1948 -11440
  119. package/plugins/publishings/x-pub/dist/profile-check.js +20806 -0
  120. package/plugins/publishings/x-pub/package.json +1 -1
  121. package/plugins/publishings/x-pub/plugin.json +6 -2
  122. package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
  123. package/plugins/publishings/xhs-pub/dist/profile-check.js +21023 -0
  124. package/plugins/publishings/xhs-pub/package.json +1 -1
  125. package/plugins/publishings/xhs-pub/plugin.json +6 -2
  126. package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
  127. package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
  128. package/plugins/publishings/zenn-pub/package.json +1 -1
  129. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
  130. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
@@ -0,0 +1,765 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { echoError } from '@gobing-ai/ts-utils';
7
+ import { type ParsedWav, parseWav, type VoiceboxClient, type VoiceboxGenerateBody } from '@gobing-ai/voice-gen';
8
+
9
+ /** Generator identity stamped into `metadata.generator` by the shared pipeline. */
10
+ export const VOXCPM_TTS_GENERATOR = 'kk:voxcpm-tts';
11
+ /** Frozen backend defaults (ADR-036 / voxcpm-daily-voice-driver). */
12
+ export const VOXCPM_DEFAULT_MODEL = 'openbmb/VoxCPM2';
13
+ export const VOXCPM_DEFAULT_DEVICE = 'auto';
14
+ export const VOXCPM_DEVICES = ['auto', 'cpu', 'cuda', 'mps'] as const;
15
+ /** Protocol framing bounds (frozen private adapter details). */
16
+ const LINE_MAX_BYTES = 1_048_576;
17
+ const STDERR_MAX_CHARS = 65_536;
18
+ /** Signal grace before SIGKILL of the owned process group. */
19
+ const TERM_GRACE_MS = 2000;
20
+ /** Bounded wait for the forced kill to land. */
21
+ const SIGKILL_GRACE_MS = 1000;
22
+ /** Signals that must tear down owned workers before the process ends. */
23
+ const SIGNAL_HANDLERS = ['SIGTERM', 'SIGINT'] as const;
24
+
25
+ export type VoxCpmDevice = (typeof VOXCPM_DEVICES)[number];
26
+ export type VoxCpmProfileMode = 'instruct' | 'reference' | 'continuation';
27
+
28
+ export interface VoxCpmProfile {
29
+ id: string;
30
+ name: string;
31
+ mode: VoxCpmProfileMode;
32
+ instruct?: string;
33
+ reference_wav_path?: string;
34
+ prompt_wav_path?: string;
35
+ prompt_text?: string;
36
+ }
37
+
38
+ export interface VoxCpmClientOptions {
39
+ env?: Record<string, string | undefined>;
40
+ /** Override the worker argv (tests point at a fake NDJSON worker). */
41
+ workerCommand?: string[];
42
+ /** Override the plugin installation root; defaults to the directory above this module. */
43
+ pluginRoot?: string;
44
+ spawn?: VoxCpmSpawn;
45
+ }
46
+
47
+ export interface VoxCpmSpawnOptions {
48
+ cwd: string;
49
+ env: Record<string, string | undefined>;
50
+ }
51
+
52
+ export type VoxCpmSpawn = (command: string[], options: VoxCpmSpawnOptions) => ChildProcess;
53
+
54
+ export interface VoxCpmClientHandle {
55
+ readonly client: VoiceboxClient;
56
+ readonly scratchDir: string;
57
+ close(): Promise<void>;
58
+ }
59
+
60
+ interface PendingRequest {
61
+ resolve: (value: Record<string, unknown>) => void;
62
+ reject: (error: Error) => void;
63
+ }
64
+
65
+ const BASE_ENV_KEYS = [
66
+ 'PATH',
67
+ 'HOME',
68
+ 'TMPDIR',
69
+ 'LANG',
70
+ 'LC_ALL',
71
+ 'PYTHONPATH',
72
+ 'PYTHONHOME',
73
+ 'VIRTUAL_ENV',
74
+ 'UV_PROJECT_ENVIRONMENT',
75
+ 'UV_CACHE_DIR',
76
+ 'UV_PYTHON',
77
+ 'HF_HOME',
78
+ 'HF_HUB_CACHE',
79
+ 'XDG_CACHE_HOME',
80
+ 'HF_HUB_OFFLINE',
81
+ 'TRANSFORMERS_CACHE',
82
+ 'HF_DATASETS_CACHE',
83
+ ];
84
+
85
+ /**
86
+ * Reject any path (including one reached through a symlink) whose canonical form contains a
87
+ * `vendors` segment — `vendors/` is reference-only and must never be a product runtime path
88
+ * (ADR-036). Returns the resolved absolute path for reuse.
89
+ */
90
+ export function assertNoVendorPath(candidate: string, label: string): string {
91
+ const resolved = resolve(candidate);
92
+ if (resolved.split(sep).includes('vendors')) {
93
+ throw new Error(`voxcpm-tts: ${label} must not reference vendors/ (reference-only): ${resolved}`);
94
+ }
95
+ if (existsSync(resolved)) {
96
+ const real = realpathSync(resolved);
97
+ if (real.split(sep).includes('vendors')) {
98
+ throw new Error(`voxcpm-tts: ${label} resolves into vendors/ (reference-only): ${real}`);
99
+ }
100
+ }
101
+ return resolved;
102
+ }
103
+
104
+ /** Validate every configured import/cache/environment path before the worker is spawned. */
105
+ function assertEnvPathsSafe(env: Record<string, string | undefined>): void {
106
+ for (const key of BASE_ENV_KEYS) {
107
+ const raw = env[key];
108
+ if (!raw) {
109
+ continue;
110
+ }
111
+ for (const part of raw.split(delimiter)) {
112
+ if (part) {
113
+ assertNoVendorPath(part, key);
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ function assertDeviceSafe(env: Record<string, string | undefined>): VoxCpmDevice {
120
+ const raw = env.VOXCPM_DEVICE ?? VOXCPM_DEFAULT_DEVICE;
121
+ if (!(VOXCPM_DEVICES as readonly string[]).includes(raw)) {
122
+ throw new Error(`voxcpm-tts: unsupported VOXCPM_DEVICE "${raw}" (allowed: ${VOXCPM_DEVICES.join(', ')})`);
123
+ }
124
+ return raw as VoxCpmDevice;
125
+ }
126
+
127
+ /**
128
+ * Parse and validate the frozen profile registry: unique nonempty ids/names; `instruct` needs an
129
+ * instruction; `reference` needs a reference WAV; `continuation` needs a paired prompt WAV/text;
130
+ * relative assets resolve against the registry directory; serialized OmniVoice prompts and mixed
131
+ * mode fields are refused (VoxCPM daily voice driver §Backend and controls).
132
+ */
133
+ export function parseVoxCpmRegistry(raw: unknown, registryDir: string): VoxCpmProfile[] {
134
+ const container = raw as { profiles?: unknown };
135
+ if (!container || typeof container !== 'object' || !Array.isArray(container.profiles)) {
136
+ throw new Error('voxcpm-tts: profile registry must be an object with a "profiles" array');
137
+ }
138
+ const seenIds = new Set<string>();
139
+ const seenNames = new Set<string>();
140
+ return container.profiles.map((entry, index) => {
141
+ const profile = entry as Record<string, unknown>;
142
+ if (!profile || typeof profile !== 'object') {
143
+ throw new Error(`voxcpm-tts: profile registry entry ${index} is not an object`);
144
+ }
145
+ const id = typeof profile.id === 'string' ? profile.id.trim() : '';
146
+ const name = typeof profile.name === 'string' ? profile.name.trim() : '';
147
+ if (!id || !name) {
148
+ throw new Error(`voxcpm-tts: profile registry entry ${index} needs nonempty id and name`);
149
+ }
150
+ if (seenIds.has(id) || seenNames.has(name)) {
151
+ throw new Error(`voxcpm-tts: duplicate profile id/name "${id}"`);
152
+ }
153
+ seenIds.add(id);
154
+ seenNames.add(name);
155
+ if ('prompt' in profile || 'serialized_prompt' in profile) {
156
+ throw new Error(`voxcpm-tts: profile "${id}" carries a serialized OmniVoice prompt`);
157
+ }
158
+ const mode = profile.mode;
159
+ if (mode !== 'instruct' && mode !== 'reference' && mode !== 'continuation') {
160
+ throw new Error(`voxcpm-tts: profile "${id}" has unsupported mode "${String(mode)}"`);
161
+ }
162
+ const asset = (key: string): string | undefined => {
163
+ const value = profile[key];
164
+ if (value === undefined) {
165
+ return undefined;
166
+ }
167
+ if (typeof value !== 'string' || !value.trim()) {
168
+ throw new Error(`voxcpm-tts: profile "${id}" field ${key} must be a nonempty string`);
169
+ }
170
+ const path = isAbsolute(value) ? value : resolve(registryDir, value);
171
+ return assertNoVendorPath(path, `profile "${id}" ${key}`);
172
+ };
173
+ const instruct = typeof profile.instruct === 'string' ? profile.instruct.trim() : undefined;
174
+ const reference = asset('reference_wav_path');
175
+ const promptWav = asset('prompt_wav_path');
176
+ const promptText = typeof profile.prompt_text === 'string' ? profile.prompt_text.trim() : undefined;
177
+
178
+ const mixed = mode === 'instruct' ? [reference, promptWav] : mode === 'reference' ? [promptWav] : [reference];
179
+ if (mixed.some((value) => value !== undefined)) {
180
+ throw new Error(`voxcpm-tts: profile "${id}" mixes mode "${mode}" with fields from another mode`);
181
+ }
182
+ if (mode === 'instruct' && !instruct) {
183
+ throw new Error(`voxcpm-tts: instruct profile "${id}" needs a nonempty instruct`);
184
+ }
185
+ if (mode === 'reference' && !reference) {
186
+ throw new Error(`voxcpm-tts: reference profile "${id}" needs reference_wav_path`);
187
+ }
188
+ if (mode === 'continuation' && (!promptWav || !promptText)) {
189
+ throw new Error(`voxcpm-tts: continuation profile "${id}" needs prompt_wav_path and prompt_text`);
190
+ }
191
+ return {
192
+ id,
193
+ name,
194
+ mode,
195
+ ...(instruct ? { instruct } : {}),
196
+ ...(reference ? { reference_wav_path: reference } : {}),
197
+ ...(promptWav ? { prompt_wav_path: promptWav } : {}),
198
+ ...(promptText ? { prompt_text: promptText } : {}),
199
+ };
200
+ });
201
+ }
202
+
203
+ const DEFAULT_PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
204
+
205
+ export function resolveVoxCpmWorkerCommand(pluginRoot: string): string[] {
206
+ return [
207
+ 'uv',
208
+ 'run',
209
+ '--project',
210
+ pluginRoot,
211
+ '--locked',
212
+ '--no-dev',
213
+ 'python',
214
+ join(pluginRoot, 'python', 'worker.py'),
215
+ ];
216
+ }
217
+
218
+ const defaultSpawn: VoxCpmSpawn = (command, options) =>
219
+ spawn(command[0] as string, command.slice(1), {
220
+ cwd: options.cwd,
221
+ env: options.env,
222
+ stdio: ['pipe', 'pipe', 'pipe'],
223
+ detached: true,
224
+ });
225
+
226
+ function readWavFile(path: string): ParsedWav {
227
+ let wav: ParsedWav;
228
+ try {
229
+ wav = parseWav(new Uint8Array(readFileSync(path)));
230
+ } catch (err: unknown) {
231
+ throw new Error(
232
+ `voxcpm-tts: invalid worker WAV at ${path}: ${err instanceof Error ? err.message : String(err)}`,
233
+ );
234
+ }
235
+ if (wav.audioFormat !== 1 || wav.bitsPerSample !== 16) {
236
+ throw new Error(
237
+ `voxcpm-tts: worker WAV must be 16-bit PCM (got format ${wav.audioFormat}/${wav.bitsPerSample}bit)`,
238
+ );
239
+ }
240
+ if (wav.numChannels !== 1) {
241
+ throw new Error(`voxcpm-tts: worker WAV must be mono (got ${wav.numChannels} channels)`);
242
+ }
243
+ if (!Number.isFinite(wav.sampleRate) || wav.sampleRate <= 0 || wav.byteRate <= 0) {
244
+ throw new Error(`voxcpm-tts: worker WAV needs a positive sample rate (got ${wav.sampleRate})`);
245
+ }
246
+ if (wav.dataBytes.length === 0) {
247
+ throw new Error('voxcpm-tts: worker WAV carries no samples');
248
+ }
249
+ return wav;
250
+ }
251
+
252
+ /**
253
+ * Invocation-scoped VoxCPM adapter over the shared `VoiceboxClient` contract: a private uv Python
254
+ * worker speaking bounded integer-id NDJSON, at most one model load per invocation, one in-flight
255
+ * render, and signal/deadline-scoped process-group teardown. Returns the client plus an async
256
+ * `close()` the entry calls from `finally`.
257
+ */
258
+ export function createVoxCpmClient(options: VoxCpmClientOptions = {}): VoxCpmClientHandle {
259
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
260
+ const spawnProcess = options.spawn ?? defaultSpawn;
261
+ const pluginRoot = assertNoVendorPath(options.pluginRoot ?? DEFAULT_PLUGIN_ROOT, 'plugin root');
262
+ const registryPath = assertNoVendorPath(env.VOXCPM_PROFILES_PATH ?? join(pluginRoot, 'profiles.json'), 'profiles');
263
+ const workerCommand = options.workerCommand ?? resolveVoxCpmWorkerCommand(pluginRoot);
264
+ const startupTimeoutMs = positiveTimeout(env.VOICEBOX_TIMEOUT_MS, 600_000);
265
+ const scratchDir = mkdtempSync(join(tmpdir(), 'kk-voxcpm-tts-'));
266
+
267
+ let child: ChildProcess | undefined;
268
+ let nextId = 1;
269
+ let fatal: Error | undefined;
270
+ let closed = false;
271
+ let stderrTail = '';
272
+ let stdoutBuffer = '';
273
+ let renderInFlight: { id: number; dest: string } | undefined;
274
+ let warnedChunk = false;
275
+ let warnedLanguage = false;
276
+ const pending = new Map<number, PendingRequest>();
277
+ const renderResults = new Map<number, Promise<Record<string, unknown>>>();
278
+ const signalHandlers = new Map<NodeJS.Signals, () => void>();
279
+ const destinations = new Map<number, string>();
280
+ const profileCache = new Map<string, VoxCpmProfile>();
281
+
282
+ function fail(error: Error): void {
283
+ if (!fatal) {
284
+ fatal = error;
285
+ }
286
+ for (const [, request] of pending) {
287
+ request.reject(fatal);
288
+ }
289
+ pending.clear();
290
+ renderInFlight = undefined;
291
+ void terminateChild();
292
+ }
293
+
294
+ function onLine(line: string): void {
295
+ if (line.length === 0) {
296
+ return;
297
+ }
298
+ if (Buffer.byteLength(line) > LINE_MAX_BYTES) {
299
+ fail(new Error('voxcpm-tts: worker response frame exceeds 1 MiB'));
300
+ return;
301
+ }
302
+ let message: Record<string, unknown>;
303
+ try {
304
+ message = JSON.parse(line) as Record<string, unknown>;
305
+ } catch {
306
+ fail(new Error(`voxcpm-tts: worker emitted invalid JSON: ${line.slice(0, 120)}`));
307
+ return;
308
+ }
309
+ const id = message.id;
310
+ if (typeof id !== 'number' || !Number.isInteger(id)) {
311
+ fail(new Error('voxcpm-tts: worker response is missing an integer id'));
312
+ return;
313
+ }
314
+ const request = pending.get(id);
315
+ if (!request) {
316
+ fail(new Error(`voxcpm-tts: worker response for unknown or duplicate id ${id}`));
317
+ return;
318
+ }
319
+ pending.delete(id);
320
+ request.resolve(message);
321
+ }
322
+
323
+ function consumeStdout(text: string): void {
324
+ stdoutBuffer += text;
325
+ let newline = stdoutBuffer.indexOf('\n');
326
+ while (newline !== -1) {
327
+ const line = stdoutBuffer.slice(0, newline).replace(/\r$/, '');
328
+ stdoutBuffer = stdoutBuffer.slice(newline + 1);
329
+ onLine(line);
330
+ newline = stdoutBuffer.indexOf('\n');
331
+ }
332
+ // Bound the in-progress remainder too: a worker streaming bytes without a newline must
333
+ // not grow the buffer without limit before `onLine`'s per-line cap can fire.
334
+ if (Buffer.byteLength(stdoutBuffer, 'utf8') > LINE_MAX_BYTES) {
335
+ fail(new Error('voxcpm-tts: worker response frame exceeds 1 MiB'));
336
+ }
337
+ }
338
+
339
+ function startWorker(): void {
340
+ if (child) {
341
+ return;
342
+ }
343
+ assertEnvPathsSafe(env);
344
+ const device = assertDeviceSafe(env);
345
+ assertNoVendorPath(workerCommand[workerCommand.length - 1] ?? pluginRoot, 'worker script');
346
+ let proc: ChildProcess;
347
+ try {
348
+ proc = spawnProcess(workerCommand, {
349
+ cwd: pluginRoot,
350
+ env: {
351
+ ...pickEnv(env),
352
+ VOXCPM_MODEL: env.VOXCPM_MODEL ?? VOXCPM_DEFAULT_MODEL,
353
+ VOXCPM_DEVICE: device,
354
+ VOXCPM_PROFILES_PATH: registryPath,
355
+ },
356
+ });
357
+ } catch (err: unknown) {
358
+ throw new Error(`voxcpm-tts: failed to spawn worker: ${err instanceof Error ? err.message : String(err)}`);
359
+ }
360
+ child = proc;
361
+ proc.on('error', (err) => fail(new Error(`voxcpm-tts: worker process error: ${err.message}`)));
362
+ proc.on('exit', (code, signal) => {
363
+ if (!closed && !fatal) {
364
+ fail(
365
+ new Error(
366
+ `voxcpm-tts: worker exited unexpectedly (code ${code ?? 'null'}, signal ${signal ?? 'none'})${stderrTail ? `: ${stderrTail.slice(-400)}` : ''}`,
367
+ ),
368
+ );
369
+ }
370
+ });
371
+ if (proc.stdout) {
372
+ void pump(proc.stdout, consumeStdout, () => {
373
+ if (!fatal && !closed) {
374
+ fail(new Error('voxcpm-tts: worker closed stdout before settling all requests'));
375
+ }
376
+ });
377
+ }
378
+ if (proc.stderr) {
379
+ void pump(proc.stderr, (text) => {
380
+ stderrTail = (stderrTail + text).slice(-STDERR_MAX_CHARS);
381
+ });
382
+ }
383
+ // Normal-signal teardown: SIGTERM/SIGINT close, terminate and reap owned workers.
384
+ for (const signal of SIGNAL_HANDLERS) {
385
+ const handler = () => {
386
+ void close();
387
+ };
388
+ signalHandlers.set(signal, handler);
389
+ process.on(signal, handler);
390
+ }
391
+ }
392
+
393
+ function removeSignalHandlers(): void {
394
+ for (const [signal, handler] of signalHandlers) {
395
+ process.off(signal, handler);
396
+ }
397
+ signalHandlers.clear();
398
+ }
399
+
400
+ /**
401
+ * Terminate and reap the owned worker group before returning: close stdin, TERM the group,
402
+ * force-kill after the grace window, then wait (bounded) until the whole group is gone. R4
403
+ * requires the invocation to own cleanup, so `close()` must not resolve while a descendant
404
+ * is still alive.
405
+ */
406
+ async function terminateChild(): Promise<void> {
407
+ const proc = child;
408
+ child = undefined;
409
+ if (!proc?.pid) {
410
+ try {
411
+ proc?.stdin?.end();
412
+ } catch {
413
+ // stdin already gone
414
+ }
415
+ return;
416
+ }
417
+ const pgid = proc.pid;
418
+ try {
419
+ proc.stdin?.end();
420
+ } catch {
421
+ // stdin already gone
422
+ }
423
+ killGroup(pgid, 'SIGTERM');
424
+ // Escalation is keyed on the OWNED GROUP surviving, not on the direct `uv` child: a
425
+ // descendant that ignores SIGTERM must still be force-killed, even when `uv` itself
426
+ // already exited. R4 mandates unconditional escalation (TERM, wait 2s, then force).
427
+ let groupGone = await waitForGroupGone(pgid, TERM_GRACE_MS);
428
+ if (!groupGone) {
429
+ killGroup(pgid, 'SIGKILL');
430
+ groupGone = await waitForGroupGone(pgid, SIGKILL_GRACE_MS);
431
+ }
432
+ await waitForExit(proc, SIGKILL_GRACE_MS); // reap the direct child (bounded)
433
+ if (!groupGone) {
434
+ // A group that survives SIGKILL is a cleanup failure, not a silent success.
435
+ echoError(
436
+ `voxcpm-tts: cleanup failure: owned worker group ${pgid} still alive after SIGKILL (${SIGKILL_GRACE_MS}ms)`,
437
+ );
438
+ }
439
+ }
440
+
441
+ function sendRequest(op: string, extra: Record<string, unknown>): Promise<Record<string, unknown>> {
442
+ const proc = child;
443
+ if (!proc?.stdin) {
444
+ throw new Error('voxcpm-tts: worker is not running');
445
+ }
446
+ const id = nextId++;
447
+ const request = { id, op, ...extra };
448
+ const promise = new Promise<Record<string, unknown>>((res, rej) => {
449
+ pending.set(id, { resolve: res, reject: rej });
450
+ });
451
+ proc.stdin.write(`${JSON.stringify(request)}\n`);
452
+ return promise;
453
+ }
454
+
455
+ async function awaitResponse(promise: Promise<Record<string, unknown>>, timeoutMs: number, label: string) {
456
+ return withDeadline(promise, timeoutMs, `${label} timed out after ${timeoutMs}ms`, (message) =>
457
+ fail(new Error(`voxcpm-tts: ${message}`)),
458
+ );
459
+ }
460
+
461
+ function resolveProfileSync(nameOrId: string): VoxCpmProfile {
462
+ const registry = loadRegistry();
463
+ const cached = profileCache.get(nameOrId);
464
+ if (cached) {
465
+ return cached;
466
+ }
467
+ const lower = nameOrId.toLowerCase();
468
+ const match = registry.find((entry) => entry.id === nameOrId || entry.name.toLowerCase() === lower);
469
+ if (!match) {
470
+ throw new Error(`voxcpm-tts: unknown voice profile "${nameOrId}"`);
471
+ }
472
+ profileCache.set(nameOrId, match);
473
+ profileCache.set(match.id, match);
474
+ return match;
475
+ }
476
+
477
+ let registry: VoxCpmProfile[] | undefined;
478
+ function loadRegistry(): VoxCpmProfile[] {
479
+ if (registry) {
480
+ return registry;
481
+ }
482
+ if (!existsSync(registryPath)) {
483
+ throw new Error(`voxcpm-tts: profile registry not found: ${registryPath}`);
484
+ }
485
+ let parsed: unknown;
486
+ try {
487
+ parsed = JSON.parse(readFileSync(registryPath, 'utf-8'));
488
+ } catch (err: unknown) {
489
+ throw new Error(
490
+ `voxcpm-tts: profile registry is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
491
+ );
492
+ }
493
+ registry = parseVoxCpmRegistry(parsed, dirname(registryPath));
494
+ return registry;
495
+ }
496
+
497
+ function policyCheck(
498
+ body: VoiceboxGenerateBody,
499
+ profile: VoxCpmProfile,
500
+ ): { instruct?: string; normalize: boolean } {
501
+ if (body.engine?.trim()) {
502
+ throw new Error(`voxcpm-tts: engine "${body.engine}" is not supported (VoxCPM2 only)`);
503
+ }
504
+ if (body.personality === true) {
505
+ throw new Error('voxcpm-tts: personality=true is not supported');
506
+ }
507
+ if (Array.isArray(body.effects_chain) && body.effects_chain.some((effect) => effect?.enabled !== false)) {
508
+ throw new Error('voxcpm-tts: an active effects_chain is not supported');
509
+ }
510
+ const requestedInstruct = body.instruct?.trim();
511
+ if (profile.mode === 'instruct') {
512
+ const instruct = requestedInstruct || profile.instruct;
513
+ if (instruct) {
514
+ return { instruct, normalize: body.normalize ?? true };
515
+ }
516
+ return { normalize: body.normalize ?? true };
517
+ }
518
+ if (requestedInstruct) {
519
+ throw new Error(`voxcpm-tts: per-request instruct is not supported for "${profile.mode}" mode`);
520
+ }
521
+ return { normalize: body.normalize ?? true };
522
+ }
523
+
524
+ function warnAdvisory(body: VoiceboxGenerateBody): void {
525
+ if (!warnedChunk && (body.max_chunk_chars !== undefined || body.crossfade_ms !== undefined)) {
526
+ warnedChunk = true;
527
+ echoError(
528
+ 'voxcpm-tts: max_chunk_chars/crossfade_ms are advisory; shared VoiceScript segmentation is the request boundary',
529
+ );
530
+ }
531
+ if (!warnedLanguage) {
532
+ warnedLanguage = true;
533
+ echoError(
534
+ 'voxcpm-tts: language is automatic multilingual inference; the requested language keyword is ignored',
535
+ );
536
+ }
537
+ }
538
+
539
+ async function close(): Promise<void> {
540
+ if (closed) {
541
+ return;
542
+ }
543
+ closed = true;
544
+ removeSignalHandlers();
545
+ for (const [, request] of pending) {
546
+ request.reject(new Error('voxcpm-tts: client closed'));
547
+ }
548
+ pending.clear();
549
+ await terminateChild();
550
+ try {
551
+ rmSync(scratchDir, { recursive: true, force: true });
552
+ } catch {
553
+ // best-effort scratch cleanup
554
+ }
555
+ }
556
+
557
+ const client: VoiceboxClient = {
558
+ url: `uv://${pluginRoot}`,
559
+
560
+ async health(): Promise<void> {
561
+ if (fatal) {
562
+ throw fatal;
563
+ }
564
+ if (closed) {
565
+ throw new Error('voxcpm-tts: client is closed');
566
+ }
567
+ if (!child) {
568
+ startWorker();
569
+ }
570
+ const response = await awaitResponse(
571
+ sendRequest('health', {}),
572
+ startupTimeoutMs,
573
+ 'voxcpm-tts worker startup',
574
+ );
575
+ if (response.ok !== true) {
576
+ throw new Error(`voxcpm-tts: worker health failed: ${String(response.error ?? 'unknown error')}`);
577
+ }
578
+ },
579
+
580
+ async resolveProfile(nameOrId: string) {
581
+ const profile = resolveProfileSync(nameOrId);
582
+ return { id: profile.id, name: profile.name };
583
+ },
584
+
585
+ async generate(body: VoiceboxGenerateBody): Promise<{ id: string }> {
586
+ if (fatal) {
587
+ throw fatal;
588
+ }
589
+ if (closed) {
590
+ throw new Error('voxcpm-tts: client is closed');
591
+ }
592
+ if (renderInFlight) {
593
+ throw new Error('voxcpm-tts: a render is already in flight; only one request at a time is allowed');
594
+ }
595
+ const profile = resolveProfileSync(body.profile_id);
596
+ const policy = policyCheck(body, profile);
597
+ warnAdvisory(body);
598
+ if (!child) {
599
+ startWorker();
600
+ }
601
+ const id = nextId;
602
+ const dest = assertNoVendorPath(join(scratchDir, `render-${id}.wav`), 'render destination');
603
+ const promise = sendRequest('render', {
604
+ text: body.text,
605
+ profile: {
606
+ id: profile.id,
607
+ name: profile.name,
608
+ mode: profile.mode,
609
+ ...(policy.instruct ? { instruct: policy.instruct } : {}),
610
+ ...(profile.reference_wav_path ? { reference_wav_path: profile.reference_wav_path } : {}),
611
+ ...(profile.prompt_wav_path ? { prompt_wav_path: profile.prompt_wav_path } : {}),
612
+ ...(profile.prompt_text ? { prompt_text: profile.prompt_text } : {}),
613
+ },
614
+ normalize: policy.normalize,
615
+ seed: body.seed ?? null,
616
+ dest,
617
+ });
618
+ renderInFlight = { id, dest };
619
+ destinations.set(id, dest);
620
+ const settled = promise.finally(() => {
621
+ if (renderInFlight?.id === id) {
622
+ renderInFlight = undefined;
623
+ }
624
+ });
625
+ renderResults.set(id, settled);
626
+ // Return promptly; waitUntilDone awaits the stored promise under its deadline.
627
+ void settled.catch(() => {});
628
+ return { id: String(id) };
629
+ },
630
+
631
+ async waitUntilDone(id: string, timeoutMs?: number) {
632
+ const numeric = Number.parseInt(id, 10);
633
+ const result = renderResults.get(numeric);
634
+ if (!result) {
635
+ return { status: 'failed' as const, error: `voxcpm-tts: unknown generation id ${id}` };
636
+ }
637
+ let response: Record<string, unknown>;
638
+ try {
639
+ response = await awaitResponse(result, timeoutMs ?? startupTimeoutMs, `voxcpm-tts render ${id}`);
640
+ } catch (err: unknown) {
641
+ if (fatal) {
642
+ throw fatal;
643
+ }
644
+ return { status: 'failed' as const, error: err instanceof Error ? err.message : String(err) };
645
+ }
646
+ renderResults.delete(numeric);
647
+ if (response.ok !== true) {
648
+ return { status: 'failed' as const, error: String(response.error ?? 'unknown error') };
649
+ }
650
+ const dest = destinations.get(numeric);
651
+ const wavPath = typeof response.wav_path === 'string' ? response.wav_path : '';
652
+ if (!dest || resolve(wavPath) !== dest) {
653
+ fail(new Error(`voxcpm-tts: worker reported an unexpected WAV path "${wavPath}"`));
654
+ }
655
+ const wav = readWavFile(dest as string);
656
+ return { status: 'completed' as const, duration: wav.dataBytes.length / wav.byteRate };
657
+ },
658
+
659
+ async downloadAudio(id: string): Promise<Uint8Array> {
660
+ if (fatal) {
661
+ throw fatal;
662
+ }
663
+ const numeric = Number.parseInt(id, 10);
664
+ const dest = destinations.get(numeric);
665
+ if (!dest) {
666
+ throw new Error(`voxcpm-tts: unknown generation id ${id}`);
667
+ }
668
+ readWavFile(dest);
669
+ return new Uint8Array(readFileSync(dest));
670
+ },
671
+ };
672
+
673
+ return { client, scratchDir, close };
674
+ }
675
+
676
+ function positiveTimeout(raw: string | undefined, fallback: number): number {
677
+ const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
678
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
679
+ }
680
+
681
+ function pickEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
682
+ const picked: Record<string, string | undefined> = {};
683
+ for (const [key, value] of Object.entries(env)) {
684
+ if (typeof value === 'string') {
685
+ picked[key] = value;
686
+ }
687
+ }
688
+ return picked;
689
+ }
690
+
691
+ function killGroup(pid: number, signal: NodeJS.Signals): void {
692
+ try {
693
+ process.kill(-pid, signal);
694
+ } catch {
695
+ try {
696
+ process.kill(pid, signal);
697
+ } catch {
698
+ // already gone
699
+ }
700
+ }
701
+ }
702
+
703
+ function waitForGroupGone(pgid: number, timeoutMs: number): Promise<boolean> {
704
+ const start = Date.now();
705
+ return new Promise((resolvePromise) => {
706
+ const poll = (): void => {
707
+ try {
708
+ process.kill(-pgid, 0);
709
+ } catch {
710
+ resolvePromise(true);
711
+ return;
712
+ }
713
+ if (Date.now() - start >= timeoutMs) {
714
+ resolvePromise(false);
715
+ return;
716
+ }
717
+ setTimeout(poll, 25);
718
+ };
719
+ poll();
720
+ });
721
+ }
722
+
723
+ function waitForExit(proc: ChildProcess, timeoutMs: number): Promise<boolean> {
724
+ return new Promise((resolvePromise) => {
725
+ if (proc.exitCode !== null || proc.signalCode !== null) {
726
+ resolvePromise(true);
727
+ return;
728
+ }
729
+ const timer = setTimeout(() => resolvePromise(false), timeoutMs);
730
+ proc.once('exit', () => {
731
+ clearTimeout(timer);
732
+ resolvePromise(true);
733
+ });
734
+ });
735
+ }
736
+
737
+ async function pump(stream: NodeJS.ReadableStream, onChunk: (text: string) => void, onEnd?: () => void): Promise<void> {
738
+ const decoder = new TextDecoder();
739
+ try {
740
+ for await (const chunk of stream) {
741
+ onChunk(decoder.decode(chunk as Uint8Array, { stream: true }));
742
+ }
743
+ onChunk(decoder.decode());
744
+ } catch {
745
+ // stream failure is surfaced through the worker exit path
746
+ } finally {
747
+ onEnd?.();
748
+ }
749
+ }
750
+
751
+ function withDeadline<T>(
752
+ promise: Promise<T>,
753
+ timeoutMs: number,
754
+ timeoutMessage: string,
755
+ onTimeout: (message: string) => void,
756
+ ): Promise<T> {
757
+ let timer: ReturnType<typeof setTimeout> | undefined;
758
+ const timeout = new Promise<never>((_, reject) => {
759
+ timer = setTimeout(() => {
760
+ onTimeout(timeoutMessage);
761
+ reject(new Error(timeoutMessage));
762
+ }, timeoutMs);
763
+ });
764
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
765
+ }