Merge pull request #22 from boudra/local-streaming-speech-tts
voice: harden local voice agent path and UUID validation
This commit is contained in:
Generated
-12
@@ -4688,17 +4688,6 @@
|
|||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@openrouter/ai-sdk-provider": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ai": "^5.0.0",
|
|
||||||
"zod": "^3.24.1 || ^v4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@opentelemetry/api": {
|
"node_modules/@opentelemetry/api": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -20286,7 +20275,6 @@
|
|||||||
"@lezer/python": "^1.1.18",
|
"@lezer/python": "^1.1.18",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "^1.1.12",
|
"@opencode-ai/sdk": "^1.1.12",
|
||||||
"@openrouter/ai-sdk-provider": "^1.2.0",
|
|
||||||
"@sctg/sentencepiece-js": "^1.1.0",
|
"@sctg/sentencepiece-js": "^1.1.0",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "^5.0.76",
|
"ai": "^5.0.76",
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { createContext, useContext, useState, ReactNode, useCallback, useEffect,
|
|||||||
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
|
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
|
||||||
import type { SessionState } from "@/stores/session-store";
|
import type { SessionState } from "@/stores/session-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { randomUUID } from "expo-crypto";
|
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
|
|
||||||
const VOICE_CONVERSATION_ID_STORAGE_KEY = "@paseo:voice-conversation-id";
|
|
||||||
const KEEP_AWAKE_TAG = "paseo:voice";
|
const KEEP_AWAKE_TAG = "paseo:voice";
|
||||||
|
const VOICE_VAD_VOLUME_THRESHOLD = 0.18;
|
||||||
|
const VOICE_VAD_SILENCE_DURATION_MS = 1400;
|
||||||
|
const VOICE_VAD_SPEECH_CONFIRMATION_MS = 120;
|
||||||
|
const VOICE_VAD_DETECTION_GRACE_PERIOD_MS = 700;
|
||||||
|
|
||||||
interface VoiceContextValue {
|
interface VoiceContextValue {
|
||||||
isVoiceMode: boolean;
|
isVoiceMode: boolean;
|
||||||
@@ -123,10 +124,10 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
console.error("[Voice] Cannot handle error - setMessages not available from SessionState");
|
console.error("[Voice] Cannot handle error - setMessages not available from SessionState");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
volumeThreshold: 0.3,
|
volumeThreshold: VOICE_VAD_VOLUME_THRESHOLD,
|
||||||
silenceDuration: 2000,
|
silenceDuration: VOICE_VAD_SILENCE_DURATION_MS,
|
||||||
speechConfirmationDuration: 300,
|
speechConfirmationDuration: VOICE_VAD_SPEECH_CONFIRMATION_MS,
|
||||||
detectionGracePeriod: 200,
|
detectionGracePeriod: VOICE_VAD_DETECTION_GRACE_PERIOD_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -166,18 +167,9 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
console.log("[Voice] Mode enabled");
|
console.log("[Voice] Mode enabled");
|
||||||
|
|
||||||
if (session?.client) {
|
if (session?.client) {
|
||||||
let voiceConversationId =
|
await session.client.setVoiceMode(true);
|
||||||
(await AsyncStorage.getItem(VOICE_CONVERSATION_ID_STORAGE_KEY)) ?? null;
|
|
||||||
if (!voiceConversationId) {
|
|
||||||
voiceConversationId = randomUUID();
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
VOICE_CONVERSATION_ID_STORAGE_KEY,
|
|
||||||
voiceConversationId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await session.client.setVoiceConversation(true, voiceConversationId);
|
|
||||||
} else {
|
} else {
|
||||||
console.warn("[Voice] setVoiceConversation skipped: daemon unavailable");
|
console.warn("[Voice] setVoiceMode skipped: daemon unavailable");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[Voice] Failed to start:", error);
|
console.error("[Voice] Failed to start:", error);
|
||||||
@@ -200,9 +192,9 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
console.log("[Voice] Mode disabled");
|
console.log("[Voice] Mode disabled");
|
||||||
|
|
||||||
if (session?.client) {
|
if (session?.client) {
|
||||||
await session.client.setVoiceConversation(false);
|
await session.client.setVoiceMode(false);
|
||||||
} else {
|
} else {
|
||||||
console.warn("[Voice] setVoiceConversation skipped: daemon unavailable");
|
console.warn("[Voice] setVoiceMode skipped: daemon unavailable");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[Voice] Failed to stop:", error);
|
console.error("[Voice] Failed to stop:", error);
|
||||||
|
|||||||
@@ -88,4 +88,37 @@ describe("SpeechSegmenter", () => {
|
|||||||
expect(lastCall.isLast).toBe(true);
|
expect(lastCall.isLast).toBe(true);
|
||||||
expect(lastCall.audioData.length).toBeGreaterThan(0);
|
expect(lastCall.audioData.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps detection alive across a brief pause and confirms short utterances", () => {
|
||||||
|
const onSpeechStart = vi.fn();
|
||||||
|
const detectingChanges: boolean[] = [];
|
||||||
|
|
||||||
|
const segmenter = new SpeechSegmenter(
|
||||||
|
{
|
||||||
|
enableContinuousStreaming: false,
|
||||||
|
volumeThreshold: 0.18,
|
||||||
|
silenceDurationMs: 1400,
|
||||||
|
speechConfirmationMs: 120,
|
||||||
|
detectionGracePeriodMs: 700,
|
||||||
|
minChunkDurationMs: 100,
|
||||||
|
pcmSampleRate: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSpeechStart,
|
||||||
|
onDetectingChange: (v) => detectingChanges.push(v),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const t0 = 20_000;
|
||||||
|
|
||||||
|
segmenter.pushVolumeLevel(0.4, t0);
|
||||||
|
segmenter.pushPcmChunk(mkPcmBytes(20));
|
||||||
|
segmenter.pushVolumeLevel(0.0, t0 + 80);
|
||||||
|
segmenter.pushPcmChunk(mkPcmBytes(20));
|
||||||
|
segmenter.pushVolumeLevel(0.4, t0 + 140);
|
||||||
|
segmenter.pushPcmChunk(mkPcmBytes(20));
|
||||||
|
|
||||||
|
expect(detectingChanges).toContain(true);
|
||||||
|
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { runInspectCommand } from './commands/agent/inspect.js'
|
|||||||
import { runWaitCommand } from './commands/agent/wait.js'
|
import { runWaitCommand } from './commands/agent/wait.js'
|
||||||
import { runAttachCommand } from './commands/agent/attach.js'
|
import { runAttachCommand } from './commands/agent/attach.js'
|
||||||
import { withOutput } from './output/index.js'
|
import { withOutput } from './output/index.js'
|
||||||
|
import { runVoiceMcpBridgeCommand } from './commands/voice-mcp-bridge.js'
|
||||||
|
|
||||||
const VERSION = '0.1.0'
|
const VERSION = '0.1.0'
|
||||||
|
|
||||||
@@ -141,5 +142,13 @@ export function createCli(): Command {
|
|||||||
// Worktree commands
|
// Worktree commands
|
||||||
program.addCommand(createWorktreeCommand())
|
program.addCommand(createWorktreeCommand())
|
||||||
|
|
||||||
|
// Internal voice MCP stdio bridge command (hidden).
|
||||||
|
program
|
||||||
|
.command('__paseo_voice_mcp_bridge', { hidden: true })
|
||||||
|
.description('Internal voice MCP bridge command')
|
||||||
|
.requiredOption('--socket <path>')
|
||||||
|
.requiredOption('--caller-agent-id <id>')
|
||||||
|
.action(runVoiceMcpBridgeCommand)
|
||||||
|
|
||||||
return program
|
return program
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { runVoiceMcpBridgeCli } from "@getpaseo/server";
|
||||||
|
|
||||||
|
type VoiceBridgeOptions = {
|
||||||
|
socket: string;
|
||||||
|
callerAgentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runVoiceMcpBridgeCommand(
|
||||||
|
options: VoiceBridgeOptions
|
||||||
|
): Promise<void> {
|
||||||
|
await runVoiceMcpBridgeCli([
|
||||||
|
"--socket",
|
||||||
|
options.socket,
|
||||||
|
"--caller-agent-id",
|
||||||
|
options.callerAgentId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"@lezer/python": "^1.1.18",
|
"@lezer/python": "^1.1.18",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "^1.1.12",
|
"@opencode-ai/sdk": "^1.1.12",
|
||||||
"@openrouter/ai-sdk-provider": "^1.2.0",
|
"@sctg/sentencepiece-js": "^1.1.0",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "^5.0.76",
|
"ai": "^5.0.76",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
@@ -45,13 +45,12 @@
|
|||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"@sctg/sentencepiece-js": "^1.1.0",
|
|
||||||
"onnxruntime-node": "^1.23.0",
|
|
||||||
"sherpa-onnx": "^1.12.23",
|
"sherpa-onnx": "^1.12.23",
|
||||||
"sherpa-onnx-node": "^1.12.23",
|
"sherpa-onnx-node": "^1.12.23",
|
||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { resolvePaseoHome } from "../src/server/paseo-home.js";
|
import { resolvePaseoHome } from "../src/server/paseo-home.js";
|
||||||
import { createRootLogger } from "../src/server/logger.js";
|
import { createRootLogger } from "../src/server/logger.js";
|
||||||
import { ensureSherpaOnnxModels } from "../src/server/speech/providers/local/sherpa/model-downloader.js";
|
import {
|
||||||
import type { SherpaOnnxModelId } from "../src/server/speech/providers/local/sherpa/model-catalog.js";
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
|
DEFAULT_LOCAL_TTS_MODEL,
|
||||||
|
ensureLocalSpeechModels,
|
||||||
|
type LocalSpeechModelId,
|
||||||
|
} from "../src/server/speech/providers/local/models.js";
|
||||||
|
|
||||||
function parseArgs(argv: string[]): { modelsDir: string; modelIds: SherpaOnnxModelId[] } {
|
function parseArgs(argv: string[]): { modelsDir: string; modelIds: LocalSpeechModelId[] } {
|
||||||
const home = resolvePaseoHome();
|
const home = resolvePaseoHome();
|
||||||
let modelsDir = process.env.PASEO_SHERPA_ONNX_MODELS_DIR || `${home}/models/sherpa-onnx`;
|
let modelsDir = process.env.PASEO_LOCAL_MODELS_DIR || `${home}/models/local-speech`;
|
||||||
const modelIds: SherpaOnnxModelId[] = [];
|
const modelIds: LocalSpeechModelId[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
const arg = argv[i];
|
const arg = argv[i];
|
||||||
@@ -16,7 +20,7 @@ function parseArgs(argv: string[]): { modelsDir: string; modelIds: SherpaOnnxMod
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (arg === "--model") {
|
if (arg === "--model") {
|
||||||
const id = argv[i + 1] as SherpaOnnxModelId | undefined;
|
const id = argv[i + 1] as LocalSpeechModelId | undefined;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error("--model requires a value");
|
throw new Error("--model requires a value");
|
||||||
}
|
}
|
||||||
@@ -27,8 +31,8 @@ function parseArgs(argv: string[]): { modelsDir: string; modelIds: SherpaOnnxMod
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (modelIds.length === 0) {
|
if (modelIds.length === 0) {
|
||||||
const stt = (process.env.PASEO_SHERPA_STT_PRESET || "zipformer-bilingual-zh-en-2023-02-20") as SherpaOnnxModelId;
|
const stt = (process.env.PASEO_LOCAL_STT_MODEL || DEFAULT_LOCAL_STT_MODEL) as LocalSpeechModelId;
|
||||||
const tts = (process.env.PASEO_SHERPA_TTS_PRESET || "pocket-tts-onnx-int8") as SherpaOnnxModelId;
|
const tts = (process.env.PASEO_LOCAL_TTS_MODEL || DEFAULT_LOCAL_TTS_MODEL) as LocalSpeechModelId;
|
||||||
modelIds.push(stt, tts);
|
modelIds.push(stt, tts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,5 +42,5 @@ function parseArgs(argv: string[]): { modelsDir: string; modelIds: SherpaOnnxMod
|
|||||||
const logger = createRootLogger({ level: "info", format: "pretty" });
|
const logger = createRootLogger({ level: "info", format: "pretty" });
|
||||||
|
|
||||||
const { modelsDir, modelIds } = parseArgs(process.argv.slice(2));
|
const { modelsDir, modelIds } = parseArgs(process.argv.slice(2));
|
||||||
await ensureSherpaOnnxModels({ modelsDir, modelIds, autoDownload: true, logger });
|
await ensureLocalSpeechModels({ modelsDir, modelIds, autoDownload: true, logger });
|
||||||
logger.info({ modelsDir, modelIds }, "Done downloading speech models");
|
logger.info({ modelsDir, modelIds }, "Done downloading speech models");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { listSherpaOnnxModels } from "../src/server/speech/providers/local/sherpa/model-catalog.js";
|
import { listLocalSpeechModels } from "../src/server/speech/providers/local/models.js";
|
||||||
|
|
||||||
const models = listSherpaOnnxModels()
|
const models = listLocalSpeechModels()
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id));
|
.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id));
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import type {
|
|||||||
AgentStreamEventPayload,
|
AgentStreamEventPayload,
|
||||||
AgentSnapshotPayload,
|
AgentSnapshotPayload,
|
||||||
AgentPermissionResolvedMessage,
|
AgentPermissionResolvedMessage,
|
||||||
VoiceConversationLoadedMessage,
|
|
||||||
CreateAgentRequestMessage,
|
CreateAgentRequestMessage,
|
||||||
DeleteVoiceConversationResponseMessage,
|
|
||||||
FileDownloadTokenResponse,
|
FileDownloadTokenResponse,
|
||||||
FileExplorerResponse,
|
FileExplorerResponse,
|
||||||
GitDiffResponse,
|
GitDiffResponse,
|
||||||
@@ -34,7 +32,6 @@ import type {
|
|||||||
ProjectIconResponse,
|
ProjectIconResponse,
|
||||||
ListCommandsResponse,
|
ListCommandsResponse,
|
||||||
ExecuteCommandResponse,
|
ExecuteCommandResponse,
|
||||||
ListVoiceConversationsResponseMessage,
|
|
||||||
ListProviderModelsResponseMessage,
|
ListProviderModelsResponseMessage,
|
||||||
SpeechModelsListResponse,
|
SpeechModelsListResponse,
|
||||||
SpeechModelsDownloadResponse,
|
SpeechModelsDownloadResponse,
|
||||||
@@ -184,9 +181,6 @@ export type CreateAgentRequestOptions = {
|
|||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
} & AgentConfigOverrides;
|
} & AgentConfigOverrides;
|
||||||
|
|
||||||
type VoiceConversationLoadedPayload = VoiceConversationLoadedMessage["payload"];
|
|
||||||
type ListVoiceConversationsPayload = ListVoiceConversationsResponseMessage["payload"];
|
|
||||||
type DeleteVoiceConversationPayload = DeleteVoiceConversationResponseMessage["payload"];
|
|
||||||
type GitDiffPayload = GitDiffResponse["payload"];
|
type GitDiffPayload = GitDiffResponse["payload"];
|
||||||
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
||||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||||
@@ -765,10 +759,6 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendUserMessage(text: string): void {
|
|
||||||
this.sendSessionMessage({ type: "user_text", text });
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAgentAttention(agentId: string | string[]): void {
|
clearAgentAttention(agentId: string | string[]): void {
|
||||||
this.sendSessionMessage({ type: "clear_agent_attention", agentId });
|
this.sendSessionMessage({ type: "clear_agent_attention", agentId });
|
||||||
}
|
}
|
||||||
@@ -926,87 +916,6 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Voice Conversation RPC
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async loadVoiceConversation(
|
|
||||||
voiceConversationId: string,
|
|
||||||
requestId?: string
|
|
||||||
): Promise<VoiceConversationLoadedPayload> {
|
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "load_voice_conversation_request",
|
|
||||||
voiceConversationId,
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
});
|
|
||||||
return this.sendRequest({
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
message,
|
|
||||||
timeout: 10000,
|
|
||||||
options: { skipQueue: true },
|
|
||||||
select: (msg) => {
|
|
||||||
if (msg.type !== "voice_conversation_loaded") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (msg.payload.requestId !== resolvedRequestId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return msg.payload;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async listVoiceConversations(requestId?: string): Promise<ListVoiceConversationsPayload> {
|
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "list_voice_conversations_request",
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
});
|
|
||||||
return this.sendRequest({
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
message,
|
|
||||||
timeout: 10000,
|
|
||||||
options: { skipQueue: true },
|
|
||||||
select: (msg) => {
|
|
||||||
if (msg.type !== "list_voice_conversations_response") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (msg.payload.requestId !== resolvedRequestId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return msg.payload;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteVoiceConversation(
|
|
||||||
voiceConversationId: string,
|
|
||||||
requestId?: string
|
|
||||||
): Promise<DeleteVoiceConversationPayload> {
|
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "delete_voice_conversation_request",
|
|
||||||
voiceConversationId,
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
});
|
|
||||||
return this.sendRequest({
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
message,
|
|
||||||
timeout: 10000,
|
|
||||||
options: { skipQueue: true },
|
|
||||||
select: (msg) => {
|
|
||||||
if (msg.type !== "delete_voice_conversation_response") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (msg.payload.requestId !== resolvedRequestId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return msg.payload;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Agent Lifecycle
|
// Agent Lifecycle
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1376,8 +1285,8 @@ export class DaemonClient {
|
|||||||
// Audio / Voice
|
// Audio / Voice
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async setVoiceConversation(enabled: boolean, voiceConversationId?: string): Promise<void> {
|
async setVoiceMode(enabled: boolean, voiceAgentId?: string): Promise<void> {
|
||||||
this.sendSessionMessage({ type: "set_voice_conversation", enabled, voiceConversationId });
|
this.sendSessionMessage({ type: "set_voice_mode", enabled, voiceAgentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendVoiceAudioChunk(
|
async sendVoiceAudioChunk(
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ describe("curateAgentActivity", () => {
|
|||||||
expect(result).toBe("[ListFiles]");
|
expect(result).toBe("[ListFiles]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not treat generic double-underscore tool names as MCP calls", () => {
|
||||||
|
const timeline: AgentTimelineItem[] = [
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "call-1",
|
||||||
|
name: "custom__tool",
|
||||||
|
input: {},
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = curateAgentActivity(timeline);
|
||||||
|
|
||||||
|
expect(result).toBe("[custom__tool]");
|
||||||
|
});
|
||||||
|
|
||||||
test("serializes todo items as [Tasks]", () => {
|
test("serializes todo items as [Tasks]", () => {
|
||||||
const timeline: AgentTimelineItem[] = [
|
const timeline: AgentTimelineItem[] = [
|
||||||
{
|
{
|
||||||
@@ -328,5 +344,37 @@ describe("curateAgentActivity", () => {
|
|||||||
|
|
||||||
expect(result).toBe("[Grep] TODO");
|
expect(result).toBe("[Grep] TODO");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows speak tool text input", () => {
|
||||||
|
const timeline: AgentTimelineItem[] = [
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "s1",
|
||||||
|
name: "speak",
|
||||||
|
input: { text: "hello from voice" },
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = curateAgentActivity(timeline);
|
||||||
|
expect(result).toBe('[speak] {"text":"hello from voice"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows MCP tool input JSON", () => {
|
||||||
|
const timeline: AgentTimelineItem[] = [
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "m1",
|
||||||
|
name: "paseo__create_agent",
|
||||||
|
input: { cwd: "/tmp/repo", initialPrompt: "do the thing" },
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = curateAgentActivity(timeline);
|
||||||
|
expect(result).toBe(
|
||||||
|
'[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
|||||||
import { extractPrincipalParam } from "../../utils/tool-call-parsers.js";
|
import { extractPrincipalParam } from "../../utils/tool-call-parsers.js";
|
||||||
|
|
||||||
const DEFAULT_MAX_ITEMS = 40;
|
const DEFAULT_MAX_ITEMS = 40;
|
||||||
|
const MAX_TOOL_INPUT_CHARS = 400;
|
||||||
|
|
||||||
function appendText(buffer: string, text: string): string {
|
function appendText(buffer: string, text: string): string {
|
||||||
const normalized = text.trim();
|
const normalized = text.trim();
|
||||||
@@ -25,6 +26,35 @@ function flushBuffers(lines: string[], buffers: { message: string; thought: stri
|
|||||||
buffers.thought = "";
|
buffers.thought = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLikelyMcpToolCall(name: string): boolean {
|
||||||
|
const normalized = name.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === "speak" ||
|
||||||
|
normalized.startsWith("mcp") ||
|
||||||
|
normalized.includes("mcp__") ||
|
||||||
|
normalized.startsWith("paseo") ||
|
||||||
|
normalized.includes("paseo__")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolInputJson(input: unknown): string | null {
|
||||||
|
if (input === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const encoded = JSON.stringify(input);
|
||||||
|
if (!encoded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (encoded.length <= MAX_TOOL_INPUT_CHARS) {
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
return `${encoded.slice(0, MAX_TOOL_INPUT_CHARS)}...`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapse timeline items:
|
* Collapse timeline items:
|
||||||
* - Dedupe tool calls by callId (pending/completed -> single)
|
* - Dedupe tool calls by callId (pending/completed -> single)
|
||||||
@@ -127,6 +157,11 @@ export function curateAgentActivity(
|
|||||||
break;
|
break;
|
||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
flushBuffers(lines, buffers);
|
flushBuffers(lines, buffers);
|
||||||
|
const inputJson = formatToolInputJson(item.input);
|
||||||
|
if (isLikelyMcpToolCall(item.name) && inputJson) {
|
||||||
|
lines.push(`[${item.name}] ${inputJson}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
const principal = extractPrincipalParam(item.input);
|
const principal = extractPrincipalParam(item.input);
|
||||||
if (principal) {
|
if (principal) {
|
||||||
lines.push(`[${item.name}] ${principal}`);
|
lines.push(`[${item.name}] ${principal}`);
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
|
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { ensureValidJson } from "../json-utils.js";
|
import { ensureValidJson } from "../json-utils.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
@@ -51,6 +49,7 @@ import { AgentStorage } from "./agent-storage.js";
|
|||||||
import { createWorktree } from "../../utils/worktree.js";
|
import { createWorktree } from "../../utils/worktree.js";
|
||||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||||
|
import { expandUserPath } from "../path-utils.js";
|
||||||
|
|
||||||
export interface AgentManagementMcpOptions {
|
export interface AgentManagementMcpOptions {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
@@ -77,13 +76,6 @@ const AgentStatusEnum = z.enum([
|
|||||||
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
|
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
|
||||||
const AGENT_WAIT_TIMEOUT_MS = 50000;
|
const AGENT_WAIT_TIMEOUT_MS = 50000;
|
||||||
|
|
||||||
function expandPath(path: string): string {
|
|
||||||
if (path.startsWith("~/") || path === "~") {
|
|
||||||
return resolve(homedir(), path.slice(2));
|
|
||||||
}
|
|
||||||
return resolve(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAgentWithTimeout(
|
async function waitForAgentWithTimeout(
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@@ -320,7 +312,7 @@ export async function createAgentManagementMcpServer(
|
|||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let resolvedCwd = expandPath(cwd);
|
let resolvedCwd = expandUserPath(cwd);
|
||||||
|
|
||||||
if (worktreeName) {
|
if (worktreeName) {
|
||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => "agent-without-model",
|
idFactory: () => "00000000-0000-4000-8000-000000000101",
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await manager.createAgent({
|
const snapshot = await manager.createAgent({
|
||||||
@@ -129,7 +129,7 @@ describe("AgentManager", () => {
|
|||||||
expect(snapshot.model).toBeUndefined();
|
expect(snapshot.model).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createAgent persists provided title before returning", async () => {
|
test("createAgent fails when cwd does not exist", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -139,7 +139,72 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => "agent-with-title",
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: join(workdir, "does-not-exist"),
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Working directory does not exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createAgent fails when generated agent ID is not a UUID", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "not-a-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
})
|
||||||
|
).rejects.toThrow("createAgent: agentId must be a UUID");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createAgent fails when explicit agent ID is not a UUID", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.createAgent(
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
},
|
||||||
|
"not-a-uuid"
|
||||||
|
)
|
||||||
|
).rejects.toThrow("createAgent: agentId must be a UUID");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createAgent persists provided title before returning", async () => {
|
||||||
|
const agentId = "00000000-0000-4000-8000-000000000102";
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => agentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await manager.createAgent({
|
const snapshot = await manager.createAgent({
|
||||||
@@ -148,12 +213,12 @@ describe("AgentManager", () => {
|
|||||||
title: "Fix Login Bug",
|
title: "Fix Login Bug",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(snapshot.id).toBe("agent-with-title");
|
expect(snapshot.id).toBe(agentId);
|
||||||
expect(snapshot.lifecycle).toBe("idle");
|
expect(snapshot.lifecycle).toBe("idle");
|
||||||
|
|
||||||
const persisted = await storage.get("agent-with-title");
|
const persisted = await storage.get(agentId);
|
||||||
expect(persisted?.title).toBe("Fix Login Bug");
|
expect(persisted?.title).toBe("Fix Login Bug");
|
||||||
expect(persisted?.id).toBe("agent-with-title");
|
expect(persisted?.id).toBe(agentId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createAgent populates runtimeInfo after session creation", async () => {
|
test("createAgent populates runtimeInfo after session creation", async () => {
|
||||||
@@ -166,7 +231,7 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => "agent-with-runtime-info",
|
idFactory: () => "00000000-0000-4000-8000-000000000103",
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await manager.createAgent({
|
const snapshot = await manager.createAgent({
|
||||||
@@ -191,7 +256,7 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => "agent-with-run-runtime",
|
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await manager.createAgent({
|
const snapshot = await manager.createAgent({
|
||||||
@@ -211,6 +276,10 @@ describe("AgentManager", () => {
|
|||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const generatedAgentIds = [
|
||||||
|
"00000000-0000-4000-8000-000000000105",
|
||||||
|
"00000000-0000-4000-8000-000000000106",
|
||||||
|
];
|
||||||
let agentCounter = 0;
|
let agentCounter = 0;
|
||||||
const manager = new AgentManager({
|
const manager = new AgentManager({
|
||||||
clients: {
|
clients: {
|
||||||
@@ -218,7 +287,7 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => `agent-${agentCounter++}`,
|
idFactory: () => generatedAgentIds[agentCounter++] ?? randomUUID(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a normal agent
|
// Create a normal agent
|
||||||
@@ -242,6 +311,7 @@ describe("AgentManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getAgent returns internal agents by ID", async () => {
|
test("getAgent returns internal agents by ID", async () => {
|
||||||
|
const internalAgentId = "00000000-0000-4000-8000-000000000107";
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -251,7 +321,7 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => "internal-agent",
|
idFactory: () => internalAgentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manager.createAgent({
|
await manager.createAgent({
|
||||||
@@ -261,7 +331,7 @@ describe("AgentManager", () => {
|
|||||||
internal: true,
|
internal: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const agent = manager.getAgent("internal-agent");
|
const agent = manager.getAgent(internalAgentId);
|
||||||
expect(agent).not.toBeNull();
|
expect(agent).not.toBeNull();
|
||||||
expect(agent?.internal).toBe(true);
|
expect(agent?.internal).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -270,6 +340,10 @@ describe("AgentManager", () => {
|
|||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const generatedAgentIds = [
|
||||||
|
"00000000-0000-4000-8000-000000000108",
|
||||||
|
"00000000-0000-4000-8000-000000000109",
|
||||||
|
];
|
||||||
let agentCounter = 0;
|
let agentCounter = 0;
|
||||||
const manager = new AgentManager({
|
const manager = new AgentManager({
|
||||||
clients: {
|
clients: {
|
||||||
@@ -277,7 +351,7 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => `agent-${agentCounter++}`,
|
idFactory: () => generatedAgentIds[agentCounter++] ?? randomUUID(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const receivedEvents: string[] = [];
|
const receivedEvents: string[] = [];
|
||||||
@@ -303,11 +377,12 @@ describe("AgentManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should only have events from the normal agent
|
// Should only have events from the normal agent
|
||||||
expect(receivedEvents.filter((id) => id === "agent-0").length).toBeGreaterThan(0);
|
expect(receivedEvents.filter((id) => id === generatedAgentIds[0]).length).toBeGreaterThan(0);
|
||||||
expect(receivedEvents.filter((id) => id === "agent-1").length).toBe(0);
|
expect(receivedEvents.filter((id) => id === generatedAgentIds[1]).length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("subscribe emits state events for internal agents when subscribed by agentId", async () => {
|
test("subscribe emits state events for internal agents when subscribed by agentId", async () => {
|
||||||
|
const internalAgentId = "00000000-0000-4000-8000-000000000110";
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -317,7 +392,7 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => "internal-agent",
|
idFactory: () => internalAgentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const receivedEvents: string[] = [];
|
const receivedEvents: string[] = [];
|
||||||
@@ -328,7 +403,7 @@ describe("AgentManager", () => {
|
|||||||
receivedEvents.push(event.agent.id);
|
receivedEvents.push(event.agent.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ agentId: "internal-agent", replayState: false }
|
{ agentId: internalAgentId, replayState: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
await manager.createAgent({
|
await manager.createAgent({
|
||||||
@@ -339,10 +414,26 @@ describe("AgentManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should receive events when subscribed by specific agentId
|
// Should receive events when subscribed by specific agentId
|
||||||
expect(receivedEvents.filter((id) => id === "internal-agent").length).toBeGreaterThan(0);
|
expect(receivedEvents.filter((id) => id === internalAgentId).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscribe fails when filter agentId is not a UUID", () => {
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
manager.subscribe(() => {}, {
|
||||||
|
agentId: "invalid-agent-id",
|
||||||
|
})
|
||||||
|
).toThrow("subscribe: agentId must be a UUID");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("onAgentAttention is not called for internal agents", async () => {
|
test("onAgentAttention is not called for internal agents", async () => {
|
||||||
|
const internalAgentId = "00000000-0000-4000-8000-000000000111";
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -353,7 +444,7 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => "internal-agent",
|
idFactory: () => internalAgentId,
|
||||||
onAgentAttention: ({ agentId }) => {
|
onAgentAttention: ({ agentId }) => {
|
||||||
attentionCalls.push(agentId);
|
attentionCalls.push(agentId);
|
||||||
},
|
},
|
||||||
@@ -461,7 +552,7 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => "plan-mode-agent",
|
idFactory: () => "00000000-0000-4000-8000-000000000112",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create agent in plan mode
|
// Create agent in plan mode
|
||||||
@@ -588,7 +679,7 @@ describe("AgentManager", () => {
|
|||||||
},
|
},
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => "close-race-agent",
|
idFactory: () => "00000000-0000-4000-8000-000000000113",
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await manager.createAgent({
|
const snapshot = await manager.createAgent({
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
import {
|
import {
|
||||||
AGENT_LIFECYCLE_STATUSES,
|
AGENT_LIFECYCLE_STATUSES,
|
||||||
type AgentLifecycleStatus,
|
type AgentLifecycleStatus,
|
||||||
} from "../../shared/agent-lifecycle.js";
|
} from "../../shared/agent-lifecycle.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
@@ -182,6 +184,7 @@ const BUSY_STATUSES: AgentLifecycleStatus[] = [
|
|||||||
"initializing",
|
"initializing",
|
||||||
"running",
|
"running",
|
||||||
];
|
];
|
||||||
|
const AgentIdSchema = z.string().uuid();
|
||||||
|
|
||||||
function isAgentBusy(status: AgentLifecycleStatus): boolean {
|
function isAgentBusy(status: AgentLifecycleStatus): boolean {
|
||||||
return BUSY_STATUSES.includes(status);
|
return BUSY_STATUSES.includes(status);
|
||||||
@@ -201,6 +204,14 @@ function createAbortError(
|
|||||||
return Object.assign(new Error(message), { name: "AbortError" });
|
return Object.assign(new Error(message), { name: "AbortError" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateAgentId(agentId: string, source: string): string {
|
||||||
|
const result = AgentIdSchema.safeParse(agentId);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(`${source}: agentId must be a UUID`);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
export class AgentManager {
|
export class AgentManager {
|
||||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||||
private readonly agents = new Map<string, ActiveManagedAgent>();
|
private readonly agents = new Map<string, ActiveManagedAgent>();
|
||||||
@@ -238,9 +249,13 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
|
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
|
||||||
|
const targetAgentId =
|
||||||
|
options?.agentId == null
|
||||||
|
? null
|
||||||
|
: validateAgentId(options.agentId, "subscribe");
|
||||||
const record: SubscriptionRecord = {
|
const record: SubscriptionRecord = {
|
||||||
callback,
|
callback,
|
||||||
agentId: options?.agentId ?? null,
|
agentId: targetAgentId,
|
||||||
};
|
};
|
||||||
this.subscribers.add(record);
|
this.subscribers.add(record);
|
||||||
|
|
||||||
@@ -334,7 +349,10 @@ export class AgentManager {
|
|||||||
options?: { labels?: Record<string, string> }
|
options?: { labels?: Record<string, string> }
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
// Generate agent ID early so we can use it in MCP config
|
// Generate agent ID early so we can use it in MCP config
|
||||||
const resolvedAgentId = agentId ?? this.idFactory();
|
const resolvedAgentId = validateAgentId(
|
||||||
|
agentId ?? this.idFactory(),
|
||||||
|
"createAgent"
|
||||||
|
);
|
||||||
const normalizedConfig = await this.normalizeConfig(config, {
|
const normalizedConfig = await this.normalizeConfig(config, {
|
||||||
labels: options?.labels,
|
labels: options?.labels,
|
||||||
agentId: resolvedAgentId,
|
agentId: resolvedAgentId,
|
||||||
@@ -364,6 +382,10 @@ export class AgentManager {
|
|||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
}
|
}
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
|
const resolvedAgentId = validateAgentId(
|
||||||
|
agentId ?? this.idFactory(),
|
||||||
|
"resumeAgent"
|
||||||
|
);
|
||||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||||
const mergedConfig = {
|
const mergedConfig = {
|
||||||
...metadata,
|
...metadata,
|
||||||
@@ -380,7 +402,7 @@ export class AgentManager {
|
|||||||
return this.registerSession(
|
return this.registerSession(
|
||||||
session,
|
session,
|
||||||
normalizedConfig,
|
normalizedConfig,
|
||||||
agentId ?? this.idFactory(),
|
resolvedAgentId,
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -981,13 +1003,14 @@ export class AgentManager {
|
|||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
}
|
}
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
if (this.agents.has(agentId)) {
|
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
||||||
throw new Error(`Agent with id ${agentId} already exists`);
|
if (this.agents.has(resolvedAgentId)) {
|
||||||
|
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const managed = {
|
const managed = {
|
||||||
id: agentId,
|
id: resolvedAgentId,
|
||||||
provider: config.provider,
|
provider: config.provider,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
session,
|
session,
|
||||||
@@ -1010,9 +1033,9 @@ export class AgentManager {
|
|||||||
labels: options?.labels ?? {},
|
labels: options?.labels ?? {},
|
||||||
} as ActiveManagedAgent;
|
} as ActiveManagedAgent;
|
||||||
|
|
||||||
this.agents.set(agentId, managed);
|
this.agents.set(resolvedAgentId, managed);
|
||||||
// Initialize previousStatus to track transitions
|
// Initialize previousStatus to track transitions
|
||||||
this.previousStatuses.set(agentId, managed.lifecycle);
|
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
||||||
await this.refreshRuntimeInfo(managed);
|
await this.refreshRuntimeInfo(managed);
|
||||||
await this.persistSnapshot(managed, {
|
await this.persistSnapshot(managed, {
|
||||||
title: config.title ?? null,
|
title: config.title ?? null,
|
||||||
@@ -1319,6 +1342,20 @@ export class AgentManager {
|
|||||||
// Always resolve cwd to absolute path for consistent history file lookup
|
// Always resolve cwd to absolute path for consistent history file lookup
|
||||||
if (normalized.cwd) {
|
if (normalized.cwd) {
|
||||||
normalized.cwd = resolve(normalized.cwd);
|
normalized.cwd = resolve(normalized.cwd);
|
||||||
|
try {
|
||||||
|
const cwdStats = await stat(normalized.cwd);
|
||||||
|
if (!cwdStats.isDirectory()) {
|
||||||
|
throw new Error(`Working directory is not a directory: ${normalized.cwd}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
throw new Error(`Working directory does not exist: ${normalized.cwd}`);
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to access working directory: ${normalized.cwd}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof normalized.model === "string") {
|
if (typeof normalized.model === "string") {
|
||||||
@@ -1338,9 +1375,10 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private requireAgent(id: string): ActiveManagedAgent {
|
private requireAgent(id: string): ActiveManagedAgent {
|
||||||
const agent = this.agents.get(id);
|
const normalizedId = validateAgentId(id, "requireAgent");
|
||||||
|
const agent = this.agents.get(normalizedId);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
throw new Error(`Unknown agent '${id}'`);
|
throw new Error(`Unknown agent '${normalizedId}'`);
|
||||||
}
|
}
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ describe("agent MCP end-to-end (offline)", () => {
|
|||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
agentClients: createTestAgentClients(),
|
agentClients: createTestAgentClients(),
|
||||||
agentStoragePath: path.join(paseoHome, "agents"),
|
agentStoragePath: path.join(paseoHome, "agents"),
|
||||||
openrouterApiKey: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import { createAgentMcpServer } from "./mcp-server.js";
|
import { createAgentMcpServer } from "./mcp-server.js";
|
||||||
@@ -48,6 +51,7 @@ function createTestDeps(): TestDeps {
|
|||||||
|
|
||||||
describe("create_agent MCP tool", () => {
|
describe("create_agent MCP tool", () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
const existingCwd = process.cwd();
|
||||||
|
|
||||||
it("requires a concise title no longer than 60 characters", async () => {
|
it("requires a concise title no longer than 60 characters", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
@@ -56,26 +60,59 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect(tool).toBeDefined();
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
const missingTitle = await tool.inputSchema.safeParseAsync({
|
const missingTitle = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: "/tmp/repo",
|
cwd: existingCwd,
|
||||||
initialMode: "default",
|
initialMode: "default",
|
||||||
|
initialPrompt: "test",
|
||||||
});
|
});
|
||||||
expect(missingTitle.success).toBe(false);
|
expect(missingTitle.success).toBe(false);
|
||||||
expect(missingTitle.error.issues[0].path).toEqual(["title"]);
|
expect(missingTitle.error.issues[0].path).toEqual(["title"]);
|
||||||
|
|
||||||
const tooLong = await tool.inputSchema.safeParseAsync({
|
const tooLong = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: "/tmp/repo",
|
cwd: existingCwd,
|
||||||
initialMode: "default",
|
initialMode: "default",
|
||||||
title: "x".repeat(61),
|
title: "x".repeat(61),
|
||||||
|
initialPrompt: "test",
|
||||||
});
|
});
|
||||||
expect(tooLong.success).toBe(false);
|
expect(tooLong.success).toBe(false);
|
||||||
expect(tooLong.error.issues[0].path).toEqual(["title"]);
|
expect(tooLong.error.issues[0].path).toEqual(["title"]);
|
||||||
|
|
||||||
const ok = await tool.inputSchema.safeParseAsync({
|
const ok = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: "/tmp/repo",
|
cwd: existingCwd,
|
||||||
|
initialMode: "default",
|
||||||
|
title: "Short title",
|
||||||
|
initialPrompt: "test",
|
||||||
|
});
|
||||||
|
expect(ok.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires initialPrompt", async () => {
|
||||||
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
|
const tool = (server as any)._registeredTools["create_agent"];
|
||||||
|
const parsed = await tool.inputSchema.safeParseAsync({
|
||||||
|
cwd: existingCwd,
|
||||||
initialMode: "default",
|
initialMode: "default",
|
||||||
title: "Short title",
|
title: "Short title",
|
||||||
});
|
});
|
||||||
expect(ok.success).toBe(true);
|
expect(parsed.success).toBe(false);
|
||||||
|
expect(parsed.error.issues.some((issue: { path: string[] }) => issue.path[0] === "initialPrompt")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces createAgent validation failures", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
spies.agentManager.createAgent.mockRejectedValue(
|
||||||
|
new Error("Working directory does not exist: /path/that/does/not/exist")
|
||||||
|
);
|
||||||
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
|
const tool = (server as any)._registeredTools["create_agent"];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tool.callback({
|
||||||
|
cwd: "/path/that/does/not/exist",
|
||||||
|
title: "Short title",
|
||||||
|
initialPrompt: "Do work",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Working directory does not exist");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes caller-provided titles directly into createAgent", async () => {
|
it("passes caller-provided titles directly into createAgent", async () => {
|
||||||
@@ -86,20 +123,24 @@ describe("create_agent MCP tool", () => {
|
|||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
|
config: { title: "Fix auth bug" },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
|
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
const tool = (server as any)._registeredTools["create_agent"];
|
const tool = (server as any)._registeredTools["create_agent"];
|
||||||
await tool.callback({
|
await tool.callback({
|
||||||
cwd: "/tmp/repo",
|
cwd: existingCwd,
|
||||||
title: " Fix auth bug ",
|
title: " Fix auth bug ",
|
||||||
|
initialPrompt: "Do work",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
cwd: "/tmp/repo",
|
cwd: existingCwd,
|
||||||
title: "Fix auth bug",
|
title: "Fix auth bug",
|
||||||
})
|
}),
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,19 +152,127 @@ describe("create_agent MCP tool", () => {
|
|||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
|
config: { title: "Fix auth" },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
|
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
const tool = (server as any)._registeredTools["create_agent"];
|
const tool = (server as any)._registeredTools["create_agent"];
|
||||||
await tool.callback({
|
await tool.callback({
|
||||||
cwd: "/tmp/repo",
|
cwd: existingCwd,
|
||||||
title: " Fix auth ",
|
title: " Fix auth ",
|
||||||
|
initialPrompt: "Do work",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
title: "Fix auth",
|
title: "Fix auth",
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows caller agents to override cwd and applies caller context labels", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
const baseDir = await mkdtemp(join(tmpdir(), "paseo-mcp-test-"));
|
||||||
|
const subdir = join(baseDir, "subdir");
|
||||||
|
await mkdir(subdir, { recursive: true });
|
||||||
|
spies.agentManager.getAgent.mockReturnValue({
|
||||||
|
id: "voice-agent",
|
||||||
|
cwd: baseDir,
|
||||||
|
provider: "codex",
|
||||||
|
currentModeId: "full-access",
|
||||||
|
} as ManagedAgent);
|
||||||
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
|
id: "child-agent",
|
||||||
|
cwd: subdir,
|
||||||
|
lifecycle: "idle",
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
config: { title: "Child" },
|
||||||
|
} as ManagedAgent);
|
||||||
|
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
callerAgentId: "voice-agent",
|
||||||
|
resolveCallerContext: () => ({
|
||||||
|
childAgentDefaultLabels: { ui: "true" },
|
||||||
|
allowCustomCwd: true,
|
||||||
|
}),
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = (server as any)._registeredTools["create_agent"];
|
||||||
|
await tool.callback({
|
||||||
|
cwd: "subdir",
|
||||||
|
title: "Child",
|
||||||
|
agentType: "codex",
|
||||||
|
initialPrompt: "Do work",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: subdir,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
{ labels: { ui: "true" } }
|
||||||
|
);
|
||||||
|
await rm(baseDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("speak MCP tool", () => {
|
||||||
|
const logger = createTestLogger();
|
||||||
|
|
||||||
|
it("invokes registered speak handler for caller agent", async () => {
|
||||||
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
|
const speak = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
callerAgentId: "voice-agent-1",
|
||||||
|
enableVoiceTools: true,
|
||||||
|
resolveSpeakHandler: () => speak,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const tool = (server as any)._registeredTools["speak"];
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
|
await tool.callback({ text: "Hello from voice agent." });
|
||||||
|
expect(speak).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
text: "Hello from voice agent.",
|
||||||
|
callerAgentId: "voice-agent-1",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails when no speak handler exists", async () => {
|
||||||
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
callerAgentId: "voice-agent-2",
|
||||||
|
enableVoiceTools: true,
|
||||||
|
resolveSpeakHandler: () => null,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const tool = (server as any)._registeredTools["speak"];
|
||||||
|
await expect(tool.callback({ text: "Hello." })).rejects.toThrow(
|
||||||
|
"No speak handler registered for caller agent"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not register speak tool unless voice tools are enabled", async () => {
|
||||||
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
callerAgentId: "agent-no-voice",
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const tool = (server as any)._registeredTools["speak"];
|
||||||
|
expect(tool).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { ensureValidJson } from "../json-utils.js";
|
import { ensureValidJson } from "../json-utils.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||||
|
import type {
|
||||||
|
ServerNotification,
|
||||||
|
ServerRequest,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentPromptInput,
|
AgentPromptInput,
|
||||||
@@ -28,6 +31,11 @@ import { AgentStorage } from "./agent-storage.js";
|
|||||||
import { createWorktree } from "../../utils/worktree.js";
|
import { createWorktree } from "../../utils/worktree.js";
|
||||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||||
|
import type {
|
||||||
|
VoiceCallerContext,
|
||||||
|
VoiceSpeakHandler,
|
||||||
|
} from "../voice-types.js";
|
||||||
|
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
||||||
|
|
||||||
export interface AgentMcpServerOptions {
|
export interface AgentMcpServerOptions {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
@@ -38,6 +46,17 @@ export interface AgentMcpServerOptions {
|
|||||||
* Used for cwd/mode inheritance when agents spawn child agents.
|
* Used for cwd/mode inheritance when agents spawn child agents.
|
||||||
*/
|
*/
|
||||||
callerAgentId?: string;
|
callerAgentId?: string;
|
||||||
|
/**
|
||||||
|
* Optional resolver for session-bound speak handlers.
|
||||||
|
* Used by hidden voice agents to narrate through daemon-managed TTS.
|
||||||
|
*/
|
||||||
|
resolveSpeakHandler?: (
|
||||||
|
callerAgentId: string
|
||||||
|
) => VoiceSpeakHandler | null;
|
||||||
|
resolveCallerContext?: (
|
||||||
|
callerAgentId: string
|
||||||
|
) => VoiceCallerContext | null;
|
||||||
|
enableVoiceTools?: boolean;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,11 +119,25 @@ const AgentStatusEnum = z.enum([
|
|||||||
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
|
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
|
||||||
const AGENT_WAIT_TIMEOUT_MS = 50000;
|
const AGENT_WAIT_TIMEOUT_MS = 50000;
|
||||||
|
|
||||||
function expandPath(path: string): string {
|
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
||||||
if (path.startsWith("~/") || path === "~") {
|
|
||||||
return resolve(homedir(), path.slice(2));
|
function resolveChildAgentCwd(params: {
|
||||||
|
parentCwd: string;
|
||||||
|
requestedCwd?: string;
|
||||||
|
lockedCwd?: string;
|
||||||
|
allowCustomCwd: boolean;
|
||||||
|
}): string {
|
||||||
|
const lockedCwd = params.lockedCwd?.trim();
|
||||||
|
if (lockedCwd) {
|
||||||
|
return expandUserPath(lockedCwd);
|
||||||
}
|
}
|
||||||
return resolve(path);
|
|
||||||
|
const requestedCwd = params.requestedCwd?.trim();
|
||||||
|
if (!requestedCwd || !params.allowCustomCwd) {
|
||||||
|
return params.parentCwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvePathFromBase(params.parentCwd, requestedCwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -252,9 +285,17 @@ async function serializeSnapshotWithMetadata(
|
|||||||
export async function createAgentMcpServer(
|
export async function createAgentMcpServer(
|
||||||
options: AgentMcpServerOptions
|
options: AgentMcpServerOptions
|
||||||
): Promise<McpServer> {
|
): Promise<McpServer> {
|
||||||
const { agentManager, agentStorage, callerAgentId, logger } = options;
|
const {
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
callerAgentId,
|
||||||
|
resolveSpeakHandler,
|
||||||
|
resolveCallerContext,
|
||||||
|
logger,
|
||||||
|
} = options;
|
||||||
const childLogger = logger.child({ module: "agent", component: "mcp-server" });
|
const childLogger = logger.child({ module: "agent", component: "mcp-server" });
|
||||||
const waitTracker = new WaitForAgentTracker(logger);
|
const waitTracker = new WaitForAgentTracker(logger);
|
||||||
|
const callerContext = callerAgentId ? resolveCallerContext?.(callerAgentId) ?? null : null;
|
||||||
|
|
||||||
const server = new McpServer({
|
const server = new McpServer({
|
||||||
name: "agent-mcp",
|
name: "agent-mcp",
|
||||||
@@ -262,6 +303,12 @@ export async function createAgentMcpServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const agentToAgentInputSchema = {
|
const agentToAgentInputSchema = {
|
||||||
|
cwd: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional working directory. Defaults to the caller agent working directory."
|
||||||
|
),
|
||||||
title: z
|
title: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -275,9 +322,10 @@ export async function createAgentMcpServer(
|
|||||||
),
|
),
|
||||||
initialPrompt: z
|
initialPrompt: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.trim()
|
||||||
|
.min(1, "initialPrompt is required")
|
||||||
.describe(
|
.describe(
|
||||||
"Optional task to start immediately after creation (non-blocking)."
|
"Required first task to run immediately after creation."
|
||||||
),
|
),
|
||||||
background: z
|
background: z
|
||||||
.boolean()
|
.boolean()
|
||||||
@@ -307,9 +355,10 @@ export async function createAgentMcpServer(
|
|||||||
),
|
),
|
||||||
initialPrompt: z
|
initialPrompt: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.trim()
|
||||||
|
.min(1, "initialPrompt is required")
|
||||||
.describe(
|
.describe(
|
||||||
"Optional task to start immediately after creation (non-blocking)."
|
"Required first task to run immediately after creation."
|
||||||
),
|
),
|
||||||
initialMode: z
|
initialMode: z
|
||||||
.string()
|
.string()
|
||||||
@@ -338,6 +387,50 @@ export async function createAgentMcpServer(
|
|||||||
const createAgentInputSchema = callerAgentId
|
const createAgentInputSchema = callerAgentId
|
||||||
? agentToAgentInputSchema
|
? agentToAgentInputSchema
|
||||||
: topLevelInputSchema;
|
: topLevelInputSchema;
|
||||||
|
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema);
|
||||||
|
const topLevelCreateAgentArgsSchema = z.object({
|
||||||
|
...topLevelInputSchema,
|
||||||
|
initialMode: topLevelInputSchema.initialMode.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
||||||
|
server.registerTool(
|
||||||
|
"speak",
|
||||||
|
{
|
||||||
|
title: "Speak",
|
||||||
|
description:
|
||||||
|
"Speak text to the user via daemon-managed voice output. Blocks until playback completes.",
|
||||||
|
inputSchema: {
|
||||||
|
text: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "text is required")
|
||||||
|
.max(4000, "text must be 4000 characters or fewer"),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
ok: z.boolean(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (args, context?: McpToolContext) => {
|
||||||
|
if (!callerAgentId) {
|
||||||
|
throw new Error("speak is only available to agent-scoped MCP sessions");
|
||||||
|
}
|
||||||
|
const handler = resolveSpeakHandler?.(callerAgentId) ?? null;
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error(`No speak handler registered for caller agent '${callerAgentId}'`);
|
||||||
|
}
|
||||||
|
await handler({
|
||||||
|
text: args.text,
|
||||||
|
callerAgentId,
|
||||||
|
signal: context?.signal,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [],
|
||||||
|
structuredContent: ensureValidJson({ ok: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"create_agent",
|
"create_agent",
|
||||||
@@ -363,33 +456,32 @@ export async function createAgentMcpServer(
|
|||||||
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args: unknown) => {
|
||||||
const {
|
let provider: AgentProvider;
|
||||||
agentType,
|
let initialPrompt: string;
|
||||||
initialPrompt,
|
let background = false;
|
||||||
background = false,
|
let normalizedTitle: string | null;
|
||||||
title,
|
|
||||||
} = args as {
|
|
||||||
cwd?: string;
|
|
||||||
agentType?: AgentProvider;
|
|
||||||
initialPrompt?: string;
|
|
||||||
initialMode?: string;
|
|
||||||
worktreeName?: string;
|
|
||||||
background?: boolean;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let resolvedCwd: string;
|
let resolvedCwd: string;
|
||||||
let resolvedMode: string | undefined;
|
let resolvedMode: string | undefined;
|
||||||
|
|
||||||
if (callerAgentId) {
|
if (callerAgentId) {
|
||||||
|
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
||||||
|
provider = callerArgs.agentType ?? "claude";
|
||||||
|
initialPrompt = callerArgs.initialPrompt;
|
||||||
|
background = callerArgs.background ?? false;
|
||||||
|
normalizedTitle = callerArgs.title.trim();
|
||||||
|
|
||||||
const parentAgent = agentManager.getAgent(callerAgentId);
|
const parentAgent = agentManager.getAgent(callerAgentId);
|
||||||
if (!parentAgent) {
|
if (!parentAgent) {
|
||||||
throw new Error(`Parent agent ${callerAgentId} not found`);
|
throw new Error(`Parent agent ${callerAgentId} not found`);
|
||||||
}
|
}
|
||||||
resolvedCwd = parentAgent.cwd;
|
resolvedCwd = resolveChildAgentCwd({
|
||||||
|
parentCwd: parentAgent.cwd,
|
||||||
const provider: AgentProvider = agentType ?? "claude";
|
requestedCwd: callerArgs.cwd,
|
||||||
|
lockedCwd: callerContext?.lockedCwd,
|
||||||
|
allowCustomCwd: callerContext?.allowCustomCwd ?? true,
|
||||||
|
});
|
||||||
const parentMode = parentAgent.currentModeId;
|
const parentMode = parentAgent.currentModeId;
|
||||||
if (parentMode) {
|
if (parentMode) {
|
||||||
resolvedMode = mapModeAcrossProviders(
|
resolvedMode = mapModeAcrossProviders(
|
||||||
@@ -399,12 +491,11 @@ export async function createAgentMcpServer(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const topLevelArgs = args as unknown as {
|
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
|
||||||
cwd: string;
|
provider = topLevelArgs.agentType ?? "claude";
|
||||||
initialMode: string;
|
initialPrompt = topLevelArgs.initialPrompt;
|
||||||
worktreeName?: string;
|
background = topLevelArgs.background ?? false;
|
||||||
baseBranch?: string;
|
normalizedTitle = topLevelArgs.title.trim();
|
||||||
};
|
|
||||||
const {
|
const {
|
||||||
cwd,
|
cwd,
|
||||||
initialMode,
|
initialMode,
|
||||||
@@ -412,7 +503,7 @@ export async function createAgentMcpServer(
|
|||||||
baseBranch,
|
baseBranch,
|
||||||
} = topLevelArgs;
|
} = topLevelArgs;
|
||||||
|
|
||||||
resolvedCwd = expandPath(cwd);
|
resolvedCwd = expandUserPath(cwd);
|
||||||
|
|
||||||
if (worktreeName) {
|
if (worktreeName) {
|
||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
@@ -431,75 +522,78 @@ export async function createAgentMcpServer(
|
|||||||
resolvedMode = initialMode;
|
resolvedMode = initialMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider: AgentProvider = agentType ?? "claude";
|
const childAgentDefaultLabels =
|
||||||
const normalizedTitle = title?.trim() ?? null;
|
callerAgentId && callerContext?.childAgentDefaultLabels
|
||||||
const snapshot = await agentManager.createAgent({
|
? callerContext.childAgentDefaultLabels
|
||||||
provider,
|
: undefined;
|
||||||
cwd: resolvedCwd,
|
const snapshot = await agentManager.createAgent(
|
||||||
modeId: resolvedMode,
|
{
|
||||||
title: normalizedTitle ?? undefined,
|
provider,
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
modeId: resolvedMode,
|
||||||
|
title: normalizedTitle ?? undefined,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const trimmedPrompt = initialPrompt.trim();
|
||||||
|
scheduleAgentMetadataGeneration({
|
||||||
|
agentManager,
|
||||||
|
agentId: snapshot.id,
|
||||||
|
cwd: snapshot.cwd,
|
||||||
|
initialPrompt: trimmedPrompt,
|
||||||
|
explicitTitle: snapshot.config.title,
|
||||||
|
paseoHome: options.paseoHome,
|
||||||
|
logger: childLogger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const trimmedPrompt = initialPrompt?.trim();
|
try {
|
||||||
if (trimmedPrompt) {
|
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
||||||
scheduleAgentMetadataGeneration({
|
} catch (error) {
|
||||||
agentManager,
|
childLogger.error(
|
||||||
agentId: snapshot.id,
|
{ err: error, agentId: snapshot.id },
|
||||||
cwd: snapshot.cwd,
|
"Failed to record initial prompt"
|
||||||
initialPrompt: trimmedPrompt,
|
);
|
||||||
explicitTitle: snapshot.config.title,
|
|
||||||
paseoHome: options.paseoHome,
|
|
||||||
logger: childLogger,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
|
||||||
} catch (error) {
|
|
||||||
childLogger.error(
|
|
||||||
{ err: error, agentId: snapshot.id },
|
|
||||||
"Failed to record initial prompt"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
|
|
||||||
|
|
||||||
// If not running in background, wait for completion
|
|
||||||
if (!background) {
|
|
||||||
const result = await waitForAgentWithTimeout(
|
|
||||||
agentManager,
|
|
||||||
snapshot.id,
|
|
||||||
{ waitForActive: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const responseData = {
|
|
||||||
agentId: snapshot.id,
|
|
||||||
type: provider,
|
|
||||||
status: result.status,
|
|
||||||
cwd: snapshot.cwd,
|
|
||||||
currentModeId: snapshot.currentModeId,
|
|
||||||
availableModes: snapshot.availableModes,
|
|
||||||
lastMessage: result.lastMessage,
|
|
||||||
permission: sanitizePermissionRequest(result.permission),
|
|
||||||
};
|
|
||||||
const validJson = ensureValidJson(responseData);
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
content: [],
|
|
||||||
structuredContent: validJson,
|
|
||||||
};
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
childLogger.error(
|
|
||||||
{ err: error, agentId: snapshot.id },
|
|
||||||
"Failed to run initial prompt"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return immediately if background=true or no initialPrompt
|
try {
|
||||||
|
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
|
||||||
|
|
||||||
|
// If not running in background, wait for completion
|
||||||
|
if (!background) {
|
||||||
|
const result = await waitForAgentWithTimeout(
|
||||||
|
agentManager,
|
||||||
|
snapshot.id,
|
||||||
|
{ waitForActive: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = {
|
||||||
|
agentId: snapshot.id,
|
||||||
|
type: provider,
|
||||||
|
status: result.status,
|
||||||
|
cwd: snapshot.cwd,
|
||||||
|
currentModeId: snapshot.currentModeId,
|
||||||
|
availableModes: snapshot.availableModes,
|
||||||
|
lastMessage: result.lastMessage,
|
||||||
|
permission: sanitizePermissionRequest(result.permission),
|
||||||
|
};
|
||||||
|
const validJson = ensureValidJson(responseData);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
content: [],
|
||||||
|
structuredContent: validJson,
|
||||||
|
};
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
childLogger.error(
|
||||||
|
{ err: error, agentId: snapshot.id },
|
||||||
|
"Failed to run initial prompt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return immediately if background=true
|
||||||
const response = {
|
const response = {
|
||||||
content: [],
|
content: [],
|
||||||
structuredContent: ensureValidJson({
|
structuredContent: ensureValidJson({
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface AgentProviderDefinition {
|
|||||||
description: string;
|
description: string;
|
||||||
defaultModeId: string | null;
|
defaultModeId: string | null;
|
||||||
modes: AgentMode[];
|
modes: AgentMode[];
|
||||||
|
voice?: {
|
||||||
|
enabled: boolean;
|
||||||
|
defaultModeId: string;
|
||||||
|
defaultModel?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLAUDE_MODES: AgentMode[] = [
|
const CLAUDE_MODES: AgentMode[] = [
|
||||||
@@ -68,6 +73,11 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
|||||||
"Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
|
"Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
|
||||||
defaultModeId: "default",
|
defaultModeId: "default",
|
||||||
modes: CLAUDE_MODES,
|
modes: CLAUDE_MODES,
|
||||||
|
voice: {
|
||||||
|
enabled: true,
|
||||||
|
defaultModeId: "default",
|
||||||
|
defaultModel: "haiku",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codex",
|
id: "codex",
|
||||||
@@ -76,6 +86,11 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
|||||||
"OpenAI's Codex workspace agent with sandbox controls and optional network access",
|
"OpenAI's Codex workspace agent with sandbox controls and optional network access",
|
||||||
defaultModeId: "auto",
|
defaultModeId: "auto",
|
||||||
modes: CODEX_MODES,
|
modes: CODEX_MODES,
|
||||||
|
voice: {
|
||||||
|
enabled: true,
|
||||||
|
defaultModeId: "read-only",
|
||||||
|
defaultModel: "gpt-5.2-mini",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "opencode",
|
id: "opencode",
|
||||||
@@ -84,6 +99,10 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
|||||||
"Open-source coding assistant with multi-provider model support",
|
"Open-source coding assistant with multi-provider model support",
|
||||||
defaultModeId: "default",
|
defaultModeId: "default",
|
||||||
modes: OPENCODE_MODES,
|
modes: OPENCODE_MODES,
|
||||||
|
voice: {
|
||||||
|
enabled: true,
|
||||||
|
defaultModeId: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
|||||||
|
|
||||||
describe("paseo daemon bootstrap", () => {
|
describe("paseo daemon bootstrap", () => {
|
||||||
test("starts and serves health endpoint", async () => {
|
test("starts and serves health endpoint", async () => {
|
||||||
const daemonHandle = await createTestPaseoDaemon();
|
const daemonHandle = await createTestPaseoDaemon({
|
||||||
|
openai: { apiKey: "test-openai-api-key" },
|
||||||
|
speech: {
|
||||||
|
providers: {
|
||||||
|
dictationStt: { provider: "openai", explicit: true },
|
||||||
|
voiceStt: { provider: "openai", explicit: true },
|
||||||
|
voiceTts: { provider: "openai", explicit: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`http://127.0.0.1:${daemonHandle.port}/api/health`,
|
`http://127.0.0.1:${daemonHandle.port}/api/health`,
|
||||||
@@ -49,11 +58,12 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
appBaseUrl: "https://app.paseo.sh",
|
appBaseUrl: "https://app.paseo.sh",
|
||||||
openai: undefined,
|
openai: undefined,
|
||||||
speech: {
|
speech: {
|
||||||
dictationSttProvider: "openai",
|
providers: {
|
||||||
voiceSttProvider: "openai",
|
dictationStt: { provider: "openai", explicit: true },
|
||||||
voiceTtsProvider: "openai",
|
voiceStt: { provider: "openai", explicit: true },
|
||||||
|
voiceTts: { provider: "openai", explicit: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
openrouterApiKey: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createServer as createHTTPServer } from "http";
|
|||||||
import { createReadStream, unlinkSync, existsSync } from "fs";
|
import { createReadStream, unlinkSync, existsSync } from "fs";
|
||||||
import { stat } from "fs/promises";
|
import { stat } from "fs/promises";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
@@ -40,20 +41,10 @@ function parseListenString(listen: string): ListenTarget {
|
|||||||
|
|
||||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import { OpenAISTT, type STTConfig } from "./speech/providers/openai/stt.js";
|
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||||
import { OpenAITTS, type TTSConfig } from "./speech/providers/openai/tts.js";
|
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
||||||
import { OpenAIRealtimeTranscriptionSession } from "./speech/providers/openai/realtime-transcription-session.js";
|
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
import { initializeSpeechRuntime } from "./speech/speech-runtime.js";
|
||||||
import { SherpaOnlineRecognizerEngine } from "./speech/providers/local/sherpa/sherpa-online-recognizer.js";
|
|
||||||
import { SherpaOfflineRecognizerEngine } from "./speech/providers/local/sherpa/sherpa-offline-recognizer.js";
|
|
||||||
import { SherpaOnnxSTT } from "./speech/providers/local/sherpa/sherpa-stt.js";
|
|
||||||
import { SherpaOnnxParakeetSTT } from "./speech/providers/local/sherpa/sherpa-parakeet-stt.js";
|
|
||||||
import { SherpaOnnxTTS } from "./speech/providers/local/sherpa/sherpa-tts.js";
|
|
||||||
import { SherpaRealtimeTranscriptionSession } from "./speech/providers/local/sherpa/sherpa-realtime-session.js";
|
|
||||||
import { SherpaParakeetRealtimeTranscriptionSession } from "./speech/providers/local/sherpa/sherpa-parakeet-realtime-session.js";
|
|
||||||
import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./speech/providers/local/sherpa/model-downloader.js";
|
|
||||||
import type { SherpaOnnxModelId } from "./speech/providers/local/sherpa/model-catalog.js";
|
|
||||||
import { PocketTtsOnnxTTS } from "./speech/providers/local/pocket/pocket-tts-onnx.js";
|
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { AgentStorage } from "./agent/agent-storage.js";
|
import { AgentStorage } from "./agent/agent-storage.js";
|
||||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||||
@@ -73,35 +64,50 @@ import type {
|
|||||||
AgentClient,
|
AgentClient,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
|
import { AGENT_PROVIDER_DEFINITIONS } from "./agent/provider-manifest.js";
|
||||||
import { acquirePidLock, releasePidLock } from "./pid-lock.js";
|
import { acquirePidLock, releasePidLock } from "./pid-lock.js";
|
||||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||||
|
import { createVoiceMcpBridgeSocketServer, type VoiceMcpBridgeSocketServer } from "./voice-mcp-bridge.js";
|
||||||
|
|
||||||
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
||||||
|
|
||||||
export type PaseoOpenAIConfig = {
|
function resolveVoiceMcpBridgeCommand(logger: Logger): { command: string; baseArgs: string[] } {
|
||||||
apiKey?: string;
|
const explicit = process.env.PASEO_BIN_PATH?.trim();
|
||||||
stt?: Partial<STTConfig> & { apiKey?: string };
|
if (explicit) {
|
||||||
tts?: Partial<TTSConfig> & { apiKey?: string };
|
const resolved = { command: explicit, baseArgs: ["__paseo_voice_mcp_bridge"] };
|
||||||
};
|
logger.info({ source: "PASEO_BIN_PATH", command: resolved.command, baseArgs: resolved.baseArgs }, "Resolved voice MCP bridge command");
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
export type PaseoSherpaOnnxConfig = {
|
const argv1 = process.argv[1]?.trim();
|
||||||
modelsDir: string;
|
if (!argv1) {
|
||||||
autoDownload?: boolean;
|
logger.warn("Could not resolve argv[1] for voice MCP bridge; falling back to 'paseo'");
|
||||||
stt?: {
|
const resolved = { command: "paseo", baseArgs: ["__paseo_voice_mcp_bridge"] };
|
||||||
preset?: string;
|
logger.info({ source: "fallback", command: resolved.command, baseArgs: resolved.baseArgs }, "Resolved voice MCP bridge command");
|
||||||
};
|
return resolved;
|
||||||
tts?: {
|
}
|
||||||
preset?: string;
|
|
||||||
speakerId?: number;
|
const base = path.basename(argv1).toLowerCase();
|
||||||
speed?: number;
|
if (base.includes("tsx") && process.argv[2]) {
|
||||||
};
|
const resolved = {
|
||||||
};
|
command: process.execPath,
|
||||||
|
baseArgs: [argv1, process.argv[2], "__paseo_voice_mcp_bridge"],
|
||||||
|
};
|
||||||
|
logger.info({ source: "tsx", command: resolved.command, baseArgs: resolved.baseArgs }, "Resolved voice MCP bridge command");
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = { command: argv1, baseArgs: ["__paseo_voice_mcp_bridge"] };
|
||||||
|
logger.info({ source: "argv", command: resolved.command, baseArgs: resolved.baseArgs }, "Resolved voice MCP bridge command");
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
|
||||||
|
export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig;
|
||||||
|
|
||||||
export type PaseoSpeechConfig = {
|
export type PaseoSpeechConfig = {
|
||||||
dictationSttProvider?: "openai" | "local";
|
providers: RequestedSpeechProviders;
|
||||||
voiceSttProvider?: "openai" | "local";
|
local?: PaseoLocalSpeechConfig;
|
||||||
voiceTtsProvider?: "openai" | "local";
|
|
||||||
sherpaOnnx?: PaseoSherpaOnnxConfig;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaseoDaemonConfig = {
|
export type PaseoDaemonConfig = {
|
||||||
@@ -120,7 +126,8 @@ export type PaseoDaemonConfig = {
|
|||||||
appBaseUrl?: string;
|
appBaseUrl?: string;
|
||||||
openai?: PaseoOpenAIConfig;
|
openai?: PaseoOpenAIConfig;
|
||||||
speech?: PaseoSpeechConfig;
|
speech?: PaseoSpeechConfig;
|
||||||
openrouterApiKey?: string | null;
|
voiceLlmProvider?: AgentProvider | null;
|
||||||
|
voiceLlmProviderExplicit?: boolean;
|
||||||
voiceLlmModel?: string | null;
|
voiceLlmModel?: string | null;
|
||||||
dictationFinalTimeoutMs?: number;
|
dictationFinalTimeoutMs?: number;
|
||||||
downloadTokenTtlMs?: number;
|
downloadTokenTtlMs?: number;
|
||||||
@@ -282,12 +289,110 @@ export async function createPaseoDaemon(
|
|||||||
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`
|
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const requestedVoiceLlmProvider = config.voiceLlmProvider ?? null;
|
||||||
|
const voiceLlmProviderExplicit = config.voiceLlmProviderExplicit ?? false;
|
||||||
|
const voiceEnabledProviders = AGENT_PROVIDER_DEFINITIONS
|
||||||
|
.filter((definition) => definition.voice?.enabled)
|
||||||
|
.map((definition) => definition.id as AgentProvider);
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
requestedVoiceLlmProvider,
|
||||||
|
voiceLlmProviderExplicit,
|
||||||
|
voiceEnabledProviders,
|
||||||
|
},
|
||||||
|
"Voice LLM provider reconciliation started"
|
||||||
|
);
|
||||||
|
|
||||||
|
const providerClients = createAllClients(logger);
|
||||||
|
Object.assign(providerClients, config.agentClients);
|
||||||
|
const voiceLlmAvailability = Object.fromEntries(
|
||||||
|
voiceEnabledProviders.map((provider) => [provider, false])
|
||||||
|
) as Record<AgentProvider, boolean>;
|
||||||
|
for (const provider of voiceEnabledProviders) {
|
||||||
|
try {
|
||||||
|
voiceLlmAvailability[provider] = await providerClients[provider].isAvailable();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, provider }, "Voice LLM provider availability check failed");
|
||||||
|
voiceLlmAvailability[provider] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedVoiceLlmProvider: AgentProvider | null = null;
|
||||||
|
if (requestedVoiceLlmProvider) {
|
||||||
|
if (!voiceEnabledProviders.includes(requestedVoiceLlmProvider)) {
|
||||||
|
logger.error(
|
||||||
|
{ provider: requestedVoiceLlmProvider, voiceEnabledProviders },
|
||||||
|
"Configured voice LLM provider does not support voice mode"
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Configured voice LLM provider '${requestedVoiceLlmProvider}' does not support voice mode`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!voiceLlmAvailability[requestedVoiceLlmProvider]) {
|
||||||
|
logger.error(
|
||||||
|
{ provider: requestedVoiceLlmProvider, voiceLlmAvailability },
|
||||||
|
"Configured voice LLM provider is unavailable"
|
||||||
|
);
|
||||||
|
throw new Error(`Configured voice LLM provider '${requestedVoiceLlmProvider}' is unavailable`);
|
||||||
|
}
|
||||||
|
resolvedVoiceLlmProvider = requestedVoiceLlmProvider;
|
||||||
|
} else {
|
||||||
|
resolvedVoiceLlmProvider =
|
||||||
|
voiceEnabledProviders.find((provider) => voiceLlmAvailability[provider]) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedVoiceLlmModeId: string | null = null;
|
||||||
|
let resolvedVoiceLlmModel: string | null = null;
|
||||||
|
|
||||||
|
if (!resolvedVoiceLlmProvider) {
|
||||||
|
if (voiceLlmProviderExplicit) {
|
||||||
|
logger.error(
|
||||||
|
{ requestedVoiceLlmProvider, voiceLlmAvailability },
|
||||||
|
"No voice LLM provider available"
|
||||||
|
);
|
||||||
|
throw new Error("No voice LLM provider available");
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
{ requestedVoiceLlmProvider, voiceLlmAvailability },
|
||||||
|
"No default voice LLM provider available; voice mode will be disabled until a provider is configured"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const resolvedVoiceProviderDefinition = AGENT_PROVIDER_DEFINITIONS.find(
|
||||||
|
(definition) => definition.id === resolvedVoiceLlmProvider
|
||||||
|
);
|
||||||
|
if (!resolvedVoiceProviderDefinition?.voice?.enabled) {
|
||||||
|
throw new Error(
|
||||||
|
`Provider '${resolvedVoiceLlmProvider}' is missing voice metadata in agent registry`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolvedVoiceLlmModeId = resolvedVoiceProviderDefinition.voice.defaultModeId;
|
||||||
|
resolvedVoiceLlmModel =
|
||||||
|
config.voiceLlmModel ?? resolvedVoiceProviderDefinition.voice.defaultModel ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
requestedVoiceLlmProvider,
|
||||||
|
voiceLlmProviderExplicit,
|
||||||
|
resolvedVoiceLlmProvider,
|
||||||
|
resolvedVoiceLlmModeId,
|
||||||
|
resolvedVoiceLlmModel,
|
||||||
|
voiceLlmAvailability,
|
||||||
|
},
|
||||||
|
"Voice LLM provider reconciliation completed"
|
||||||
|
);
|
||||||
|
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||||
|
let voiceMcpBridgeServer: VoiceMcpBridgeSocketServer | null = null;
|
||||||
|
|
||||||
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
|
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
|
||||||
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
|
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
|
||||||
const agentMcpServer = await createAgentMcpServer({
|
const agentMcpServer = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
|
enableVoiceTools: false,
|
||||||
|
resolveSpeakHandler: (callerAgentId) => wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null,
|
||||||
|
resolveCallerContext: (callerAgentId) => wsServer?.resolveVoiceCallerContext(callerAgentId) ?? null,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,6 +414,9 @@ export async function createPaseoDaemon(
|
|||||||
agentStorage,
|
agentStorage,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
|
enableVoiceTools: false,
|
||||||
|
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
|
||||||
|
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -414,385 +522,37 @@ export async function createPaseoDaemon(
|
|||||||
logger.info("Agent MCP HTTP endpoint disabled");
|
logger.info("Agent MCP HTTP endpoint disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const voiceMcpSocketPath = path.join(config.paseoHome, "runtime", "voice-mcp.sock");
|
||||||
let sttService: SpeechToTextProvider | null = null;
|
const voiceMcpBridgeCommand = resolveVoiceMcpBridgeCommand(logger);
|
||||||
let ttsService: TextToSpeechProvider | null = null;
|
voiceMcpBridgeServer = createVoiceMcpBridgeSocketServer({
|
||||||
let dictationSttService: SpeechToTextProvider | null = null;
|
socketPath: voiceMcpSocketPath,
|
||||||
|
logger,
|
||||||
let sherpaOnline: SherpaOnlineRecognizerEngine | null = null;
|
createAgentMcpServerForCaller: async (callerAgentId) => {
|
||||||
let sherpaOffline: SherpaOfflineRecognizerEngine | null = null;
|
return createAgentMcpServer({
|
||||||
let sherpaTts: TextToSpeechProvider | null = null;
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
const openaiApiKey = config.openai?.apiKey;
|
paseoHome: config.paseoHome,
|
||||||
const speechConfig = config.speech ?? null;
|
callerAgentId,
|
||||||
const sherpaConfig = speechConfig?.sherpaOnnx ?? null;
|
enableVoiceTools: false,
|
||||||
|
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
|
||||||
const voiceSttProvider = speechConfig?.voiceSttProvider ?? "local";
|
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
|
||||||
const voiceTtsProvider = speechConfig?.voiceTtsProvider ?? "local";
|
|
||||||
const dictationSttProvider = speechConfig?.dictationSttProvider ?? "local";
|
|
||||||
|
|
||||||
const wantsLocalDictation = dictationSttProvider === "local";
|
|
||||||
const wantsLocalVoiceStt = voiceSttProvider === "local";
|
|
||||||
const wantsLocalVoiceTts = voiceTtsProvider === "local";
|
|
||||||
|
|
||||||
const openaiSttApiKey = config.openai?.stt?.apiKey ?? openaiApiKey;
|
|
||||||
const openaiTtsApiKey = config.openai?.tts?.apiKey ?? openaiApiKey;
|
|
||||||
const openaiDictationApiKey = openaiApiKey;
|
|
||||||
|
|
||||||
const missingOpenAiCredentialsFor: string[] = [];
|
|
||||||
if (voiceSttProvider === "openai" && !openaiSttApiKey) {
|
|
||||||
missingOpenAiCredentialsFor.push("voice.stt");
|
|
||||||
}
|
|
||||||
if (voiceTtsProvider === "openai" && !openaiTtsApiKey) {
|
|
||||||
missingOpenAiCredentialsFor.push("voice.tts");
|
|
||||||
}
|
|
||||||
if (dictationSttProvider === "openai" && !openaiDictationApiKey) {
|
|
||||||
missingOpenAiCredentialsFor.push("dictation.stt");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingOpenAiCredentialsFor.length > 0) {
|
|
||||||
logger.error(
|
|
||||||
{
|
|
||||||
requestedProviders: {
|
|
||||||
dictationStt: dictationSttProvider,
|
|
||||||
voiceStt: voiceSttProvider,
|
|
||||||
voiceTts: voiceTtsProvider,
|
|
||||||
},
|
|
||||||
missingOpenAiCredentialsFor,
|
|
||||||
},
|
|
||||||
"Invalid speech configuration: OpenAI provider selected but credentials are missing"
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`Missing OpenAI credentials for configured speech features: ${missingOpenAiCredentialsFor.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
requestedProviders: {
|
|
||||||
dictationStt: dictationSttProvider,
|
|
||||||
voiceStt: voiceSttProvider,
|
|
||||||
voiceTts: voiceTtsProvider,
|
|
||||||
},
|
|
||||||
availability: {
|
|
||||||
openai: {
|
|
||||||
stt: Boolean(openaiSttApiKey),
|
|
||||||
tts: Boolean(openaiTtsApiKey),
|
|
||||||
dictationStt: Boolean(openaiDictationApiKey),
|
|
||||||
},
|
|
||||||
local: {
|
|
||||||
configured: Boolean(sherpaConfig),
|
|
||||||
modelsDir: sherpaConfig?.modelsDir ?? null,
|
|
||||||
autoDownload: sherpaConfig?.autoDownload ?? null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Speech provider reconciliation started"
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((wantsLocalDictation || wantsLocalVoiceStt || wantsLocalVoiceTts) && sherpaConfig) {
|
|
||||||
const autoDownload = sherpaConfig.autoDownload ?? (process.env.VITEST ? false : true);
|
|
||||||
let sttPreset = (sherpaConfig.stt?.preset ?? "parakeet-tdt-0.6b-v3-int8").trim();
|
|
||||||
if (
|
|
||||||
sttPreset !== "zipformer-bilingual-zh-en-2023-02-20" &&
|
|
||||||
sttPreset !== "paraformer-bilingual-zh-en" &&
|
|
||||||
sttPreset !== "parakeet-tdt-0.6b-v3-int8"
|
|
||||||
) {
|
|
||||||
throw new Error(`Unknown local STT preset: ${sttPreset}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ttsPreset = (sherpaConfig.tts?.preset ?? "pocket-tts-onnx-int8").trim();
|
|
||||||
if (
|
|
||||||
ttsPreset !== "kitten-nano-en-v0_1-fp16" &&
|
|
||||||
ttsPreset !== "kokoro-en-v0_19" &&
|
|
||||||
ttsPreset !== "pocket-tts-onnx-int8"
|
|
||||||
) {
|
|
||||||
throw new Error(`Unknown local TTS preset: ${ttsPreset}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelIds: SherpaOnnxModelId[] = [];
|
|
||||||
if (wantsLocalDictation || wantsLocalVoiceStt) {
|
|
||||||
modelIds.push(sttPreset as SherpaOnnxModelId);
|
|
||||||
}
|
|
||||||
if (wantsLocalVoiceTts) {
|
|
||||||
modelIds.push(ttsPreset as SherpaOnnxModelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
modelsDir: sherpaConfig.modelsDir,
|
|
||||||
modelIds,
|
|
||||||
autoDownload,
|
|
||||||
},
|
|
||||||
"Ensuring local speech models"
|
|
||||||
);
|
|
||||||
await ensureSherpaOnnxModels({
|
|
||||||
modelsDir: sherpaConfig.modelsDir,
|
|
||||||
modelIds,
|
|
||||||
autoDownload,
|
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
},
|
||||||
logger.error(
|
});
|
||||||
{
|
const {
|
||||||
err,
|
sttService,
|
||||||
modelsDir: sherpaConfig.modelsDir,
|
ttsService,
|
||||||
autoDownload,
|
dictationSttService,
|
||||||
hint:
|
cleanup: cleanupSpeechRuntime,
|
||||||
"Run: npm run dev --workspace=@getpaseo/server, then run: " +
|
localModelConfig,
|
||||||
"`tsx packages/server/scripts/download-speech-models.ts --models-dir <DIR> --model <MODEL_ID>`",
|
} = await initializeSpeechRuntime({
|
||||||
},
|
logger,
|
||||||
"Failed to ensure local speech models"
|
openaiConfig: config.openai,
|
||||||
);
|
speechConfig: config.speech,
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if ((wantsLocalDictation || wantsLocalVoiceStt) && sherpaConfig) {
|
wsServer = new VoiceAssistantWebSocketServer(
|
||||||
let preset = (sherpaConfig.stt?.preset ?? "parakeet-tdt-0.6b-v3-int8").trim();
|
|
||||||
if (
|
|
||||||
preset !== "zipformer-bilingual-zh-en-2023-02-20" &&
|
|
||||||
preset !== "paraformer-bilingual-zh-en" &&
|
|
||||||
preset !== "parakeet-tdt-0.6b-v3-int8"
|
|
||||||
) {
|
|
||||||
throw new Error(`Unknown local STT preset: ${preset}`);
|
|
||||||
}
|
|
||||||
const base = sherpaConfig.modelsDir;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (preset === "parakeet-tdt-0.6b-v3-int8") {
|
|
||||||
const modelDir = getSherpaOnnxModelDir(base, "parakeet-tdt-0.6b-v3-int8");
|
|
||||||
sherpaOffline = new SherpaOfflineRecognizerEngine(
|
|
||||||
{
|
|
||||||
model: {
|
|
||||||
kind: "nemo_transducer",
|
|
||||||
encoder: `${modelDir}/encoder.int8.onnx`,
|
|
||||||
decoder: `${modelDir}/decoder.int8.onnx`,
|
|
||||||
joiner: `${modelDir}/joiner.int8.onnx`,
|
|
||||||
tokens: `${modelDir}/tokens.txt`,
|
|
||||||
},
|
|
||||||
numThreads: 2,
|
|
||||||
debug: 0,
|
|
||||||
},
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const model =
|
|
||||||
preset === "paraformer-bilingual-zh-en"
|
|
||||||
? {
|
|
||||||
kind: "paraformer" as const,
|
|
||||||
encoder: `${base}/sherpa-onnx-streaming-paraformer-bilingual-zh-en/encoder.int8.onnx`,
|
|
||||||
decoder: `${base}/sherpa-onnx-streaming-paraformer-bilingual-zh-en/decoder.int8.onnx`,
|
|
||||||
tokens: `${base}/sherpa-onnx-streaming-paraformer-bilingual-zh-en/tokens.txt`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
kind: "transducer" as const,
|
|
||||||
encoder: `${base}/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20/encoder-epoch-99-avg-1.onnx`,
|
|
||||||
decoder: `${base}/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20/decoder-epoch-99-avg-1.onnx`,
|
|
||||||
joiner: `${base}/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20/joiner-epoch-99-avg-1.onnx`,
|
|
||||||
tokens: `${base}/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20/tokens.txt`,
|
|
||||||
modelType: "zipformer",
|
|
||||||
};
|
|
||||||
|
|
||||||
sherpaOnline = new SherpaOnlineRecognizerEngine(
|
|
||||||
{
|
|
||||||
model,
|
|
||||||
numThreads: 1,
|
|
||||||
debug: 0,
|
|
||||||
},
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
{
|
|
||||||
err,
|
|
||||||
modelsDir: sherpaConfig.modelsDir,
|
|
||||||
preset,
|
|
||||||
hint: `Run: tsx packages/server/scripts/download-speech-models.ts --models-dir '${sherpaConfig.modelsDir}' --model '${preset}'`,
|
|
||||||
},
|
|
||||||
"Failed to initialize Sherpa STT (models missing or invalid)"
|
|
||||||
);
|
|
||||||
sherpaOnline = null;
|
|
||||||
sherpaOffline = null;
|
|
||||||
}
|
|
||||||
} else if (wantsLocalDictation || wantsLocalVoiceStt) {
|
|
||||||
logger.warn(
|
|
||||||
{ configured: Boolean(sherpaConfig) },
|
|
||||||
"Local STT selected but local provider config is missing; STT will be unavailable"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wantsLocalVoiceTts && sherpaConfig) {
|
|
||||||
let preset = (sherpaConfig.tts?.preset ?? "pocket-tts-onnx-int8").trim();
|
|
||||||
if (
|
|
||||||
preset !== "kitten-nano-en-v0_1-fp16" &&
|
|
||||||
preset !== "kokoro-en-v0_19" &&
|
|
||||||
preset !== "pocket-tts-onnx-int8"
|
|
||||||
) {
|
|
||||||
throw new Error(`Unknown local TTS preset: ${preset}`);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (preset === "pocket-tts-onnx-int8") {
|
|
||||||
const modelDir = getSherpaOnnxModelDir(sherpaConfig.modelsDir, "pocket-tts-onnx-int8");
|
|
||||||
sherpaTts = await PocketTtsOnnxTTS.create(
|
|
||||||
{
|
|
||||||
modelDir,
|
|
||||||
precision: "int8",
|
|
||||||
targetChunkMs: 50,
|
|
||||||
},
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const modelDir = `${sherpaConfig.modelsDir}/${preset}`;
|
|
||||||
sherpaTts = new SherpaOnnxTTS(
|
|
||||||
{
|
|
||||||
preset: preset as any,
|
|
||||||
modelDir,
|
|
||||||
speakerId: sherpaConfig.tts?.speakerId,
|
|
||||||
speed: sherpaConfig.tts?.speed,
|
|
||||||
},
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
{
|
|
||||||
err,
|
|
||||||
preset,
|
|
||||||
hint: `Run: tsx packages/server/scripts/download-speech-models.ts --models-dir '${sherpaConfig.modelsDir}' --model '${preset}'`,
|
|
||||||
},
|
|
||||||
"Failed to initialize Sherpa TTS (models missing or invalid)"
|
|
||||||
);
|
|
||||||
sherpaTts = null;
|
|
||||||
}
|
|
||||||
} else if (wantsLocalVoiceTts) {
|
|
||||||
logger.warn(
|
|
||||||
{ configured: Boolean(sherpaConfig) },
|
|
||||||
"Local TTS selected but local provider config is missing; TTS will be unavailable"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wantsLocalVoiceStt && sherpaOffline) {
|
|
||||||
sttService = new SherpaOnnxParakeetSTT({ engine: sherpaOffline }, logger);
|
|
||||||
} else if (wantsLocalVoiceStt && sherpaOnline) {
|
|
||||||
sttService = new SherpaOnnxSTT({ engine: sherpaOnline }, logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wantsLocalVoiceTts && sherpaTts) {
|
|
||||||
ttsService = sherpaTts;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wantsLocalDictation && sherpaOnline) {
|
|
||||||
dictationSttService = {
|
|
||||||
id: "local",
|
|
||||||
createSession: () => new SherpaRealtimeTranscriptionSession({ engine: sherpaOnline! }),
|
|
||||||
};
|
|
||||||
} else if (wantsLocalDictation && sherpaOffline) {
|
|
||||||
dictationSttService = {
|
|
||||||
id: "local",
|
|
||||||
createSession: () =>
|
|
||||||
new SherpaParakeetRealtimeTranscriptionSession({ engine: sherpaOffline! }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const needsOpenAiStt = !sttService && voiceSttProvider === "openai";
|
|
||||||
const needsOpenAiTts = !ttsService && voiceTtsProvider === "openai";
|
|
||||||
const needsOpenAiDictation = dictationSttProvider === "openai";
|
|
||||||
|
|
||||||
if (
|
|
||||||
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&
|
|
||||||
(openaiSttApiKey || openaiTtsApiKey || openaiDictationApiKey)
|
|
||||||
) {
|
|
||||||
logger.info("OpenAI speech provider initialized");
|
|
||||||
|
|
||||||
if (needsOpenAiStt) {
|
|
||||||
if (openaiSttApiKey) {
|
|
||||||
const { apiKey: _sttApiKey, ...sttConfig } = config.openai?.stt ?? {};
|
|
||||||
sttService = new OpenAISTT(
|
|
||||||
{
|
|
||||||
apiKey: openaiSttApiKey,
|
|
||||||
...sttConfig,
|
|
||||||
},
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsOpenAiTts) {
|
|
||||||
if (openaiTtsApiKey) {
|
|
||||||
const { apiKey: _ttsApiKey, ...ttsConfig } = config.openai?.tts ?? {};
|
|
||||||
ttsService = new OpenAITTS(
|
|
||||||
{
|
|
||||||
apiKey: openaiTtsApiKey,
|
|
||||||
voice: "alloy",
|
|
||||||
model: "tts-1",
|
|
||||||
responseFormat: "pcm",
|
|
||||||
...ttsConfig,
|
|
||||||
},
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsOpenAiDictation) {
|
|
||||||
const transcriptionModel =
|
|
||||||
process.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? "gpt-4o-transcribe";
|
|
||||||
|
|
||||||
dictationSttService = {
|
|
||||||
id: "openai",
|
|
||||||
createSession: ({ logger: sessionLogger, language, prompt }) =>
|
|
||||||
new OpenAIRealtimeTranscriptionSession({
|
|
||||||
apiKey: openaiDictationApiKey!,
|
|
||||||
logger: sessionLogger,
|
|
||||||
transcriptionModel,
|
|
||||||
...(language ? { language } : {}),
|
|
||||||
...(prompt ? { prompt } : {}),
|
|
||||||
turnDetection: null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if (needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) {
|
|
||||||
logger.warn("OPENAI_API_KEY not set - OpenAI STT/TTS/dictation is unavailable");
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveProviders = {
|
|
||||||
dictationStt: dictationSttService?.id ?? "unavailable",
|
|
||||||
voiceStt: sttService?.id ?? "unavailable",
|
|
||||||
voiceTts: !ttsService ? "unavailable" : ttsService === sherpaTts ? "local" : "openai",
|
|
||||||
};
|
|
||||||
const unavailableFeatures = [
|
|
||||||
!dictationSttService ? "dictation.stt" : null,
|
|
||||||
!sttService ? "voice.stt" : null,
|
|
||||||
!ttsService ? "voice.tts" : null,
|
|
||||||
].filter((feature): feature is string => feature !== null);
|
|
||||||
|
|
||||||
if (unavailableFeatures.length > 0) {
|
|
||||||
logger.error(
|
|
||||||
{
|
|
||||||
requestedProviders: {
|
|
||||||
dictationStt: dictationSttProvider,
|
|
||||||
voiceStt: voiceSttProvider,
|
|
||||||
voiceTts: voiceTtsProvider,
|
|
||||||
},
|
|
||||||
effectiveProviders,
|
|
||||||
unavailableFeatures,
|
|
||||||
},
|
|
||||||
"Speech provider reconciliation failed: configured features are unavailable"
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`Configured speech features unavailable: ${unavailableFeatures.join(", ")}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
effectiveProviders,
|
|
||||||
},
|
|
||||||
"Speech provider reconciliation completed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsServer = new VoiceAssistantWebSocketServer(
|
|
||||||
httpServer,
|
httpServer,
|
||||||
logger,
|
logger,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -805,12 +565,26 @@ export async function createPaseoDaemon(
|
|||||||
{ stt: sttService, tts: ttsService },
|
{ stt: sttService, tts: ttsService },
|
||||||
terminalManager,
|
terminalManager,
|
||||||
{
|
{
|
||||||
openrouterApiKey: config.openrouterApiKey ?? null,
|
voiceLlmProvider: resolvedVoiceLlmProvider,
|
||||||
voiceLlmModel: config.voiceLlmModel ?? null,
|
voiceLlmModeId: resolvedVoiceLlmModeId,
|
||||||
|
voiceLlmProviderExplicit,
|
||||||
|
voiceLlmModel: resolvedVoiceLlmModel,
|
||||||
|
voiceAgentMcpStdio: {
|
||||||
|
command: voiceMcpBridgeCommand.command,
|
||||||
|
baseArgs: [
|
||||||
|
...voiceMcpBridgeCommand.baseArgs,
|
||||||
|
"--socket",
|
||||||
|
voiceMcpSocketPath,
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
PASEO_HOME: config.paseoHome,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
||||||
stt: dictationSttService,
|
stt: dictationSttService,
|
||||||
|
localModels: localModelConfig ?? undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -870,7 +644,12 @@ export async function createPaseoDaemon(
|
|||||||
relayTransport?.stop().catch(() => undefined);
|
relayTransport?.stop().catch(() => undefined);
|
||||||
relayTransport = startRelayTransport({
|
relayTransport = startRelayTransport({
|
||||||
logger,
|
logger,
|
||||||
attachSocket: (ws) => wsServer.attachExternalSocket(ws),
|
attachSocket: (ws) => {
|
||||||
|
if (!wsServer) {
|
||||||
|
throw new Error("WebSocket server not initialized");
|
||||||
|
}
|
||||||
|
return wsServer.attachExternalSocket(ws);
|
||||||
|
},
|
||||||
relayEndpoint,
|
relayEndpoint,
|
||||||
serverId,
|
serverId,
|
||||||
daemonKeyPair: daemonKeyPair.keyPair,
|
daemonKeyPair: daemonKeyPair.keyPair,
|
||||||
@@ -896,6 +675,9 @@ export async function createPaseoDaemon(
|
|||||||
httpServer.listen(listenTarget.path);
|
httpServer.listen(listenTarget.path);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (voiceMcpBridgeServer) {
|
||||||
|
await voiceMcpBridgeServer.start();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
@@ -905,13 +687,14 @@ export async function createPaseoDaemon(
|
|||||||
await agentStorage.flush().catch(() => undefined);
|
await agentStorage.flush().catch(() => undefined);
|
||||||
await shutdownProviders(logger);
|
await shutdownProviders(logger);
|
||||||
terminalManager.killAll();
|
terminalManager.killAll();
|
||||||
if (sherpaTts && typeof (sherpaTts as any).free === "function") {
|
cleanupSpeechRuntime();
|
||||||
(sherpaTts as any).free();
|
|
||||||
}
|
|
||||||
sherpaOnline?.free();
|
|
||||||
sherpaOffline?.free();
|
|
||||||
await relayTransport?.stop().catch(() => undefined);
|
await relayTransport?.stop().catch(() => undefined);
|
||||||
await wsServer.close();
|
if (wsServer) {
|
||||||
|
await wsServer.close();
|
||||||
|
}
|
||||||
|
if (voiceMcpBridgeServer) {
|
||||||
|
await voiceMcpBridgeServer.stop().catch(() => undefined);
|
||||||
|
}
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
httpServer.close(() => resolve());
|
httpServer.close(() => resolve());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { PaseoDaemonConfig } from "./bootstrap.js";
|
import type { PaseoDaemonConfig } from "./bootstrap.js";
|
||||||
import type { STTConfig } from "./speech/providers/openai/stt.js";
|
|
||||||
import type { TTSConfig } from "./speech/providers/openai/tts.js";
|
|
||||||
import { loadPersistedConfig } from "./persisted-config.js";
|
import { loadPersistedConfig } from "./persisted-config.js";
|
||||||
|
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||||
|
import { AgentProviderSchema } from "./agent/provider-manifest.js";
|
||||||
|
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
|
||||||
import {
|
import {
|
||||||
mergeAllowedHosts,
|
mergeAllowedHosts,
|
||||||
parseAllowedHostsEnv,
|
parseAllowedHostsEnv,
|
||||||
@@ -13,7 +15,6 @@ import {
|
|||||||
const DEFAULT_PORT = 6767;
|
const DEFAULT_PORT = 6767;
|
||||||
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||||
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
|
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
|
||||||
|
|
||||||
function getDefaultListen(): string {
|
function getDefaultListen(): string {
|
||||||
// Main HTTP server defaults to TCP
|
// Main HTTP server defaults to TCP
|
||||||
return `127.0.0.1:${DEFAULT_PORT}`;
|
return `127.0.0.1:${DEFAULT_PORT}`;
|
||||||
@@ -26,97 +27,14 @@ export type CliConfigOverrides = Partial<{
|
|||||||
allowedHosts: AllowedHostsConfig;
|
allowedHosts: AllowedHostsConfig;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function parseOpenAIConfig(
|
const OptionalVoiceLlmProviderSchema = z
|
||||||
env: NodeJS.ProcessEnv,
|
.union([z.string(), z.null(), z.undefined()])
|
||||||
configApiKey: string | undefined,
|
.transform((value): string | null => (typeof value === "string" ? value.trim().toLowerCase() : null))
|
||||||
config: {
|
.pipe(z.union([AgentProviderSchema, z.null()]));
|
||||||
dictationSttModel?: string;
|
|
||||||
dictationSttConfidenceThreshold?: number;
|
|
||||||
voiceSttModel?: string;
|
|
||||||
voiceTtsVoice?: TTSConfig["voice"];
|
|
||||||
voiceTtsModel?: TTSConfig["model"];
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const apiKey = env.OPENAI_API_KEY ?? configApiKey;
|
|
||||||
if (!apiKey) return undefined;
|
|
||||||
|
|
||||||
const sttConfidenceThreshold = env.STT_CONFIDENCE_THRESHOLD
|
function parseOptionalVoiceLlmProvider(value: unknown): AgentProvider | null {
|
||||||
? parseFloat(env.STT_CONFIDENCE_THRESHOLD)
|
const parsed = OptionalVoiceLlmProviderSchema.safeParse(value);
|
||||||
: config.dictationSttConfidenceThreshold;
|
return parsed.success ? parsed.data : null;
|
||||||
const sttModel = (
|
|
||||||
env.STT_MODEL ??
|
|
||||||
config.voiceSttModel ??
|
|
||||||
config.dictationSttModel
|
|
||||||
) as STTConfig["model"] | undefined;
|
|
||||||
const ttsVoice = (env.TTS_VOICE || "alloy") as
|
|
||||||
| "alloy"
|
|
||||||
| "echo"
|
|
||||||
| "fable"
|
|
||||||
| "onyx"
|
|
||||||
| "nova"
|
|
||||||
| "shimmer";
|
|
||||||
const ttsModel = (env.TTS_MODEL || config.voiceTtsModel || "tts-1") as
|
|
||||||
| "tts-1"
|
|
||||||
| "tts-1-hd";
|
|
||||||
const configuredVoice = config.voiceTtsVoice;
|
|
||||||
|
|
||||||
return {
|
|
||||||
apiKey,
|
|
||||||
stt: {
|
|
||||||
apiKey,
|
|
||||||
confidenceThreshold: sttConfidenceThreshold,
|
|
||||||
...(sttModel ? { model: sttModel } : {}),
|
|
||||||
},
|
|
||||||
tts: {
|
|
||||||
apiKey,
|
|
||||||
voice: configuredVoice ?? ttsVoice,
|
|
||||||
model: ttsModel,
|
|
||||||
responseFormat: "pcm" as TTSConfig["responseFormat"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSpeechProviderId(value: unknown): "openai" | "local" | null {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (normalized === "openai") return "openai";
|
|
||||||
if (normalized === "local") return "local";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSherpaSttPreset(value: string): string {
|
|
||||||
const raw = value.trim();
|
|
||||||
const normalized = raw.toLowerCase();
|
|
||||||
if (normalized === "zipformer" || normalized === "zipformer-bilingual") {
|
|
||||||
return "zipformer-bilingual-zh-en-2023-02-20";
|
|
||||||
}
|
|
||||||
if (normalized === "paraformer") {
|
|
||||||
return "paraformer-bilingual-zh-en";
|
|
||||||
}
|
|
||||||
if (normalized === "parakeet" || normalized === "parakeet-v3" || normalized === "parakeet-tdt") {
|
|
||||||
return "parakeet-tdt-0.6b-v3-int8";
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSherpaTtsPreset(value: string): string {
|
|
||||||
const raw = value.trim();
|
|
||||||
const normalized = raw.toLowerCase();
|
|
||||||
if (normalized === "pocket" || normalized === "pocket-tts") {
|
|
||||||
return "pocket-tts-onnx-int8";
|
|
||||||
}
|
|
||||||
if (normalized === "kitten") {
|
|
||||||
return "kitten-nano-en-v0_1-fp16";
|
|
||||||
}
|
|
||||||
if (normalized === "kokoro") {
|
|
||||||
return "kokoro-en-v0_19";
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(
|
export function loadConfig(
|
||||||
@@ -171,81 +89,19 @@ export function loadConfig(
|
|||||||
const appBaseUrl =
|
const appBaseUrl =
|
||||||
env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL;
|
env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL;
|
||||||
|
|
||||||
const openai = parseOpenAIConfig(env, persisted.providers?.openai?.apiKey, {
|
const { openai, speech } = resolveSpeechConfig({
|
||||||
dictationSttModel: persisted.features?.dictation?.stt?.model,
|
paseoHome,
|
||||||
dictationSttConfidenceThreshold:
|
env,
|
||||||
persisted.features?.dictation?.stt?.confidenceThreshold,
|
persisted,
|
||||||
voiceSttModel: persisted.features?.voiceMode?.stt?.model,
|
|
||||||
voiceTtsModel: persisted.features?.voiceMode?.tts?.model,
|
|
||||||
voiceTtsVoice: persisted.features?.voiceMode?.tts?.voice,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const dictationSttProvider =
|
const envVoiceLlmProvider = parseOptionalVoiceLlmProvider(env.PASEO_VOICE_LLM_PROVIDER);
|
||||||
parseSpeechProviderId(env.PASEO_DICTATION_STT_PROVIDER) ??
|
const persistedVoiceLlmProvider = parseOptionalVoiceLlmProvider(
|
||||||
parseSpeechProviderId(persisted.features?.dictation?.stt?.provider) ??
|
persisted.features?.voiceMode?.llm?.provider
|
||||||
"local";
|
);
|
||||||
|
const voiceLlmProvider = envVoiceLlmProvider ?? persistedVoiceLlmProvider ?? null;
|
||||||
const voiceSttProvider =
|
const voiceLlmProviderExplicit =
|
||||||
parseSpeechProviderId(env.PASEO_VOICE_STT_PROVIDER) ??
|
envVoiceLlmProvider !== null || persistedVoiceLlmProvider !== null;
|
||||||
parseSpeechProviderId(persisted.features?.voiceMode?.stt?.provider) ??
|
|
||||||
"local";
|
|
||||||
|
|
||||||
const voiceTtsProvider =
|
|
||||||
parseSpeechProviderId(env.PASEO_VOICE_TTS_PROVIDER) ??
|
|
||||||
parseSpeechProviderId(persisted.features?.voiceMode?.tts?.provider) ??
|
|
||||||
"local";
|
|
||||||
|
|
||||||
const shouldConfigureSherpa =
|
|
||||||
dictationSttProvider === "local" ||
|
|
||||||
voiceSttProvider === "local" ||
|
|
||||||
voiceTtsProvider === "local" ||
|
|
||||||
typeof env.PASEO_SHERPA_ONNX_MODELS_DIR === "string" ||
|
|
||||||
Boolean(persisted.providers?.sherpaOnnx);
|
|
||||||
|
|
||||||
const sherpaModelsDir =
|
|
||||||
(env.PASEO_SHERPA_ONNX_MODELS_DIR ?? persisted.providers?.sherpaOnnx?.modelsDir)?.trim() ||
|
|
||||||
path.join(paseoHome, "models", "sherpa-onnx");
|
|
||||||
|
|
||||||
const sherpaOnnx = shouldConfigureSherpa
|
|
||||||
? {
|
|
||||||
modelsDir: sherpaModelsDir,
|
|
||||||
autoDownload:
|
|
||||||
env.PASEO_SHERPA_ONNX_AUTO_DOWNLOAD !== undefined
|
|
||||||
? env.PASEO_SHERPA_ONNX_AUTO_DOWNLOAD === "1"
|
|
||||||
: persisted.providers?.sherpaOnnx?.autoDownload ??
|
|
||||||
// In tests we should never hit the network unexpectedly.
|
|
||||||
Boolean(env.VITEST) === false,
|
|
||||||
stt: {
|
|
||||||
preset: normalizeSherpaSttPreset(
|
|
||||||
(env.PASEO_SHERPA_STT_PRESET ?? persisted.providers?.sherpaOnnx?.stt?.preset)?.trim() ||
|
|
||||||
(persisted.features?.voiceMode?.stt?.preset ??
|
|
||||||
persisted.features?.dictation?.stt?.preset)?.trim() ||
|
|
||||||
"parakeet-tdt-0.6b-v3-int8"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
tts: {
|
|
||||||
preset: normalizeSherpaTtsPreset(
|
|
||||||
(env.PASEO_SHERPA_TTS_PRESET ??
|
|
||||||
persisted.providers?.sherpaOnnx?.tts?.preset ??
|
|
||||||
persisted.features?.voiceMode?.tts?.preset)?.trim() ||
|
|
||||||
(env.VITEST ? "kitten-nano-en-v0_1-fp16" : "pocket-tts-onnx-int8")
|
|
||||||
),
|
|
||||||
speakerId:
|
|
||||||
env.PASEO_SHERPA_TTS_SPEAKER_ID !== undefined
|
|
||||||
? Number.parseInt(env.PASEO_SHERPA_TTS_SPEAKER_ID, 10)
|
|
||||||
: persisted.providers?.sherpaOnnx?.tts?.speakerId ??
|
|
||||||
persisted.features?.voiceMode?.tts?.speakerId,
|
|
||||||
speed:
|
|
||||||
env.PASEO_SHERPA_TTS_SPEED !== undefined
|
|
||||||
? Number.parseFloat(env.PASEO_SHERPA_TTS_SPEED)
|
|
||||||
: persisted.providers?.sherpaOnnx?.tts?.speed ??
|
|
||||||
persisted.features?.voiceMode?.tts?.speed,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const openrouterApiKey =
|
|
||||||
env.OPENROUTER_API_KEY ?? persisted.providers?.openrouter?.apiKey ?? null;
|
|
||||||
const voiceLlmModel = persisted.features?.voiceMode?.llm?.model ?? null;
|
const voiceLlmModel = persisted.features?.voiceMode?.llm?.model ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -265,13 +121,9 @@ export function loadConfig(
|
|||||||
relayPublicEndpoint,
|
relayPublicEndpoint,
|
||||||
appBaseUrl,
|
appBaseUrl,
|
||||||
openai,
|
openai,
|
||||||
speech: {
|
speech,
|
||||||
dictationSttProvider,
|
voiceLlmProvider,
|
||||||
voiceSttProvider,
|
voiceLlmProviderExplicit,
|
||||||
voiceTtsProvider,
|
|
||||||
...(sherpaOnnx ? { sherpaOnnx } : {}),
|
|
||||||
},
|
|
||||||
openrouterApiKey,
|
|
||||||
voiceLlmModel,
|
voiceLlmModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
|||||||
import { tmpdir, homedir } from "node:os";
|
import { tmpdir, homedir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createDaemonTestContext,
|
createDaemonTestContext,
|
||||||
@@ -18,9 +19,9 @@ import {
|
|||||||
|
|
||||||
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
||||||
|
|
||||||
const sherpaModelsDir =
|
const localModelsDir =
|
||||||
process.env.PASEO_SHERPA_ONNX_MODELS_DIR ??
|
process.env.PASEO_LOCAL_MODELS_DIR ??
|
||||||
path.join(homedir(), ".paseo", "models", "sherpa-onnx");
|
path.join(homedir(), ".paseo", "models", "local-speech");
|
||||||
|
|
||||||
function hasSherpaZipformerModels(modelsDir: string): boolean {
|
function hasSherpaZipformerModels(modelsDir: string): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +50,7 @@ function hasSherpaKittenModels(modelsDir: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasLocalSpeech = hasSherpaZipformerModels(sherpaModelsDir) && hasSherpaKittenModels(sherpaModelsDir);
|
const hasLocalSpeech = hasSherpaZipformerModels(localModelsDir) && hasSherpaKittenModels(localModelsDir);
|
||||||
const hasAnySpeech = hasLocalSpeech || Boolean(openaiApiKey);
|
const hasAnySpeech = hasLocalSpeech || Boolean(openaiApiKey);
|
||||||
const speechTest = hasAnySpeech ? test : test.skip;
|
const speechTest = hasAnySpeech ? test : test.skip;
|
||||||
|
|
||||||
@@ -92,23 +93,42 @@ describe("daemon client E2E", () => {
|
|||||||
let ctx: DaemonTestContext;
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
const speechConfig =
|
||||||
|
openaiApiKey
|
||||||
|
? {
|
||||||
|
providers: {
|
||||||
|
dictationStt: { provider: "openai" as const, explicit: true },
|
||||||
|
voiceStt: { provider: "openai" as const, explicit: true },
|
||||||
|
voiceTts: { provider: "openai" as const, explicit: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: hasLocalSpeech
|
||||||
|
? {
|
||||||
|
providers: {
|
||||||
|
dictationStt: { provider: "local" as const, explicit: true },
|
||||||
|
voiceStt: { provider: "local" as const, explicit: true },
|
||||||
|
voiceTts: { provider: "local" as const, explicit: true },
|
||||||
|
},
|
||||||
|
local: {
|
||||||
|
modelsDir: localModelsDir,
|
||||||
|
models: {
|
||||||
|
dictationStt:
|
||||||
|
process.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
||||||
|
"zipformer-bilingual-zh-en-2023-02-20",
|
||||||
|
voiceStt:
|
||||||
|
process.env.PASEO_VOICE_LOCAL_STT_MODEL ??
|
||||||
|
"zipformer-bilingual-zh-en-2023-02-20",
|
||||||
|
voiceTts:
|
||||||
|
process.env.PASEO_VOICE_LOCAL_TTS_MODEL ?? "kitten-nano-en-v0_1-fp16",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
ctx = await createDaemonTestContext({
|
ctx = await createDaemonTestContext({
|
||||||
dictationFinalTimeoutMs: 5000,
|
dictationFinalTimeoutMs: 5000,
|
||||||
...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}),
|
...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}),
|
||||||
speech: {
|
...(speechConfig ? { speech: speechConfig } : {}),
|
||||||
dictationSttProvider: "local",
|
|
||||||
voiceSttProvider: "local",
|
|
||||||
voiceTtsProvider: "local",
|
|
||||||
sherpaOnnx: {
|
|
||||||
modelsDir: sherpaModelsDir,
|
|
||||||
stt: {
|
|
||||||
preset: process.env.PASEO_SHERPA_STT_PRESET ?? "zipformer-bilingual-zh-en-2023-02-20",
|
|
||||||
},
|
|
||||||
tts: {
|
|
||||||
preset: process.env.PASEO_SHERPA_TTS_PRESET ?? "kitten-nano-en-v0_1-fp16",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
@@ -119,22 +139,18 @@ describe("daemon client E2E", () => {
|
|||||||
test("handles session actions", async () => {
|
test("handles session actions", async () => {
|
||||||
expect(ctx.client.isConnected).toBe(true);
|
expect(ctx.client.isConnected).toBe(true);
|
||||||
|
|
||||||
const voiceConversationId = `voice-${Date.now()}`;
|
|
||||||
const loadResult = await ctx.client.loadVoiceConversation(voiceConversationId);
|
|
||||||
expect(loadResult.voiceConversationId).toBe(voiceConversationId);
|
|
||||||
expect(typeof loadResult.messageCount).toBe("number");
|
|
||||||
|
|
||||||
const agents = await ctx.client.fetchAgents();
|
const agents = await ctx.client.fetchAgents();
|
||||||
expect(Array.isArray(agents)).toBe(true);
|
expect(Array.isArray(agents)).toBe(true);
|
||||||
|
|
||||||
const listResult = await ctx.client.listVoiceConversations();
|
const voiceAgents = await ctx.client.fetchAgents({
|
||||||
expect(Array.isArray(listResult.conversations)).toBe(true);
|
filter: { labels: { surface: "voice" } },
|
||||||
|
});
|
||||||
|
expect(Array.isArray(voiceAgents)).toBe(true);
|
||||||
|
|
||||||
const missingId = `missing-${Date.now()}`;
|
await expect(ctx.client.setVoiceMode(true)).resolves.toBeUndefined();
|
||||||
const deleteResult = await ctx.client.deleteVoiceConversation(missingId);
|
await expect(ctx.client.setVoiceMode(false)).resolves.toBeUndefined();
|
||||||
expect(deleteResult.voiceConversationId).toBe(missingId);
|
|
||||||
expect(deleteResult.success).toBe(false);
|
await ctx.client.deleteAgent(randomUUID());
|
||||||
expect(deleteResult.error).toBeTruthy();
|
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test("emits server_info on websocket connect", async () => {
|
test("emits server_info on websocket connect", async () => {
|
||||||
@@ -160,19 +176,23 @@ describe("daemon client E2E", () => {
|
|||||||
await client.close();
|
await client.close();
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
test("matches request IDs for concurrent session requests", async () => {
|
test("handles concurrent filtered agent fetch requests", async () => {
|
||||||
const firstRequestId = `list-${Date.now()}-a`;
|
const firstRequestId = `fetch-${Date.now()}-a`;
|
||||||
const secondRequestId = `list-${Date.now()}-b`;
|
const secondRequestId = `fetch-${Date.now()}-b`;
|
||||||
|
|
||||||
const [first, second] = await Promise.all([
|
const [first, second] = await Promise.all([
|
||||||
ctx.client.listVoiceConversations(firstRequestId),
|
ctx.client.fetchAgents({
|
||||||
ctx.client.listVoiceConversations(secondRequestId),
|
requestId: firstRequestId,
|
||||||
|
filter: { labels: { surface: "voice" } },
|
||||||
|
}),
|
||||||
|
ctx.client.fetchAgents({
|
||||||
|
requestId: secondRequestId,
|
||||||
|
filter: { labels: { surface: "voice" } },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(Array.isArray(first.conversations)).toBe(true);
|
expect(Array.isArray(first)).toBe(true);
|
||||||
expect(Array.isArray(second.conversations)).toBe(true);
|
expect(Array.isArray(second)).toBe(true);
|
||||||
expect(first.requestId).toBe(firstRequestId);
|
|
||||||
expect(second.requestId).toBe(secondRequestId);
|
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
@@ -355,7 +375,7 @@ describe("daemon client E2E", () => {
|
|||||||
expect(sawAssistantMessage).toBe(true);
|
expect(sawAssistantMessage).toBe(true);
|
||||||
expect(sawRawAssistantMessage).toBe(true);
|
expect(sawRawAssistantMessage).toBe(true);
|
||||||
|
|
||||||
await ctx.client.setVoiceConversation(false);
|
await ctx.client.setVoiceMode(false);
|
||||||
|
|
||||||
await ctx.client.abortRequest();
|
await ctx.client.abortRequest();
|
||||||
await ctx.client.audioPlayed("audio-1");
|
await ctx.client.audioPlayed("audio-1");
|
||||||
@@ -566,16 +586,16 @@ describe("daemon client E2E", () => {
|
|||||||
120000
|
120000
|
||||||
);
|
);
|
||||||
|
|
||||||
test.runIf(Boolean(process.env.OPENROUTER_API_KEY))(
|
speechTest(
|
||||||
"streams session activity logs and chunks",
|
"does not process non-voice audio through the voice agent path",
|
||||||
async () => {
|
async () => {
|
||||||
await ctx.client.setVoiceConversation(false);
|
await ctx.client.setVoiceMode(false);
|
||||||
|
|
||||||
let sawAssistantChunk = false;
|
|
||||||
let sawTranscriptLog = false;
|
let sawTranscriptLog = false;
|
||||||
|
let sawAssistantChunk = false;
|
||||||
let sawAssistantLog = false;
|
let sawAssistantLog = false;
|
||||||
|
|
||||||
const completion = waitForSignal(60000, (resolve) => {
|
const transcriptSeen = waitForSignal(60000, (resolve) => {
|
||||||
const unsubscribeChunk = ctx.client.on("assistant_chunk", (message) => {
|
const unsubscribeChunk = ctx.client.on("assistant_chunk", (message) => {
|
||||||
if (message.type !== "assistant_chunk") {
|
if (message.type !== "assistant_chunk") {
|
||||||
return;
|
return;
|
||||||
@@ -583,9 +603,6 @@ describe("daemon client E2E", () => {
|
|||||||
if (message.payload.chunk.length > 0) {
|
if (message.payload.chunk.length > 0) {
|
||||||
sawAssistantChunk = true;
|
sawAssistantChunk = true;
|
||||||
}
|
}
|
||||||
if (sawAssistantChunk && sawTranscriptLog && sawAssistantLog) {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubscribeActivity = ctx.client.on("activity_log", (message) => {
|
const unsubscribeActivity = ctx.client.on("activity_log", (message) => {
|
||||||
@@ -594,13 +611,11 @@ describe("daemon client E2E", () => {
|
|||||||
}
|
}
|
||||||
if (message.payload.type === "transcript") {
|
if (message.payload.type === "transcript") {
|
||||||
sawTranscriptLog = true;
|
sawTranscriptLog = true;
|
||||||
|
resolve();
|
||||||
}
|
}
|
||||||
if (message.payload.type === "assistant") {
|
if (message.payload.type === "assistant") {
|
||||||
sawAssistantLog = true;
|
sawAssistantLog = true;
|
||||||
}
|
}
|
||||||
if (sawAssistantChunk && sawTranscriptLog && sawAssistantLog) {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -609,16 +624,30 @@ describe("daemon client E2E", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.client.sendUserMessage("Say 'hello' and nothing else");
|
const fixturePath = path.resolve(
|
||||||
await completion;
|
process.cwd(),
|
||||||
|
"..",
|
||||||
|
"app",
|
||||||
|
"e2e",
|
||||||
|
"fixtures",
|
||||||
|
"recording.wav"
|
||||||
|
);
|
||||||
|
const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath));
|
||||||
|
await ctx.client.sendVoiceAudioChunk(wav.toString("base64"), "audio/wav", true);
|
||||||
|
await transcriptSeen;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
expect(sawTranscriptLog).toBe(true);
|
||||||
|
expect(sawAssistantChunk).toBe(false);
|
||||||
|
expect(sawAssistantLog).toBe(false);
|
||||||
},
|
},
|
||||||
120000
|
90000
|
||||||
);
|
);
|
||||||
|
|
||||||
speechTest(
|
speechTest(
|
||||||
"voice mode buffers audio until isLast and emits transcription_result",
|
"voice mode buffers audio until isLast and emits transcription_result",
|
||||||
async () => {
|
async () => {
|
||||||
await ctx.client.setVoiceConversation(true, `voice-${Date.now()}`);
|
await ctx.client.setVoiceMode(true, randomUUID());
|
||||||
|
|
||||||
const transcription = waitForSignal(30_000, (resolve) => {
|
const transcription = waitForSignal(30_000, (resolve) => {
|
||||||
const unsubscribe = ctx.client.on("transcription_result", (message) => {
|
const unsubscribe = ctx.client.on("transcription_result", (message) => {
|
||||||
@@ -713,7 +742,7 @@ describe("daemon client E2E", () => {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await Promise.allSettled([transcription, errorSignal]);
|
await Promise.allSettled([transcription, errorSignal]);
|
||||||
await ctx.client.setVoiceConversation(false);
|
await ctx.client.setVoiceMode(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
90_000
|
90_000
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
|||||||
PASEO_HOME: tempHome,
|
PASEO_HOME: tempHome,
|
||||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||||
OPENAI_API_KEY: "",
|
OPENAI_API_KEY: "",
|
||||||
|
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
|
||||||
PASEO_LOG_FORMAT: "json",
|
PASEO_LOG_FORMAT: "json",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export { resolvePaseoHome } from "./paseo-home.js";
|
|||||||
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
||||||
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
||||||
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
|
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
|
||||||
|
export { runVoiceMcpBridgeCli } from "./voice-mcp-bridge.js";
|
||||||
|
|
||||||
// Agent SDK types for CLI commands
|
// Agent SDK types for CLI commands
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ import { resolvePaseoHome } from "./paseo-home.js";
|
|||||||
import { createRootLogger } from "./logger.js";
|
import { createRootLogger } from "./logger.js";
|
||||||
import { loadPersistedConfig } from "./persisted-config.js";
|
import { loadPersistedConfig } from "./persisted-config.js";
|
||||||
import { PidLockError } from "./pid-lock.js";
|
import { PidLockError } from "./pid-lock.js";
|
||||||
|
import { runVoiceMcpBridgeCli } from "./voice-mcp-bridge.js";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const bridgeArgIndex = process.argv.findIndex((arg) => arg === "__paseo_voice_mcp_bridge");
|
||||||
|
if (bridgeArgIndex >= 0) {
|
||||||
|
await runVoiceMcpBridgeCli(process.argv.slice(bridgeArgIndex + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let paseoHome: string;
|
let paseoHome: string;
|
||||||
let logger: ReturnType<typeof createRootLogger>;
|
let logger: ReturnType<typeof createRootLogger>;
|
||||||
let config: ReturnType<typeof loadConfig>;
|
let config: ReturnType<typeof loadConfig>;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { homedir } from "node:os";
|
||||||
|
import { isAbsolute, resolve } from "node:path";
|
||||||
|
|
||||||
|
function hasHomePrefix(value: string): boolean {
|
||||||
|
return value === "~" || value.startsWith("~/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandUserPath(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (hasHomePrefix(trimmed)) {
|
||||||
|
return resolve(homedir(), trimmed.slice(2));
|
||||||
|
}
|
||||||
|
return resolve(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePathFromBase(baseCwd: string, requestedPath: string): string {
|
||||||
|
const trimmed = requestedPath.trim();
|
||||||
|
if (hasHomePrefix(trimmed) || isAbsolute(trimmed)) {
|
||||||
|
return expandUserPath(trimmed);
|
||||||
|
}
|
||||||
|
return resolve(baseCwd, trimmed);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||||
|
|
||||||
const LogConfigSchema = z
|
const LogConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -17,44 +18,25 @@ const ProviderCredentialsSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const SherpaOnnxProviderSchema = z
|
const LocalSpeechProviderSchema = z
|
||||||
.object({
|
.object({
|
||||||
modelsDir: z.string().min(1).optional(),
|
modelsDir: z.string().min(1).optional(),
|
||||||
autoDownload: z.boolean().optional(),
|
autoDownload: z.boolean().optional(),
|
||||||
stt: z
|
|
||||||
.object({
|
|
||||||
preset: z.string().min(1).optional(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.optional(),
|
|
||||||
tts: z
|
|
||||||
.object({
|
|
||||||
preset: z.string().min(1).optional(),
|
|
||||||
speakerId: z.number().int().optional(),
|
|
||||||
speed: z.number().optional(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.optional(),
|
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const ProvidersSchema = z
|
const ProvidersSchema = z
|
||||||
.object({
|
.object({
|
||||||
openai: ProviderCredentialsSchema.optional(),
|
openai: ProviderCredentialsSchema.optional(),
|
||||||
openrouter: ProviderCredentialsSchema.optional(),
|
local: LocalSpeechProviderSchema.optional(),
|
||||||
sherpaOnnx: SherpaOnnxProviderSchema.optional(),
|
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const SpeechProviderIdSchema = z.preprocess(
|
const SpeechProviderIdSchema = z
|
||||||
(value) => {
|
.string()
|
||||||
if (typeof value !== "string") {
|
.trim()
|
||||||
return value;
|
.toLowerCase()
|
||||||
}
|
.pipe(z.enum(["openai", "local"]));
|
||||||
return value.trim().toLowerCase();
|
|
||||||
},
|
|
||||||
z.enum(["openai", "local"])
|
|
||||||
);
|
|
||||||
|
|
||||||
const FeatureDictationSchema = z
|
const FeatureDictationSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -62,7 +44,6 @@ const FeatureDictationSchema = z
|
|||||||
.object({
|
.object({
|
||||||
provider: SpeechProviderIdSchema.optional(),
|
provider: SpeechProviderIdSchema.optional(),
|
||||||
model: z.string().min(1).optional(),
|
model: z.string().min(1).optional(),
|
||||||
preset: z.string().min(1).optional(),
|
|
||||||
confidenceThreshold: z.number().optional(),
|
confidenceThreshold: z.number().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -74,7 +55,7 @@ const FeatureVoiceModeSchema = z
|
|||||||
.object({
|
.object({
|
||||||
llm: z
|
llm: z
|
||||||
.object({
|
.object({
|
||||||
provider: z.enum(["openrouter"]).optional(),
|
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),
|
||||||
model: z.string().min(1).optional(),
|
model: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -83,16 +64,14 @@ const FeatureVoiceModeSchema = z
|
|||||||
.object({
|
.object({
|
||||||
provider: SpeechProviderIdSchema.optional(),
|
provider: SpeechProviderIdSchema.optional(),
|
||||||
model: z.string().min(1).optional(),
|
model: z.string().min(1).optional(),
|
||||||
preset: z.string().min(1).optional(),
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
tts: z
|
tts: z
|
||||||
.object({
|
.object({
|
||||||
provider: SpeechProviderIdSchema.optional(),
|
provider: SpeechProviderIdSchema.optional(),
|
||||||
model: z.enum(["tts-1", "tts-1-hd"]).optional(),
|
model: z.string().min(1).optional(),
|
||||||
voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(),
|
voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(),
|
||||||
preset: z.string().min(1).optional(),
|
|
||||||
speakerId: z.number().int().optional(),
|
speakerId: z.number().int().optional(),
|
||||||
speed: z.number().optional(),
|
speed: z.number().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { buildVoiceAgentMcpServerConfig } from "./session.js";
|
||||||
|
|
||||||
|
describe("voice MCP stdio config", () => {
|
||||||
|
test("builds stdio MCP config for voice agent", () => {
|
||||||
|
const config = buildVoiceAgentMcpServerConfig({
|
||||||
|
callerAgentId: "voice-agent-123",
|
||||||
|
command: "/usr/local/bin/paseo",
|
||||||
|
baseArgs: ["__paseo_voice_mcp_bridge", "--socket", "/tmp/paseo-voice.sock"],
|
||||||
|
env: {
|
||||||
|
PASEO_HOME: "/tmp/paseo-home",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.type).toBe("stdio");
|
||||||
|
expect(config.command).toBe("/usr/local/bin/paseo");
|
||||||
|
expect(config.args).toEqual([
|
||||||
|
"__paseo_voice_mcp_bridge",
|
||||||
|
"--socket",
|
||||||
|
"/tmp/paseo-voice.sock",
|
||||||
|
"--caller-agent-id",
|
||||||
|
"voice-agent-123",
|
||||||
|
]);
|
||||||
|
expect(config.env).toEqual({
|
||||||
|
PASEO_HOME: "/tmp/paseo-home",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { PersistedConfig } from "../../../persisted-config.js";
|
||||||
|
import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
|
DEFAULT_LOCAL_TTS_MODEL,
|
||||||
|
LocalSttModelIdSchema,
|
||||||
|
LocalTtsModelIdSchema,
|
||||||
|
type LocalSpeechModelId,
|
||||||
|
type LocalSttModelId,
|
||||||
|
type LocalTtsModelId,
|
||||||
|
} from "./models.js";
|
||||||
|
|
||||||
|
export type LocalSpeechModelConfig = {
|
||||||
|
dictationStt: LocalSttModelId;
|
||||||
|
voiceStt: LocalSttModelId;
|
||||||
|
voiceTts: LocalTtsModelId;
|
||||||
|
voiceTtsSpeakerId?: number;
|
||||||
|
voiceTtsSpeed?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocalSpeechProviderConfig = {
|
||||||
|
modelsDir: string;
|
||||||
|
autoDownload?: boolean;
|
||||||
|
models: LocalSpeechModelConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedLocalSpeechConfig = {
|
||||||
|
local: LocalSpeechProviderConfig | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId };
|
||||||
|
|
||||||
|
const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech");
|
||||||
|
|
||||||
|
const BooleanStringSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.pipe(z.enum(["1", "0", "true", "false", "yes", "no"]))
|
||||||
|
.transform((value) => value === "1" || value === "true" || value === "yes");
|
||||||
|
|
||||||
|
const OptionalBooleanFlagSchema = z
|
||||||
|
.union([z.boolean(), BooleanStringSchema])
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const NumberLikeSchema = z.union([
|
||||||
|
z.number(),
|
||||||
|
z.string().trim().min(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OptionalFiniteNumberSchema = NumberLikeSchema
|
||||||
|
.pipe(z.coerce.number().finite())
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const OptionalIntegerSchema = NumberLikeSchema
|
||||||
|
.pipe(z.coerce.number().int())
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const LocalSpeechResolutionSchema = z.object({
|
||||||
|
includeProviderConfig: z.boolean(),
|
||||||
|
modelsDir: z.string().trim().min(1),
|
||||||
|
autoDownload: OptionalBooleanFlagSchema.default(true),
|
||||||
|
dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
||||||
|
voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
||||||
|
voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL),
|
||||||
|
voiceLocalTtsSpeakerId: OptionalIntegerSchema,
|
||||||
|
voiceLocalTtsSpeed: OptionalFiniteNumberSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
function persistedLocalFeatureModel(
|
||||||
|
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"],
|
||||||
|
model: string | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (provider !== "local") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIncludeLocalProviderConfig(params: {
|
||||||
|
providers: RequestedSpeechProviders;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
persisted: PersistedConfig;
|
||||||
|
}): boolean {
|
||||||
|
const localRequestedByFeature =
|
||||||
|
params.providers.dictationStt.provider === "local" ||
|
||||||
|
params.providers.voiceStt.provider === "local" ||
|
||||||
|
params.providers.voiceTts.provider === "local";
|
||||||
|
|
||||||
|
return (
|
||||||
|
localRequestedByFeature ||
|
||||||
|
params.env.PASEO_LOCAL_MODELS_DIR !== undefined ||
|
||||||
|
params.persisted.providers?.local !== undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLocalSpeechConfig(params: {
|
||||||
|
paseoHome: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
persisted: PersistedConfig;
|
||||||
|
providers: RequestedSpeechProviders;
|
||||||
|
}): ResolvedLocalSpeechConfig {
|
||||||
|
const includeProviderConfig = shouldIncludeLocalProviderConfig(params);
|
||||||
|
|
||||||
|
const parsed = LocalSpeechResolutionSchema.parse({
|
||||||
|
includeProviderConfig,
|
||||||
|
modelsDir:
|
||||||
|
params.env.PASEO_LOCAL_MODELS_DIR ??
|
||||||
|
params.persisted.providers?.local?.modelsDir ??
|
||||||
|
path.join(params.paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR),
|
||||||
|
autoDownload:
|
||||||
|
params.env.PASEO_LOCAL_AUTO_DOWNLOAD ??
|
||||||
|
params.persisted.providers?.local?.autoDownload,
|
||||||
|
dictationLocalSttModel:
|
||||||
|
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
||||||
|
persistedLocalFeatureModel(
|
||||||
|
params.providers.dictationStt.provider,
|
||||||
|
params.persisted.features?.dictation?.stt?.model
|
||||||
|
) ??
|
||||||
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
|
voiceLocalSttModel:
|
||||||
|
params.env.PASEO_VOICE_LOCAL_STT_MODEL ??
|
||||||
|
persistedLocalFeatureModel(
|
||||||
|
params.providers.voiceStt.provider,
|
||||||
|
params.persisted.features?.voiceMode?.stt?.model
|
||||||
|
) ??
|
||||||
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
|
voiceLocalTtsModel:
|
||||||
|
params.env.PASEO_VOICE_LOCAL_TTS_MODEL ??
|
||||||
|
persistedLocalFeatureModel(
|
||||||
|
params.providers.voiceTts.provider,
|
||||||
|
params.persisted.features?.voiceMode?.tts?.model
|
||||||
|
) ??
|
||||||
|
DEFAULT_LOCAL_TTS_MODEL,
|
||||||
|
voiceLocalTtsSpeakerId:
|
||||||
|
params.env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID ??
|
||||||
|
params.persisted.features?.voiceMode?.tts?.speakerId,
|
||||||
|
voiceLocalTtsSpeed:
|
||||||
|
params.env.PASEO_VOICE_LOCAL_TTS_SPEED ??
|
||||||
|
params.persisted.features?.voiceMode?.tts?.speed,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
local:
|
||||||
|
parsed.includeProviderConfig
|
||||||
|
? {
|
||||||
|
modelsDir: parsed.modelsDir,
|
||||||
|
autoDownload: parsed.autoDownload,
|
||||||
|
models: {
|
||||||
|
dictationStt: parsed.dictationLocalSttModel,
|
||||||
|
voiceStt: parsed.voiceLocalSttModel,
|
||||||
|
voiceTts: parsed.voiceLocalTtsModel,
|
||||||
|
...(parsed.voiceLocalTtsSpeakerId !== undefined
|
||||||
|
? { voiceTtsSpeakerId: parsed.voiceLocalTtsSpeakerId }
|
||||||
|
: {}),
|
||||||
|
...(parsed.voiceLocalTtsSpeed !== undefined
|
||||||
|
? { voiceTtsSpeed: parsed.voiceLocalTtsSpeed }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
ensureSherpaOnnxModels,
|
||||||
|
getSherpaOnnxModelDir,
|
||||||
|
} from "./sherpa/model-downloader.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
|
DEFAULT_LOCAL_TTS_MODEL,
|
||||||
|
LocalSttModelIdSchema,
|
||||||
|
LocalTtsModelIdSchema,
|
||||||
|
listSherpaOnnxModels,
|
||||||
|
type LocalSpeechModelId,
|
||||||
|
type LocalSttModelId,
|
||||||
|
type LocalTtsModelId,
|
||||||
|
} from "./sherpa/model-catalog.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
|
DEFAULT_LOCAL_TTS_MODEL,
|
||||||
|
LocalSttModelIdSchema,
|
||||||
|
LocalTtsModelIdSchema,
|
||||||
|
type LocalSpeechModelId,
|
||||||
|
type LocalSttModelId,
|
||||||
|
type LocalTtsModelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocalSpeechModelSpec = ReturnType<typeof listSherpaOnnxModels>[number];
|
||||||
|
|
||||||
|
export function listLocalSpeechModels(): LocalSpeechModelSpec[] {
|
||||||
|
return listSherpaOnnxModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechModelId): string {
|
||||||
|
return getSherpaOnnxModelDir(modelsDir, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureLocalSpeechModels(options: {
|
||||||
|
modelsDir: string;
|
||||||
|
modelIds: LocalSpeechModelId[];
|
||||||
|
autoDownload?: boolean;
|
||||||
|
logger: import("pino").Logger;
|
||||||
|
}): Promise<Record<LocalSpeechModelId, string>> {
|
||||||
|
return ensureSherpaOnnxModels({
|
||||||
|
modelsDir: options.modelsDir,
|
||||||
|
modelIds: options.modelIds,
|
||||||
|
autoDownload: options.autoDownload ?? true,
|
||||||
|
logger: options.logger,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
|
import type { PaseoSpeechConfig } from "../../../bootstrap.js";
|
||||||
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech-provider.js";
|
||||||
|
import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||||
|
import { PocketTtsOnnxTTS } from "./pocket/pocket-tts-onnx.js";
|
||||||
|
import {
|
||||||
|
ensureLocalSpeechModels,
|
||||||
|
getLocalSpeechModelDir,
|
||||||
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
|
DEFAULT_LOCAL_TTS_MODEL,
|
||||||
|
LocalSttModelIdSchema,
|
||||||
|
LocalTtsModelIdSchema,
|
||||||
|
type LocalSpeechModelId,
|
||||||
|
type LocalSttModelId,
|
||||||
|
type LocalTtsModelId,
|
||||||
|
} from "./models.js";
|
||||||
|
import { SherpaOfflineRecognizerEngine } from "./sherpa/sherpa-offline-recognizer.js";
|
||||||
|
import { SherpaOnlineRecognizerEngine } from "./sherpa/sherpa-online-recognizer.js";
|
||||||
|
import { SherpaOnnxParakeetSTT } from "./sherpa/sherpa-parakeet-stt.js";
|
||||||
|
import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-parakeet-realtime-session.js";
|
||||||
|
import { SherpaRealtimeTranscriptionSession } from "./sherpa/sherpa-realtime-session.js";
|
||||||
|
import { SherpaOnnxSTT } from "./sherpa/sherpa-stt.js";
|
||||||
|
import { SherpaOnnxTTS } from "./sherpa/sherpa-tts.js";
|
||||||
|
|
||||||
|
type LocalSttEngine =
|
||||||
|
| { kind: "offline"; engine: SherpaOfflineRecognizerEngine }
|
||||||
|
| { kind: "online"; engine: SherpaOnlineRecognizerEngine };
|
||||||
|
|
||||||
|
type ResolvedLocalModels = {
|
||||||
|
dictationLocalSttModel: LocalSttModelId;
|
||||||
|
voiceLocalSttModel: LocalSttModelId;
|
||||||
|
voiceLocalTtsModel: LocalTtsModelId;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocalSpeechAvailability = {
|
||||||
|
configured: boolean;
|
||||||
|
modelsDir: string | null;
|
||||||
|
autoDownload: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitializedLocalSpeech = {
|
||||||
|
sttService: SpeechToTextProvider | null;
|
||||||
|
ttsService: TextToSpeechProvider | null;
|
||||||
|
dictationSttService: SpeechToTextProvider | null;
|
||||||
|
localVoiceTtsProvider: TextToSpeechProvider | null;
|
||||||
|
localModelConfig: {
|
||||||
|
modelsDir: string;
|
||||||
|
defaultModelIds: LocalSpeechModelId[];
|
||||||
|
} | null;
|
||||||
|
availability: LocalSpeechAvailability;
|
||||||
|
cleanup: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildModelDownloadHint(modelId: LocalSpeechModelId): string {
|
||||||
|
return `Use 'paseo speech download --model ${modelId}' to download this model.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredLocalModels(
|
||||||
|
speechConfig: PaseoSpeechConfig | null
|
||||||
|
): ResolvedLocalModels {
|
||||||
|
return {
|
||||||
|
dictationLocalSttModel: LocalSttModelIdSchema.parse(
|
||||||
|
speechConfig?.local?.models.dictationStt ?? DEFAULT_LOCAL_STT_MODEL
|
||||||
|
),
|
||||||
|
voiceLocalSttModel: LocalSttModelIdSchema.parse(
|
||||||
|
speechConfig?.local?.models.voiceStt ?? DEFAULT_LOCAL_STT_MODEL
|
||||||
|
),
|
||||||
|
voiceLocalTtsModel: LocalTtsModelIdSchema.parse(
|
||||||
|
speechConfig?.local?.models.voiceTts ?? DEFAULT_LOCAL_TTS_MODEL
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalSpeechAvailability(
|
||||||
|
speechConfig: PaseoSpeechConfig | null
|
||||||
|
): LocalSpeechAvailability {
|
||||||
|
const localConfig = speechConfig?.local ?? null;
|
||||||
|
return {
|
||||||
|
configured: Boolean(localConfig),
|
||||||
|
modelsDir: localConfig?.modelsDir ?? null,
|
||||||
|
autoDownload: localConfig?.autoDownload ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRequiredLocalModelIds(params: {
|
||||||
|
providers: RequestedSpeechProviders;
|
||||||
|
models: ResolvedLocalModels;
|
||||||
|
}): LocalSpeechModelId[] {
|
||||||
|
const ids = new Set<LocalSpeechModelId>();
|
||||||
|
if (params.providers.dictationStt.provider === "local") {
|
||||||
|
ids.add(params.models.dictationLocalSttModel);
|
||||||
|
}
|
||||||
|
if (params.providers.voiceStt.provider === "local") {
|
||||||
|
ids.add(params.models.voiceLocalSttModel);
|
||||||
|
}
|
||||||
|
if (params.providers.voiceTts.provider === "local") {
|
||||||
|
ids.add(params.models.voiceLocalTtsModel);
|
||||||
|
}
|
||||||
|
return Array.from(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLocalSttEngine(params: {
|
||||||
|
modelId: LocalSttModelId;
|
||||||
|
modelsDir: string;
|
||||||
|
logger: Logger;
|
||||||
|
}): Promise<LocalSttEngine> {
|
||||||
|
const { modelId, modelsDir, logger } = params;
|
||||||
|
|
||||||
|
if (modelId === "parakeet-tdt-0.6b-v3-int8") {
|
||||||
|
const modelDir = getLocalSpeechModelDir(modelsDir, modelId);
|
||||||
|
return {
|
||||||
|
kind: "offline",
|
||||||
|
engine: new SherpaOfflineRecognizerEngine(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
kind: "nemo_transducer",
|
||||||
|
encoder: `${modelDir}/encoder.int8.onnx`,
|
||||||
|
decoder: `${modelDir}/decoder.int8.onnx`,
|
||||||
|
joiner: `${modelDir}/joiner.int8.onnx`,
|
||||||
|
tokens: `${modelDir}/tokens.txt`,
|
||||||
|
},
|
||||||
|
numThreads: 2,
|
||||||
|
debug: 0,
|
||||||
|
},
|
||||||
|
logger
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelId === "paraformer-bilingual-zh-en") {
|
||||||
|
const modelDir = getLocalSpeechModelDir(modelsDir, modelId);
|
||||||
|
return {
|
||||||
|
kind: "online",
|
||||||
|
engine: new SherpaOnlineRecognizerEngine(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
kind: "paraformer",
|
||||||
|
encoder: `${modelDir}/encoder.int8.onnx`,
|
||||||
|
decoder: `${modelDir}/decoder.int8.onnx`,
|
||||||
|
tokens: `${modelDir}/tokens.txt`,
|
||||||
|
},
|
||||||
|
numThreads: 1,
|
||||||
|
debug: 0,
|
||||||
|
},
|
||||||
|
logger
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelId === "zipformer-bilingual-zh-en-2023-02-20") {
|
||||||
|
const modelDir = getLocalSpeechModelDir(modelsDir, modelId);
|
||||||
|
return {
|
||||||
|
kind: "online",
|
||||||
|
engine: new SherpaOnlineRecognizerEngine(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
kind: "transducer",
|
||||||
|
encoder: `${modelDir}/encoder-epoch-99-avg-1.onnx`,
|
||||||
|
decoder: `${modelDir}/decoder-epoch-99-avg-1.onnx`,
|
||||||
|
joiner: `${modelDir}/joiner-epoch-99-avg-1.onnx`,
|
||||||
|
tokens: `${modelDir}/tokens.txt`,
|
||||||
|
modelType: "zipformer",
|
||||||
|
},
|
||||||
|
numThreads: 1,
|
||||||
|
debug: 0,
|
||||||
|
},
|
||||||
|
logger
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported local STT model '${modelId}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeLocalSpeechServices(params: {
|
||||||
|
providers: RequestedSpeechProviders;
|
||||||
|
speechConfig: PaseoSpeechConfig | null;
|
||||||
|
logger: Logger;
|
||||||
|
}): Promise<InitializedLocalSpeech> {
|
||||||
|
const { providers, logger, speechConfig } = params;
|
||||||
|
const localConfig = speechConfig?.local ?? null;
|
||||||
|
const localModels = resolveConfiguredLocalModels(speechConfig);
|
||||||
|
|
||||||
|
let sttService: SpeechToTextProvider | null = null;
|
||||||
|
let ttsService: TextToSpeechProvider | null = null;
|
||||||
|
let dictationSttService: SpeechToTextProvider | null = null;
|
||||||
|
let localVoiceTtsProvider: TextToSpeechProvider | null = null;
|
||||||
|
|
||||||
|
const requiredLocalModelIds = computeRequiredLocalModelIds({
|
||||||
|
providers,
|
||||||
|
models: localModels,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requiredLocalModelIds.length > 0 && localConfig) {
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
modelsDir: localConfig.modelsDir,
|
||||||
|
modelIds: requiredLocalModelIds,
|
||||||
|
autoDownload: localConfig.autoDownload ?? true,
|
||||||
|
},
|
||||||
|
"Ensuring local speech models"
|
||||||
|
);
|
||||||
|
await ensureLocalSpeechModels({
|
||||||
|
modelsDir: localConfig.modelsDir,
|
||||||
|
modelIds: requiredLocalModelIds,
|
||||||
|
autoDownload: localConfig.autoDownload ?? true,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
err,
|
||||||
|
modelsDir: localConfig.modelsDir,
|
||||||
|
modelIds: requiredLocalModelIds,
|
||||||
|
autoDownload: localConfig.autoDownload ?? true,
|
||||||
|
hint:
|
||||||
|
"Use `paseo speech models` to inspect status and " +
|
||||||
|
"`paseo speech download --model <MODEL_ID>` to fetch missing models.",
|
||||||
|
},
|
||||||
|
"Failed to ensure local speech models"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localSttEngines = new Map<LocalSttModelId, LocalSttEngine>();
|
||||||
|
|
||||||
|
const getLocalSttEngine = async (
|
||||||
|
modelId: LocalSttModelId
|
||||||
|
): Promise<LocalSttEngine | null> => {
|
||||||
|
const existing = localSttEngines.get(modelId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
if (!localConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await createLocalSttEngine({
|
||||||
|
modelId,
|
||||||
|
modelsDir: localConfig.modelsDir,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
localSttEngines.set(modelId, created);
|
||||||
|
return created;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
err,
|
||||||
|
modelsDir: localConfig.modelsDir,
|
||||||
|
modelId,
|
||||||
|
hint: buildModelDownloadHint(modelId),
|
||||||
|
},
|
||||||
|
"Failed to initialize local STT engine (models missing or invalid)"
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (providers.voiceStt.provider === "local") {
|
||||||
|
if (!localConfig) {
|
||||||
|
logger.warn(
|
||||||
|
{ configured: false },
|
||||||
|
"Local STT selected for voice but local provider config is missing; STT will be unavailable"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const voiceEngine = await getLocalSttEngine(localModels.voiceLocalSttModel);
|
||||||
|
if (voiceEngine?.kind === "offline") {
|
||||||
|
sttService = new SherpaOnnxParakeetSTT({ engine: voiceEngine.engine }, logger);
|
||||||
|
} else if (voiceEngine?.kind === "online") {
|
||||||
|
sttService = new SherpaOnnxSTT({ engine: voiceEngine.engine }, logger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providers.dictationStt.provider === "local") {
|
||||||
|
if (!localConfig) {
|
||||||
|
logger.warn(
|
||||||
|
{ configured: false },
|
||||||
|
"Local STT selected for dictation but local provider config is missing; dictation STT will be unavailable"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const dictationEngine = await getLocalSttEngine(localModels.dictationLocalSttModel);
|
||||||
|
if (dictationEngine?.kind === "offline") {
|
||||||
|
dictationSttService = {
|
||||||
|
id: "local",
|
||||||
|
createSession: () =>
|
||||||
|
new SherpaParakeetRealtimeTranscriptionSession({ engine: dictationEngine.engine }),
|
||||||
|
};
|
||||||
|
} else if (dictationEngine?.kind === "online") {
|
||||||
|
dictationSttService = {
|
||||||
|
id: "local",
|
||||||
|
createSession: () => new SherpaRealtimeTranscriptionSession({ engine: dictationEngine.engine }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providers.voiceTts.provider === "local") {
|
||||||
|
if (!localConfig) {
|
||||||
|
logger.warn(
|
||||||
|
{ configured: false },
|
||||||
|
"Local TTS selected for voice but local provider config is missing; TTS will be unavailable"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (localModels.voiceLocalTtsModel === "pocket-tts-onnx-int8") {
|
||||||
|
const modelDir = getLocalSpeechModelDir(localConfig.modelsDir, localModels.voiceLocalTtsModel);
|
||||||
|
localVoiceTtsProvider = await PocketTtsOnnxTTS.create(
|
||||||
|
{
|
||||||
|
modelDir,
|
||||||
|
precision: "int8",
|
||||||
|
targetChunkMs: 50,
|
||||||
|
},
|
||||||
|
logger
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const modelDir = getLocalSpeechModelDir(localConfig.modelsDir, localModels.voiceLocalTtsModel);
|
||||||
|
localVoiceTtsProvider = new SherpaOnnxTTS(
|
||||||
|
{
|
||||||
|
preset: localModels.voiceLocalTtsModel,
|
||||||
|
modelDir,
|
||||||
|
speakerId: speechConfig?.local?.models.voiceTtsSpeakerId,
|
||||||
|
speed: speechConfig?.local?.models.voiceTtsSpeed,
|
||||||
|
},
|
||||||
|
logger
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ttsService = localVoiceTtsProvider;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
err,
|
||||||
|
modelsDir: localConfig.modelsDir,
|
||||||
|
modelId: localModels.voiceLocalTtsModel,
|
||||||
|
hint: buildModelDownloadHint(localModels.voiceLocalTtsModel),
|
||||||
|
},
|
||||||
|
"Failed to initialize local TTS engine (models missing or invalid)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
const maybeFreeable = localVoiceTtsProvider as unknown as { free?: () => void } | null;
|
||||||
|
if (typeof maybeFreeable?.free === "function") {
|
||||||
|
maybeFreeable.free();
|
||||||
|
}
|
||||||
|
for (const engine of localSttEngines.values()) {
|
||||||
|
engine.engine.free();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sttService,
|
||||||
|
ttsService,
|
||||||
|
dictationSttService,
|
||||||
|
localVoiceTtsProvider,
|
||||||
|
localModelConfig:
|
||||||
|
localConfig
|
||||||
|
? {
|
||||||
|
modelsDir: localConfig.modelsDir,
|
||||||
|
defaultModelIds: requiredLocalModelIds,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
availability: getLocalSpeechAvailability(speechConfig),
|
||||||
|
cleanup,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,26 +1,22 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export type SherpaOnnxModelKind = "stt-online" | "stt-offline" | "tts";
|
export type SherpaOnnxModelKind = "stt-online" | "stt-offline" | "tts";
|
||||||
|
|
||||||
export type SherpaOnnxModelId =
|
type DefaultModelRole = "stt" | "tts";
|
||||||
| "zipformer-bilingual-zh-en-2023-02-20"
|
|
||||||
| "paraformer-bilingual-zh-en"
|
|
||||||
| "parakeet-tdt-0.6b-v3-int8"
|
|
||||||
| "kitten-nano-en-v0_1-fp16"
|
|
||||||
| "kokoro-en-v0_19"
|
|
||||||
| "pocket-tts-onnx-int8";
|
|
||||||
|
|
||||||
export type SherpaOnnxModelSpec = {
|
type SherpaOnnxCatalogEntry = {
|
||||||
id: SherpaOnnxModelId;
|
|
||||||
kind: SherpaOnnxModelKind;
|
kind: SherpaOnnxModelKind;
|
||||||
archiveUrl?: string;
|
archiveUrl?: string;
|
||||||
downloadFiles?: Array<{ url: string; relPath: string }>;
|
downloadFiles?: Array<{ url: string; relPath: string }>;
|
||||||
extractedDir: string;
|
extractedDir: string;
|
||||||
requiredFiles: string[];
|
requiredFiles: string[];
|
||||||
description: string;
|
description: string;
|
||||||
|
aliases?: readonly string[];
|
||||||
|
defaultFor?: DefaultModelRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SHERPA_ONNX_MODEL_CATALOG: Record<SherpaOnnxModelId, SherpaOnnxModelSpec> = {
|
export const SHERPA_ONNX_MODEL_CATALOG = {
|
||||||
"zipformer-bilingual-zh-en-2023-02-20": {
|
"zipformer-bilingual-zh-en-2023-02-20": {
|
||||||
id: "zipformer-bilingual-zh-en-2023-02-20",
|
|
||||||
kind: "stt-online",
|
kind: "stt-online",
|
||||||
archiveUrl:
|
archiveUrl:
|
||||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2",
|
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2",
|
||||||
@@ -32,44 +28,45 @@ export const SHERPA_ONNX_MODEL_CATALOG: Record<SherpaOnnxModelId, SherpaOnnxMode
|
|||||||
"tokens.txt",
|
"tokens.txt",
|
||||||
],
|
],
|
||||||
description: "Streaming Zipformer transducer (fast, good accuracy).",
|
description: "Streaming Zipformer transducer (fast, good accuracy).",
|
||||||
|
aliases: ["zipformer", "zipformer-bilingual"],
|
||||||
},
|
},
|
||||||
"paraformer-bilingual-zh-en": {
|
"paraformer-bilingual-zh-en": {
|
||||||
id: "paraformer-bilingual-zh-en",
|
|
||||||
kind: "stt-online",
|
kind: "stt-online",
|
||||||
archiveUrl:
|
archiveUrl:
|
||||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-paraformer-bilingual-zh-en.tar.bz2",
|
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-paraformer-bilingual-zh-en.tar.bz2",
|
||||||
extractedDir: "sherpa-onnx-streaming-paraformer-bilingual-zh-en",
|
extractedDir: "sherpa-onnx-streaming-paraformer-bilingual-zh-en",
|
||||||
requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt"],
|
requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt"],
|
||||||
description: "Streaming Paraformer (often strong accuracy; heavier).",
|
description: "Streaming Paraformer (often strong accuracy; heavier).",
|
||||||
|
aliases: ["paraformer"],
|
||||||
},
|
},
|
||||||
"parakeet-tdt-0.6b-v3-int8": {
|
"parakeet-tdt-0.6b-v3-int8": {
|
||||||
id: "parakeet-tdt-0.6b-v3-int8",
|
|
||||||
kind: "stt-offline",
|
kind: "stt-offline",
|
||||||
archiveUrl:
|
archiveUrl:
|
||||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2",
|
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2",
|
||||||
extractedDir: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8",
|
extractedDir: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8",
|
||||||
requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"],
|
requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"],
|
||||||
description: "NVIDIA Parakeet TDT v3 (offline NeMo transducer, multilingual).",
|
description: "NVIDIA Parakeet TDT v3 (offline NeMo transducer, multilingual).",
|
||||||
|
aliases: ["parakeet", "parakeet-v3", "parakeet-tdt"],
|
||||||
|
defaultFor: "stt",
|
||||||
},
|
},
|
||||||
"kitten-nano-en-v0_1-fp16": {
|
"kitten-nano-en-v0_1-fp16": {
|
||||||
id: "kitten-nano-en-v0_1-fp16",
|
|
||||||
kind: "tts",
|
kind: "tts",
|
||||||
archiveUrl:
|
archiveUrl:
|
||||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kitten-nano-en-v0_1-fp16.tar.bz2",
|
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kitten-nano-en-v0_1-fp16.tar.bz2",
|
||||||
extractedDir: "kitten-nano-en-v0_1-fp16",
|
extractedDir: "kitten-nano-en-v0_1-fp16",
|
||||||
requiredFiles: ["model.fp16.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"],
|
requiredFiles: ["model.fp16.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"],
|
||||||
description: "KittenTTS (small, fast English TTS).",
|
description: "KittenTTS (small, fast English TTS).",
|
||||||
|
aliases: ["kitten"],
|
||||||
},
|
},
|
||||||
"kokoro-en-v0_19": {
|
"kokoro-en-v0_19": {
|
||||||
id: "kokoro-en-v0_19",
|
|
||||||
kind: "tts",
|
kind: "tts",
|
||||||
archiveUrl: "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2",
|
archiveUrl: "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2",
|
||||||
extractedDir: "kokoro-en-v0_19",
|
extractedDir: "kokoro-en-v0_19",
|
||||||
requiredFiles: ["model.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"],
|
requiredFiles: ["model.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"],
|
||||||
description: "Kokoro TTS (higher quality; larger).",
|
description: "Kokoro TTS (higher quality; larger).",
|
||||||
|
aliases: ["kokoro"],
|
||||||
},
|
},
|
||||||
"pocket-tts-onnx-int8": {
|
"pocket-tts-onnx-int8": {
|
||||||
id: "pocket-tts-onnx-int8",
|
|
||||||
kind: "tts",
|
kind: "tts",
|
||||||
extractedDir: "pocket-tts-onnx-int8",
|
extractedDir: "pocket-tts-onnx-int8",
|
||||||
downloadFiles: [
|
downloadFiles: [
|
||||||
@@ -112,17 +109,110 @@ export const SHERPA_ONNX_MODEL_CATALOG: Record<SherpaOnnxModelId, SherpaOnnxMode
|
|||||||
"reference_sample.wav",
|
"reference_sample.wav",
|
||||||
],
|
],
|
||||||
description: "Pocket TTS ONNX (INT8) with streaming decode support (via onnxruntime).",
|
description: "Pocket TTS ONNX (INT8) with streaming decode support (via onnxruntime).",
|
||||||
|
aliases: ["pocket", "pocket-tts"],
|
||||||
|
defaultFor: "tts",
|
||||||
},
|
},
|
||||||
|
} as const satisfies Record<string, SherpaOnnxCatalogEntry>;
|
||||||
|
|
||||||
|
export type SherpaOnnxModelId = keyof typeof SHERPA_ONNX_MODEL_CATALOG;
|
||||||
|
export type LocalSpeechModelId = SherpaOnnxModelId;
|
||||||
|
|
||||||
|
type ModelIdByKind<K extends SherpaOnnxModelKind> = {
|
||||||
|
[Id in SherpaOnnxModelId]: (typeof SHERPA_ONNX_MODEL_CATALOG)[Id]["kind"] extends K
|
||||||
|
? Id
|
||||||
|
: never;
|
||||||
|
}[SherpaOnnxModelId];
|
||||||
|
|
||||||
|
export type LocalSttModelId = ModelIdByKind<"stt-online"> | ModelIdByKind<"stt-offline">;
|
||||||
|
export type LocalTtsModelId = ModelIdByKind<"tts">;
|
||||||
|
|
||||||
|
const ALL_MODEL_IDS = Object.keys(SHERPA_ONNX_MODEL_CATALOG) as SherpaOnnxModelId[];
|
||||||
|
|
||||||
|
export const LOCAL_STT_MODEL_IDS = ALL_MODEL_IDS.filter(
|
||||||
|
(id): id is LocalSttModelId => SHERPA_ONNX_MODEL_CATALOG[id].kind !== "tts"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LOCAL_TTS_MODEL_IDS = ALL_MODEL_IDS.filter(
|
||||||
|
(id): id is LocalTtsModelId => SHERPA_ONNX_MODEL_CATALOG[id].kind === "tts"
|
||||||
|
);
|
||||||
|
|
||||||
|
function resolveDefaultModelId(role: "stt"): LocalSttModelId;
|
||||||
|
function resolveDefaultModelId(role: "tts"): LocalTtsModelId;
|
||||||
|
function resolveDefaultModelId(role: DefaultModelRole): SherpaOnnxModelId {
|
||||||
|
const match = ALL_MODEL_IDS.find((id) => {
|
||||||
|
const entry: SherpaOnnxCatalogEntry = SHERPA_ONNX_MODEL_CATALOG[id];
|
||||||
|
return entry.defaultFor === role;
|
||||||
|
});
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`No default model configured for role '${role}'`);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LOCAL_STT_MODEL = resolveDefaultModelId("stt");
|
||||||
|
export const DEFAULT_LOCAL_TTS_MODEL = resolveDefaultModelId("tts");
|
||||||
|
|
||||||
|
function buildAliasMap<T extends string>(modelIds: readonly T[]): Record<string, T> {
|
||||||
|
const aliasMap: Record<string, T> = {};
|
||||||
|
for (const modelId of modelIds) {
|
||||||
|
const aliases = SHERPA_ONNX_MODEL_CATALOG[modelId as SherpaOnnxModelId].aliases ?? [];
|
||||||
|
for (const alias of aliases) {
|
||||||
|
aliasMap[alias.trim().toLowerCase()] = modelId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliasMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAliasedModelIdSchema<T extends string>(params: {
|
||||||
|
modelIds: readonly T[];
|
||||||
|
aliases: Record<string, T>;
|
||||||
|
}): z.ZodType<T, z.ZodTypeDef, string> {
|
||||||
|
const validIds = new Set(params.modelIds);
|
||||||
|
return z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.refine(
|
||||||
|
(value): value is T =>
|
||||||
|
validIds.has(value as T) || Object.prototype.hasOwnProperty.call(params.aliases, value),
|
||||||
|
{
|
||||||
|
message: "Invalid model id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.transform((value) => params.aliases[value] ?? (value as T));
|
||||||
|
}
|
||||||
|
|
||||||
|
const STT_MODEL_ALIASES = buildAliasMap(LOCAL_STT_MODEL_IDS);
|
||||||
|
const TTS_MODEL_ALIASES = buildAliasMap(LOCAL_TTS_MODEL_IDS);
|
||||||
|
|
||||||
|
export const LocalSttModelIdSchema = createAliasedModelIdSchema({
|
||||||
|
modelIds: LOCAL_STT_MODEL_IDS,
|
||||||
|
aliases: STT_MODEL_ALIASES,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LocalTtsModelIdSchema = createAliasedModelIdSchema({
|
||||||
|
modelIds: LOCAL_TTS_MODEL_IDS,
|
||||||
|
aliases: TTS_MODEL_ALIASES,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SherpaOnnxModelSpec = SherpaOnnxCatalogEntry & {
|
||||||
|
id: SherpaOnnxModelId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function listSherpaOnnxModels(): SherpaOnnxModelSpec[] {
|
export function listSherpaOnnxModels(): SherpaOnnxModelSpec[] {
|
||||||
return Object.values(SHERPA_ONNX_MODEL_CATALOG);
|
return ALL_MODEL_IDS.map((id) => ({
|
||||||
|
id,
|
||||||
|
...SHERPA_ONNX_MODEL_CATALOG[id],
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSherpaOnnxModelSpec(id: SherpaOnnxModelId): SherpaOnnxModelSpec {
|
export function getSherpaOnnxModelSpec(id: SherpaOnnxModelId): SherpaOnnxModelSpec {
|
||||||
const spec = SHERPA_ONNX_MODEL_CATALOG[id];
|
const spec = SHERPA_ONNX_MODEL_CATALOG[id];
|
||||||
if (!spec) {
|
if (!spec) {
|
||||||
throw new Error(`Unknown sherpa-onnx model id: ${id}`);
|
throw new Error(`Unknown local speech model id: ${id}`);
|
||||||
}
|
}
|
||||||
return spec;
|
return {
|
||||||
|
id,
|
||||||
|
...spec,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ async function isNonEmptyFile(filePath: string): Promise<boolean> {
|
|||||||
export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOptions): Promise<string> {
|
export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOptions): Promise<string> {
|
||||||
const logger = options.logger.child({
|
const logger = options.logger.child({
|
||||||
module: "speech",
|
module: "speech",
|
||||||
provider: "sherpa-onnx",
|
provider: "local",
|
||||||
component: "model-downloader",
|
component: "model-downloader",
|
||||||
modelId: options.modelId,
|
modelId: options.modelId,
|
||||||
});
|
});
|
||||||
@@ -109,8 +109,8 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
|
|||||||
|
|
||||||
if (!options.autoDownload) {
|
if (!options.autoDownload) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Missing sherpa-onnx model files for ${options.modelId} in ${modelDir}. ` +
|
`Missing local speech model files for ${options.modelId} in ${modelDir}. ` +
|
||||||
`Set PASEO_SHERPA_ONNX_AUTO_DOWNLOAD=1 to auto-download.`
|
`Set PASEO_LOCAL_AUTO_DOWNLOAD=1 to auto-download.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ export class SherpaOfflineRecognizerEngine {
|
|||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
|
|
||||||
constructor(config: SherpaOfflineRecognizerConfig, logger: pino.Logger) {
|
constructor(config: SherpaOfflineRecognizerConfig, logger: pino.Logger) {
|
||||||
this.logger = logger.child({ module: "speech", provider: "sherpa-onnx", component: "offline-recognizer" });
|
this.logger = logger.child({ module: "speech", provider: "local", component: "offline-recognizer" });
|
||||||
|
|
||||||
assertFileExists(config.model.encoder, "offline encoder");
|
assertFileExists(config.model.encoder, "offline encoder");
|
||||||
assertFileExists(config.model.decoder, "offline decoder");
|
assertFileExists(config.model.decoder, "offline decoder");
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ export class SherpaOnlineRecognizerEngine {
|
|||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
|
|
||||||
constructor(config: SherpaOnlineRecognizerConfig, logger: pino.Logger) {
|
constructor(config: SherpaOnlineRecognizerConfig, logger: pino.Logger) {
|
||||||
this.logger = logger.child({ module: "speech", provider: "sherpa-onnx", component: "online-recognizer" });
|
this.logger = logger.child({ module: "speech", provider: "local", component: "online-recognizer" });
|
||||||
|
|
||||||
const { model } = config;
|
const { model } = config;
|
||||||
if (model.kind === "transducer") {
|
if (model.kind === "transducer") {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function loadSherpaOnnx(): SherpaOnnxModule {
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
cached = require("sherpa-onnx") as SherpaOnnxModule;
|
cached = require("sherpa-onnx") as SherpaOnnxModule;
|
||||||
return cached;
|
return cached;
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ function platformArch(): string {
|
|||||||
return `${platform}-${process.arch}`;
|
return `${platform}-${process.arch}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prependLibraryPath(envKey: "DYLD_LIBRARY_PATH" | "LD_LIBRARY_PATH", dir: string): void {
|
function prependLibraryPath(
|
||||||
|
envKey: "DYLD_LIBRARY_PATH" | "LD_LIBRARY_PATH" | "PATH",
|
||||||
|
dir: string
|
||||||
|
): void {
|
||||||
const current = process.env[envKey] ?? "";
|
const current = process.env[envKey] ?? "";
|
||||||
const parts = current.split(":").filter(Boolean);
|
const parts = current.split(path.delimiter).filter(Boolean);
|
||||||
if (parts.includes(dir)) {
|
if (parts.includes(dir)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.env[envKey] = [dir, ...parts].join(":");
|
process.env[envKey] = [dir, ...parts].join(path.delimiter);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
|
export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
|
||||||
@@ -31,8 +34,7 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
|
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
|
||||||
// that contains the native addon + its shared libraries. On macOS/Linux we need to ensure
|
// that contains the native addon and shared libraries. Ensure the OS loader path includes it.
|
||||||
// the appropriate library path env var includes that directory before requiring the addon.
|
|
||||||
const arch = platformArch();
|
const arch = platformArch();
|
||||||
const pkgName = `sherpa-onnx-${arch}`;
|
const pkgName = `sherpa-onnx-${arch}`;
|
||||||
|
|
||||||
@@ -43,6 +45,8 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
|
|||||||
prependLibraryPath("DYLD_LIBRARY_PATH", pkgDir);
|
prependLibraryPath("DYLD_LIBRARY_PATH", pkgDir);
|
||||||
} else if (process.platform === "linux") {
|
} else if (process.platform === "linux") {
|
||||||
prependLibraryPath("LD_LIBRARY_PATH", pkgDir);
|
prependLibraryPath("LD_LIBRARY_PATH", pkgDir);
|
||||||
|
} else if (process.platform === "win32") {
|
||||||
|
prependLibraryPath("PATH", pkgDir);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Best effort - if the platform package isn't present, require() below will throw a useful error.
|
// Best effort - if the platform package isn't present, require() below will throw a useful error.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export class SherpaOnnxParakeetSTT implements SpeechToTextProvider {
|
|||||||
constructor(config: SherpaParakeetSttConfig, logger: pino.Logger) {
|
constructor(config: SherpaParakeetSttConfig, logger: pino.Logger) {
|
||||||
this.engine = config.engine;
|
this.engine = config.engine;
|
||||||
this.silencePeakThreshold = config.silencePeakThreshold ?? 300;
|
this.silencePeakThreshold = config.silencePeakThreshold ?? 300;
|
||||||
this.logger = logger.child({ module: "speech", provider: "sherpa-onnx", component: "parakeet-stt" });
|
this.logger = logger.child({ module: "speech", provider: "local", component: "parakeet-stt" });
|
||||||
}
|
}
|
||||||
|
|
||||||
public createSession(params: {
|
public createSession(params: {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class SherpaOnnxSTT implements SpeechToTextProvider {
|
|||||||
this.engine = config.engine;
|
this.engine = config.engine;
|
||||||
this.silencePeakThreshold = config.silencePeakThreshold ?? 300;
|
this.silencePeakThreshold = config.silencePeakThreshold ?? 300;
|
||||||
this.tailPaddingMs = config.tailPaddingMs ?? 500;
|
this.tailPaddingMs = config.tailPaddingMs ?? 500;
|
||||||
this.logger = logger.child({ module: "speech", provider: "sherpa-onnx", component: "stt" });
|
this.logger = logger.child({ module: "speech", provider: "local", component: "stt" });
|
||||||
}
|
}
|
||||||
|
|
||||||
public createSession(params: {
|
public createSession(params: {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class SherpaOnnxTTS implements TextToSpeechProvider {
|
|||||||
if (config.preset !== "kokoro-en-v0_19" && config.preset !== "kitten-nano-en-v0_1-fp16") {
|
if (config.preset !== "kokoro-en-v0_19" && config.preset !== "kitten-nano-en-v0_1-fp16") {
|
||||||
throw new Error(`Unsupported Sherpa TTS preset: ${config.preset}`);
|
throw new Error(`Unsupported Sherpa TTS preset: ${config.preset}`);
|
||||||
}
|
}
|
||||||
this.logger = logger.child({ module: "speech", provider: "sherpa-onnx", component: "tts" });
|
this.logger = logger.child({ module: "speech", provider: "local", component: "tts" });
|
||||||
this.speakerId = config.speakerId ?? 0;
|
this.speakerId = config.speakerId ?? 0;
|
||||||
this.speed = config.speed ?? 1.0;
|
this.speed = config.speed ?? 1.0;
|
||||||
|
|
||||||
|
|||||||
+22
-9
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
|
|||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./model-downloader.js";
|
import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./model-downloader.js";
|
||||||
@@ -96,7 +97,7 @@ describe("speech models (download E2E)", () => {
|
|||||||
const set = getModelSet();
|
const set = getModelSet();
|
||||||
|
|
||||||
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
|
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
|
||||||
const modelsDir = path.join(paseoHomeRoot, ".paseo", "models", "sherpa-onnx");
|
const modelsDir = path.join(paseoHomeRoot, ".paseo", "models", "local-speech");
|
||||||
|
|
||||||
const modelIds: SherpaOnnxModelId[] =
|
const modelIds: SherpaOnnxModelId[] =
|
||||||
set === "parakeet-pocket"
|
set === "parakeet-pocket"
|
||||||
@@ -114,14 +115,26 @@ describe("speech models (download E2E)", () => {
|
|||||||
paseoHomeRoot,
|
paseoHomeRoot,
|
||||||
dictationFinalTimeoutMs: 8000,
|
dictationFinalTimeoutMs: 8000,
|
||||||
speech: {
|
speech: {
|
||||||
dictationSttProvider: "local",
|
providers: {
|
||||||
voiceSttProvider: "local",
|
dictationStt: { provider: "local", explicit: true },
|
||||||
voiceTtsProvider: "local",
|
voiceStt: { provider: "local", explicit: true },
|
||||||
sherpaOnnx: {
|
voiceTts: { provider: "local", explicit: true },
|
||||||
|
},
|
||||||
|
local: {
|
||||||
modelsDir,
|
modelsDir,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
stt: { preset: set === "parakeet-pocket" ? "parakeet-tdt-0.6b-v3-int8" : "zipformer-bilingual-zh-en-2023-02-20" },
|
models: {
|
||||||
tts: { preset: set === "parakeet-pocket" ? "pocket-tts-onnx-int8" : "kitten-nano-en-v0_1-fp16" },
|
dictationStt:
|
||||||
|
set === "parakeet-pocket"
|
||||||
|
? "parakeet-tdt-0.6b-v3-int8"
|
||||||
|
: "zipformer-bilingual-zh-en-2023-02-20",
|
||||||
|
voiceStt:
|
||||||
|
set === "parakeet-pocket"
|
||||||
|
? "parakeet-tdt-0.6b-v3-int8"
|
||||||
|
: "zipformer-bilingual-zh-en-2023-02-20",
|
||||||
|
voiceTts:
|
||||||
|
set === "parakeet-pocket" ? "pocket-tts-onnx-int8" : "kitten-nano-en-v0_1-fp16",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -178,7 +191,7 @@ describe("speech models (download E2E)", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.client.setVoiceConversation(true, `voice-download-${Date.now()}`);
|
await ctx.client.setVoiceMode(true, randomUUID());
|
||||||
for (let offset = 0; offset < pcm16.length; offset += chunkBytes) {
|
for (let offset = 0; offset < pcm16.length; offset += chunkBytes) {
|
||||||
const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes));
|
const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes));
|
||||||
const isLast = offset + chunkBytes >= pcm16.length;
|
const isLast = offset + chunkBytes >= pcm16.length;
|
||||||
@@ -188,7 +201,7 @@ describe("speech models (download E2E)", () => {
|
|||||||
if (voiceText.length > 0) {
|
if (voiceText.length > 0) {
|
||||||
expect(voiceText).toContain("voice note");
|
expect(voiceText).toContain("voice note");
|
||||||
}
|
}
|
||||||
await ctx.client.setVoiceConversation(false);
|
await ctx.client.setVoiceMode(false);
|
||||||
|
|
||||||
// Streaming TTS: generate locally from downloaded model and validate chunking.
|
// Streaming TTS: generate locally from downloaded model and validate chunking.
|
||||||
const ttsText = "This is a voice note.";
|
const ttsText = "This is a voice note.";
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { PersistedConfigSchema } from "../../../persisted-config.js";
|
||||||
|
import { resolveOpenAiSpeechConfig } from "./config.js";
|
||||||
|
|
||||||
|
describe("resolveOpenAiSpeechConfig", () => {
|
||||||
|
test("treats empty OPENAI_API_KEY as unset", () => {
|
||||||
|
const persisted = PersistedConfigSchema.parse({});
|
||||||
|
const env = {
|
||||||
|
OPENAI_API_KEY: "",
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
const resolved = resolveOpenAiSpeechConfig({
|
||||||
|
env,
|
||||||
|
persisted,
|
||||||
|
providers: {
|
||||||
|
dictationStt: { provider: "local", explicit: false },
|
||||||
|
voiceStt: { provider: "local", explicit: false },
|
||||||
|
voiceTts: { provider: "local", explicit: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses trimmed OPENAI_API_KEY when configured", () => {
|
||||||
|
const persisted = PersistedConfigSchema.parse({});
|
||||||
|
const env = {
|
||||||
|
OPENAI_API_KEY: " sk-test ",
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
const resolved = resolveOpenAiSpeechConfig({
|
||||||
|
env,
|
||||||
|
persisted,
|
||||||
|
providers: {
|
||||||
|
dictationStt: { provider: "openai", explicit: true },
|
||||||
|
voiceStt: { provider: "openai", explicit: true },
|
||||||
|
voiceTts: { provider: "openai", explicit: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved?.apiKey).toBe("sk-test");
|
||||||
|
expect(resolved?.stt?.apiKey).toBe("sk-test");
|
||||||
|
expect(resolved?.tts?.apiKey).toBe("sk-test");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { PersistedConfig } from "../../../persisted-config.js";
|
||||||
|
import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||||
|
import type { STTConfig } from "./stt.js";
|
||||||
|
import type { TTSConfig } from "./tts.js";
|
||||||
|
|
||||||
|
export const DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
|
||||||
|
export const DEFAULT_OPENAI_TTS_MODEL = "tts-1";
|
||||||
|
|
||||||
|
export type OpenAiSpeechProviderConfig = {
|
||||||
|
apiKey?: string;
|
||||||
|
stt?: Partial<STTConfig> & { apiKey?: string };
|
||||||
|
tts?: Partial<TTSConfig> & { apiKey?: string };
|
||||||
|
realtimeTranscriptionModel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpenAiTtsVoiceSchema = z.enum([
|
||||||
|
"alloy",
|
||||||
|
"echo",
|
||||||
|
"fable",
|
||||||
|
"onyx",
|
||||||
|
"nova",
|
||||||
|
"shimmer",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]);
|
||||||
|
|
||||||
|
const NumberLikeSchema = z.union([
|
||||||
|
z.number(),
|
||||||
|
z.string().trim().min(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OptionalFiniteNumberSchema = NumberLikeSchema
|
||||||
|
.pipe(z.coerce.number().finite())
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const OptionalTrimmedStringSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => (value && value.length > 0 ? value : undefined));
|
||||||
|
|
||||||
|
const OpenAiSpeechResolutionSchema = z.object({
|
||||||
|
apiKey: OptionalTrimmedStringSchema,
|
||||||
|
sttConfidenceThreshold: OptionalFiniteNumberSchema,
|
||||||
|
sttModel: OptionalTrimmedStringSchema,
|
||||||
|
ttsVoice: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.pipe(OpenAiTtsVoiceSchema)
|
||||||
|
.default("alloy"),
|
||||||
|
ttsModel: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.pipe(OpenAiTtsModelSchema)
|
||||||
|
.default(DEFAULT_OPENAI_TTS_MODEL),
|
||||||
|
realtimeTranscriptionModel: OptionalTrimmedStringSchema.default(
|
||||||
|
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function resolveOpenAiSpeechConfig(params: {
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
persisted: PersistedConfig;
|
||||||
|
providers: RequestedSpeechProviders;
|
||||||
|
}): OpenAiSpeechProviderConfig | undefined {
|
||||||
|
const parsed = OpenAiSpeechResolutionSchema.parse({
|
||||||
|
apiKey: params.env.OPENAI_API_KEY ?? params.persisted.providers?.openai?.apiKey,
|
||||||
|
sttConfidenceThreshold:
|
||||||
|
params.env.STT_CONFIDENCE_THRESHOLD ??
|
||||||
|
params.persisted.features?.dictation?.stt?.confidenceThreshold,
|
||||||
|
sttModel:
|
||||||
|
params.env.STT_MODEL ??
|
||||||
|
(params.providers.voiceStt.provider === "openai"
|
||||||
|
? params.persisted.features?.voiceMode?.stt?.model
|
||||||
|
: undefined) ??
|
||||||
|
(params.providers.dictationStt.provider === "openai"
|
||||||
|
? params.persisted.features?.dictation?.stt?.model
|
||||||
|
: undefined),
|
||||||
|
ttsVoice:
|
||||||
|
params.env.TTS_VOICE ??
|
||||||
|
(params.providers.voiceTts.provider === "openai"
|
||||||
|
? params.persisted.features?.voiceMode?.tts?.voice
|
||||||
|
: undefined) ??
|
||||||
|
"alloy",
|
||||||
|
ttsModel:
|
||||||
|
params.env.TTS_MODEL ??
|
||||||
|
(params.providers.voiceTts.provider === "openai"
|
||||||
|
? params.persisted.features?.voiceMode?.tts?.model
|
||||||
|
: undefined) ??
|
||||||
|
DEFAULT_OPENAI_TTS_MODEL,
|
||||||
|
realtimeTranscriptionModel:
|
||||||
|
params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ??
|
||||||
|
(params.providers.dictationStt.provider === "openai"
|
||||||
|
? params.persisted.features?.dictation?.stt?.model
|
||||||
|
: undefined) ??
|
||||||
|
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.apiKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: parsed.apiKey,
|
||||||
|
stt: {
|
||||||
|
apiKey: parsed.apiKey,
|
||||||
|
...(parsed.sttConfidenceThreshold !== undefined
|
||||||
|
? { confidenceThreshold: parsed.sttConfidenceThreshold }
|
||||||
|
: {}),
|
||||||
|
...(parsed.sttModel
|
||||||
|
? { model: parsed.sttModel }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
tts: {
|
||||||
|
apiKey: parsed.apiKey,
|
||||||
|
voice: parsed.ttsVoice,
|
||||||
|
model: parsed.ttsModel,
|
||||||
|
responseFormat: "pcm",
|
||||||
|
},
|
||||||
|
realtimeTranscriptionModel: parsed.realtimeTranscriptionModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech-provider.js";
|
||||||
|
import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||||
|
DEFAULT_OPENAI_TTS_MODEL,
|
||||||
|
type OpenAiSpeechProviderConfig,
|
||||||
|
} from "./config.js";
|
||||||
|
import { OpenAIRealtimeTranscriptionSession } from "./realtime-transcription-session.js";
|
||||||
|
import { OpenAISTT } from "./stt.js";
|
||||||
|
import { OpenAITTS } from "./tts.js";
|
||||||
|
|
||||||
|
type OpenAiCredentialState = {
|
||||||
|
openaiSttApiKey: string | undefined;
|
||||||
|
openaiTtsApiKey: string | undefined;
|
||||||
|
openaiDictationApiKey: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenAiSpeechAvailability = {
|
||||||
|
stt: boolean;
|
||||||
|
tts: boolean;
|
||||||
|
dictationStt: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpeechServices = {
|
||||||
|
sttService: SpeechToTextProvider | null;
|
||||||
|
ttsService: TextToSpeechProvider | null;
|
||||||
|
dictationSttService: SpeechToTextProvider | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveOpenAiCredentials(
|
||||||
|
openaiConfig: OpenAiSpeechProviderConfig | undefined
|
||||||
|
): OpenAiCredentialState {
|
||||||
|
const openaiApiKey = openaiConfig?.apiKey;
|
||||||
|
return {
|
||||||
|
openaiSttApiKey: openaiConfig?.stt?.apiKey ?? openaiApiKey,
|
||||||
|
openaiTtsApiKey: openaiConfig?.tts?.apiKey ?? openaiApiKey,
|
||||||
|
openaiDictationApiKey: openaiApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenAiSpeechAvailability(
|
||||||
|
openaiConfig: OpenAiSpeechProviderConfig | undefined
|
||||||
|
): OpenAiSpeechAvailability {
|
||||||
|
const credentials = resolveOpenAiCredentials(openaiConfig);
|
||||||
|
return {
|
||||||
|
stt: Boolean(credentials.openaiSttApiKey),
|
||||||
|
tts: Boolean(credentials.openaiTtsApiKey),
|
||||||
|
dictationStt: Boolean(credentials.openaiDictationApiKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateOpenAiCredentialRequirements(params: {
|
||||||
|
providers: RequestedSpeechProviders;
|
||||||
|
openaiConfig: OpenAiSpeechProviderConfig | undefined;
|
||||||
|
logger: Logger;
|
||||||
|
}): void {
|
||||||
|
const { providers, logger, openaiConfig } = params;
|
||||||
|
const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
|
||||||
|
|
||||||
|
const missingOpenAiCredentialsFor: string[] = [];
|
||||||
|
if (providers.voiceStt.provider === "openai" && !openAiCredentials.openaiSttApiKey) {
|
||||||
|
missingOpenAiCredentialsFor.push("voice.stt");
|
||||||
|
}
|
||||||
|
if (providers.voiceTts.provider === "openai" && !openAiCredentials.openaiTtsApiKey) {
|
||||||
|
missingOpenAiCredentialsFor.push("voice.tts");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
providers.dictationStt.provider === "openai" &&
|
||||||
|
!openAiCredentials.openaiDictationApiKey
|
||||||
|
) {
|
||||||
|
missingOpenAiCredentialsFor.push("dictation.stt");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingOpenAiCredentialsFor.length > 0) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
requestedProviders: {
|
||||||
|
dictationStt: providers.dictationStt.provider,
|
||||||
|
voiceStt: providers.voiceStt.provider,
|
||||||
|
voiceTts: providers.voiceTts.provider,
|
||||||
|
},
|
||||||
|
missingOpenAiCredentialsFor,
|
||||||
|
},
|
||||||
|
"Invalid speech configuration: OpenAI provider selected but credentials are missing"
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Missing OpenAI credentials for configured speech features: ${missingOpenAiCredentialsFor.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeOpenAiSpeechServices(params: {
|
||||||
|
providers: RequestedSpeechProviders;
|
||||||
|
openaiConfig: OpenAiSpeechProviderConfig | undefined;
|
||||||
|
existing: SpeechServices;
|
||||||
|
logger: Logger;
|
||||||
|
}): SpeechServices {
|
||||||
|
const { providers, openaiConfig, existing, logger } = params;
|
||||||
|
const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
|
||||||
|
|
||||||
|
let sttService = existing.sttService;
|
||||||
|
let ttsService = existing.ttsService;
|
||||||
|
let dictationSttService = existing.dictationSttService;
|
||||||
|
|
||||||
|
const needsOpenAiStt = !sttService && providers.voiceStt.provider === "openai";
|
||||||
|
const needsOpenAiTts = !ttsService && providers.voiceTts.provider === "openai";
|
||||||
|
const needsOpenAiDictation =
|
||||||
|
!dictationSttService && providers.dictationStt.provider === "openai";
|
||||||
|
|
||||||
|
if (
|
||||||
|
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&
|
||||||
|
(openAiCredentials.openaiSttApiKey ||
|
||||||
|
openAiCredentials.openaiTtsApiKey ||
|
||||||
|
openAiCredentials.openaiDictationApiKey)
|
||||||
|
) {
|
||||||
|
logger.info("OpenAI speech provider initialized");
|
||||||
|
|
||||||
|
if (needsOpenAiStt && openAiCredentials.openaiSttApiKey) {
|
||||||
|
const { apiKey: _sttApiKey, ...sttConfig } = openaiConfig?.stt ?? {};
|
||||||
|
sttService = new OpenAISTT(
|
||||||
|
{
|
||||||
|
apiKey: openAiCredentials.openaiSttApiKey,
|
||||||
|
...sttConfig,
|
||||||
|
},
|
||||||
|
logger
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsOpenAiTts && openAiCredentials.openaiTtsApiKey) {
|
||||||
|
const { apiKey: _ttsApiKey, ...ttsConfig } = openaiConfig?.tts ?? {};
|
||||||
|
ttsService = new OpenAITTS(
|
||||||
|
{
|
||||||
|
apiKey: openAiCredentials.openaiTtsApiKey,
|
||||||
|
voice: "alloy",
|
||||||
|
model: DEFAULT_OPENAI_TTS_MODEL,
|
||||||
|
responseFormat: "pcm",
|
||||||
|
...ttsConfig,
|
||||||
|
},
|
||||||
|
logger
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dictationApiKey = openAiCredentials.openaiDictationApiKey;
|
||||||
|
if (needsOpenAiDictation && dictationApiKey) {
|
||||||
|
dictationSttService = {
|
||||||
|
id: "openai",
|
||||||
|
createSession: ({ logger: sessionLogger, language, prompt }) =>
|
||||||
|
new OpenAIRealtimeTranscriptionSession({
|
||||||
|
apiKey: dictationApiKey,
|
||||||
|
logger: sessionLogger,
|
||||||
|
transcriptionModel:
|
||||||
|
openaiConfig?.realtimeTranscriptionModel ??
|
||||||
|
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||||
|
...(language ? { language } : {}),
|
||||||
|
...(prompt ? { prompt } : {}),
|
||||||
|
turnDetection: null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) {
|
||||||
|
logger.warn("OpenAI speech providers are configured but credentials are missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sttService,
|
||||||
|
ttsService,
|
||||||
|
dictationSttService,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { PersistedConfigSchema } from "../persisted-config.js";
|
||||||
|
import { resolveSpeechConfig } from "./speech-config-resolver.js";
|
||||||
|
|
||||||
|
describe("resolveSpeechConfig", () => {
|
||||||
|
test("resolves local-first defaults without env overrides", () => {
|
||||||
|
const paseoHome = "/tmp/paseo-home";
|
||||||
|
const persisted = PersistedConfigSchema.parse({});
|
||||||
|
const env = {} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
const result = resolveSpeechConfig({
|
||||||
|
paseoHome,
|
||||||
|
env,
|
||||||
|
persisted,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.openai).toBeUndefined();
|
||||||
|
expect(result.speech.providers.dictationStt).toEqual({
|
||||||
|
provider: "local",
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
expect(result.speech.providers.voiceStt).toEqual({
|
||||||
|
provider: "local",
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
expect(result.speech.providers.voiceTts).toEqual({
|
||||||
|
provider: "local",
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
expect(result.speech.local).toEqual({
|
||||||
|
modelsDir: path.join(paseoHome, "models", "local-speech"),
|
||||||
|
autoDownload: true,
|
||||||
|
models: {
|
||||||
|
dictationStt: "parakeet-tdt-0.6b-v3-int8",
|
||||||
|
voiceStt: "parakeet-tdt-0.6b-v3-int8",
|
||||||
|
voiceTts: "pocket-tts-onnx-int8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.speech.local?.models.dictationStt).toBe("parakeet-tdt-0.6b-v3-int8");
|
||||||
|
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
|
||||||
|
expect(result.speech.local?.models.voiceTts).toBe("pocket-tts-onnx-int8");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves feature-scoped local model env vars", () => {
|
||||||
|
const persisted = PersistedConfigSchema.parse({
|
||||||
|
features: {
|
||||||
|
voiceMode: {
|
||||||
|
stt: { provider: "openai", model: "gpt-4o-transcribe" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
openai: { apiKey: "persisted-key" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const env = {
|
||||||
|
PASEO_DICTATION_LOCAL_STT_MODEL: "zipformer",
|
||||||
|
PASEO_VOICE_LOCAL_STT_MODEL: "parakeet",
|
||||||
|
PASEO_VOICE_LOCAL_TTS_MODEL: "kitten",
|
||||||
|
PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5",
|
||||||
|
PASEO_VOICE_LOCAL_TTS_SPEED: "1.35",
|
||||||
|
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
|
||||||
|
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
|
||||||
|
OPENAI_API_KEY: "env-key",
|
||||||
|
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||||
|
PASEO_DICTATION_STT_PROVIDER: "local",
|
||||||
|
PASEO_VOICE_TTS_PROVIDER: "local",
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
const result = resolveSpeechConfig({
|
||||||
|
paseoHome: "/tmp/paseo-home",
|
||||||
|
env,
|
||||||
|
persisted,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.speech.local).toEqual({
|
||||||
|
modelsDir: "/tmp/models",
|
||||||
|
autoDownload: false,
|
||||||
|
models: {
|
||||||
|
dictationStt: "zipformer-bilingual-zh-en-2023-02-20",
|
||||||
|
voiceStt: "parakeet-tdt-0.6b-v3-int8",
|
||||||
|
voiceTts: "kitten-nano-en-v0_1-fp16",
|
||||||
|
voiceTtsSpeakerId: 5,
|
||||||
|
voiceTtsSpeed: 1.35,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.speech.providers.dictationStt).toEqual({
|
||||||
|
provider: "local",
|
||||||
|
explicit: true,
|
||||||
|
});
|
||||||
|
expect(result.speech.providers.voiceStt).toEqual({
|
||||||
|
provider: "openai",
|
||||||
|
explicit: true,
|
||||||
|
});
|
||||||
|
expect(result.speech.providers.voiceTts).toEqual({
|
||||||
|
provider: "local",
|
||||||
|
explicit: true,
|
||||||
|
});
|
||||||
|
expect(result.speech.local?.models.dictationStt).toBe("zipformer-bilingual-zh-en-2023-02-20");
|
||||||
|
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
|
||||||
|
expect(result.speech.local?.models.voiceTts).toBe("kitten-nano-en-v0_1-fp16");
|
||||||
|
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(5);
|
||||||
|
expect(result.speech.local?.models.voiceTtsSpeed).toBe(1.35);
|
||||||
|
expect(result.openai?.apiKey).toBe("env-key");
|
||||||
|
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores deprecated shared local model env vars", () => {
|
||||||
|
const persisted = PersistedConfigSchema.parse({});
|
||||||
|
const env = {
|
||||||
|
PASEO_LOCAL_STT_MODEL: "zipformer-bilingual-zh-en-2023-02-20",
|
||||||
|
PASEO_LOCAL_TTS_MODEL: "kitten-nano-en-v0_1-fp16",
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
const result = resolveSpeechConfig({
|
||||||
|
paseoHome: "/tmp/paseo-home",
|
||||||
|
env,
|
||||||
|
persisted,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.speech.local?.models.dictationStt).toBe("parakeet-tdt-0.6b-v3-int8");
|
||||||
|
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
|
||||||
|
expect(result.speech.local?.models.voiceTts).toBe("pocket-tts-onnx-int8");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { PersistedConfig } from "../persisted-config.js";
|
||||||
|
import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js";
|
||||||
|
import { resolveLocalSpeechConfig } from "./providers/local/config.js";
|
||||||
|
import { resolveOpenAiSpeechConfig } from "./providers/openai/config.js";
|
||||||
|
import {
|
||||||
|
SpeechProviderIdSchema,
|
||||||
|
type RequestedSpeechProvider,
|
||||||
|
type RequestedSpeechProviders,
|
||||||
|
} from "./speech-types.js";
|
||||||
|
|
||||||
|
const OptionalSpeechProviderSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.pipe(SpeechProviderIdSchema)
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const RequestedSpeechProvidersSchema = z.object({
|
||||||
|
dictationStt: OptionalSpeechProviderSchema.default("local"),
|
||||||
|
voiceStt: OptionalSpeechProviderSchema.default("local"),
|
||||||
|
voiceTts: OptionalSpeechProviderSchema.default("local"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveRequestedSpeechProviders(params: {
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
persisted: PersistedConfig;
|
||||||
|
}): RequestedSpeechProviders {
|
||||||
|
const resolveFeatureProvider = (
|
||||||
|
configuredValue: string | undefined,
|
||||||
|
parsedValue: z.infer<typeof SpeechProviderIdSchema>
|
||||||
|
): RequestedSpeechProvider => ({
|
||||||
|
provider: parsedValue,
|
||||||
|
explicit: configuredValue !== undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dictationSttProviderFromConfig =
|
||||||
|
params.env.PASEO_DICTATION_STT_PROVIDER ??
|
||||||
|
params.persisted.features?.dictation?.stt?.provider;
|
||||||
|
const voiceSttProviderFromConfig =
|
||||||
|
params.env.PASEO_VOICE_STT_PROVIDER ??
|
||||||
|
params.persisted.features?.voiceMode?.stt?.provider;
|
||||||
|
const voiceTtsProviderFromConfig =
|
||||||
|
params.env.PASEO_VOICE_TTS_PROVIDER ??
|
||||||
|
params.persisted.features?.voiceMode?.tts?.provider;
|
||||||
|
|
||||||
|
const parsed = RequestedSpeechProvidersSchema.parse({
|
||||||
|
dictationStt: dictationSttProviderFromConfig ?? "local",
|
||||||
|
voiceStt: voiceSttProviderFromConfig ?? "local",
|
||||||
|
voiceTts: voiceTtsProviderFromConfig ?? "local",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dictationStt: resolveFeatureProvider(
|
||||||
|
dictationSttProviderFromConfig,
|
||||||
|
parsed.dictationStt
|
||||||
|
),
|
||||||
|
voiceStt: resolveFeatureProvider(
|
||||||
|
voiceSttProviderFromConfig,
|
||||||
|
parsed.voiceStt
|
||||||
|
),
|
||||||
|
voiceTts: resolveFeatureProvider(
|
||||||
|
voiceTtsProviderFromConfig,
|
||||||
|
parsed.voiceTts
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSpeechConfig(params: {
|
||||||
|
paseoHome: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
persisted: PersistedConfig;
|
||||||
|
}): {
|
||||||
|
openai: PaseoOpenAIConfig | undefined;
|
||||||
|
speech: PaseoSpeechConfig;
|
||||||
|
} {
|
||||||
|
const providers = resolveRequestedSpeechProviders({
|
||||||
|
env: params.env,
|
||||||
|
persisted: params.persisted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const local = resolveLocalSpeechConfig({
|
||||||
|
paseoHome: params.paseoHome,
|
||||||
|
env: params.env,
|
||||||
|
persisted: params.persisted,
|
||||||
|
providers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openai = resolveOpenAiSpeechConfig({
|
||||||
|
env: params.env,
|
||||||
|
persisted: params.persisted,
|
||||||
|
providers,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
openai,
|
||||||
|
speech: {
|
||||||
|
providers,
|
||||||
|
...(local.local
|
||||||
|
? { local: local.local }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
|
import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js";
|
||||||
|
import {
|
||||||
|
getLocalSpeechAvailability,
|
||||||
|
initializeLocalSpeechServices,
|
||||||
|
} from "./providers/local/runtime.js";
|
||||||
|
import type { LocalSpeechModelId } from "./providers/local/config.js";
|
||||||
|
import {
|
||||||
|
getOpenAiSpeechAvailability,
|
||||||
|
initializeOpenAiSpeechServices,
|
||||||
|
validateOpenAiCredentialRequirements,
|
||||||
|
} from "./providers/openai/runtime.js";
|
||||||
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js";
|
||||||
|
import type { RequestedSpeechProviders } from "./speech-types.js";
|
||||||
|
|
||||||
|
function resolveRequestedSpeechProviders(
|
||||||
|
speechConfig: PaseoSpeechConfig | null
|
||||||
|
): RequestedSpeechProviders {
|
||||||
|
const fromConfig = speechConfig?.providers;
|
||||||
|
if (fromConfig) {
|
||||||
|
return fromConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dictationStt: { provider: "local", explicit: false },
|
||||||
|
voiceStt: { provider: "local", explicit: false },
|
||||||
|
voiceTts: { provider: "local", explicit: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InitializedSpeechRuntime = {
|
||||||
|
sttService: SpeechToTextProvider | null;
|
||||||
|
ttsService: TextToSpeechProvider | null;
|
||||||
|
dictationSttService: SpeechToTextProvider | null;
|
||||||
|
cleanup: () => void;
|
||||||
|
localModelConfig: {
|
||||||
|
modelsDir: string;
|
||||||
|
defaultModelIds: LocalSpeechModelId[];
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function initializeSpeechRuntime(params: {
|
||||||
|
logger: Logger;
|
||||||
|
openaiConfig?: PaseoOpenAIConfig;
|
||||||
|
speechConfig?: PaseoSpeechConfig;
|
||||||
|
}): Promise<InitializedSpeechRuntime> {
|
||||||
|
const logger = params.logger;
|
||||||
|
const speechConfig = params.speechConfig ?? null;
|
||||||
|
const openaiConfig = params.openaiConfig;
|
||||||
|
const providers = resolveRequestedSpeechProviders(speechConfig);
|
||||||
|
|
||||||
|
validateOpenAiCredentialRequirements({
|
||||||
|
providers,
|
||||||
|
openaiConfig,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
requestedProviders: {
|
||||||
|
dictationStt: providers.dictationStt.provider,
|
||||||
|
voiceStt: providers.voiceStt.provider,
|
||||||
|
voiceTts: providers.voiceTts.provider,
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
openai: getOpenAiSpeechAvailability(openaiConfig),
|
||||||
|
local: getLocalSpeechAvailability(speechConfig),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Speech provider reconciliation started"
|
||||||
|
);
|
||||||
|
|
||||||
|
const localSpeech = await initializeLocalSpeechServices({
|
||||||
|
providers,
|
||||||
|
speechConfig,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openAiSpeech = initializeOpenAiSpeechServices({
|
||||||
|
providers,
|
||||||
|
openaiConfig,
|
||||||
|
existing: {
|
||||||
|
sttService: localSpeech.sttService,
|
||||||
|
ttsService: localSpeech.ttsService,
|
||||||
|
dictationSttService: localSpeech.dictationSttService,
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveProviders = {
|
||||||
|
dictationStt: openAiSpeech.dictationSttService?.id ?? "unavailable",
|
||||||
|
voiceStt: openAiSpeech.sttService?.id ?? "unavailable",
|
||||||
|
voiceTts:
|
||||||
|
!openAiSpeech.ttsService
|
||||||
|
? "unavailable"
|
||||||
|
: openAiSpeech.ttsService === localSpeech.localVoiceTtsProvider
|
||||||
|
? "local"
|
||||||
|
: "openai",
|
||||||
|
};
|
||||||
|
const unavailableFeatures = [
|
||||||
|
!openAiSpeech.dictationSttService ? "dictation.stt" : null,
|
||||||
|
!openAiSpeech.sttService ? "voice.stt" : null,
|
||||||
|
!openAiSpeech.ttsService ? "voice.tts" : null,
|
||||||
|
].filter((feature): feature is string => feature !== null);
|
||||||
|
const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => {
|
||||||
|
if (feature === "dictation.stt") {
|
||||||
|
return providers.dictationStt.explicit;
|
||||||
|
}
|
||||||
|
if (feature === "voice.stt") {
|
||||||
|
return providers.voiceStt.explicit;
|
||||||
|
}
|
||||||
|
return providers.voiceTts.explicit;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (explicitlyConfiguredUnavailableFeatures.length > 0) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
requestedProviders: {
|
||||||
|
dictationStt: providers.dictationStt.provider,
|
||||||
|
voiceStt: providers.voiceStt.provider,
|
||||||
|
voiceTts: providers.voiceTts.provider,
|
||||||
|
},
|
||||||
|
explicitProviders: {
|
||||||
|
dictationStt: providers.dictationStt.explicit,
|
||||||
|
voiceStt: providers.voiceStt.explicit,
|
||||||
|
voiceTts: providers.voiceTts.explicit,
|
||||||
|
},
|
||||||
|
effectiveProviders,
|
||||||
|
unavailableFeatures: explicitlyConfiguredUnavailableFeatures,
|
||||||
|
},
|
||||||
|
"Speech provider reconciliation failed: configured features are unavailable"
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Configured speech features unavailable: ${explicitlyConfiguredUnavailableFeatures.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unavailableFeatures.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
requestedProviders: {
|
||||||
|
dictationStt: providers.dictationStt.provider,
|
||||||
|
voiceStt: providers.voiceStt.provider,
|
||||||
|
voiceTts: providers.voiceTts.provider,
|
||||||
|
},
|
||||||
|
explicitProviders: {
|
||||||
|
dictationStt: providers.dictationStt.explicit,
|
||||||
|
voiceStt: providers.voiceStt.explicit,
|
||||||
|
voiceTts: providers.voiceTts.explicit,
|
||||||
|
},
|
||||||
|
effectiveProviders,
|
||||||
|
unavailableFeatures,
|
||||||
|
},
|
||||||
|
"Speech provider reconciliation completed with unavailable default features"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
effectiveProviders,
|
||||||
|
},
|
||||||
|
"Speech provider reconciliation completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sttService: openAiSpeech.sttService,
|
||||||
|
ttsService: openAiSpeech.ttsService,
|
||||||
|
dictationSttService: openAiSpeech.dictationSttService,
|
||||||
|
cleanup: localSpeech.cleanup,
|
||||||
|
localModelConfig: localSpeech.localModelConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const SpeechProviderIdSchema = z.enum(["openai", "local"]);
|
||||||
|
export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>;
|
||||||
|
|
||||||
|
export const RequestedSpeechProviderSchema = z.object({
|
||||||
|
provider: SpeechProviderIdSchema,
|
||||||
|
explicit: z.boolean(),
|
||||||
|
});
|
||||||
|
export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>;
|
||||||
|
|
||||||
|
export type RequestedSpeechProviders = {
|
||||||
|
dictationStt: RequestedSpeechProvider;
|
||||||
|
voiceStt: RequestedSpeechProvider;
|
||||||
|
voiceTts: RequestedSpeechProvider;
|
||||||
|
};
|
||||||
@@ -21,6 +21,9 @@ type TestPaseoDaemonOptions = {
|
|||||||
cleanup?: boolean;
|
cleanup?: boolean;
|
||||||
openai?: PaseoOpenAIConfig;
|
openai?: PaseoOpenAIConfig;
|
||||||
speech?: PaseoSpeechConfig;
|
speech?: PaseoSpeechConfig;
|
||||||
|
voiceLlmProvider?: PaseoDaemonConfig["voiceLlmProvider"];
|
||||||
|
voiceLlmProviderExplicit?: boolean;
|
||||||
|
voiceLlmModel?: string | null;
|
||||||
dictationFinalTimeoutMs?: number;
|
dictationFinalTimeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +81,9 @@ export async function createTestPaseoDaemon(
|
|||||||
appBaseUrl: "https://app.paseo.sh",
|
appBaseUrl: "https://app.paseo.sh",
|
||||||
openai: options.openai,
|
openai: options.openai,
|
||||||
speech: options.speech,
|
speech: options.speech,
|
||||||
openrouterApiKey: null,
|
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
||||||
|
voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false,
|
||||||
|
voiceLlmModel: options.voiceLlmModel ?? null,
|
||||||
dictationFinalTimeoutMs: options.dictationFinalTimeoutMs,
|
dictationFinalTimeoutMs: options.dictationFinalTimeoutMs,
|
||||||
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { readFile, writeFile, readdir, unlink, mkdir, stat } from "fs/promises";
|
|
||||||
import { join } from "path";
|
|
||||||
import type { ModelMessage } from "@ai-sdk/provider-utils";
|
|
||||||
import { standardizePrompt } from "ai/internal";
|
|
||||||
|
|
||||||
type LoggerLike = {
|
|
||||||
child(bindings: Record<string, unknown>): LoggerLike;
|
|
||||||
info(...args: any[]): void;
|
|
||||||
debug(...args: any[]): void;
|
|
||||||
warn(...args: any[]): void;
|
|
||||||
error(...args: any[]): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getLogger(logger: LoggerLike): LoggerLike {
|
|
||||||
return logger.child({ module: "voice-conversation-store" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VoiceConversationMetadata {
|
|
||||||
id: string;
|
|
||||||
lastUpdated: Date;
|
|
||||||
messageCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VoiceConversationData {
|
|
||||||
voiceConversationId: string;
|
|
||||||
lastUpdated: string;
|
|
||||||
messageCount: number;
|
|
||||||
messages: ModelMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class VoiceConversationStore {
|
|
||||||
private readonly baseDir: string;
|
|
||||||
|
|
||||||
constructor(baseDir: string) {
|
|
||||||
this.baseDir = baseDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureBaseDir(): Promise<void> {
|
|
||||||
await mkdir(this.baseDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async save(
|
|
||||||
logger: LoggerLike,
|
|
||||||
voiceConversationId: string,
|
|
||||||
messages: ModelMessage[]
|
|
||||||
): Promise<void> {
|
|
||||||
const log = getLogger(logger);
|
|
||||||
await this.ensureBaseDir();
|
|
||||||
|
|
||||||
const filepath = join(this.baseDir, `${voiceConversationId}.json`);
|
|
||||||
const data: VoiceConversationData = {
|
|
||||||
voiceConversationId,
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
messageCount: messages.length,
|
|
||||||
messages,
|
|
||||||
};
|
|
||||||
|
|
||||||
await writeFile(filepath, JSON.stringify(data, null, 2), "utf-8");
|
|
||||||
log.debug({ voiceConversationId, messageCount: messages.length }, "Saved voice conversation");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load voice conversation from disk.
|
|
||||||
* Returns null when missing or invalid (best-effort).
|
|
||||||
*/
|
|
||||||
public async load(
|
|
||||||
logger: LoggerLike,
|
|
||||||
voiceConversationId: string
|
|
||||||
): Promise<ModelMessage[] | null> {
|
|
||||||
const log = getLogger(logger);
|
|
||||||
const filepath = join(this.baseDir, `${voiceConversationId}.json`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stat(filepath);
|
|
||||||
} catch {
|
|
||||||
log.debug({ voiceConversationId }, "Voice conversation not found");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileContent = await readFile(filepath, "utf-8");
|
|
||||||
const data: VoiceConversationData = JSON.parse(fileContent);
|
|
||||||
|
|
||||||
const result = await standardizePrompt({ prompt: data.messages });
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
{ voiceConversationId, messageCount: data.messageCount },
|
|
||||||
"Loaded voice conversation"
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.messages as ModelMessage[];
|
|
||||||
} catch (error) {
|
|
||||||
log.warn({ err: error, voiceConversationId }, "Failed to load voice conversation");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async list(logger: LoggerLike): Promise<VoiceConversationMetadata[]> {
|
|
||||||
const log = getLogger(logger);
|
|
||||||
try {
|
|
||||||
await this.ensureBaseDir();
|
|
||||||
|
|
||||||
const files = await readdir(this.baseDir);
|
|
||||||
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
||||||
const conversations: VoiceConversationMetadata[] = [];
|
|
||||||
|
|
||||||
for (const file of jsonFiles) {
|
|
||||||
try {
|
|
||||||
const filepath = join(this.baseDir, file);
|
|
||||||
const fileContent = await readFile(filepath, "utf-8");
|
|
||||||
const data: VoiceConversationData = JSON.parse(fileContent);
|
|
||||||
|
|
||||||
conversations.push({
|
|
||||||
id: data.voiceConversationId,
|
|
||||||
lastUpdated: new Date(data.lastUpdated),
|
|
||||||
messageCount: data.messageCount,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.warn({ err: error, file }, "Failed to read voice conversation file");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conversations.sort(
|
|
||||||
(a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime()
|
|
||||||
);
|
|
||||||
return conversations;
|
|
||||||
} catch (error) {
|
|
||||||
log.warn({ err: error }, "Failed to list voice conversations");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async delete(logger: LoggerLike, voiceConversationId: string): Promise<void> {
|
|
||||||
const log = getLogger(logger);
|
|
||||||
const filepath = join(this.baseDir, `${voiceConversationId}.json`);
|
|
||||||
await unlink(filepath);
|
|
||||||
log.debug({ voiceConversationId }, "Deleted voice conversation");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
|
|
||||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
|
||||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
|
||||||
|
|
||||||
async function waitForFile(filepath: string, timeoutMs = 5000): Promise<void> {
|
|
||||||
const start = Date.now();
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
|
||||||
if (existsSync(filepath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Date.now() - start > timeoutMs) {
|
|
||||||
throw new Error(`Timed out waiting for file: ${filepath}`);
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForJsonFile<T>(
|
|
||||||
filepath: string,
|
|
||||||
timeoutMs = 5000
|
|
||||||
): Promise<T> {
|
|
||||||
const start = Date.now();
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
|
||||||
if (existsSync(filepath)) {
|
|
||||||
try {
|
|
||||||
const raw = readFileSync(filepath, "utf8");
|
|
||||||
if (raw.trim().length > 0) {
|
|
||||||
return JSON.parse(raw) as T;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// File may exist but still be mid-write; retry.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Date.now() - start > timeoutMs) {
|
|
||||||
throw new Error(`Timed out waiting for valid JSON: ${filepath}`);
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("voice conversations - daemon E2E", () => {
|
|
||||||
test(
|
|
||||||
"two concurrent clients persist independently under paseoHome/voice-conversations",
|
|
||||||
async () => {
|
|
||||||
const daemon = await createTestPaseoDaemon();
|
|
||||||
const url = `ws://127.0.0.1:${daemon.port}/ws`;
|
|
||||||
|
|
||||||
const clientA = new DaemonClient({ url });
|
|
||||||
const clientB = new DaemonClient({ url });
|
|
||||||
await clientA.connect();
|
|
||||||
await clientB.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const voiceConversationIdA = uuidv4();
|
|
||||||
const voiceConversationIdB = uuidv4();
|
|
||||||
|
|
||||||
await clientA.setVoiceConversation(true, voiceConversationIdA);
|
|
||||||
await clientB.setVoiceConversation(true, voiceConversationIdB);
|
|
||||||
|
|
||||||
// Minimal traffic to cause a persist without requiring external APIs.
|
|
||||||
await clientA.setVoiceConversation(false);
|
|
||||||
await clientB.setVoiceConversation(false);
|
|
||||||
|
|
||||||
const fileA = join(
|
|
||||||
daemon.paseoHome,
|
|
||||||
"voice-conversations",
|
|
||||||
`${voiceConversationIdA}.json`
|
|
||||||
);
|
|
||||||
const fileB = join(
|
|
||||||
daemon.paseoHome,
|
|
||||||
"voice-conversations",
|
|
||||||
`${voiceConversationIdB}.json`
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitForFile(fileA);
|
|
||||||
await waitForFile(fileB);
|
|
||||||
|
|
||||||
const dataA = await waitForJsonFile<{
|
|
||||||
voiceConversationId: string;
|
|
||||||
messageCount: number;
|
|
||||||
messages: unknown[];
|
|
||||||
}>(fileA);
|
|
||||||
const dataB = await waitForJsonFile<{
|
|
||||||
voiceConversationId: string;
|
|
||||||
messageCount: number;
|
|
||||||
messages: unknown[];
|
|
||||||
}>(fileB);
|
|
||||||
|
|
||||||
expect(dataA.voiceConversationId).toBe(voiceConversationIdA);
|
|
||||||
expect(dataB.voiceConversationId).toBe(voiceConversationIdB);
|
|
||||||
expect(dataA.messageCount).toBe(0);
|
|
||||||
expect(dataB.messageCount).toBe(0);
|
|
||||||
expect(Array.isArray(dataA.messages)).toBe(true);
|
|
||||||
expect(Array.isArray(dataB.messages)).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await clientA.close().catch(() => undefined);
|
|
||||||
await clientB.close().catch(() => undefined);
|
|
||||||
await daemon.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
30000
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
"WS attach ignores URL conversationId param for voice conversation state",
|
|
||||||
async () => {
|
|
||||||
const daemon = await createTestPaseoDaemon();
|
|
||||||
const urlConversationId = `url-${uuidv4()}`;
|
|
||||||
const url = `ws://127.0.0.1:${daemon.port}/ws?conversationId=${encodeURIComponent(
|
|
||||||
urlConversationId
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
const client = new DaemonClient({ url });
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const voiceConversationId = `client-${uuidv4()}`;
|
|
||||||
await client.setVoiceConversation(true, voiceConversationId);
|
|
||||||
await client.setVoiceConversation(false);
|
|
||||||
|
|
||||||
const file = join(
|
|
||||||
daemon.paseoHome,
|
|
||||||
"voice-conversations",
|
|
||||||
`${voiceConversationId}.json`
|
|
||||||
);
|
|
||||||
const urlFile = join(
|
|
||||||
daemon.paseoHome,
|
|
||||||
"voice-conversations",
|
|
||||||
`${urlConversationId}.json`
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitForFile(file);
|
|
||||||
expect(existsSync(urlFile)).toBe(false);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await daemon.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
30000
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
|
||||||
|
|
||||||
|
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
||||||
|
const shouldRun =
|
||||||
|
process.env.PASEO_VOICE_LOCAL_AGENT_E2E === "1" &&
|
||||||
|
Boolean(openaiApiKey) &&
|
||||||
|
!process.env.CI;
|
||||||
|
|
||||||
|
function waitForSignal<T>(
|
||||||
|
timeoutMs: number,
|
||||||
|
setup: (
|
||||||
|
resolve: (value: T) => void,
|
||||||
|
reject: (error: Error) => void
|
||||||
|
) => () => void
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let cleanup: (() => void) | null = null;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup?.();
|
||||||
|
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
cleanup = setup(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup?.();
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup?.();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(shouldRun ? describe : describe.skip)(
|
||||||
|
"voice local-agent e2e",
|
||||||
|
() => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createDaemonTestContext({
|
||||||
|
agentClients: {},
|
||||||
|
openai: { apiKey: openaiApiKey! },
|
||||||
|
speech: {
|
||||||
|
providers: {
|
||||||
|
dictationStt: { provider: "openai", explicit: true },
|
||||||
|
voiceStt: { provider: "openai", explicit: true },
|
||||||
|
voiceTts: { provider: "openai", explicit: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
voiceLlmProvider: "codex",
|
||||||
|
voiceLlmProviderExplicit: true,
|
||||||
|
voiceLlmModel: "gpt-5.2-mini",
|
||||||
|
});
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"routes voice turns through local agent speak tool",
|
||||||
|
async () => {
|
||||||
|
await ctx.client.setVoiceMode(true, randomUUID());
|
||||||
|
|
||||||
|
const audioPromise = waitForSignal<{ chunkId: string }>(120000, (resolve, reject) => {
|
||||||
|
const offAudio = ctx.client.on("audio_output", (msg) => {
|
||||||
|
if (msg.type !== "audio_output") return;
|
||||||
|
resolve({ chunkId: msg.payload.id });
|
||||||
|
});
|
||||||
|
const offError = ctx.client.on("activity_log", (msg) => {
|
||||||
|
if (msg.type !== "activity_log") return;
|
||||||
|
if (msg.payload.type !== "error") return;
|
||||||
|
reject(new Error(String(msg.payload.content)));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
offAudio();
|
||||||
|
offError();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantLogPromise = waitForSignal<string>(120000, (resolve, reject) => {
|
||||||
|
const offLog = ctx.client.on("activity_log", (msg) => {
|
||||||
|
if (msg.type !== "activity_log") return;
|
||||||
|
if (msg.payload.type !== "assistant") return;
|
||||||
|
const content = String(msg.payload.content ?? "");
|
||||||
|
if (!content.trim()) return;
|
||||||
|
resolve(content);
|
||||||
|
});
|
||||||
|
const offError = ctx.client.on("activity_log", (msg) => {
|
||||||
|
if (msg.type !== "activity_log") return;
|
||||||
|
if (msg.payload.type !== "error") return;
|
||||||
|
reject(new Error(String(msg.payload.content)));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
offLog();
|
||||||
|
offError();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixturePath = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"..",
|
||||||
|
"app",
|
||||||
|
"e2e",
|
||||||
|
"fixtures",
|
||||||
|
"recording.wav"
|
||||||
|
);
|
||||||
|
const wav = await readFile(fixturePath);
|
||||||
|
await ctx.client.sendVoiceAudioChunk(wav.toString("base64"), "audio/wav", true);
|
||||||
|
|
||||||
|
const [{ chunkId }, assistantText] = await Promise.all([
|
||||||
|
audioPromise,
|
||||||
|
assistantLogPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(chunkId.length).toBeGreaterThan(0);
|
||||||
|
expect(assistantText.trim().length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const agents = await ctx.client.fetchAgents();
|
||||||
|
expect(
|
||||||
|
agents.some((agent) => String(agent.labels?.surface ?? "") === "voice")
|
||||||
|
).toBe(false);
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { experimental_createMCPClient } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
import { createVoiceMcpBridgeSocketServer } from "./voice-mcp-bridge.js";
|
||||||
|
|
||||||
|
describe("voice MCP bridge", () => {
|
||||||
|
test("proxies stdio MCP messages through unix socket bridge", async () => {
|
||||||
|
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-voice-mcp-bridge-"));
|
||||||
|
const socketPath = path.join(tmpRoot, "voice-mcp.sock");
|
||||||
|
const callerAgentId = "voice-agent-bridge-test";
|
||||||
|
|
||||||
|
const bridge = createVoiceMcpBridgeSocketServer({
|
||||||
|
socketPath,
|
||||||
|
logger: pino({ level: "silent" }),
|
||||||
|
createAgentMcpServerForCaller: async (callerId) => {
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "bridge-test-server",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"echo_caller",
|
||||||
|
{
|
||||||
|
value: z.string().optional(),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({
|
||||||
|
callerAgentId: callerId,
|
||||||
|
value: args.value ?? null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
callerAgentId: callerId,
|
||||||
|
value: args.value ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.start();
|
||||||
|
|
||||||
|
const tsxBin = path.resolve(process.cwd(), "../../node_modules/.bin/tsx");
|
||||||
|
const serverIndex = path.resolve(process.cwd(), "src/server/index.ts");
|
||||||
|
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: process.execPath,
|
||||||
|
args: [
|
||||||
|
tsxBin,
|
||||||
|
serverIndex,
|
||||||
|
"__paseo_voice_mcp_bridge",
|
||||||
|
"--socket",
|
||||||
|
socketPath,
|
||||||
|
"--caller-agent-id",
|
||||||
|
callerAgentId,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await experimental_createMCPClient({ transport });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: "echo_caller",
|
||||||
|
args: { value: "ok" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload =
|
||||||
|
((result as { structuredContent?: { callerAgentId?: string; value?: string | null } })
|
||||||
|
.structuredContent) ?? null;
|
||||||
|
|
||||||
|
expect(payload?.callerAgentId).toBe(callerAgentId);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await bridge.stop();
|
||||||
|
await rm(tmpRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import net from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
import pino from "pino";
|
||||||
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
|
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
|
||||||
|
|
||||||
|
type BridgeEnvelope =
|
||||||
|
| { type: "init"; callerAgentId: string }
|
||||||
|
| { type: "mcp"; message: JSONRPCMessage }
|
||||||
|
| { type: "ready" }
|
||||||
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvelope(raw: string): BridgeEnvelope {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!isRecord(parsed) || typeof parsed.type !== "string") {
|
||||||
|
throw new Error("Invalid bridge envelope");
|
||||||
|
}
|
||||||
|
if (parsed.type === "init") {
|
||||||
|
const callerAgentId = typeof parsed.callerAgentId === "string" ? parsed.callerAgentId.trim() : "";
|
||||||
|
if (!callerAgentId) {
|
||||||
|
throw new Error("Invalid init payload: callerAgentId is required");
|
||||||
|
}
|
||||||
|
return { type: "init", callerAgentId };
|
||||||
|
}
|
||||||
|
if (parsed.type === "mcp") {
|
||||||
|
if (!("message" in parsed)) {
|
||||||
|
throw new Error("Invalid mcp payload: message is required");
|
||||||
|
}
|
||||||
|
return { type: "mcp", message: parsed.message as JSONRPCMessage };
|
||||||
|
}
|
||||||
|
if (parsed.type === "ready") return { type: "ready" };
|
||||||
|
if (parsed.type === "error") {
|
||||||
|
return { type: "error", message: typeof parsed.message === "string" ? parsed.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown envelope type: ${parsed.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeEnvelope(envelope: BridgeEnvelope): string {
|
||||||
|
return `${JSON.stringify(envelope)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoiceMcpBridgeSocketServer = {
|
||||||
|
socketPath: string;
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createVoiceMcpBridgeSocketServer(params: {
|
||||||
|
socketPath: string;
|
||||||
|
logger: Logger;
|
||||||
|
createAgentMcpServerForCaller: (callerAgentId: string) => Promise<{ connect: (transport: InMemoryTransport) => Promise<void>; close?: () => Promise<void> }>;
|
||||||
|
}): VoiceMcpBridgeSocketServer {
|
||||||
|
const logger = params.logger.child({ module: "voice-mcp-bridge" });
|
||||||
|
const sockets = new Set<net.Socket>();
|
||||||
|
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
const connectionLogger = logger.child({ component: "connection" });
|
||||||
|
let readBuffer = "";
|
||||||
|
let initialized = false;
|
||||||
|
let clientTransport: InMemoryTransport | null = null;
|
||||||
|
let mcpServer: { close?: () => Promise<void> } | null = null;
|
||||||
|
|
||||||
|
const send = (payload: BridgeEnvelope) => {
|
||||||
|
socket.write(encodeEnvelope(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fail = (message: string) => {
|
||||||
|
send({ type: "error", message });
|
||||||
|
socket.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
sockets.delete(socket);
|
||||||
|
const closeTasks: Promise<unknown>[] = [];
|
||||||
|
if (clientTransport) {
|
||||||
|
closeTasks.push(clientTransport.close().catch(() => undefined));
|
||||||
|
}
|
||||||
|
if (mcpServer?.close) {
|
||||||
|
closeTasks.push(mcpServer.close().catch(() => undefined));
|
||||||
|
}
|
||||||
|
await Promise.all(closeTasks);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
readBuffer += chunk.toString("utf8");
|
||||||
|
while (true) {
|
||||||
|
const newlineIndex = readBuffer.indexOf("\n");
|
||||||
|
if (newlineIndex < 0) break;
|
||||||
|
const line = readBuffer.slice(0, newlineIndex).trim();
|
||||||
|
readBuffer = readBuffer.slice(newlineIndex + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
let message: BridgeEnvelope;
|
||||||
|
try {
|
||||||
|
message = parseEnvelope(line);
|
||||||
|
} catch (error) {
|
||||||
|
fail(error instanceof Error ? error.message : String(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "init") {
|
||||||
|
if (initialized) {
|
||||||
|
fail("Bridge already initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [proxyClient, proxyServer] = InMemoryTransport.createLinkedPair();
|
||||||
|
const serverInstance = await params.createAgentMcpServerForCaller(message.callerAgentId);
|
||||||
|
await serverInstance.connect(proxyServer);
|
||||||
|
await proxyClient.start();
|
||||||
|
proxyClient.onmessage = (jsonrpcMessage) => {
|
||||||
|
send({ type: "mcp", message: jsonrpcMessage });
|
||||||
|
};
|
||||||
|
clientTransport = proxyClient;
|
||||||
|
mcpServer = serverInstance;
|
||||||
|
send({ type: "ready" });
|
||||||
|
} catch (error) {
|
||||||
|
connectionLogger.error({ err: error }, "Failed to initialize voice MCP bridge connection");
|
||||||
|
fail(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "mcp") {
|
||||||
|
if (!clientTransport) {
|
||||||
|
fail("Bridge is not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void clientTransport.send(message.message).catch((error) => {
|
||||||
|
connectionLogger.error({ err: error }, "Failed to forward MCP message");
|
||||||
|
fail(error instanceof Error ? error.message : String(error));
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", (error) => {
|
||||||
|
connectionLogger.error({ err: error }, "Voice MCP bridge socket error");
|
||||||
|
});
|
||||||
|
socket.on("close", () => {
|
||||||
|
void cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
socketPath: params.socketPath,
|
||||||
|
async start() {
|
||||||
|
await mkdir(path.dirname(params.socketPath), { recursive: true });
|
||||||
|
await rm(params.socketPath, { force: true }).catch(() => undefined);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(params.socketPath, () => {
|
||||||
|
server.off("error", reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.info({ socketPath: params.socketPath }, "Voice MCP bridge socket server listening");
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
for (const socket of sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await rm(params.socketPath, { force: true }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBridgeCliArgs(argv: string[]): { socketPath: string; callerAgentId: string } {
|
||||||
|
let socketPath: string | null = null;
|
||||||
|
let callerAgentId: string | null = null;
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === "--socket") {
|
||||||
|
socketPath = argv[index + 1] ?? null;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--caller-agent-id") {
|
||||||
|
callerAgentId = argv[index + 1] ?? null;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!socketPath?.trim()) {
|
||||||
|
throw new Error("Missing required --socket <path>");
|
||||||
|
}
|
||||||
|
if (!callerAgentId?.trim()) {
|
||||||
|
throw new Error("Missing required --caller-agent-id <id>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
socketPath: socketPath.trim(),
|
||||||
|
callerAgentId: callerAgentId.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runVoiceMcpBridgeCli(argv: string[], logger?: Logger): Promise<void> {
|
||||||
|
const bridgeLogger = logger ?? pino({ level: "error" });
|
||||||
|
const parsed = parseBridgeCliArgs(argv);
|
||||||
|
|
||||||
|
const socket = net.createConnection(parsed.socketPath);
|
||||||
|
const stdioTransport = new StdioServerTransport(process.stdin, process.stdout);
|
||||||
|
let socketBuffer = "";
|
||||||
|
let stdioStarted = false;
|
||||||
|
const pendingMcpMessages: JSONRPCMessage[] = [];
|
||||||
|
|
||||||
|
let resolveReady: (() => void) | null = null;
|
||||||
|
let rejectReady: ((error: Error) => void) | null = null;
|
||||||
|
const readyPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
resolveReady = () => {
|
||||||
|
resolveReady = null;
|
||||||
|
rejectReady = null;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
rejectReady = (error: Error) => {
|
||||||
|
resolveReady = null;
|
||||||
|
rejectReady = null;
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const failReady = (message: string) => {
|
||||||
|
if (rejectReady) {
|
||||||
|
rejectReady(new Error(message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendEnvelope = (payload: BridgeEnvelope) => {
|
||||||
|
socket.write(encodeEnvelope(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
socketBuffer += chunk.toString("utf8");
|
||||||
|
while (true) {
|
||||||
|
const newlineIndex = socketBuffer.indexOf("\n");
|
||||||
|
if (newlineIndex < 0) break;
|
||||||
|
const line = socketBuffer.slice(0, newlineIndex).trim();
|
||||||
|
socketBuffer = socketBuffer.slice(newlineIndex + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
let envelope: BridgeEnvelope;
|
||||||
|
try {
|
||||||
|
envelope = parseEnvelope(line);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
bridgeLogger.error({ err: error }, "Failed to parse voice MCP bridge envelope");
|
||||||
|
failReady(`Failed to parse voice MCP bridge envelope: ${message}`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envelope.type === "ready") {
|
||||||
|
resolveReady?.();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (envelope.type === "error") {
|
||||||
|
failReady(`Voice MCP bridge error: ${envelope.message}`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (envelope.type === "mcp") {
|
||||||
|
if (!stdioStarted) {
|
||||||
|
pendingMcpMessages.push(envelope.message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
void stdioTransport.send(envelope.message).catch((error) => {
|
||||||
|
bridgeLogger.error({ err: error }, "Failed to forward MCP message to stdio transport");
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", (error) => {
|
||||||
|
bridgeLogger.error({ err: error }, "Voice MCP bridge socket client error");
|
||||||
|
failReady(`Voice MCP bridge socket client error: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.once("connect", () => resolve());
|
||||||
|
socket.once("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
sendEnvelope({ type: "init", callerAgentId: parsed.callerAgentId });
|
||||||
|
|
||||||
|
const readyTimeoutPromise = new Promise<void>((_, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error("Timed out waiting for voice MCP bridge initialization"));
|
||||||
|
}, 10000);
|
||||||
|
readyPromise.finally(() => clearTimeout(timeout)).catch(() => undefined);
|
||||||
|
});
|
||||||
|
await Promise.race([readyPromise, readyTimeoutPromise]);
|
||||||
|
|
||||||
|
stdioTransport.onmessage = (message) => {
|
||||||
|
sendEnvelope({ type: "mcp", message });
|
||||||
|
};
|
||||||
|
stdioTransport.onerror = (error) => {
|
||||||
|
bridgeLogger.error({ err: error }, "Voice MCP stdio transport error");
|
||||||
|
socket.destroy();
|
||||||
|
};
|
||||||
|
stdioTransport.onclose = () => {
|
||||||
|
socket.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
await stdioTransport.start();
|
||||||
|
stdioStarted = true;
|
||||||
|
for (const message of pendingMcpMessages) {
|
||||||
|
await stdioTransport.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
socket.once("close", () => resolve());
|
||||||
|
process.stdin.once("end", () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
await stdioTransport.close().catch(() => undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import type { AgentPermissionRequest } from "./agent/agent-sdk-types.js";
|
||||||
|
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
||||||
|
|
||||||
|
function buildRequest(partial: Partial<AgentPermissionRequest>): AgentPermissionRequest {
|
||||||
|
return {
|
||||||
|
id: "req-1",
|
||||||
|
provider: "codex",
|
||||||
|
name: "unknown",
|
||||||
|
kind: "tool",
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isVoicePermissionAllowed", () => {
|
||||||
|
test("allows speak tool", () => {
|
||||||
|
const result = isVoicePermissionAllowed(
|
||||||
|
buildRequest({ name: "speak" })
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows explicit MCP/paseo tool names", () => {
|
||||||
|
expect(
|
||||||
|
isVoicePermissionAllowed(buildRequest({ name: "mcp__paseo__create_agent" }))
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isVoicePermissionAllowed(buildRequest({ name: "paseo_create_agent" }))
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("denies non-tool permission kinds", () => {
|
||||||
|
const result = isVoicePermissionAllowed(
|
||||||
|
buildRequest({ kind: "mode", name: "mcp__paseo__create_agent" })
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows codextool only when metadata references MCP/paseo", () => {
|
||||||
|
const allowed = isVoicePermissionAllowed(
|
||||||
|
buildRequest({
|
||||||
|
name: "codextool",
|
||||||
|
metadata: {
|
||||||
|
questions: [{ question: "Allow codextool to call mcp paseo create_agent?" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("denies codextool when metadata includes shell-like operations", () => {
|
||||||
|
const denied = isVoicePermissionAllowed(
|
||||||
|
buildRequest({
|
||||||
|
name: "codextool",
|
||||||
|
metadata: {
|
||||||
|
questions: [{ question: "Allow codextool to execute shell command?" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(denied).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { AgentPermissionRequest } from "./agent/agent-sdk-types.js";
|
||||||
|
|
||||||
|
const ALLOWED_TOKEN_SET = ["mcp", "paseo", "speak"];
|
||||||
|
const DENIED_TOKEN_SET = [
|
||||||
|
"bash",
|
||||||
|
"shell",
|
||||||
|
"terminal",
|
||||||
|
"command",
|
||||||
|
"execute",
|
||||||
|
"edit",
|
||||||
|
"write",
|
||||||
|
"read",
|
||||||
|
"fetch",
|
||||||
|
"http",
|
||||||
|
"web",
|
||||||
|
];
|
||||||
|
|
||||||
|
function containsAny(text: string, tokens: readonly string[]): boolean {
|
||||||
|
return tokens.some((token) => text.includes(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyMetadata(metadata: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(metadata ?? {})?.toLowerCase() ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice assistant policy: allow only MCP/paseo/speak tool requests.
|
||||||
|
* All non-tool permission requests are denied.
|
||||||
|
*/
|
||||||
|
export function isVoicePermissionAllowed(request: AgentPermissionRequest): boolean {
|
||||||
|
if (request.kind !== "tool") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = request.name.trim().toLowerCase();
|
||||||
|
if (!normalizedName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName === "speak") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalizedName.includes("mcp") || normalizedName.includes("paseo")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName !== "codextool") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataText = stringifyMetadata({
|
||||||
|
metadata: request.metadata ?? null,
|
||||||
|
input: request.input ?? null,
|
||||||
|
});
|
||||||
|
if (!metadataText) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionsAllowedTooling = containsAny(metadataText, ALLOWED_TOKEN_SET);
|
||||||
|
const mentionsDeniedTooling = containsAny(metadataText, DENIED_TOKEN_SET);
|
||||||
|
return mentionsAllowedTooling && !mentionsDeniedTooling;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type VoiceSpeakHandler = (params: {
|
||||||
|
text: string;
|
||||||
|
callerAgentId: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}) => Promise<void>;
|
||||||
|
|
||||||
|
export type VoiceCallerContext = {
|
||||||
|
childAgentDefaultLabels?: Record<string, string>;
|
||||||
|
lockedCwd?: string;
|
||||||
|
allowCustomCwd?: boolean;
|
||||||
|
enableVoiceTools?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VoiceMcpStdioConfig = {
|
||||||
|
command: string;
|
||||||
|
baseArgs: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
@@ -19,8 +19,13 @@ import { Session } from "./session.js";
|
|||||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||||
import { PushTokenStore } from "./push/token-store.js";
|
import { PushTokenStore } from "./push/token-store.js";
|
||||||
import { PushService } from "./push/push-service.js";
|
import { PushService } from "./push/push-service.js";
|
||||||
import { VoiceConversationStore } from "./voice-conversation-store.js";
|
|
||||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||||
|
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
||||||
|
import type {
|
||||||
|
VoiceCallerContext,
|
||||||
|
VoiceMcpStdioConfig,
|
||||||
|
VoiceSpeakHandler,
|
||||||
|
} from "./voice-types.js";
|
||||||
|
|
||||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||||
|
|
||||||
@@ -69,15 +74,26 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private readonly stt: SpeechToTextProvider | null;
|
private readonly stt: SpeechToTextProvider | null;
|
||||||
private readonly tts: TextToSpeechProvider | null;
|
private readonly tts: TextToSpeechProvider | null;
|
||||||
private readonly terminalManager: TerminalManager | null;
|
private readonly terminalManager: TerminalManager | null;
|
||||||
private readonly voiceConversationStore: VoiceConversationStore;
|
|
||||||
private readonly dictation: {
|
private readonly dictation: {
|
||||||
finalTimeoutMs?: number;
|
finalTimeoutMs?: number;
|
||||||
stt?: SpeechToTextProvider | null;
|
stt?: SpeechToTextProvider | null;
|
||||||
|
localModels?: {
|
||||||
|
modelsDir: string;
|
||||||
|
defaultModelIds: LocalSpeechModelId[];
|
||||||
|
};
|
||||||
} | null;
|
} | null;
|
||||||
private readonly voice: {
|
private readonly voice: {
|
||||||
openrouterApiKey?: string | null;
|
voiceLlmProvider?: AgentProvider | null;
|
||||||
|
voiceLlmModeId?: string | null;
|
||||||
|
voiceLlmProviderExplicit?: boolean;
|
||||||
voiceLlmModel?: string | null;
|
voiceLlmModel?: string | null;
|
||||||
|
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
private readonly voiceSpeakHandlers = new Map<
|
||||||
|
string,
|
||||||
|
VoiceSpeakHandler
|
||||||
|
>();
|
||||||
|
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
server: HTTPServer,
|
server: HTTPServer,
|
||||||
@@ -92,12 +108,19 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
speech?: { stt: SpeechToTextProvider | null; tts: TextToSpeechProvider | null },
|
speech?: { stt: SpeechToTextProvider | null; tts: TextToSpeechProvider | null },
|
||||||
terminalManager?: TerminalManager | null,
|
terminalManager?: TerminalManager | null,
|
||||||
voice?: {
|
voice?: {
|
||||||
openrouterApiKey?: string | null;
|
voiceLlmProvider?: AgentProvider | null;
|
||||||
|
voiceLlmModeId?: string | null;
|
||||||
|
voiceLlmProviderExplicit?: boolean;
|
||||||
voiceLlmModel?: string | null;
|
voiceLlmModel?: string | null;
|
||||||
|
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||||
},
|
},
|
||||||
dictation?: {
|
dictation?: {
|
||||||
finalTimeoutMs?: number;
|
finalTimeoutMs?: number;
|
||||||
stt?: SpeechToTextProvider | null;
|
stt?: SpeechToTextProvider | null;
|
||||||
|
localModels?: {
|
||||||
|
modelsDir: string;
|
||||||
|
defaultModelIds: LocalSpeechModelId[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
@@ -110,9 +133,6 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.stt = speech?.stt ?? null;
|
this.stt = speech?.stt ?? null;
|
||||||
this.tts = speech?.tts ?? null;
|
this.tts = speech?.tts ?? null;
|
||||||
this.terminalManager = terminalManager ?? null;
|
this.terminalManager = terminalManager ?? null;
|
||||||
this.voiceConversationStore = new VoiceConversationStore(
|
|
||||||
join(paseoHome, "voice-conversations")
|
|
||||||
);
|
|
||||||
this.voice = voice ?? null;
|
this.voice = voice ?? null;
|
||||||
this.dictation = dictation ?? null;
|
this.dictation = dictation ?? null;
|
||||||
|
|
||||||
@@ -208,25 +228,38 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
const clientId = `client-${++this.clientIdCounter}`;
|
const clientId = `client-${++this.clientIdCounter}`;
|
||||||
const connectionLogger = this.logger.child({ clientId });
|
const connectionLogger = this.logger.child({ clientId });
|
||||||
|
|
||||||
const session = new Session(
|
const session = new Session({
|
||||||
clientId,
|
clientId,
|
||||||
(msg) => {
|
onMessage: (msg) => {
|
||||||
this.sendToClient(ws, wrapSessionMessage(msg));
|
this.sendToClient(ws, wrapSessionMessage(msg));
|
||||||
},
|
},
|
||||||
connectionLogger.child({ module: "session" }),
|
logger: connectionLogger.child({ module: "session" }),
|
||||||
this.downloadTokenStore,
|
downloadTokenStore: this.downloadTokenStore,
|
||||||
this.pushTokenStore,
|
pushTokenStore: this.pushTokenStore,
|
||||||
this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
this.agentManager,
|
agentManager: this.agentManager,
|
||||||
this.agentStorage,
|
agentStorage: this.agentStorage,
|
||||||
this.createAgentMcpTransport,
|
createAgentMcpTransport: this.createAgentMcpTransport,
|
||||||
this.stt,
|
stt: this.stt,
|
||||||
this.tts,
|
tts: this.tts,
|
||||||
this.terminalManager,
|
terminalManager: this.terminalManager,
|
||||||
this.voiceConversationStore,
|
voice: this.voice ?? undefined,
|
||||||
this.voice ?? undefined,
|
voiceBridge: {
|
||||||
this.dictation ?? undefined
|
registerVoiceSpeakHandler: (agentId, handler) => {
|
||||||
);
|
this.voiceSpeakHandlers.set(agentId, handler);
|
||||||
|
},
|
||||||
|
unregisterVoiceSpeakHandler: (agentId) => {
|
||||||
|
this.voiceSpeakHandlers.delete(agentId);
|
||||||
|
},
|
||||||
|
registerVoiceCallerContext: (agentId, context) => {
|
||||||
|
this.voiceCallerContexts.set(agentId, context);
|
||||||
|
},
|
||||||
|
unregisterVoiceCallerContext: (agentId) => {
|
||||||
|
this.voiceCallerContexts.delete(agentId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dictation: this.dictation ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
this.sessions.set(ws, session);
|
this.sessions.set(ws, session);
|
||||||
|
|
||||||
@@ -263,6 +296,18 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public resolveVoiceSpeakHandler(
|
||||||
|
callerAgentId: string
|
||||||
|
): VoiceSpeakHandler | null {
|
||||||
|
return this.voiceSpeakHandlers.get(callerAgentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public resolveVoiceCallerContext(
|
||||||
|
callerAgentId: string
|
||||||
|
): VoiceCallerContext | null {
|
||||||
|
return this.voiceCallerContexts.get(callerAgentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
private async detachSocket(
|
private async detachSocket(
|
||||||
ws: WebSocketLike,
|
ws: WebSocketLike,
|
||||||
connectionLogger: pino.Logger,
|
connectionLogger: pino.Logger,
|
||||||
|
|||||||
@@ -294,11 +294,6 @@ export type AgentStreamEventPayload = z.infer<
|
|||||||
// Session Inbound Messages (Session receives these)
|
// Session Inbound Messages (Session receives these)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const UserTextMessageSchema = z.object({
|
|
||||||
type: z.literal("user_text"),
|
|
||||||
text: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const VoiceAudioChunkMessageSchema = z.object({
|
export const VoiceAudioChunkMessageSchema = z.object({
|
||||||
type: z.literal("voice_audio_chunk"),
|
type: z.literal("voice_audio_chunk"),
|
||||||
audio: z.string(), // base64 encoded
|
audio: z.string(), // base64 encoded
|
||||||
@@ -337,23 +332,6 @@ export const UnsubscribeAgentUpdatesMessageSchema = z.object({
|
|||||||
subscriptionId: z.string(),
|
subscriptionId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LoadVoiceConversationRequestMessageSchema = z.object({
|
|
||||||
type: z.literal("load_voice_conversation_request"),
|
|
||||||
voiceConversationId: z.string(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ListVoiceConversationsRequestMessageSchema = z.object({
|
|
||||||
type: z.literal("list_voice_conversations_request"),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DeleteVoiceConversationRequestMessageSchema = z.object({
|
|
||||||
type: z.literal("delete_voice_conversation_request"),
|
|
||||||
voiceConversationId: z.string(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DeleteAgentRequestMessageSchema = z.object({
|
export const DeleteAgentRequestMessageSchema = z.object({
|
||||||
type: z.literal("delete_agent_request"),
|
type: z.literal("delete_agent_request"),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
@@ -366,10 +344,10 @@ export const ArchiveAgentRequestMessageSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SetVoiceConversationMessageSchema = z.object({
|
export const SetVoiceModeMessageSchema = z.object({
|
||||||
type: z.literal("set_voice_conversation"),
|
type: z.literal("set_voice_mode"),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
voiceConversationId: z.string().optional(),
|
voiceAgentId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SendAgentMessageSchema = z.object({
|
export const SendAgentMessageSchema = z.object({
|
||||||
@@ -876,7 +854,6 @@ export const KillTerminalRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
UserTextMessageSchema,
|
|
||||||
VoiceAudioChunkMessageSchema,
|
VoiceAudioChunkMessageSchema,
|
||||||
AbortRequestMessageSchema,
|
AbortRequestMessageSchema,
|
||||||
AudioPlayedMessageSchema,
|
AudioPlayedMessageSchema,
|
||||||
@@ -884,12 +861,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FetchAgentRequestMessageSchema,
|
FetchAgentRequestMessageSchema,
|
||||||
SubscribeAgentUpdatesMessageSchema,
|
SubscribeAgentUpdatesMessageSchema,
|
||||||
UnsubscribeAgentUpdatesMessageSchema,
|
UnsubscribeAgentUpdatesMessageSchema,
|
||||||
LoadVoiceConversationRequestMessageSchema,
|
|
||||||
ListVoiceConversationsRequestMessageSchema,
|
|
||||||
DeleteVoiceConversationRequestMessageSchema,
|
|
||||||
DeleteAgentRequestMessageSchema,
|
DeleteAgentRequestMessageSchema,
|
||||||
ArchiveAgentRequestMessageSchema,
|
ArchiveAgentRequestMessageSchema,
|
||||||
SetVoiceConversationMessageSchema,
|
SetVoiceModeMessageSchema,
|
||||||
SendAgentMessageRequestSchema,
|
SendAgentMessageRequestSchema,
|
||||||
WaitForFinishRequestSchema,
|
WaitForFinishRequestSchema,
|
||||||
DictationStreamStartMessageSchema,
|
DictationStreamStartMessageSchema,
|
||||||
@@ -1127,15 +1101,6 @@ export const ArtifactMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const VoiceConversationLoadedMessageSchema = z.object({
|
|
||||||
type: z.literal("voice_conversation_loaded"),
|
|
||||||
payload: z.object({
|
|
||||||
voiceConversationId: z.string(),
|
|
||||||
messageCount: z.number(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AgentUpdateMessageSchema = z.object({
|
export const AgentUpdateMessageSchema = z.object({
|
||||||
type: z.literal("agent_update"),
|
type: z.literal("agent_update"),
|
||||||
payload: z.discriminatedUnion("kind", [
|
payload: z.discriminatedUnion("kind", [
|
||||||
@@ -1225,30 +1190,6 @@ export const WaitForFinishResponseMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ListVoiceConversationsResponseMessageSchema = z.object({
|
|
||||||
type: z.literal("list_voice_conversations_response"),
|
|
||||||
payload: z.object({
|
|
||||||
conversations: z.array(
|
|
||||||
z.object({
|
|
||||||
id: z.string(),
|
|
||||||
lastUpdated: z.string(),
|
|
||||||
messageCount: z.number(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DeleteVoiceConversationResponseMessageSchema = z.object({
|
|
||||||
type: z.literal("delete_voice_conversation_response"),
|
|
||||||
payload: z.object({
|
|
||||||
voiceConversationId: z.string(),
|
|
||||||
success: z.boolean(),
|
|
||||||
error: z.string().optional(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AgentPermissionRequestMessageSchema = z.object({
|
export const AgentPermissionRequestMessageSchema = z.object({
|
||||||
type: z.literal("agent_permission_request"),
|
type: z.literal("agent_permission_request"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -1679,7 +1620,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
RpcErrorMessageSchema,
|
RpcErrorMessageSchema,
|
||||||
InitializeAgentResponseMessageSchema,
|
InitializeAgentResponseMessageSchema,
|
||||||
ArtifactMessageSchema,
|
ArtifactMessageSchema,
|
||||||
VoiceConversationLoadedMessageSchema,
|
|
||||||
AgentUpdateMessageSchema,
|
AgentUpdateMessageSchema,
|
||||||
AgentStreamMessageSchema,
|
AgentStreamMessageSchema,
|
||||||
AgentStreamSnapshotMessageSchema,
|
AgentStreamSnapshotMessageSchema,
|
||||||
@@ -1691,8 +1631,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetAgentModelResponseMessageSchema,
|
SetAgentModelResponseMessageSchema,
|
||||||
SetAgentThinkingResponseMessageSchema,
|
SetAgentThinkingResponseMessageSchema,
|
||||||
WaitForFinishResponseMessageSchema,
|
WaitForFinishResponseMessageSchema,
|
||||||
ListVoiceConversationsResponseMessageSchema,
|
|
||||||
DeleteVoiceConversationResponseMessageSchema,
|
|
||||||
AgentPermissionRequestMessageSchema,
|
AgentPermissionRequestMessageSchema,
|
||||||
AgentPermissionResolvedMessageSchema,
|
AgentPermissionResolvedMessageSchema,
|
||||||
AgentDeletedMessageSchema,
|
AgentDeletedMessageSchema,
|
||||||
@@ -1737,9 +1675,6 @@ export type TranscriptionResultMessage = z.infer<typeof TranscriptionResultMessa
|
|||||||
export type StatusMessage = z.infer<typeof StatusMessageSchema>;
|
export type StatusMessage = z.infer<typeof StatusMessageSchema>;
|
||||||
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
|
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
|
||||||
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
||||||
export type VoiceConversationLoadedMessage = z.infer<
|
|
||||||
typeof VoiceConversationLoadedMessageSchema
|
|
||||||
>;
|
|
||||||
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
|
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
|
||||||
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
||||||
export type AgentStreamSnapshotMessage = z.infer<
|
export type AgentStreamSnapshotMessage = z.infer<
|
||||||
@@ -1758,12 +1693,6 @@ export type SendAgentMessageResponseMessage = z.infer<
|
|||||||
export type WaitForFinishResponseMessage = z.infer<
|
export type WaitForFinishResponseMessage = z.infer<
|
||||||
typeof WaitForFinishResponseMessageSchema
|
typeof WaitForFinishResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
export type ListVoiceConversationsResponseMessage = z.infer<
|
|
||||||
typeof ListVoiceConversationsResponseMessageSchema
|
|
||||||
>;
|
|
||||||
export type DeleteVoiceConversationResponseMessage = z.infer<
|
|
||||||
typeof DeleteVoiceConversationResponseMessageSchema
|
|
||||||
>;
|
|
||||||
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
|
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
|
||||||
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
|
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
|
||||||
export type AgentDeletedMessage = z.infer<typeof AgentDeletedMessageSchema>;
|
export type AgentDeletedMessage = z.infer<typeof AgentDeletedMessageSchema>;
|
||||||
@@ -1778,7 +1707,6 @@ export type InitializeAgentResponseMessage = z.infer<typeof InitializeAgentRespo
|
|||||||
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
||||||
|
|
||||||
// Type exports for inbound message types
|
// Type exports for inbound message types
|
||||||
export type UserTextMessage = z.infer<typeof UserTextMessageSchema>;
|
|
||||||
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
|
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
|
||||||
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
|
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
|
||||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
// Load repo-root .env for integration/E2E tests (OpenAI, etc.)
|
// Load package-local .env.test first for integration/E2E credentials, then repo-root .env fallback.
|
||||||
|
dotenv.config({ path: path.resolve(process.cwd(), ".env.test"), override: true });
|
||||||
dotenv.config({ path: path.resolve(process.cwd(), "../.env") });
|
dotenv.config({ path: path.resolve(process.cwd(), "../.env") });
|
||||||
|
|
||||||
process.env.GIT_TERMINAL_PROMPT = "0";
|
process.env.GIT_TERMINAL_PROMPT = "0";
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const PrincipalParamSchema = z.union([
|
|||||||
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
|
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
|
||||||
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
|
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
|
||||||
z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })),
|
z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })),
|
||||||
|
z.object({ text: z.string() }).transform((d) => ({ type: "text" as const, value: d.text })),
|
||||||
// Files array (Codex apply_patch)
|
// Files array (Codex apply_patch)
|
||||||
z.object({ files: z.array(FileEntrySchema).nonempty() }).transform((d) => ({ type: "path" as const, value: d.files[0].path })),
|
z.object({ files: z.array(FileEntrySchema).nonempty() }).transform((d) => ({ type: "path" as const, value: d.files[0].path })),
|
||||||
// TodoWrite - show in_progress item or count
|
// TodoWrite - show in_progress item or count
|
||||||
|
|||||||
@@ -86,8 +86,18 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"openrouter": {
|
"local": {
|
||||||
"$ref": "#/definitions/PaseoConfigV1/properties/providers/properties/openai"
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"modelsDir": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"autoDownload": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -104,7 +114,8 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openai"
|
"openai",
|
||||||
|
"local"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
@@ -129,7 +140,9 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openrouter"
|
"claude",
|
||||||
|
"codex",
|
||||||
|
"opencode"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
@@ -145,7 +158,8 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openai"
|
"openai",
|
||||||
|
"local"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
@@ -161,15 +175,13 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openai"
|
"openai",
|
||||||
|
"local"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"minLength": 1
|
||||||
"tts-1",
|
|
||||||
"tts-1-hd"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Route as DocsRouteImport } from './routes/docs'
|
|||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as DocsIndexRouteImport } from './routes/docs/index'
|
import { Route as DocsIndexRouteImport } from './routes/docs/index'
|
||||||
import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees'
|
import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees'
|
||||||
|
import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
|
||||||
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
|
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
|
||||||
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
|
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
|
||||||
import { Route as DocsCliRouteImport } from './routes/docs/cli'
|
import { Route as DocsCliRouteImport } from './routes/docs/cli'
|
||||||
@@ -38,6 +39,11 @@ const DocsWorktreesRoute = DocsWorktreesRouteImport.update({
|
|||||||
path: '/worktrees',
|
path: '/worktrees',
|
||||||
getParentRoute: () => DocsRoute,
|
getParentRoute: () => DocsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DocsVoiceRoute = DocsVoiceRouteImport.update({
|
||||||
|
id: '/voice',
|
||||||
|
path: '/voice',
|
||||||
|
getParentRoute: () => DocsRoute,
|
||||||
|
} as any)
|
||||||
const DocsSecurityRoute = DocsSecurityRouteImport.update({
|
const DocsSecurityRoute = DocsSecurityRouteImport.update({
|
||||||
id: '/security',
|
id: '/security',
|
||||||
path: '/security',
|
path: '/security',
|
||||||
@@ -66,6 +72,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/docs/cli': typeof DocsCliRoute
|
'/docs/cli': typeof DocsCliRoute
|
||||||
'/docs/configuration': typeof DocsConfigurationRoute
|
'/docs/configuration': typeof DocsConfigurationRoute
|
||||||
'/docs/security': typeof DocsSecurityRoute
|
'/docs/security': typeof DocsSecurityRoute
|
||||||
|
'/docs/voice': typeof DocsVoiceRoute
|
||||||
'/docs/worktrees': typeof DocsWorktreesRoute
|
'/docs/worktrees': typeof DocsWorktreesRoute
|
||||||
'/docs/': typeof DocsIndexRoute
|
'/docs/': typeof DocsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -75,6 +82,7 @@ export interface FileRoutesByTo {
|
|||||||
'/docs/cli': typeof DocsCliRoute
|
'/docs/cli': typeof DocsCliRoute
|
||||||
'/docs/configuration': typeof DocsConfigurationRoute
|
'/docs/configuration': typeof DocsConfigurationRoute
|
||||||
'/docs/security': typeof DocsSecurityRoute
|
'/docs/security': typeof DocsSecurityRoute
|
||||||
|
'/docs/voice': typeof DocsVoiceRoute
|
||||||
'/docs/worktrees': typeof DocsWorktreesRoute
|
'/docs/worktrees': typeof DocsWorktreesRoute
|
||||||
'/docs': typeof DocsIndexRoute
|
'/docs': typeof DocsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -86,6 +94,7 @@ export interface FileRoutesById {
|
|||||||
'/docs/cli': typeof DocsCliRoute
|
'/docs/cli': typeof DocsCliRoute
|
||||||
'/docs/configuration': typeof DocsConfigurationRoute
|
'/docs/configuration': typeof DocsConfigurationRoute
|
||||||
'/docs/security': typeof DocsSecurityRoute
|
'/docs/security': typeof DocsSecurityRoute
|
||||||
|
'/docs/voice': typeof DocsVoiceRoute
|
||||||
'/docs/worktrees': typeof DocsWorktreesRoute
|
'/docs/worktrees': typeof DocsWorktreesRoute
|
||||||
'/docs/': typeof DocsIndexRoute
|
'/docs/': typeof DocsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -98,6 +107,7 @@ export interface FileRouteTypes {
|
|||||||
| '/docs/cli'
|
| '/docs/cli'
|
||||||
| '/docs/configuration'
|
| '/docs/configuration'
|
||||||
| '/docs/security'
|
| '/docs/security'
|
||||||
|
| '/docs/voice'
|
||||||
| '/docs/worktrees'
|
| '/docs/worktrees'
|
||||||
| '/docs/'
|
| '/docs/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -107,6 +117,7 @@ export interface FileRouteTypes {
|
|||||||
| '/docs/cli'
|
| '/docs/cli'
|
||||||
| '/docs/configuration'
|
| '/docs/configuration'
|
||||||
| '/docs/security'
|
| '/docs/security'
|
||||||
|
| '/docs/voice'
|
||||||
| '/docs/worktrees'
|
| '/docs/worktrees'
|
||||||
| '/docs'
|
| '/docs'
|
||||||
id:
|
id:
|
||||||
@@ -117,6 +128,7 @@ export interface FileRouteTypes {
|
|||||||
| '/docs/cli'
|
| '/docs/cli'
|
||||||
| '/docs/configuration'
|
| '/docs/configuration'
|
||||||
| '/docs/security'
|
| '/docs/security'
|
||||||
|
| '/docs/voice'
|
||||||
| '/docs/worktrees'
|
| '/docs/worktrees'
|
||||||
| '/docs/'
|
| '/docs/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -156,6 +168,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DocsWorktreesRouteImport
|
preLoaderRoute: typeof DocsWorktreesRouteImport
|
||||||
parentRoute: typeof DocsRoute
|
parentRoute: typeof DocsRoute
|
||||||
}
|
}
|
||||||
|
'/docs/voice': {
|
||||||
|
id: '/docs/voice'
|
||||||
|
path: '/voice'
|
||||||
|
fullPath: '/docs/voice'
|
||||||
|
preLoaderRoute: typeof DocsVoiceRouteImport
|
||||||
|
parentRoute: typeof DocsRoute
|
||||||
|
}
|
||||||
'/docs/security': {
|
'/docs/security': {
|
||||||
id: '/docs/security'
|
id: '/docs/security'
|
||||||
path: '/security'
|
path: '/security'
|
||||||
@@ -192,6 +211,7 @@ interface DocsRouteChildren {
|
|||||||
DocsCliRoute: typeof DocsCliRoute
|
DocsCliRoute: typeof DocsCliRoute
|
||||||
DocsConfigurationRoute: typeof DocsConfigurationRoute
|
DocsConfigurationRoute: typeof DocsConfigurationRoute
|
||||||
DocsSecurityRoute: typeof DocsSecurityRoute
|
DocsSecurityRoute: typeof DocsSecurityRoute
|
||||||
|
DocsVoiceRoute: typeof DocsVoiceRoute
|
||||||
DocsWorktreesRoute: typeof DocsWorktreesRoute
|
DocsWorktreesRoute: typeof DocsWorktreesRoute
|
||||||
DocsIndexRoute: typeof DocsIndexRoute
|
DocsIndexRoute: typeof DocsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -201,6 +221,7 @@ const DocsRouteChildren: DocsRouteChildren = {
|
|||||||
DocsCliRoute: DocsCliRoute,
|
DocsCliRoute: DocsCliRoute,
|
||||||
DocsConfigurationRoute: DocsConfigurationRoute,
|
DocsConfigurationRoute: DocsConfigurationRoute,
|
||||||
DocsSecurityRoute: DocsSecurityRoute,
|
DocsSecurityRoute: DocsSecurityRoute,
|
||||||
|
DocsVoiceRoute: DocsVoiceRoute,
|
||||||
DocsWorktreesRoute: DocsWorktreesRoute,
|
DocsWorktreesRoute: DocsWorktreesRoute,
|
||||||
DocsIndexRoute: DocsIndexRoute,
|
DocsIndexRoute: DocsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const Route = createFileRoute('/docs')({
|
|||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Getting started', href: '/docs' },
|
{ name: 'Getting started', href: '/docs' },
|
||||||
|
{ name: 'Voice', href: '/docs/voice' },
|
||||||
{ name: 'Git worktrees', href: '/docs/worktrees' },
|
{ name: 'Git worktrees', href: '/docs/worktrees' },
|
||||||
{ name: 'CLI', href: '/docs/cli' },
|
{ name: 'CLI', href: '/docs/cli' },
|
||||||
{ name: 'Configuration', href: '/docs/configuration' },
|
{ name: 'Configuration', href: '/docs/configuration' },
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ function Configuration() {
|
|||||||
"$schema": "https://paseo.sh/schemas/paseo.config.v1.json",
|
"$schema": "https://paseo.sh/schemas/paseo.config.v1.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"providers": {
|
"providers": {
|
||||||
"openai": { "apiKey": "..." },
|
"openai": { "apiKey": "..." }
|
||||||
"openrouter": { "apiKey": "..." }
|
|
||||||
},
|
},
|
||||||
"daemon": {
|
"daemon": {
|
||||||
"listen": "127.0.0.1:6767",
|
"listen": "127.0.0.1:6767",
|
||||||
@@ -79,13 +78,32 @@ function Configuration() {
|
|||||||
</pre>
|
</pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xl font-medium">Voice</h2>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
Voice is configured through <code className="font-mono">features.dictation</code> and{' '}
|
||||||
|
<code className="font-mono">features.voiceMode</code>, with provider credentials under{' '}
|
||||||
|
<code className="font-mono">providers</code>.
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
For voice philosophy, architecture, and complete local/OpenAI setup examples, see{' '}
|
||||||
|
<a href="/docs/voice" className="underline hover:text-white/80">Voice docs</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xl font-medium">Common env vars</h2>
|
<h2 className="text-xl font-medium">Common env vars</h2>
|
||||||
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
||||||
<li><code className="font-mono">PASEO_HOME</code> — set Paseo home directory</li>
|
<li><code className="font-mono">PASEO_HOME</code> — set Paseo home directory</li>
|
||||||
<li><code className="font-mono">PASEO_LISTEN</code> — override <code className="font-mono">daemon.listen</code></li>
|
<li><code className="font-mono">PASEO_LISTEN</code> — override <code className="font-mono">daemon.listen</code></li>
|
||||||
<li><code className="font-mono">PASEO_ALLOWED_HOSTS</code> — override/extend <code className="font-mono">daemon.allowedHosts</code></li>
|
<li><code className="font-mono">PASEO_ALLOWED_HOSTS</code> — override/extend <code className="font-mono">daemon.allowedHosts</code></li>
|
||||||
<li><code className="font-mono">OPENAI_API_KEY</code> and <code className="font-mono">OPENROUTER_API_KEY</code> — override provider keys</li>
|
<li><code className="font-mono">OPENAI_API_KEY</code> — override OpenAI provider key</li>
|
||||||
|
<li><code className="font-mono">PASEO_VOICE_LLM_PROVIDER</code> — override voice LLM provider (<code className="font-mono">claude</code>, <code className="font-mono">codex</code>, <code className="font-mono">opencode</code>)</li>
|
||||||
|
<li><code className="font-mono">PASEO_DICTATION_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_TTS_PROVIDER</code> — override voice provider selection (<code className="font-mono">local</code> or <code className="font-mono">openai</code>)</li>
|
||||||
|
<li><code className="font-mono">PASEO_LOCAL_MODELS_DIR</code> and <code className="font-mono">PASEO_LOCAL_AUTO_DOWNLOAD</code> — control local model directory and download behavior</li>
|
||||||
|
<li><code className="font-mono">PASEO_DICTATION_LOCAL_STT_MODEL</code> — override local dictation STT model</li>
|
||||||
|
<li><code className="font-mono">PASEO_VOICE_LOCAL_STT_MODEL</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_MODEL</code> — override local voice STT/TTS models</li>
|
||||||
|
<li><code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEAKER_ID</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEED</code> — optional local voice TTS tuning</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -101,4 +119,3 @@ function Configuration() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,20 +87,25 @@ function GettingStarted() {
|
|||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xl font-medium">Voice Setup</h2>
|
<h2 className="text-xl font-medium">Voice Setup</h2>
|
||||||
<p className="text-white/60">
|
<p className="text-white/60">
|
||||||
Voice features currently require an OpenAI API key. Set it as an environment variable before running the server:
|
Paseo includes first-class voice support with a local-first architecture and configurable speech
|
||||||
|
providers.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-card border border-border rounded-lg p-4 font-mono text-sm">
|
|
||||||
<span className="text-muted-foreground select-none">$ </span>
|
|
||||||
<span>export OPENAI_API_KEY=your-key-here</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-white/60">
|
<p className="text-white/60">
|
||||||
Local voice support is coming soon.
|
For architecture, local model behavior, and provider configuration, see the Voice docs page.
|
||||||
</p>
|
</p>
|
||||||
|
<a href="/docs/voice" className="underline hover:text-white/80">
|
||||||
|
Voice docs
|
||||||
|
</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xl font-medium">Next</h2>
|
<h2 className="text-xl font-medium">Next</h2>
|
||||||
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
||||||
|
<li>
|
||||||
|
<a href="/docs/voice" className="underline hover:text-white/80">
|
||||||
|
Voice
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/docs/configuration" className="underline hover:text-white/80">
|
<a href="/docs/configuration" className="underline hover:text-white/80">
|
||||||
Configuration
|
Configuration
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/docs/voice')({
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{ title: 'Voice - Paseo Docs' },
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: 'Paseo voice architecture, local-first model execution, and provider configuration.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: VoiceDocs,
|
||||||
|
})
|
||||||
|
|
||||||
|
function VoiceDocs() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-medium font-title mb-4">Voice</h1>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
Paseo has first-class voice support for dictation and realtime conversations with your coding
|
||||||
|
environment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xl font-medium">Philosophy</h2>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
Voice is local-first. You can run speech fully on-device, or choose OpenAI for speech features.
|
||||||
|
For voice reasoning/orchestration, Paseo reuses agent providers already installed and authenticated
|
||||||
|
on your machine.
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
This keeps credentials and execution in your environment and avoids introducing a separate
|
||||||
|
cloud-only voice stack.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xl font-medium">Architecture</h2>
|
||||||
|
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
||||||
|
<li>Speech I/O: STT and TTS providers per feature (<code className="font-mono">local</code> or <code className="font-mono">openai</code>)</li>
|
||||||
|
<li>Local speech runtime: ONNX models executed on CPU by default</li>
|
||||||
|
<li>Voice LLM orchestration: hidden agent session using your configured provider (<code className="font-mono">claude</code>, <code className="font-mono">codex</code>, or <code className="font-mono">opencode</code>)</li>
|
||||||
|
<li>Tooling path: MCP stdio bridge for voice tools and agent control</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xl font-medium">Local Speech</h2>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
Local speech defaults to model IDs <code className="font-mono">parakeet-tdt-0.6b-v3-int8</code>{' '}
|
||||||
|
(STT) and <code className="font-mono">pocket-tts-onnx-int8</code> (TTS).
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
Missing models are downloaded at daemon startup into{' '}
|
||||||
|
<code className="font-mono">$PASEO_HOME/models/local-speech</code> when auto-download is enabled.
|
||||||
|
Downloads happen only for missing files.
|
||||||
|
</p>
|
||||||
|
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
|
||||||
|
{`{
|
||||||
|
"version": 1,
|
||||||
|
"features": {
|
||||||
|
"dictation": { "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8" } },
|
||||||
|
"voiceMode": {
|
||||||
|
"llm": { "provider": "claude", "model": "haiku" },
|
||||||
|
"stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8" },
|
||||||
|
"tts": { "provider": "local", "model": "pocket-tts-onnx-int8" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"local": {
|
||||||
|
"modelsDir": "~/.paseo/models/local-speech",
|
||||||
|
"autoDownload": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xl font-medium">OpenAI Speech Option</h2>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to{' '}
|
||||||
|
<code className="font-mono">openai</code> and providing <code className="font-mono">OPENAI_API_KEY</code>.
|
||||||
|
</p>
|
||||||
|
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
|
||||||
|
{`{
|
||||||
|
"version": 1,
|
||||||
|
"features": {
|
||||||
|
"dictation": { "stt": { "provider": "openai" } },
|
||||||
|
"voiceMode": {
|
||||||
|
"stt": { "provider": "openai" },
|
||||||
|
"tts": { "provider": "openai" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"openai": { "apiKey": "..." }
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xl font-medium">Environment Variables</h2>
|
||||||
|
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
||||||
|
<li><code className="font-mono">OPENAI_API_KEY</code> — OpenAI speech credentials</li>
|
||||||
|
<li><code className="font-mono">PASEO_VOICE_LLM_PROVIDER</code> — voice agent provider override</li>
|
||||||
|
<li><code className="font-mono">PASEO_LOCAL_MODELS_DIR</code>, <code className="font-mono">PASEO_LOCAL_AUTO_DOWNLOAD</code> — local model storage and download policy</li>
|
||||||
|
<li><code className="font-mono">PASEO_DICTATION_LOCAL_STT_MODEL</code> — local dictation STT model ID</li>
|
||||||
|
<li><code className="font-mono">PASEO_VOICE_LOCAL_STT_MODEL</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_MODEL</code> — local voice STT/TTS model IDs</li>
|
||||||
|
<li><code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEAKER_ID</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEED</code> — optional local voice TTS tuning</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xl font-medium">Operational Notes</h2>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
Realtime voice can launch and control agents. Treat voice prompts with the same care as direct
|
||||||
|
agent instructions, especially when specifying working directories or destructive operations.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -286,8 +286,8 @@ function FAQ() {
|
|||||||
<FAQItem question="Is this free?">
|
<FAQItem question="Is this free?">
|
||||||
Paseo is free and open source. It wraps CLI tools like Claude Code and
|
Paseo is free and open source. It wraps CLI tools like Claude Code and
|
||||||
Codex, which you'll need to have installed and configured with your
|
Codex, which you'll need to have installed and configured with your
|
||||||
own credentials. Voice features currently require an OpenAI API key,
|
own credentials. Voice is local-first by default and can optionally use
|
||||||
but local voice is coming soon.
|
OpenAI speech providers if you configure them.
|
||||||
</FAQItem>
|
</FAQItem>
|
||||||
<FAQItem question="Does my code leave my machine?">
|
<FAQItem question="Does my code leave my machine?">
|
||||||
Paseo itself doesn't send your code anywhere. Agents run locally and
|
Paseo itself doesn't send your code anywhere. Agents run locally and
|
||||||
|
|||||||
Reference in New Issue
Block a user