@daniel156161/prism 0.2.92 → 0.2.96

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 (59) hide show
  1. package/README.md +0 -50
  2. package/dist/pi/pi-context-files.d.ts +6 -0
  3. package/dist/pi/pi-context-files.js +17 -0
  4. package/dist/pi/pi-context-files.js.map +1 -0
  5. package/dist/pi/pi-extensions.d.ts +0 -1
  6. package/dist/pi/pi-extensions.js +7 -22
  7. package/dist/pi/pi-extensions.js.map +1 -1
  8. package/dist/pi/pi-npm-extension-entry.d.ts +2 -0
  9. package/dist/pi/pi-npm-extension-entry.js +74 -0
  10. package/dist/pi/pi-npm-extension-entry.js.map +1 -0
  11. package/dist/pi/pi-package.js +4 -0
  12. package/dist/pi/pi-package.js.map +1 -1
  13. package/dist/pi/pi-patch-context-files.d.ts +12 -0
  14. package/dist/pi/pi-patch-context-files.js +46 -0
  15. package/dist/pi/pi-patch-context-files.js.map +1 -0
  16. package/dist/pi/pi-patch-interactive.js +5 -1
  17. package/dist/pi/pi-patch-interactive.js.map +1 -1
  18. package/dist/pi/plan-mode-tools.d.ts +13 -0
  19. package/dist/pi/plan-mode-tools.js +54 -0
  20. package/dist/pi/plan-mode-tools.js.map +1 -0
  21. package/dist/prism-extensions/core/mex-anchor.d.ts +9 -0
  22. package/dist/prism-extensions/core/mex-anchor.js +29 -0
  23. package/dist/prism-extensions/core/mex-anchor.js.map +1 -0
  24. package/dist/prism-extensions/core/mex-cli.d.ts +68 -0
  25. package/dist/prism-extensions/core/mex-cli.js +183 -0
  26. package/dist/prism-extensions/core/mex-cli.js.map +1 -0
  27. package/dist/prism-extensions/core/mex-graph-db.d.ts +26 -0
  28. package/dist/prism-extensions/core/mex-graph-db.js +69 -0
  29. package/dist/prism-extensions/core/mex-graph-db.js.map +1 -0
  30. package/dist/prism-extensions/integrations/ai-memory-system.js +27 -6
  31. package/dist/prism-extensions/integrations/ai-memory-system.js.map +1 -1
  32. package/dist/prism-extensions/integrations/mex-memory.d.ts +21 -0
  33. package/dist/prism-extensions/integrations/mex-memory.js +231 -0
  34. package/dist/prism-extensions/integrations/mex-memory.js.map +1 -0
  35. package/dist/prism-extensions/tools/builtin-tools.js +2 -0
  36. package/dist/prism-extensions/tools/builtin-tools.js.map +1 -1
  37. package/dist/prism-extensions/tools/mex-tools.d.ts +2 -0
  38. package/dist/prism-extensions/tools/mex-tools.js +78 -0
  39. package/dist/prism-extensions/tools/mex-tools.js.map +1 -0
  40. package/dist/prism-extensions/ui/toolbar.d.ts +2 -0
  41. package/dist/prism-extensions/ui/toolbar.js +10 -1
  42. package/dist/prism-extensions/ui/toolbar.js.map +1 -1
  43. package/node_modules/@earendil-works/pi-coding-agent/dist/core/resource-loader.js +15 -0
  44. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js +6 -6
  45. package/package.json +5 -3
  46. package/src/prism-extensions/README.md +10 -1
  47. package/src/prism-extensions/core/mex-anchor.ts +29 -0
  48. package/src/prism-extensions/core/mex-cli.ts +217 -0
  49. package/src/prism-extensions/core/mex-graph-db.ts +76 -0
  50. package/src/prism-extensions/integrations/ai-memory-system.ts +30 -6
  51. package/src/prism-extensions/integrations/mex-memory.ts +264 -0
  52. package/src/prism-extensions/tools/builtin-tools.ts +2 -0
  53. package/src/prism-extensions/tools/mex-tools.ts +93 -0
  54. package/src/prism-extensions/ui/toolbar.ts +10 -1
  55. package/src/prism-extensions/commands/voice-command.ts +0 -49
  56. package/src/prism-extensions/core/voice-runtime.ts +0 -322
  57. package/src/prism-extensions/core/voicebox-client.ts +0 -205
  58. package/src/prism-extensions/core/voicebox-service.ts +0 -137
  59. package/src/prism-extensions/integrations/honcho-memory.ts +0 -299
@@ -0,0 +1,93 @@
1
+ import { Type } from "typebox"
2
+ import {
3
+ buildCheckArgs,
4
+ buildGraphArgs,
5
+ buildTimelineArgs,
6
+ hasMexScaffold,
7
+ isMexCliInstalled,
8
+ MEX_LOG_TYPES,
9
+ runMex,
10
+ type MexRunOptions,
11
+ } from "../core/mex-cli.js"
12
+ import type { ToolboxToolDefinition } from "./toolbox.js"
13
+ import { textToolResult, timeoutMs, type ToolInput } from "./toolbox-utils.js"
14
+
15
+ const MEX_TAGS = ["mex", "codegraph", "graph", "wiki", "scaffold", "drift", "documentation", "memory"]
16
+
17
+ function runOptions(params: ToolInput, fallbackTimeout: number): MexRunOptions {
18
+ return {
19
+ cwd: typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : process.cwd(),
20
+ timeoutMs: timeoutMs(params.timeoutMs, fallbackTimeout),
21
+ }
22
+ }
23
+
24
+ function guardScaffold(cwd: string): void {
25
+ if (!isMexCliInstalled()) throw new Error("mex CLI not found. Install it with: npm install -g mex-agent")
26
+ if (!hasMexScaffold(cwd)) throw new Error(`No .mex scaffold in ${cwd}. Run 'npx mex-agent setup' first.`)
27
+ }
28
+
29
+ async function execute(action: string, args: string[], options: MexRunOptions) {
30
+ guardScaffold(options.cwd ?? process.cwd())
31
+ const result = await runMex(args, options)
32
+ return textToolResult(result.output || `mex ${action} produced no output.`, { action, ok: result.ok, exitCode: result.exitCode, cwd: options.cwd })
33
+ }
34
+
35
+ function createMexGraphBuildTool(): ToolboxToolDefinition {
36
+ return {
37
+ name: "mex_graph_build",
38
+ label: "Mex Graph Build",
39
+ description: "Rebuild the local mex code knowledge graph (.mex/graph.db) so scope/query/impact results are current.",
40
+ tags: [...MEX_TAGS, "build", "index", "rebuild"],
41
+ parameters: Type.Object({
42
+ cwd: Type.Optional(Type.String({ description: "Repository root. Defaults to the current working directory." })),
43
+ json: Type.Optional(Type.Boolean({ description: "Return the build summary as JSON." })),
44
+ timeoutMs: Type.Optional(Type.Number({ description: "Command timeout in milliseconds. Default 120000." })),
45
+ }),
46
+ async execute(_id: string, params: ToolInput) {
47
+ const options = runOptions(params, 120_000)
48
+ return execute("graph", buildGraphArgs({ json: params.json, root: params.cwd }), options)
49
+ },
50
+ }
51
+ }
52
+
53
+ function createMexCheckTool(): ToolboxToolDefinition {
54
+ return {
55
+ name: "mex_check",
56
+ label: "Mex Check",
57
+ description: "Run mex drift detection between the .mex wiki and the codebase (paths, commands, dependencies, links, grounded symbols).",
58
+ tags: [...MEX_TAGS, "check", "health", "stale", "validate"],
59
+ parameters: Type.Object({
60
+ cwd: Type.Optional(Type.String({ description: "Repository root. Defaults to the current working directory." })),
61
+ json: Type.Optional(Type.Boolean({ description: "Return the full drift report as JSON." })),
62
+ quiet: Type.Optional(Type.Boolean({ description: "Single-line summary only." })),
63
+ timeoutMs: Type.Optional(Type.Number({ description: "Command timeout in milliseconds. Default 120000." })),
64
+ }),
65
+ async execute(_id: string, params: ToolInput) {
66
+ return execute("check", buildCheckArgs({ json: params.json, quiet: params.quiet }), runOptions(params, 120_000))
67
+ },
68
+ }
69
+ }
70
+
71
+ function createMexTimelineTool(): ToolboxToolDefinition {
72
+ return {
73
+ name: "mex_timeline",
74
+ label: "Mex Timeline",
75
+ description: `Read recent entries from the mex event log (${MEX_LOG_TYPES.join("/")}) for repo history and past decisions.`,
76
+ tags: [...MEX_TAGS, "timeline", "events", "decisions", "history", "log"],
77
+ parameters: Type.Object({
78
+ cwd: Type.Optional(Type.String({ description: "Repository root. Defaults to the current working directory." })),
79
+ since: Type.Optional(Type.String({ description: "YYYY-MM-DD or relative like 30d." })),
80
+ type: Type.Optional(Type.String({ description: MEX_LOG_TYPES.join(" | ") })),
81
+ limit: Type.Optional(Type.Number({ description: "Maximum entries." })),
82
+ json: Type.Optional(Type.Boolean({ description: "Return events as JSON." })),
83
+ timeoutMs: Type.Optional(Type.Number({ description: "Command timeout in milliseconds. Default 30000." })),
84
+ }),
85
+ async execute(_id: string, params: ToolInput) {
86
+ return execute("timeline", buildTimelineArgs(params), runOptions(params, 30_000))
87
+ },
88
+ }
89
+ }
90
+
91
+ export function createMexToolboxTools(): ToolboxToolDefinition[] {
92
+ return [createMexGraphBuildTool(), createMexCheckTool(), createMexTimelineTool()]
93
+ }
@@ -72,6 +72,13 @@ export function toolbarAccent(theme: ThemeLike, text: string): string {
72
72
  return fg(theme, "accent", text)
73
73
  }
74
74
 
75
+ /** Role-play status is only worth a toolbar slot while a role-play is actually running. */
76
+ export function rpLabel(status: string | undefined): string | undefined {
77
+ const value = status?.trim()
78
+ if (!value || /^RP:\s*off$/i.test(value)) return undefined
79
+ return value
80
+ }
81
+
75
82
  export function cavemanColor(mode: string): number {
76
83
  const normalized = mode.toLowerCase()
77
84
  if (normalized === "lite") return 226
@@ -185,8 +192,10 @@ class ToolbarFooter {
185
192
  const obsidian = statuses?.get("obsidian-memory")
186
193
  const logseq = statuses?.get("logseq-memory")
187
194
  const aiMemory = statuses?.get("ai-memory")
195
+ const mex = statuses?.get("mex")
196
+ const rp = rpLabel(statuses?.get("rp"))
188
197
 
189
- return [joinDistributed([mode ? fg(this.theme, "success", mode) : undefined, model, fg(this.theme, "accent", `🔧 ${toolCalls}`), fg(this.theme, "dim", `↑${formatTokens(input)}`), fg(this.theme, "dim", `↓${formatTokens(output)}`), fg(this.theme, "success", `$${cost.toFixed(4)}`), context, caveman, obsidian, logseq, aiMemory ? `AI Memory: ${aiMemory}` : undefined, thinking].filter(Boolean) as string[], Math.max(0, width))]
198
+ return [joinDistributed([mode ? fg(this.theme, "success", mode) : undefined, model, fg(this.theme, "accent", `🔧 ${toolCalls}`), fg(this.theme, "dim", `↑${formatTokens(input)}`), fg(this.theme, "dim", `↓${formatTokens(output)}`), fg(this.theme, "success", `$${cost.toFixed(4)}`), context, caveman, obsidian, logseq, aiMemory ? `AI Memory: ${aiMemory}` : undefined, mex, rp, thinking].filter(Boolean) as string[], Math.max(0, width))]
190
199
  }
191
200
  }
192
201
 
@@ -1,49 +0,0 @@
1
- import { LocalVoiceRuntime, parseVoiceMode, voiceStatus, type VoiceMode } from "../core/voice-runtime.js"
2
-
3
- type ExtensionAPI = any
4
-
5
- export default function voiceCommandExtension(pi: ExtensionAPI): void {
6
- let runtime: LocalVoiceRuntime | undefined
7
- let mode: VoiceMode = "off"
8
-
9
- function ensureRuntime(ctx: any): LocalVoiceRuntime {
10
- runtime ??= new LocalVoiceRuntime({
11
- sendUserMessage: (message) => pi.sendUserMessage(message),
12
- notify: (message, level) => ctx?.ui?.notify?.(message, level),
13
- setStatus: (value) => ctx?.ui?.setStatus?.("voice", value),
14
- })
15
- return runtime
16
- }
17
-
18
- pi.registerCommand("voice", {
19
- description: "Local voice mode: /voice on | output | input | off",
20
- handler: async (args: string, ctx: any) => {
21
- const next = parseVoiceMode(args)
22
- if (!next) {
23
- ctx.ui.notify(`Voice mode: ${mode}. Use /voice on, /voice output, /voice input, or /voice off.`, "info")
24
- return
25
- }
26
- mode = next
27
- const voice = ensureRuntime(ctx)
28
- voice.setMode(mode)
29
- ctx.ui.setStatus("voice", voiceStatus(mode))
30
- if (mode === "output" || mode === "on") {
31
- const ok = await voice.ensureVoicebox()
32
- if (voice.isVoiceboxEnabled() && !ok) {
33
- ctx.ui.notify("Voicebox is not reachable and could not be started. Check voicebox settings/PRISM_VOICEBOX_URL.", "warning")
34
- }
35
- }
36
- ctx.ui.notify(mode === "off" ? "Voice off" : `Voice ${mode}`, "info")
37
- },
38
- })
39
-
40
- pi.on("session_start", (_event: any, ctx: any) => {
41
- if (mode !== "off") ensureRuntime(ctx).setMode(mode)
42
- })
43
-
44
- pi.on("message_end", async (event: any, ctx: any) => {
45
- if (mode === "on" || mode === "output") await ensureRuntime(ctx).speakAssistantMessage(event.message)
46
- })
47
-
48
- pi.on?.("session_shutdown", () => runtime?.shutdown())
49
- }
@@ -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
- }