@gobing-ai/knowledge-kit 0.0.35 → 0.0.37

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 (124) 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 +12996 -10476
  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 +1004 -10803
  16. package/plugins/generations/daily-article-gen/package.json +1 -1
  17. package/plugins/generations/dailynews-gen/dist/index.js +1111 -10888
  18. package/plugins/generations/dailynews-gen/package.json +1 -1
  19. package/plugins/generations/dailynews-gen/src/script-plan.ts +32 -0
  20. package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
  21. package/plugins/generations/episode-plan-gen/package.json +1 -1
  22. package/plugins/generations/gemini-tts/dist/index.js +1104 -10903
  23. package/plugins/generations/gemini-tts/package.json +1 -1
  24. package/plugins/generations/image-gen/dist/index.js +1239 -11035
  25. package/plugins/generations/image-gen/package.json +1 -1
  26. package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
  27. package/plugins/generations/news-report-gen/package.json +1 -1
  28. package/plugins/generations/voice-gen/dist/index.js +1062 -10861
  29. package/plugins/generations/voice-gen/package.json +1 -1
  30. package/plugins/generations/voxcpm-tts/README.md +72 -0
  31. package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
  32. package/plugins/generations/voxcpm-tts/package.json +16 -0
  33. package/plugins/generations/voxcpm-tts/plugin.json +6 -0
  34. package/plugins/generations/voxcpm-tts/profiles.json +10 -0
  35. package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
  36. package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
  37. package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
  38. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
  39. package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
  40. package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
  41. package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
  42. package/plugins/ingestions/aihot-ingest/package.json +1 -1
  43. package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
  44. package/plugins/ingestions/horizon-ingest/package.json +1 -1
  45. package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
  46. package/plugins/ingestions/karakeep-local/package.json +1 -1
  47. package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
  48. package/plugins/ingestions/last30days-ingest/package.json +1 -1
  49. package/plugins/ingestions/web-search/dist/index.js +1250 -11033
  50. package/plugins/ingestions/web-search/package.json +1 -1
  51. package/plugins/kk/README.md +29 -2
  52. package/plugins/kk/plugin.json +1 -1
  53. package/plugins/kk/schedules/cron.tmpl +9 -1
  54. package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
  55. package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
  56. package/plugins/kk/scripts/daily-stages.ts +428 -46
  57. package/plugins/kk/scripts/daily-voice-style.zh.md +1 -1
  58. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -146
  59. package/plugins/kk/skills/publish/SKILL.md +8 -4
  60. package/plugins/kk/workflows/feature-verification.yaml +93 -0
  61. package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
  62. package/plugins/kk/workflows/kk-collect.yaml +43 -11
  63. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +4 -2
  64. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +87 -16
  65. package/plugins/kk/workflows/kk-daily-safety.yaml +15 -2
  66. package/plugins/kk/workflows/kk-itc.yaml +8 -1
  67. package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
  68. package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
  69. package/plugins/publishings/emdash-pub/package.json +1 -1
  70. package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
  71. package/plugins/publishings/infoq-pub/dist/profile-check.js +1677 -11199
  72. package/plugins/publishings/infoq-pub/package.json +1 -1
  73. package/plugins/publishings/infoq-pub/plugin.json +5 -1
  74. package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
  75. package/plugins/publishings/juejin-pub/dist/profile-check.js +1677 -11199
  76. package/plugins/publishings/juejin-pub/package.json +1 -1
  77. package/plugins/publishings/juejin-pub/plugin.json +5 -1
  78. package/plugins/publishings/medium-pub/dist/index.js +978 -10805
  79. package/plugins/publishings/medium-pub/package.json +1 -1
  80. package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
  81. package/plugins/publishings/podcast-pub/package.json +1 -1
  82. package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
  83. package/plugins/publishings/qiita-pub/package.json +1 -1
  84. package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
  85. package/plugins/publishings/substack-pub/dist/profile-check.js +1698 -11200
  86. package/plugins/publishings/substack-pub/package.json +1 -1
  87. package/plugins/publishings/substack-pub/plugin.json +4 -1
  88. package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
  89. package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
  90. package/plugins/publishings/surfdash-pub/package.json +1 -1
  91. package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
  92. package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
  93. package/plugins/publishings/threads-pub/dist/index.js +12394 -0
  94. package/plugins/publishings/threads-pub/package.json +17 -0
  95. package/plugins/publishings/threads-pub/plugin.json +7 -0
  96. package/plugins/publishings/threads-pub/src/index.ts +340 -0
  97. package/plugins/publishings/threads-pub/tsconfig.json +4 -0
  98. package/plugins/publishings/wechatmp-pub/dist/index.js +2508 -11695
  99. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +1656 -11188
  100. package/plugins/publishings/wechatmp-pub/package.json +1 -1
  101. package/plugins/publishings/wechatmp-pub/plugin.json +5 -1
  102. package/plugins/publishings/wechatmp-pub/src/enrich.ts +244 -71
  103. package/plugins/publishings/wechatmp-pub/src/flow.ts +412 -96
  104. package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
  105. package/plugins/publishings/wechatmp-pub/src/page.ts +16 -0
  106. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
  107. package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
  108. package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
  109. package/plugins/publishings/weibo-pub/package.json +19 -0
  110. package/plugins/publishings/weibo-pub/plugin.json +12 -0
  111. package/plugins/publishings/weibo-pub/src/index.ts +868 -0
  112. package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
  113. package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
  114. package/plugins/publishings/x-pub/dist/index.js +1948 -11440
  115. package/plugins/publishings/x-pub/dist/profile-check.js +1677 -11199
  116. package/plugins/publishings/x-pub/package.json +1 -1
  117. package/plugins/publishings/x-pub/plugin.json +5 -1
  118. package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
  119. package/plugins/publishings/xhs-pub/dist/profile-check.js +1697 -11212
  120. package/plugins/publishings/xhs-pub/package.json +1 -1
  121. package/plugins/publishings/xhs-pub/plugin.json +5 -1
  122. package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
  123. package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
  124. package/plugins/publishings/zenn-pub/package.json +1 -1
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@gobing-ai/voxcpm-tts",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "typecheck": "tsc --noEmit"
7
+ },
8
+ "dependencies": {
9
+ "@gobing-ai/ts-utils": "catalog:",
10
+ "@gobing-ai/voice-gen": "workspace:*"
11
+ },
12
+ "devDependencies": {
13
+ "@gobing-ai/kk-core": "workspace:*",
14
+ "@types/bun": "1.4.3"
15
+ }
16
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "voxcpm-tts",
3
+ "kind": "generator",
4
+ "entry": "./dist/index.js",
5
+ "version": "1.0.0"
6
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "profiles": [
3
+ {
4
+ "id": "robin-news",
5
+ "name": "robin-news",
6
+ "mode": "instruct",
7
+ "instruct": "清晰、自然、沉稳的中文新闻播报,语速适中。"
8
+ }
9
+ ]
10
+ }
@@ -0,0 +1,9 @@
1
+ [project]
2
+ name = "voxcpm-tts"
3
+ version = "1.0.0"
4
+ description = "VoxCPM-backed voice generator worker for knowledge-kit (independent uv project)"
5
+ requires-python = "==3.12.*"
6
+ dependencies = ["voxcpm==2.0.3"]
7
+
8
+ [dependency-groups]
9
+ dev = ["pytest"]
@@ -0,0 +1,353 @@
1
+ """VoxCPM inference worker for the kk `voxcpm-tts` generator plugin.
2
+
3
+ A private, invocation-scoped NDJSON worker the TypeScript adapter spawns through `uv run
4
+ --locked`. One model load per process; sequential integer-id health/render requests on stdin;
5
+ one JSON response per line on stdout. At startup fd 1 is pointed at stderr and the protocol
6
+ writes to the saved original fd, so library stdout stays clean at the fd level too — not just
7
+ the Python `sys.stdout` object (ADR-036 / docs/design/voxcpm-daily-voice-driver.md).
8
+
9
+ Backend: independently published ``voxcpm==2.0.3`` on Python 3.12. `vendors/` is reference-only —
10
+ every model/cache/profile/import path is canonicalized and refused if it routes into `vendors/`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import io
17
+ import json
18
+ import os
19
+ import struct
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ LINE_MAX_BYTES = 1_048_576
24
+ _VENDOR_SEGMENT = "vendors"
25
+ _ENV_PATH_KEYS = (
26
+ "PYTHONPATH",
27
+ "UV_PROJECT_ENVIRONMENT",
28
+ "UV_CACHE_DIR",
29
+ "HF_HOME",
30
+ "HF_HUB_CACHE",
31
+ "XDG_CACHE_HOME",
32
+ "TRANSFORMERS_CACHE",
33
+ "HF_DATASETS_CACHE",
34
+ )
35
+
36
+
37
+ class WorkerError(Exception):
38
+ """An actionable worker failure surfaced to the adapter as `{ok:false, error}`."""
39
+
40
+
41
+ def _canonical(path: str) -> str:
42
+ return str(Path(path).expanduser().resolve())
43
+
44
+
45
+ def vendor_blocked(path: str) -> bool:
46
+ """True when the canonical path (or any symlink target) contains a `vendors` segment."""
47
+ resolved = Path(path).expanduser()
48
+ try:
49
+ resolved = resolved.resolve()
50
+ except OSError:
51
+ resolved = resolved.absolute()
52
+ if _VENDOR_SEGMENT in resolved.parts:
53
+ return True
54
+ if resolved.exists():
55
+ real = Path(os.path.realpath(resolved))
56
+ if _VENDOR_SEGMENT in real.parts:
57
+ return True
58
+ return False
59
+
60
+
61
+ def assert_no_vendor(path: str, label: str) -> str:
62
+ if vendor_blocked(path):
63
+ raise WorkerError(f"{label} must not reference vendors/ (reference-only): {path}")
64
+ return _canonical(path)
65
+
66
+
67
+ def assert_env_paths_safe(environ: dict[str, str]) -> None:
68
+ for key in _ENV_PATH_KEYS:
69
+ raw = environ.get(key)
70
+ if not raw:
71
+ continue
72
+ for part in raw.split(os.pathsep):
73
+ if part:
74
+ assert_no_vendor(part, key)
75
+
76
+
77
+ def parse_registry(raw: object, registry_dir: str) -> list[dict]:
78
+ if not isinstance(raw, dict) or not isinstance(raw.get("profiles"), list):
79
+ raise WorkerError('profile registry must be an object with a "profiles" array')
80
+ seen_ids: set[str] = set()
81
+ seen_names: set[str] = set()
82
+ profiles: list[dict] = []
83
+ for index, entry in enumerate(raw["profiles"]):
84
+ if not isinstance(entry, dict):
85
+ raise WorkerError(f"profile entry {index} is not an object")
86
+ pid = str(entry.get("id", "")).strip()
87
+ name = str(entry.get("name", "")).strip()
88
+ if not pid or not name:
89
+ raise WorkerError(f"profile entry {index} needs nonempty id and name")
90
+ if pid in seen_ids or name in seen_names:
91
+ raise WorkerError(f"duplicate profile id/name {pid!r}")
92
+ seen_ids.add(pid)
93
+ seen_names.add(name)
94
+ mode = entry.get("mode")
95
+ if mode not in ("instruct", "reference", "continuation"):
96
+ raise WorkerError(f"profile {pid!r} has unsupported mode {mode!r}")
97
+ resolved = dict(entry)
98
+ for key in ("reference_wav_path", "prompt_wav_path"):
99
+ value = entry.get(key)
100
+ if value is None:
101
+ continue
102
+ if not isinstance(value, str) or not value.strip():
103
+ raise WorkerError(f"profile {pid!r} field {key} must be a nonempty string")
104
+ path = value if os.path.isabs(value) else os.path.join(registry_dir, value)
105
+ resolved[key] = assert_no_vendor(path, f"profile {pid!r} {key}")
106
+ if mode == "instruct" and not str(entry.get("instruct", "")).strip():
107
+ raise WorkerError(f"instruct profile {pid!r} needs a nonempty instruct")
108
+ if mode == "reference" and not resolved.get("reference_wav_path"):
109
+ raise WorkerError(f"reference profile {pid!r} needs reference_wav_path")
110
+ if mode == "continuation" and not (resolved.get("prompt_wav_path") and entry.get("prompt_text")):
111
+ raise WorkerError(f"continuation profile {pid!r} needs prompt_wav_path and prompt_text")
112
+ profiles.append(resolved)
113
+ return profiles
114
+
115
+
116
+ def load_registry(environ: dict[str, str]) -> list[dict]:
117
+ path = environ.get("VOXCPM_PROFILES_PATH")
118
+ if not path:
119
+ raise WorkerError("VOXCPM_PROFILES_PATH is not set")
120
+ assert_no_vendor(path, "VOXCPM_PROFILES_PATH")
121
+ try:
122
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
123
+ except FileNotFoundError as err:
124
+ raise WorkerError(f"profile registry not found: {path}") from err
125
+ except json.JSONDecodeError as err:
126
+ raise WorkerError(f"profile registry is not valid JSON: {err}") from err
127
+ return parse_registry(raw, str(Path(path).parent))
128
+
129
+
130
+ def apply_instruct(text: str, instruct: str | None) -> str:
131
+ """VoxCPM2 renders an instruction as a parenthesized text prefix; the spoken transcript is
132
+ unchanged for downstream QC."""
133
+ if instruct:
134
+ return f"({instruct}){text}"
135
+ return text
136
+
137
+
138
+ def to_pcm16(waveform: object) -> bytes:
139
+ import numpy as np # imported lazily so helper tests need no torch/numpy
140
+
141
+ array = np.asarray(waveform, dtype="float32").reshape(-1)
142
+ if array.size == 0:
143
+ raise WorkerError("model returned an empty waveform")
144
+ if not np.all(np.isfinite(array)):
145
+ raise WorkerError("model returned a non-finite waveform")
146
+ clipped = np.clip(array, -1.0, 1.0)
147
+ return (clipped * 32767.0).astype("<i2").tobytes()
148
+
149
+
150
+ def write_pcm16_wav(path: str, pcm: bytes, sample_rate: int, channels: int = 1) -> None:
151
+ if channels != 1:
152
+ raise WorkerError(f"refusing to write {channels}-channel audio; VoxCPM output must be mono")
153
+ if not isinstance(sample_rate, int) or sample_rate <= 0:
154
+ raise WorkerError(f"invalid sample rate {sample_rate}")
155
+ block_align = channels * 2
156
+ byte_rate = sample_rate * block_align
157
+ header = b"RIFF" + struct.pack("<I", 36 + len(pcm)) + b"WAVE"
158
+ header += b"fmt " + struct.pack("<IHHIIHH", 16, 1, channels, sample_rate, byte_rate, block_align, 16)
159
+ header += b"data" + struct.pack("<I", len(pcm))
160
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
161
+ with open(path, "wb") as handle:
162
+ handle.write(header)
163
+ handle.write(pcm)
164
+
165
+
166
+ def _snapshot_revision(model_id: str, local_dir: str) -> str:
167
+ try:
168
+ from huggingface_hub import scan_cache_dir # type: ignore
169
+
170
+ for repo in scan_cache_dir().repos:
171
+ if repo.repo_id == model_id:
172
+ return repo.repo_path.name
173
+ except Exception: # noqa: BLE001 - provenance is best-effort, never fatal
174
+ return "unknown"
175
+ return "unknown"
176
+
177
+
178
+ def load_backend(environ: dict[str, str]) -> dict:
179
+ """Resolve the local snapshot, load the model once, and report provenance."""
180
+ assert_env_paths_safe(environ)
181
+ model_id = environ.get("VOXCPM_MODEL", "openbmb/VoxCPM2")
182
+ device = environ.get("VOXCPM_DEVICE", "auto")
183
+ if device not in ("auto", "cpu", "cuda", "mps"):
184
+ raise WorkerError(f"unsupported VOXCPM_DEVICE {device!r} (allowed: auto, cpu, cuda, mps)")
185
+
186
+ with contextlib.redirect_stdout(sys.stderr):
187
+ import voxcpm # type: ignore
188
+ from huggingface_hub import snapshot_download # type: ignore
189
+
190
+ if os.path.isdir(model_id):
191
+ local = assert_no_vendor(model_id, "VOXCPM_MODEL")
192
+ revision = "unknown"
193
+ else:
194
+ with contextlib.redirect_stdout(sys.stderr):
195
+ local = snapshot_download(repo_id=model_id)
196
+ local = assert_no_vendor(local, "model snapshot")
197
+ revision = _snapshot_revision(model_id, local)
198
+
199
+ with contextlib.redirect_stdout(sys.stderr):
200
+ model = voxcpm.VoxCPM.from_pretrained(
201
+ local,
202
+ load_denoiser=False,
203
+ optimize=False,
204
+ device=None if device == "auto" else device,
205
+ )
206
+ package_version = getattr(voxcpm, "__version__", "unknown")
207
+ sample_rate = int(model.tts_model.sample_rate)
208
+ if sample_rate <= 0:
209
+ raise WorkerError(f"model reported a non-positive sample rate {sample_rate}")
210
+ return {
211
+ "model": model,
212
+ "path": local,
213
+ "revision": revision,
214
+ "package": package_version,
215
+ "sample_rate": sample_rate,
216
+ }
217
+
218
+
219
+ def _render(state: dict, request: dict) -> dict:
220
+ text = request.get("text")
221
+ if not isinstance(text, str) or not text.strip():
222
+ raise WorkerError("render text must be a nonempty string")
223
+ dest = request.get("dest")
224
+ if not isinstance(dest, str) or not os.path.isabs(dest):
225
+ raise WorkerError("render destination must be an absolute path")
226
+ assert_no_vendor(dest, "render destination")
227
+
228
+ profile = request.get("profile") or {}
229
+ mode = profile.get("mode")
230
+ out_text = text
231
+ if mode == "instruct":
232
+ out_text = apply_instruct(text, profile.get("instruct"))
233
+ elif mode == "reference":
234
+ reference = assert_no_vendor(profile["reference_wav_path"], "reference_wav_path")
235
+ profile = {**profile, "reference_wav_path": reference}
236
+ elif mode == "continuation":
237
+ prompt_wav = assert_no_vendor(profile["prompt_wav_path"], "prompt_wav_path")
238
+ profile = {**profile, "prompt_wav_path": prompt_wav}
239
+ else:
240
+ raise WorkerError(f"unsupported profile mode {mode!r}")
241
+
242
+ seed = request.get("seed")
243
+ if seed is not None:
244
+ import torch # type: ignore
245
+
246
+ torch.manual_seed(int(seed))
247
+
248
+ import numpy as np # type: ignore
249
+
250
+ with contextlib.redirect_stdout(sys.stderr):
251
+ if mode == "instruct":
252
+ waveform = state["model"].generate(text=out_text)
253
+ elif mode == "reference":
254
+ waveform = state["model"].generate(text=out_text, reference_wav_path=profile["reference_wav_path"])
255
+ else:
256
+ waveform = state["model"].generate(
257
+ text=out_text,
258
+ prompt_wav_path=profile["prompt_wav_path"],
259
+ prompt_text=profile["prompt_text"],
260
+ )
261
+ del np
262
+ pcm = to_pcm16(waveform)
263
+ sample_rate = state["sample_rate"]
264
+ write_pcm16_wav(dest, pcm, sample_rate)
265
+ duration = len(pcm) / 2 / sample_rate
266
+ return {
267
+ "ok": True,
268
+ "duration": duration,
269
+ "wav_path": dest,
270
+ "sample_rate": sample_rate,
271
+ "model": {"path": state["path"], "revision": state["revision"], "package": state["package"]},
272
+ }
273
+
274
+
275
+ def _respond(stdout: io.TextIOBase, request_id: object, payload: dict) -> None:
276
+ message = {"id": request_id, **payload}
277
+ stdout.write(json.dumps(message) + "\n")
278
+ stdout.flush()
279
+
280
+
281
+ def _handle(state: dict, environ: dict[str, str], request: dict) -> dict:
282
+ op = request.get("op")
283
+ if op == "health":
284
+ backend = state.get("backend")
285
+ if backend is None:
286
+ load_registry(environ)
287
+ backend = load_backend(environ)
288
+ state["backend"] = backend
289
+ return {
290
+ "ok": True,
291
+ "sample_rate": backend["sample_rate"],
292
+ "model": {
293
+ "path": backend["path"],
294
+ "revision": backend["revision"],
295
+ "package": backend["package"],
296
+ },
297
+ }
298
+ if op == "render":
299
+ backend = state.get("backend")
300
+ if backend is None:
301
+ raise WorkerError("render before health; the model is not loaded")
302
+ return _render(backend, request)
303
+ raise WorkerError(f"unsupported op {op!r}")
304
+
305
+
306
+ def isolate_protocol_stdout() -> io.TextIOBase:
307
+ """Point fd 1 at stderr so a native library writing to fd 1 cannot corrupt the NDJSON
308
+ channel, and return the real protocol stream (the saved original fd 1). R4's "protocol
309
+ stdout stays clean" must hold for fd-level writes, not only Python `sys.stdout`."""
310
+ saved = os.dup(1)
311
+ os.dup2(2, 1)
312
+ return os.fdopen(saved, "w", encoding="utf-8", newline="\n")
313
+
314
+
315
+ def main(
316
+ argv: list[str] | None = None,
317
+ environ: dict[str, str] | None = None,
318
+ protocol_stdout: io.TextIOBase | None = None,
319
+ ) -> int:
320
+ del argv
321
+ env = environ if environ is not None else dict(os.environ)
322
+ stdout = protocol_stdout if protocol_stdout is not None else sys.stdout
323
+ state: dict = {}
324
+ for raw in sys.stdin:
325
+ line = raw.rstrip("\n")
326
+ if not line.strip():
327
+ continue
328
+ if len(line.encode("utf-8")) > LINE_MAX_BYTES:
329
+ _respond(stdout, None, {"ok": False, "error": "request frame exceeds 1 MiB"})
330
+ return 1
331
+ try:
332
+ request = json.loads(line)
333
+ if not isinstance(request, dict) or not isinstance(request.get("id"), int):
334
+ raise WorkerError("request must carry an integer id")
335
+ except (json.JSONDecodeError, WorkerError) as err:
336
+ _respond(stdout, None, {"ok": False, "error": f"malformed request: {err}"})
337
+ continue
338
+ try:
339
+ payload = _handle(state, env, request)
340
+ except WorkerError as err:
341
+ _respond(stdout, request.get("id"), {"ok": False, "error": str(err)})
342
+ continue
343
+ except Exception as err: # noqa: BLE001 - surface any backend failure actionably
344
+ _respond(stdout, request.get("id"), {"ok": False, "error": f"{type(err).__name__}: {err}"})
345
+ continue
346
+ # The success path MUST answer too: the adapter resolves a request by its id, so a
347
+ # silent success left every health/render waiting out its deadline (task 0258).
348
+ _respond(stdout, request.get("id"), payload)
349
+ return 0
350
+
351
+
352
+ if __name__ == "__main__":
353
+ raise SystemExit(main(protocol_stdout=isolate_protocol_stdout()))
@@ -0,0 +1,42 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { echoError } from '@gobing-ai/ts-utils';
3
+ import { processGeneratorIO } from '@gobing-ai/voice-gen';
4
+ import { createVoxCpmClient, VOXCPM_TTS_GENERATOR, type VoxCpmClientOptions } from './voxcpm-client';
5
+
6
+ export * from './voxcpm-client';
7
+
8
+ /**
9
+ * `kk` plugin entry: render Doc[] into audio Content through the shared voice-gen pipeline,
10
+ * injecting the invocation-scoped VoxCPM adapter and closing it (worker, signals, scratch) in
11
+ * `finally`. The worker stays lazy until `health()` runs after shared Doc validation, so empty
12
+ * input starts nothing (AC3).
13
+ */
14
+ export async function main(argv: string[] = process.argv.slice(2), options: VoxCpmClientOptions = {}): Promise<number> {
15
+ const { values } = parseArgs({
16
+ args: argv,
17
+ options: {
18
+ in: { type: 'string' },
19
+ out: { type: 'string' },
20
+ },
21
+ });
22
+
23
+ if (!values.in || !values.out) {
24
+ echoError('voxcpm-tts failed: Missing required arguments: --in and --out');
25
+ return 1;
26
+ }
27
+
28
+ const handle = createVoxCpmClient(options);
29
+ try {
30
+ await processGeneratorIO(values.in, values.out, handle.client, undefined, VOXCPM_TTS_GENERATOR);
31
+ return 0;
32
+ } catch (err: unknown) {
33
+ echoError(`voxcpm-tts failed: ${err instanceof Error ? err.message : String(err)}`);
34
+ return 1;
35
+ } finally {
36
+ await handle.close();
37
+ }
38
+ }
39
+
40
+ if (import.meta.main) {
41
+ process.exit(await main());
42
+ }