@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.
- package/README.md +0 -50
- package/dist/pi/pi-context-files.d.ts +6 -0
- package/dist/pi/pi-context-files.js +17 -0
- package/dist/pi/pi-context-files.js.map +1 -0
- package/dist/pi/pi-extensions.d.ts +0 -1
- package/dist/pi/pi-extensions.js +7 -22
- package/dist/pi/pi-extensions.js.map +1 -1
- package/dist/pi/pi-npm-extension-entry.d.ts +2 -0
- package/dist/pi/pi-npm-extension-entry.js +74 -0
- package/dist/pi/pi-npm-extension-entry.js.map +1 -0
- package/dist/pi/pi-package.js +4 -0
- package/dist/pi/pi-package.js.map +1 -1
- package/dist/pi/pi-patch-context-files.d.ts +12 -0
- package/dist/pi/pi-patch-context-files.js +46 -0
- package/dist/pi/pi-patch-context-files.js.map +1 -0
- package/dist/pi/pi-patch-interactive.js +5 -1
- package/dist/pi/pi-patch-interactive.js.map +1 -1
- package/dist/pi/plan-mode-tools.d.ts +13 -0
- package/dist/pi/plan-mode-tools.js +54 -0
- package/dist/pi/plan-mode-tools.js.map +1 -0
- package/dist/prism-extensions/core/mex-anchor.d.ts +9 -0
- package/dist/prism-extensions/core/mex-anchor.js +29 -0
- package/dist/prism-extensions/core/mex-anchor.js.map +1 -0
- package/dist/prism-extensions/core/mex-cli.d.ts +68 -0
- package/dist/prism-extensions/core/mex-cli.js +183 -0
- package/dist/prism-extensions/core/mex-cli.js.map +1 -0
- package/dist/prism-extensions/core/mex-graph-db.d.ts +26 -0
- package/dist/prism-extensions/core/mex-graph-db.js +69 -0
- package/dist/prism-extensions/core/mex-graph-db.js.map +1 -0
- package/dist/prism-extensions/integrations/ai-memory-system.js +27 -6
- package/dist/prism-extensions/integrations/ai-memory-system.js.map +1 -1
- package/dist/prism-extensions/integrations/mex-memory.d.ts +21 -0
- package/dist/prism-extensions/integrations/mex-memory.js +231 -0
- package/dist/prism-extensions/integrations/mex-memory.js.map +1 -0
- package/dist/prism-extensions/tools/builtin-tools.js +2 -0
- package/dist/prism-extensions/tools/builtin-tools.js.map +1 -1
- package/dist/prism-extensions/tools/mex-tools.d.ts +2 -0
- package/dist/prism-extensions/tools/mex-tools.js +78 -0
- package/dist/prism-extensions/tools/mex-tools.js.map +1 -0
- package/dist/prism-extensions/ui/toolbar.d.ts +2 -0
- package/dist/prism-extensions/ui/toolbar.js +10 -1
- package/dist/prism-extensions/ui/toolbar.js.map +1 -1
- package/node_modules/@earendil-works/pi-coding-agent/dist/core/resource-loader.js +15 -0
- package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js +6 -6
- package/package.json +5 -3
- package/src/prism-extensions/README.md +10 -1
- package/src/prism-extensions/core/mex-anchor.ts +29 -0
- package/src/prism-extensions/core/mex-cli.ts +217 -0
- package/src/prism-extensions/core/mex-graph-db.ts +76 -0
- package/src/prism-extensions/integrations/ai-memory-system.ts +30 -6
- package/src/prism-extensions/integrations/mex-memory.ts +264 -0
- package/src/prism-extensions/tools/builtin-tools.ts +2 -0
- package/src/prism-extensions/tools/mex-tools.ts +93 -0
- package/src/prism-extensions/ui/toolbar.ts +10 -1
- package/src/prism-extensions/commands/voice-command.ts +0 -49
- package/src/prism-extensions/core/voice-runtime.ts +0 -322
- package/src/prism-extensions/core/voicebox-client.ts +0 -205
- package/src/prism-extensions/core/voicebox-service.ts +0 -137
- 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
|
-
}
|