@daniel156161/prism 0.2.92 → 0.2.97

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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +386 -181
  3. package/dist/pi/pi-context-files.d.ts +6 -0
  4. package/dist/pi/pi-context-files.js +17 -0
  5. package/dist/pi/pi-context-files.js.map +1 -0
  6. package/dist/pi/pi-extensions.d.ts +0 -1
  7. package/dist/pi/pi-extensions.js +7 -22
  8. package/dist/pi/pi-extensions.js.map +1 -1
  9. package/dist/pi/pi-npm-extension-entry.d.ts +2 -0
  10. package/dist/pi/pi-npm-extension-entry.js +74 -0
  11. package/dist/pi/pi-npm-extension-entry.js.map +1 -0
  12. package/dist/pi/pi-package.js +4 -0
  13. package/dist/pi/pi-package.js.map +1 -1
  14. package/dist/pi/pi-patch-context-files.d.ts +12 -0
  15. package/dist/pi/pi-patch-context-files.js +46 -0
  16. package/dist/pi/pi-patch-context-files.js.map +1 -0
  17. package/dist/pi/pi-patch-interactive.js +5 -1
  18. package/dist/pi/pi-patch-interactive.js.map +1 -1
  19. package/dist/pi/plan-mode-tools.d.ts +13 -0
  20. package/dist/pi/plan-mode-tools.js +54 -0
  21. package/dist/pi/plan-mode-tools.js.map +1 -0
  22. package/dist/prism-extensions/core/mex-anchor.d.ts +9 -0
  23. package/dist/prism-extensions/core/mex-anchor.js +29 -0
  24. package/dist/prism-extensions/core/mex-anchor.js.map +1 -0
  25. package/dist/prism-extensions/core/mex-cli.d.ts +68 -0
  26. package/dist/prism-extensions/core/mex-cli.js +183 -0
  27. package/dist/prism-extensions/core/mex-cli.js.map +1 -0
  28. package/dist/prism-extensions/core/mex-graph-db.d.ts +26 -0
  29. package/dist/prism-extensions/core/mex-graph-db.js +69 -0
  30. package/dist/prism-extensions/core/mex-graph-db.js.map +1 -0
  31. package/dist/prism-extensions/integrations/ai-memory-system.js +27 -6
  32. package/dist/prism-extensions/integrations/ai-memory-system.js.map +1 -1
  33. package/dist/prism-extensions/integrations/mex-memory.d.ts +21 -0
  34. package/dist/prism-extensions/integrations/mex-memory.js +231 -0
  35. package/dist/prism-extensions/integrations/mex-memory.js.map +1 -0
  36. package/dist/prism-extensions/tools/builtin-tools.js +2 -0
  37. package/dist/prism-extensions/tools/builtin-tools.js.map +1 -1
  38. package/dist/prism-extensions/tools/mex-tools.d.ts +2 -0
  39. package/dist/prism-extensions/tools/mex-tools.js +78 -0
  40. package/dist/prism-extensions/tools/mex-tools.js.map +1 -0
  41. package/dist/prism-extensions/tools/prism-diagnostic-tools.js +10 -10
  42. package/dist/prism-extensions/tools/prism-diagnostic-tools.js.map +1 -1
  43. package/dist/prism-extensions/tools/toolbox.js +1 -1
  44. package/dist/prism-extensions/tools/toolbox.js.map +1 -1
  45. package/dist/prism-extensions/ui/toolbar.d.ts +2 -0
  46. package/dist/prism-extensions/ui/toolbar.js +10 -1
  47. package/dist/prism-extensions/ui/toolbar.js.map +1 -1
  48. package/node_modules/@earendil-works/pi-coding-agent/dist/core/resource-loader.js +15 -0
  49. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js +6 -6
  50. package/package.json +17 -4
  51. package/src/prism-extensions/README.md +10 -1
  52. package/src/prism-extensions/core/mex-anchor.ts +29 -0
  53. package/src/prism-extensions/core/mex-cli.ts +217 -0
  54. package/src/prism-extensions/core/mex-graph-db.ts +76 -0
  55. package/src/prism-extensions/integrations/ai-memory-system.ts +30 -6
  56. package/src/prism-extensions/integrations/mex-memory.ts +264 -0
  57. package/src/prism-extensions/tools/builtin-tools.ts +2 -0
  58. package/src/prism-extensions/tools/mex-tools.ts +93 -0
  59. package/src/prism-extensions/tools/prism-diagnostic-tools.ts +10 -10
  60. package/src/prism-extensions/tools/toolbox.ts +1 -1
  61. package/src/prism-extensions/ui/toolbar.ts +10 -1
  62. package/src/prism-extensions/commands/voice-command.ts +0 -49
  63. package/src/prism-extensions/core/voice-runtime.ts +0 -322
  64. package/src/prism-extensions/core/voicebox-client.ts +0 -205
  65. package/src/prism-extensions/core/voicebox-service.ts +0 -137
  66. package/src/prism-extensions/integrations/honcho-memory.ts +0 -299
@@ -1,322 +0,0 @@
1
- import * as fs from "node:fs"
2
- import * as os from "node:os"
3
- import * as path from "node:path"
4
- import { spawn, spawnSync, type ChildProcess } from "node:child_process"
5
- import { readSettings } from "./shared-config.js"
6
- import { ensureVoiceboxServiceRunning, stopVoiceboxService } from "./voicebox-service.js"
7
- import { isVoiceboxBackend, resolveVoiceboxConfig, streamSpeechWithVoicebox } from "./voicebox-client.js"
8
-
9
- export type VoiceMode = "off" | "on" | "input" | "output"
10
-
11
- export interface VoiceConfig {
12
- wakeWord: string
13
- sttCommand?: string
14
- whisperModel?: string
15
- ttsCommand?: string
16
- piperModel?: string
17
- audioPlayer?: string
18
- loopCooldownMs: number
19
- }
20
-
21
- export interface VoiceRuntimeOptions {
22
- env?: NodeJS.ProcessEnv
23
- sendUserMessage: (message: string) => void | Promise<void>
24
- notify?: (message: string, level?: string) => void
25
- setStatus?: (value: string | undefined) => void
26
- spawnProcess?: typeof spawn
27
- spawnSyncProcess?: typeof spawnSync
28
- }
29
-
30
- export function parseVoiceMode(input: string): VoiceMode | undefined {
31
- const value = String(input || "").trim().toLowerCase()
32
- if (!value || value === "status") return undefined
33
- if (["on", "full", "both", "all"].includes(value)) return "on"
34
- if (["off", "disable", "disabled", "stop"].includes(value)) return "off"
35
- if (["input", "in", "listen", "mic"].includes(value)) return "input"
36
- if (["output", "out", "speak", "tts"].includes(value)) return "output"
37
- return undefined
38
- }
39
-
40
- export function voiceStatus(mode: VoiceMode): string | undefined {
41
- if (mode === "off") return undefined
42
- if (mode === "on") return "🎙 voice:in+out"
43
- return mode === "input" ? "🎙 voice:in" : "🔊 voice:out"
44
- }
45
-
46
- function nonEmpty(value: unknown): string | undefined {
47
- return typeof value === "string" && value.trim() ? value.trim() : undefined
48
- }
49
-
50
- export function resolveVoiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceConfig {
51
- const settings = readSettings(undefined, env)?.voice ?? {}
52
- return {
53
- wakeWord: nonEmpty(env.PRISM_VOICE_WAKE_WORD) ?? nonEmpty(settings.wakeWord) ?? "prism",
54
- sttCommand: nonEmpty(env.PRISM_VOICE_STT_COMMAND) ?? nonEmpty(settings.sttCommand),
55
- whisperModel: nonEmpty(env.PRISM_VOICE_WHISPER_MODEL) ?? nonEmpty(settings.whisperModel),
56
- ttsCommand: nonEmpty(env.PRISM_VOICE_TTS_COMMAND) ?? nonEmpty(settings.ttsCommand),
57
- piperModel: nonEmpty(env.PRISM_VOICE_PIPER_MODEL) ?? nonEmpty(settings.piperModel),
58
- audioPlayer: nonEmpty(env.PRISM_VOICE_AUDIO_PLAYER) ?? nonEmpty(settings.audioPlayer),
59
- loopCooldownMs: Math.max(0, Number(env.PRISM_VOICE_LOOP_COOLDOWN_MS ?? settings.loopCooldownMs ?? 1500) || 1500),
60
- }
61
- }
62
-
63
- export function stripWakeWord(transcript: string, wakeWord: string): string | undefined {
64
- const text = transcript.replace(/\s+/g, " ").trim()
65
- if (!text) return undefined
66
- const escaped = wakeWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
67
- const match = text.match(new RegExp(`(?:^|\\b)(?:hey\\s+|ok\\s+|okay\\s+)?${escaped}\\b[:,;\\-]?\\s*(.*)$`, "iu"))
68
- const command = match?.[1]?.trim()
69
- return command || undefined
70
- }
71
-
72
- export function normalizeVoiceText(text: string): string {
73
- return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").replace(/\s+/g, " ").trim()
74
- }
75
-
76
- export function shouldIgnoreTranscript(transcript: string, state: { isSpeaking: boolean; lastSpokenText?: string; lastSpokenAt?: number }, now = Date.now(), cooldownMs = 1500): boolean {
77
- if (state.isSpeaking) return true
78
- if (state.lastSpokenAt && now - state.lastSpokenAt < cooldownMs) return true
79
- const heard = normalizeVoiceText(transcript)
80
- const spoken = normalizeVoiceText(state.lastSpokenText ?? "")
81
- if (!heard || !spoken) return false
82
- return heard.length >= 12 && (spoken.includes(heard) || heard.includes(spoken.slice(0, Math.min(spoken.length, 160))))
83
- }
84
-
85
- function isThinkingTextBlock(block: any): boolean {
86
- if (!block || typeof block !== "object") return false
87
- const marker = String(block.type ?? block.kind ?? block.name ?? block.category ?? block.role ?? "").toLowerCase()
88
- if (/thinking|reasoning|scratchpad|analysis/.test(marker)) return true
89
- if (block.thinking || block.reasoning || block.isThinking || block.isReasoning) return true
90
- const metadata = block.metadata ?? block.providerMetadata ?? block.experimental_providerMetadata
91
- if (metadata && typeof metadata === "object") {
92
- const metadataMarker = JSON.stringify(metadata).toLowerCase()
93
- if (/thinking|reasoning|scratchpad|analysis/.test(metadataMarker)) return true
94
- }
95
- return false
96
- }
97
-
98
- function stripThinkingText(text: string): string {
99
- return text
100
- .replace(/<\s*(think|thinking|reasoning|analysis|scratchpad)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/giu, " ")
101
- .replace(/^\s*(?:#{1,6}\s*)?(?:thinking|reasoning|analysis|scratchpad|internal notes?)\s*:?\s*$/gimu, " ")
102
- }
103
-
104
- export function messageToSpeechText(message: any, maxChars = 4000): string {
105
- if (!message || message.role !== "assistant" || message.stopReason === "error") return ""
106
- const content = message.content
107
- if (Array.isArray(content) && content.some((block: any) => block?.type === "toolCall" || block?.type === "tool-call" || block?.toolCallId || block?.name && block?.arguments)) return ""
108
- const text = Array.isArray(content)
109
- ? content.filter((block: any) => block?.type === "text" && typeof block.text === "string" && !isThinkingTextBlock(block)).map((block: any) => block.text).join("\n")
110
- : typeof content === "string" ? content : ""
111
- return stripThinkingText(text)
112
- .replace(/```[\s\S]*?```/g, "Code block omitted.")
113
- .replace(/`([^`]+)`/g, "$1")
114
- .replace(/\s+/g, " ")
115
- .trim()
116
- .slice(0, maxChars)
117
- }
118
-
119
- function commandExists(command: string, spawnSyncProcess: typeof spawnSync): boolean {
120
- return spawnSyncProcess("sh", ["-lc", `command -v ${JSON.stringify(command)} >/dev/null 2>&1`], { stdio: "ignore" }).status === 0
121
- }
122
-
123
- function defaultSttCommand(config: VoiceConfig): string | undefined {
124
- if (!config.whisperModel) return undefined
125
- return `whisper-stream -m ${JSON.stringify(config.whisperModel)} --step 3000 --length 5000 --keep 500 -t 4`
126
- }
127
-
128
- function chooseAudioPlayer(config: VoiceConfig, spawnSyncProcess: typeof spawnSync): string | undefined {
129
- if (config.audioPlayer) return config.audioPlayer
130
- for (const candidate of ["paplay", "aplay", "ffplay"]) {
131
- if (commandExists(candidate, spawnSyncProcess)) return candidate === "ffplay" ? "ffplay -nodisp -autoexit" : candidate
132
- }
133
- return undefined
134
- }
135
-
136
- export class LocalVoiceRuntime {
137
- private mode: VoiceMode = "off"
138
- private sttProcess?: ChildProcess
139
- private isSpeaking = false
140
- private lastSpokenText = ""
141
- private lastSpokenAt = 0
142
- private readonly env: NodeJS.ProcessEnv
143
- private readonly spawnProcess: typeof spawn
144
- private readonly spawnSyncProcess: typeof spawnSync
145
-
146
- constructor(private readonly options: VoiceRuntimeOptions) {
147
- this.env = options.env ?? process.env
148
- this.spawnProcess = options.spawnProcess ?? spawn
149
- this.spawnSyncProcess = options.spawnSyncProcess ?? spawnSync
150
- }
151
-
152
- getMode(): VoiceMode { return this.mode }
153
-
154
- setMode(mode: VoiceMode): void {
155
- this.mode = mode
156
- this.options.setStatus?.(voiceStatus(mode))
157
- if (mode === "on" || mode === "input") this.startInput()
158
- else {
159
- this.stopInput()
160
- if (mode === "off") void this.stopVoicebox()
161
- }
162
- }
163
-
164
- shutdown(): void {
165
- this.stopInput()
166
- void this.stopVoicebox()
167
- this.options.setStatus?.(undefined)
168
- }
169
-
170
- async speakAssistantMessage(message: any): Promise<void> {
171
- if (this.mode !== "on" && this.mode !== "output") return
172
- const text = messageToSpeechText(message)
173
- if (!text) return
174
- await this.speak(text)
175
- }
176
-
177
- isVoiceboxEnabled(): boolean {
178
- return isVoiceboxBackend(this.env)
179
- }
180
-
181
- async checkVoicebox(): Promise<boolean> {
182
- if (!this.isVoiceboxEnabled()) return false
183
- const { checkVoiceboxHealth } = await import("./voicebox-client.js")
184
- return checkVoiceboxHealth(resolveVoiceboxConfig(this.env))
185
- }
186
-
187
- async ensureVoicebox(): Promise<boolean> {
188
- if (!this.isVoiceboxEnabled()) return false
189
- return ensureVoiceboxServiceRunning(this.env, this.spawnSyncProcess, this.options.notify)
190
- }
191
-
192
- async stopVoicebox(): Promise<boolean> {
193
- if (!this.isVoiceboxEnabled()) return false
194
- return stopVoiceboxService(this.env, this.spawnSyncProcess)
195
- }
196
-
197
- handleTranscript(rawTranscript: string): void {
198
- if (this.mode !== "on" && this.mode !== "input") return
199
- const config = resolveVoiceConfig(this.env)
200
- if (shouldIgnoreTranscript(rawTranscript, { isSpeaking: this.isSpeaking, lastSpokenText: this.lastSpokenText, lastSpokenAt: this.lastSpokenAt }, Date.now(), config.loopCooldownMs)) return
201
- const command = stripWakeWord(rawTranscript, config.wakeWord)
202
- if (!command) return
203
- void this.options.sendUserMessage(command)
204
- }
205
-
206
- private startInput(): void {
207
- if (this.sttProcess) return
208
- const config = resolveVoiceConfig(this.env)
209
- const command = config.sttCommand ?? defaultSttCommand(config)
210
- if (!command) {
211
- this.options.notify?.("Voice input needs PRISM_VOICE_STT_COMMAND or PRISM_VOICE_WHISPER_MODEL (whisper.cpp whisper-stream).", "warning")
212
- return
213
- }
214
-
215
- const child = this.spawnProcess(command, { shell: true, stdio: ["ignore", "pipe", "pipe"], env: this.env })
216
- this.sttProcess = child
217
- child.stdout?.setEncoding("utf-8")
218
- child.stdout?.on("data", (chunk: string) => {
219
- for (const line of String(chunk).split(/\r?\n/)) this.handleTranscript(line)
220
- })
221
- child.stderr?.setEncoding("utf-8")
222
- child.stderr?.on("data", (chunk: string) => {
223
- const line = String(chunk).split(/\r?\n/).find((value) => value.trim())
224
- if (line && /error|failed|no such|cannot/i.test(line)) this.options.notify?.(`Voice input: ${line.trim()}`, "warning")
225
- })
226
- child.on("exit", () => {
227
- if (this.sttProcess === child) this.sttProcess = undefined
228
- if (this.mode === "on" || this.mode === "input") this.options.notify?.("Voice input stopped.", "warning")
229
- })
230
- this.options.notify?.(`Voice input listening for wake word “${config.wakeWord}”.`, "info")
231
- }
232
-
233
- private stopInput(): void {
234
- const child = this.sttProcess
235
- this.sttProcess = undefined
236
- if (!child || child.killed) return
237
- child.kill("SIGTERM")
238
- setTimeout(() => { if (!child.killed) child.kill("SIGKILL") }, 1000).unref?.()
239
- }
240
-
241
- private async speak(text: string): Promise<void> {
242
- const config = resolveVoiceConfig(this.env)
243
- const command = config.ttsCommand
244
- this.isSpeaking = true
245
- this.lastSpokenText = text
246
- try {
247
- if (isVoiceboxBackend(this.env)) {
248
- const ok = await this.ensureVoicebox()
249
- if (!ok) {
250
- this.options.notify?.("Voicebox is not reachable and could not be started.", "warning")
251
- return
252
- }
253
- const player = chooseAudioPlayer(config, this.spawnSyncProcess)
254
- if (!player) {
255
- this.options.notify?.("Voicebox output needs an audio player: paplay, aplay, ffplay, or PRISM_VOICE_AUDIO_PLAYER.", "warning")
256
- return
257
- }
258
- await this.streamVoiceboxToPlayer(text, player)
259
- return
260
- }
261
- if (command) {
262
- await this.runShell(command, text)
263
- return
264
- }
265
- if (!config.piperModel) {
266
- this.options.notify?.("Voice output needs PRISM_VOICE_TTS_COMMAND or PRISM_VOICE_PIPER_MODEL (Piper TTS).", "warning")
267
- return
268
- }
269
- const player = chooseAudioPlayer(config, this.spawnSyncProcess)
270
- if (!player) {
271
- this.options.notify?.("Voice output needs an audio player: paplay, aplay, ffplay, or PRISM_VOICE_AUDIO_PLAYER.", "warning")
272
- return
273
- }
274
- const wav = path.join(os.tmpdir(), `prism-voice-${process.pid}-${Date.now()}.wav`)
275
- await this.runCommand("piper", ["--model", config.piperModel, "--output_file", wav], text)
276
- await this.runShell(`${player} ${JSON.stringify(wav)}`, "")
277
- fs.rmSync(wav, { force: true })
278
- } finally {
279
- this.lastSpokenAt = Date.now()
280
- this.isSpeaking = false
281
- }
282
- }
283
-
284
- private streamVoiceboxToPlayer(text: string, player: string): Promise<void> {
285
- return new Promise((resolve) => {
286
- const child = this.spawnProcess(player, { shell: true, stdio: ["pipe", "ignore", "pipe"], env: this.env })
287
- let settled = false
288
- const finish = () => {
289
- if (settled) return
290
- settled = true
291
- resolve()
292
- }
293
- child.on("exit", finish)
294
- child.on("error", finish)
295
- streamSpeechWithVoicebox(text, (chunk) => {
296
- if (!child.stdin?.writable) return
297
- child.stdin.write(Buffer.from(chunk))
298
- }, resolveVoiceboxConfig(this.env)).then(() => child.stdin?.end()).catch(() => {
299
- child.stdin?.destroy()
300
- finish()
301
- })
302
- })
303
- }
304
-
305
- private runShell(command: string, input: string): Promise<void> {
306
- return new Promise((resolve) => {
307
- const child = this.spawnProcess(command, { shell: true, stdio: ["pipe", "ignore", "pipe"], env: this.env })
308
- child.stdin?.end(input)
309
- child.on("exit", () => resolve())
310
- child.on("error", () => resolve())
311
- })
312
- }
313
-
314
- private runCommand(command: string, args: string[], input: string): Promise<void> {
315
- return new Promise((resolve) => {
316
- const child = this.spawnProcess(command, args, { stdio: ["pipe", "ignore", "pipe"], env: this.env })
317
- child.stdin?.end(input)
318
- child.on("exit", () => resolve())
319
- child.on("error", () => resolve())
320
- })
321
- }
322
- }
@@ -1,205 +0,0 @@
1
- import { readSettings } from "./shared-config.js"
2
-
3
- export interface VoiceboxConfig {
4
- baseUrl: string
5
- profile?: string
6
- language: string
7
- engine?: string
8
- personality?: boolean
9
- clientId: string
10
- timeoutMs: number
11
- generationTimeoutMs: number
12
- }
13
-
14
- export interface VoiceboxGenerationStatus {
15
- id: string
16
- status: string
17
- duration?: number
18
- error?: string | null
19
- source?: string
20
- }
21
-
22
- function nonEmpty(value: unknown): string | undefined {
23
- return typeof value === "string" && value.trim() ? value.trim() : undefined
24
- }
25
-
26
- function boolSetting(value: unknown): boolean | undefined {
27
- if (typeof value === "boolean") return value
28
- if (typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim())) return true
29
- if (typeof value === "string" && /^(0|false|no|off)$/i.test(value.trim())) return false
30
- return undefined
31
- }
32
-
33
- function numberSetting(value: unknown): number | undefined {
34
- if (typeof value === "number" && Number.isFinite(value)) return value
35
- if (typeof value === "string" && value.trim()) {
36
- const parsed = Number(value)
37
- if (Number.isFinite(parsed)) return parsed
38
- }
39
- return undefined
40
- }
41
-
42
- function getVoiceboxSettings(env: NodeJS.ProcessEnv): any {
43
- const settings = readSettings(undefined, env)
44
- return settings?.voicebox ?? settings?.voice?.voicebox ?? {}
45
- }
46
-
47
- function getVoiceSettings(env: NodeJS.ProcessEnv): any {
48
- return readSettings(undefined, env)?.voice ?? {}
49
- }
50
-
51
- export function normalizeVoiceboxBaseUrl(value: string): string {
52
- return value.replace(/\/+$/, "")
53
- }
54
-
55
- export function resolveVoiceboxConfig(env: NodeJS.ProcessEnv = process.env): VoiceboxConfig {
56
- const voicebox = getVoiceboxSettings(env)
57
- return {
58
- baseUrl: normalizeVoiceboxBaseUrl(nonEmpty(env.PRISM_VOICEBOX_URL) ?? nonEmpty(voicebox.url) ?? nonEmpty(voicebox.baseUrl) ?? "http://127.0.0.1:17493"),
59
- profile: nonEmpty(env.PRISM_VOICEBOX_PROFILE) ?? nonEmpty(voicebox.profile),
60
- language: nonEmpty(env.PRISM_VOICEBOX_LANGUAGE) ?? nonEmpty(voicebox.language) ?? "en",
61
- engine: nonEmpty(env.PRISM_VOICEBOX_ENGINE) ?? nonEmpty(voicebox.engine),
62
- personality: boolSetting(env.PRISM_VOICEBOX_PERSONALITY) ?? boolSetting(voicebox.personality) ?? false,
63
- clientId: nonEmpty(env.PRISM_VOICEBOX_CLIENT_ID) ?? nonEmpty(voicebox.clientId) ?? "prism",
64
- timeoutMs: Math.max(1_000, numberSetting(env.PRISM_VOICEBOX_TIMEOUT_MS) ?? numberSetting(voicebox.timeoutMs) ?? 30_000),
65
- generationTimeoutMs: Math.max(1_000, numberSetting(env.PRISM_VOICEBOX_GENERATION_TIMEOUT_MS) ?? numberSetting(voicebox.generationTimeoutMs) ?? 180_000),
66
- }
67
- }
68
-
69
- export function resolveVoiceBackend(env: NodeJS.ProcessEnv = process.env): string | undefined {
70
- return nonEmpty(env.PRISM_VOICE_BACKEND) ?? nonEmpty(getVoiceSettings(env).backend)
71
- }
72
-
73
- export function isVoiceboxBackend(env: NodeJS.ProcessEnv = process.env): boolean {
74
- return /^(voicebox|auto)$/i.test(resolveVoiceBackend(env) ?? "") || !!nonEmpty(env.PRISM_VOICEBOX_URL) || !!nonEmpty(getVoiceboxSettings(env).url) || !!nonEmpty(getVoiceboxSettings(env).baseUrl)
75
- }
76
-
77
- async function fetchJson(url: string, init: RequestInit, timeoutMs: number): Promise<any> {
78
- const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
79
- const text = await response.text()
80
- const parsed = parseJsonOrText(text)
81
- if (!response.ok) {
82
- const detail = typeof parsed?.detail === "string" ? parsed.detail : typeof parsed === "string" ? parsed : `HTTP ${response.status}`
83
- throw new Error(detail)
84
- }
85
- return parsed
86
- }
87
-
88
- async function fetchBuffer(url: string, init: RequestInit, timeoutMs: number): Promise<Buffer> {
89
- const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
90
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
91
- return Buffer.from(await response.arrayBuffer())
92
- }
93
-
94
- async function fetchStream(url: string, init: RequestInit, timeoutMs: number, onChunk: (chunk: Uint8Array) => void | Promise<void>): Promise<void> {
95
- const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
96
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
97
- if (!response.body) return
98
- const reader = response.body.getReader()
99
- try {
100
- while (true) {
101
- const { done, value } = await reader.read()
102
- if (done) break
103
- if (value?.byteLength) await onChunk(value)
104
- }
105
- } finally {
106
- reader.releaseLock()
107
- }
108
- }
109
-
110
- function parseJsonOrText(text: string): any {
111
- try { return text ? JSON.parse(text) : undefined } catch { return text }
112
- }
113
-
114
- function parseVoiceboxStatusEvent(text: string): VoiceboxGenerationStatus | undefined {
115
- for (const line of text.split(/\r?\n/)) {
116
- if (!line.startsWith("data:")) continue
117
- const parsed = parseJsonOrText(line.replace(/^data:\s*/, ""))
118
- if (parsed && typeof parsed === "object" && typeof parsed.status === "string") return parsed
119
- }
120
- const parsed = parseJsonOrText(text)
121
- if (parsed && typeof parsed === "object" && typeof parsed.status === "string") return parsed
122
- return undefined
123
- }
124
-
125
- export async function checkVoiceboxHealth(config = resolveVoiceboxConfig()): Promise<boolean> {
126
- try {
127
- await fetchJson(`${config.baseUrl}/health`, { method: "GET" }, Math.min(config.timeoutMs, 5_000))
128
- return true
129
- } catch {
130
- return false
131
- }
132
- }
133
-
134
- export async function speakWithVoicebox(text: string, config = resolveVoiceboxConfig()): Promise<any> {
135
- return fetchJson(`${config.baseUrl}/speak`, {
136
- method: "POST",
137
- headers: {
138
- "Content-Type": "application/json",
139
- "X-Voicebox-Client-Id": config.clientId,
140
- },
141
- body: JSON.stringify({
142
- text,
143
- profile: config.profile,
144
- language: config.language,
145
- engine: config.engine,
146
- personality: config.personality,
147
- }),
148
- }, config.timeoutMs)
149
- }
150
-
151
- function isAbortLikeError(error: unknown): boolean {
152
- return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")
153
- }
154
-
155
- export async function waitForVoiceboxGeneration(id: string, config = resolveVoiceboxConfig()): Promise<VoiceboxGenerationStatus> {
156
- const deadline = Date.now() + config.generationTimeoutMs
157
- let lastStatus: VoiceboxGenerationStatus | undefined
158
- while (Date.now() < deadline) {
159
- try {
160
- const response = await fetch(`${config.baseUrl}/generate/${encodeURIComponent(id)}/status`, { method: "GET", signal: AbortSignal.timeout(Math.min(config.timeoutMs, 10_000)) })
161
- const text = await response.text()
162
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
163
- lastStatus = parseVoiceboxStatusEvent(text) ?? lastStatus
164
- if (lastStatus?.status === "completed") return lastStatus
165
- if (lastStatus?.status === "failed") throw new Error(lastStatus.error || "Voicebox generation failed")
166
- } catch (error) {
167
- if (!isAbortLikeError(error)) throw error
168
- // Voicebox can block status responses while lazily loading a TTS model.
169
- // Treat individual poll timeouts as transient; the overall generation
170
- // timeout below remains the hard limit.
171
- }
172
- await new Promise((resolve) => setTimeout(resolve, 500))
173
- }
174
- throw new Error(`Voicebox generation timed out${lastStatus?.status ? ` (${lastStatus.status})` : ""}`)
175
- }
176
-
177
- export async function downloadVoiceboxAudio(id: string, config = resolveVoiceboxConfig()): Promise<Buffer> {
178
- return fetchBuffer(`${config.baseUrl}/audio/${encodeURIComponent(id)}`, { method: "GET" }, config.timeoutMs)
179
- }
180
-
181
- export async function synthesizeSpeechWithVoicebox(text: string, config = resolveVoiceboxConfig()): Promise<Buffer> {
182
- const generation = await speakWithVoicebox(text, config)
183
- const id = String(generation?.id ?? "")
184
- if (!id) throw new Error("Voicebox did not return a generation id")
185
- await waitForVoiceboxGeneration(id, config)
186
- return downloadVoiceboxAudio(id, config)
187
- }
188
-
189
- export async function streamSpeechWithVoicebox(text: string, onChunk: (chunk: Uint8Array) => void | Promise<void>, config = resolveVoiceboxConfig()): Promise<void> {
190
- const generation = await speakWithVoicebox(text, config)
191
- const id = String(generation?.id ?? "")
192
- if (!id) throw new Error("Voicebox did not return a generation id")
193
- await waitForVoiceboxGeneration(id, config)
194
- await fetchStream(`${config.baseUrl}/audio/${encodeURIComponent(id)}`, { method: "GET" }, config.timeoutMs, onChunk)
195
- }
196
-
197
- export async function transcribeWithVoicebox(audio: Blob | Buffer | Uint8Array, options: { fileName?: string; language?: string; model?: string } = {}, config = resolveVoiceboxConfig()): Promise<string> {
198
- const form = new FormData()
199
- const file = audio instanceof Blob ? audio : new Blob([new Uint8Array(audio)])
200
- form.append("file", file, options.fileName ?? "audio.wav")
201
- if (options.language) form.append("language", options.language)
202
- if (options.model) form.append("model", options.model)
203
- const result = await fetchJson(`${config.baseUrl}/transcribe`, { method: "POST", body: form }, config.timeoutMs)
204
- return String(result?.text ?? "")
205
- }
@@ -1,137 +0,0 @@
1
- import * as fs from "node:fs"
2
- import * as os from "node:os"
3
- import * as path from "node:path"
4
- import { spawnSync, type SpawnSyncReturns } from "node:child_process"
5
- import { readSettings } from "./shared-config.js"
6
- import { checkVoiceboxHealth, resolveVoiceboxConfig } from "./voicebox-client.js"
7
-
8
- const VOICEBOX_REPO_URL = "https://github.com/jamiepine/voicebox.git"
9
-
10
- export interface VoiceboxServiceConfig {
11
- autoInstall: boolean
12
- autoStart: boolean
13
- autoStop: boolean
14
- installDir: string
15
- installCommand: string
16
- startCommand: string
17
- stopCommand: string
18
- startupTimeoutMs: number
19
- installTimeoutMs: number
20
- stopTimeoutMs: number
21
- }
22
-
23
- function nonEmpty(value: unknown): string | undefined {
24
- return typeof value === "string" && value.trim() ? value.trim() : undefined
25
- }
26
-
27
- function boolSetting(value: unknown): boolean | undefined {
28
- if (typeof value === "boolean") return value
29
- if (typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim())) return true
30
- if (typeof value === "string" && /^(0|false|no|off)$/i.test(value.trim())) return false
31
- return undefined
32
- }
33
-
34
- function numberSetting(value: unknown): number | undefined {
35
- if (typeof value === "number" && Number.isFinite(value)) return value
36
- if (typeof value === "string" && value.trim()) {
37
- const parsed = Number(value)
38
- if (Number.isFinite(parsed)) return parsed
39
- }
40
- return undefined
41
- }
42
-
43
- function quote(value: string): string {
44
- return JSON.stringify(value)
45
- }
46
-
47
- function defaultInstallDir(env: NodeJS.ProcessEnv): string {
48
- return nonEmpty(env.PRISM_VOICEBOX_DIR) ?? path.join(os.homedir(), ".local", "share", "prism", "voicebox")
49
- }
50
-
51
- function defaultInstallCommand(installDir: string): string {
52
- return `mkdir -p ${quote(path.dirname(installDir))} && git clone ${quote(VOICEBOX_REPO_URL)} ${quote(installDir)}`
53
- }
54
-
55
- function defaultStartCommand(installDir: string): string {
56
- return `if command -v prism-voicebox >/dev/null 2>&1; then prism-voicebox start; else cd ${quote(installDir)} && docker compose up -d --build; fi`
57
- }
58
-
59
- function defaultStopCommand(installDir: string): string {
60
- return `if command -v prism-voicebox >/dev/null 2>&1; then prism-voicebox stop; else cd ${quote(installDir)} && docker compose down; fi`
61
- }
62
-
63
- export function resolveVoiceboxServiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceboxServiceConfig {
64
- const settings = readSettings(undefined, env)?.voicebox ?? {}
65
- const installDir = nonEmpty(env.PRISM_VOICEBOX_DIR) ?? nonEmpty(settings.installDir) ?? defaultInstallDir(env)
66
- return {
67
- autoInstall: boolSetting(env.PRISM_VOICEBOX_AUTO_INSTALL) ?? boolSetting(settings.autoInstall) ?? true,
68
- autoStart: boolSetting(env.PRISM_VOICEBOX_AUTO_START) ?? boolSetting(settings.autoStart) ?? true,
69
- autoStop: boolSetting(env.PRISM_VOICEBOX_AUTO_STOP) ?? boolSetting(settings.autoStop) ?? true,
70
- installDir,
71
- installCommand: nonEmpty(env.PRISM_VOICEBOX_INSTALL_COMMAND) ?? nonEmpty(settings.installCommand) ?? defaultInstallCommand(installDir),
72
- startCommand: nonEmpty(env.PRISM_VOICEBOX_START_COMMAND) ?? nonEmpty(settings.startCommand) ?? defaultStartCommand(installDir),
73
- stopCommand: nonEmpty(env.PRISM_VOICEBOX_STOP_COMMAND) ?? nonEmpty(settings.stopCommand) ?? defaultStopCommand(installDir),
74
- startupTimeoutMs: Math.max(1_000, numberSetting(env.PRISM_VOICEBOX_STARTUP_TIMEOUT_MS) ?? numberSetting(settings.startupTimeoutMs) ?? 120_000),
75
- installTimeoutMs: Math.max(1_000, numberSetting(env.PRISM_VOICEBOX_INSTALL_TIMEOUT_MS) ?? numberSetting(settings.installTimeoutMs) ?? 300_000),
76
- stopTimeoutMs: Math.max(1_000, numberSetting(env.PRISM_VOICEBOX_STOP_TIMEOUT_MS) ?? numberSetting(settings.stopTimeoutMs) ?? 60_000),
77
- }
78
- }
79
-
80
- async function sleep(ms: number): Promise<void> {
81
- await new Promise((resolve) => setTimeout(resolve, ms))
82
- }
83
-
84
- function runShell(command: string, timeoutMs: number, env: NodeJS.ProcessEnv, spawnSyncProcess: typeof spawnSync): boolean {
85
- const result: SpawnSyncReturns<Buffer> = spawnSyncProcess("sh", ["-lc", command], { env, stdio: "ignore", timeout: timeoutMs })
86
- return !result.error && (result.status === 0 || result.status === null)
87
- }
88
-
89
- export function isVoiceboxServiceInstalled(env: NodeJS.ProcessEnv = process.env): boolean {
90
- const service = resolveVoiceboxServiceConfig(env)
91
- return fs.existsSync(path.join(service.installDir, "docker-compose.yml")) || fs.existsSync(path.join(service.installDir, "compose.yml"))
92
- }
93
-
94
- export async function installVoiceboxService(env: NodeJS.ProcessEnv = process.env, spawnSyncProcess: typeof spawnSync = spawnSync): Promise<boolean> {
95
- const service = resolveVoiceboxServiceConfig(env)
96
- if (isVoiceboxServiceInstalled(env)) return true
97
- if (!service.autoInstall) return false
98
- return runShell(service.installCommand, service.installTimeoutMs, env, spawnSyncProcess) && isVoiceboxServiceInstalled(env)
99
- }
100
-
101
- export async function startVoiceboxService(env: NodeJS.ProcessEnv = process.env, spawnSyncProcess: typeof spawnSync = spawnSync): Promise<boolean> {
102
- const service = resolveVoiceboxServiceConfig(env)
103
- if (!service.autoStart) return false
104
- if (!isVoiceboxServiceInstalled(env) && !(await installVoiceboxService(env, spawnSyncProcess))) return false
105
- return runShell(service.startCommand, service.startupTimeoutMs, env, spawnSyncProcess)
106
- }
107
-
108
- export async function stopVoiceboxService(env: NodeJS.ProcessEnv = process.env, spawnSyncProcess: typeof spawnSync = spawnSync): Promise<boolean> {
109
- const service = resolveVoiceboxServiceConfig(env)
110
- if (!service.autoStop) return false
111
- if (!isVoiceboxServiceInstalled(env)) return false
112
- return runShell(service.stopCommand, service.stopTimeoutMs, env, spawnSyncProcess)
113
- }
114
-
115
- export async function ensureVoiceboxServiceRunning(env: NodeJS.ProcessEnv = process.env, spawnSyncProcess: typeof spawnSync = spawnSync, onStatus?: (message: string, level?: string) => void): Promise<boolean> {
116
- const config = resolveVoiceboxConfig(env)
117
- if (await checkVoiceboxHealth(config)) return true
118
- const service = resolveVoiceboxServiceConfig(env)
119
-
120
- if (!isVoiceboxServiceInstalled(env)) {
121
- if (!service.autoInstall) return false
122
- onStatus?.(`Voicebox is not installed. Installing to ${service.installDir}…`, "info")
123
- if (!(await installVoiceboxService(env, spawnSyncProcess))) return false
124
- }
125
-
126
- if (!service.autoStart) return false
127
- onStatus?.("Voicebox is not running. Starting local Voicebox container…", "info")
128
- const started = await startVoiceboxService(env, spawnSyncProcess)
129
- if (!started) return false
130
-
131
- const deadline = Date.now() + service.startupTimeoutMs
132
- while (Date.now() < deadline) {
133
- if (await checkVoiceboxHealth(config)) return true
134
- await sleep(1_000)
135
- }
136
- return false
137
- }