Update files
This commit is contained in:
@@ -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" }));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
voiceSttProvider: "openai",
|
voiceSttProvider: "openai",
|
||||||
voiceTtsProvider: "openai",
|
voiceTtsProvider: "openai",
|
||||||
},
|
},
|
||||||
openrouterApiKey: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -74,13 +74,12 @@ 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";
|
import { createVoiceMcpBridgeSocketServer, type VoiceMcpBridgeSocketServer } from "./voice-mcp-bridge.js";
|
||||||
|
|
||||||
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
||||||
type VoiceAgentProvider = "claude" | "codex" | "opencode";
|
|
||||||
const VOICE_AGENT_FALLBACK_ORDER: VoiceAgentProvider[] = ["claude", "codex", "opencode"];
|
|
||||||
|
|
||||||
function resolveVoiceMcpBridgeCommand(logger: Logger): { command: string; baseArgs: string[] } {
|
function resolveVoiceMcpBridgeCommand(logger: Logger): { command: string; baseArgs: string[] } {
|
||||||
const explicit = process.env.PASEO_BIN_PATH?.trim();
|
const explicit = process.env.PASEO_BIN_PATH?.trim();
|
||||||
@@ -147,8 +146,7 @@ export type PaseoDaemonConfig = {
|
|||||||
appBaseUrl?: string;
|
appBaseUrl?: string;
|
||||||
openai?: PaseoOpenAIConfig;
|
openai?: PaseoOpenAIConfig;
|
||||||
speech?: PaseoSpeechConfig;
|
speech?: PaseoSpeechConfig;
|
||||||
openrouterApiKey?: string | null;
|
voiceLlmProvider?: AgentProvider | null;
|
||||||
voiceLlmProvider?: "openrouter" | "local-agent" | "claude" | "codex" | "opencode" | null;
|
|
||||||
voiceLlmProviderExplicit?: boolean;
|
voiceLlmProviderExplicit?: boolean;
|
||||||
voiceLlmModel?: string | null;
|
voiceLlmModel?: string | null;
|
||||||
dictationFinalTimeoutMs?: number;
|
dictationFinalTimeoutMs?: number;
|
||||||
@@ -313,21 +311,24 @@ export async function createPaseoDaemon(
|
|||||||
|
|
||||||
const requestedVoiceLlmProvider = config.voiceLlmProvider ?? null;
|
const requestedVoiceLlmProvider = config.voiceLlmProvider ?? null;
|
||||||
const voiceLlmProviderExplicit = config.voiceLlmProviderExplicit ?? false;
|
const voiceLlmProviderExplicit = config.voiceLlmProviderExplicit ?? false;
|
||||||
|
const voiceEnabledProviders = AGENT_PROVIDER_DEFINITIONS
|
||||||
|
.filter((definition) => definition.voice?.enabled)
|
||||||
|
.map((definition) => definition.id as AgentProvider);
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
requestedVoiceLlmProvider,
|
requestedVoiceLlmProvider,
|
||||||
voiceLlmProviderExplicit,
|
voiceLlmProviderExplicit,
|
||||||
|
voiceEnabledProviders,
|
||||||
},
|
},
|
||||||
"Voice LLM provider reconciliation started"
|
"Voice LLM provider reconciliation started"
|
||||||
);
|
);
|
||||||
|
|
||||||
const providerClients = createAllClients(logger);
|
const providerClients = createAllClients(logger);
|
||||||
const voiceLlmAvailability: Record<VoiceAgentProvider, boolean> = {
|
Object.assign(providerClients, config.agentClients);
|
||||||
claude: false,
|
const voiceLlmAvailability = Object.fromEntries(
|
||||||
codex: false,
|
voiceEnabledProviders.map((provider) => [provider, false])
|
||||||
opencode: false,
|
) as Record<AgentProvider, boolean>;
|
||||||
};
|
for (const provider of voiceEnabledProviders) {
|
||||||
for (const provider of VOICE_AGENT_FALLBACK_ORDER) {
|
|
||||||
try {
|
try {
|
||||||
voiceLlmAvailability[provider] = await providerClients[provider].isAvailable();
|
voiceLlmAvailability[provider] = await providerClients[provider].isAvailable();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -336,21 +337,17 @@ export async function createPaseoDaemon(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const voiceLlmDefaultProvider =
|
let resolvedVoiceLlmProvider: AgentProvider | null = null;
|
||||||
VOICE_AGENT_FALLBACK_ORDER.find((provider) => voiceLlmAvailability[provider]) ?? null;
|
if (requestedVoiceLlmProvider) {
|
||||||
|
if (!voiceEnabledProviders.includes(requestedVoiceLlmProvider)) {
|
||||||
if (requestedVoiceLlmProvider === "openrouter") {
|
logger.error(
|
||||||
const openrouterApiKey =
|
{ provider: requestedVoiceLlmProvider, voiceEnabledProviders },
|
||||||
config.openrouterApiKey ?? process.env.OPENROUTER_API_KEY ?? null;
|
"Configured voice LLM provider does not support voice mode"
|
||||||
if (!openrouterApiKey) {
|
);
|
||||||
logger.error("voiceMode.llm.provider is openrouter but no OpenRouter API key is configured");
|
throw new Error(
|
||||||
throw new Error("Missing OpenRouter API key for voiceMode.llm.provider=openrouter");
|
`Configured voice LLM provider '${requestedVoiceLlmProvider}' does not support voice mode`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (
|
|
||||||
requestedVoiceLlmProvider === "claude" ||
|
|
||||||
requestedVoiceLlmProvider === "codex" ||
|
|
||||||
requestedVoiceLlmProvider === "opencode"
|
|
||||||
) {
|
|
||||||
if (!voiceLlmAvailability[requestedVoiceLlmProvider]) {
|
if (!voiceLlmAvailability[requestedVoiceLlmProvider]) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ provider: requestedVoiceLlmProvider, voiceLlmAvailability },
|
{ provider: requestedVoiceLlmProvider, voiceLlmAvailability },
|
||||||
@@ -358,20 +355,40 @@ export async function createPaseoDaemon(
|
|||||||
);
|
);
|
||||||
throw new Error(`Configured voice LLM provider '${requestedVoiceLlmProvider}' is unavailable`);
|
throw new Error(`Configured voice LLM provider '${requestedVoiceLlmProvider}' is unavailable`);
|
||||||
}
|
}
|
||||||
} else if (!voiceLlmDefaultProvider) {
|
resolvedVoiceLlmProvider = requestedVoiceLlmProvider;
|
||||||
|
} else {
|
||||||
|
resolvedVoiceLlmProvider =
|
||||||
|
voiceEnabledProviders.find((provider) => voiceLlmAvailability[provider]) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedVoiceLlmProvider) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ requestedVoiceLlmProvider, voiceLlmAvailability },
|
{ requestedVoiceLlmProvider, voiceLlmAvailability },
|
||||||
"No local voice LLM provider available for fallback"
|
"No voice LLM provider available"
|
||||||
);
|
);
|
||||||
throw new Error("No local voice LLM provider available (claude/codex/opencode)");
|
throw new Error("No voice LLM provider available");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resolvedVoiceLlmModeId = resolvedVoiceProviderDefinition.voice.defaultModeId;
|
||||||
|
const resolvedVoiceLlmModel =
|
||||||
|
config.voiceLlmModel ?? resolvedVoiceProviderDefinition.voice.defaultModel ?? null;
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
requestedVoiceLlmProvider,
|
requestedVoiceLlmProvider,
|
||||||
voiceLlmProviderExplicit,
|
voiceLlmProviderExplicit,
|
||||||
|
resolvedVoiceLlmProvider,
|
||||||
|
resolvedVoiceLlmModeId,
|
||||||
|
resolvedVoiceLlmModel,
|
||||||
voiceLlmAvailability,
|
voiceLlmAvailability,
|
||||||
voiceLlmDefaultProvider,
|
|
||||||
},
|
},
|
||||||
"Voice LLM provider reconciliation completed"
|
"Voice LLM provider reconciliation completed"
|
||||||
);
|
);
|
||||||
@@ -926,12 +943,10 @@ export async function createPaseoDaemon(
|
|||||||
{ stt: sttService, tts: ttsService },
|
{ stt: sttService, tts: ttsService },
|
||||||
terminalManager,
|
terminalManager,
|
||||||
{
|
{
|
||||||
openrouterApiKey: config.openrouterApiKey ?? null,
|
voiceLlmProvider: resolvedVoiceLlmProvider,
|
||||||
voiceLlmProvider: config.voiceLlmProvider ?? null,
|
voiceLlmModeId: resolvedVoiceLlmModeId,
|
||||||
voiceLlmProviderExplicit,
|
voiceLlmProviderExplicit,
|
||||||
voiceLlmDefaultProvider,
|
voiceLlmModel: resolvedVoiceLlmModel,
|
||||||
voiceLlmModel: config.voiceLlmModel ?? null,
|
|
||||||
voiceLlmAvailability,
|
|
||||||
voiceAgentMcpStdio: {
|
voiceAgentMcpStdio: {
|
||||||
command: voiceMcpBridgeCommand.command,
|
command: voiceMcpBridgeCommand.command,
|
||||||
baseArgs: [
|
baseArgs: [
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { PaseoDaemonConfig } from "./bootstrap.js";
|
|||||||
import type { STTConfig } from "./speech/providers/openai/stt.js";
|
import type { STTConfig } from "./speech/providers/openai/stt.js";
|
||||||
import type { TTSConfig } from "./speech/providers/openai/tts.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 { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||||
import {
|
import {
|
||||||
mergeAllowedHosts,
|
mergeAllowedHosts,
|
||||||
parseAllowedHostsEnv,
|
parseAllowedHostsEnv,
|
||||||
@@ -13,15 +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";
|
||||||
const VOICE_LLM_PROVIDER_IDS = [
|
|
||||||
"openrouter",
|
|
||||||
"local-agent",
|
|
||||||
"claude",
|
|
||||||
"codex",
|
|
||||||
"opencode",
|
|
||||||
] as const;
|
|
||||||
type VoiceLlmProviderId = (typeof VOICE_LLM_PROVIDER_IDS)[number];
|
|
||||||
|
|
||||||
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}`;
|
||||||
@@ -97,7 +90,7 @@ function parseSpeechProviderId(value: unknown): "openai" | "local" | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseVoiceLlmProviderId(value: unknown): VoiceLlmProviderId | null {
|
function parseVoiceLlmProviderId(value: unknown): AgentProvider | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -105,8 +98,8 @@ function parseVoiceLlmProviderId(value: unknown): VoiceLlmProviderId | null {
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (VOICE_LLM_PROVIDER_IDS as readonly string[]).includes(normalized)
|
return (AGENT_PROVIDER_IDS as readonly string[]).includes(normalized)
|
||||||
? (normalized as VoiceLlmProviderId)
|
? (normalized as AgentProvider)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +258,6 @@ export function loadConfig(
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const openrouterApiKey =
|
|
||||||
env.OPENROUTER_API_KEY ?? persisted.providers?.openrouter?.apiKey ?? null;
|
|
||||||
const envVoiceLlmProvider = parseVoiceLlmProviderId(env.PASEO_VOICE_LLM_PROVIDER);
|
const envVoiceLlmProvider = parseVoiceLlmProviderId(env.PASEO_VOICE_LLM_PROVIDER);
|
||||||
const persistedVoiceLlmProvider = parseVoiceLlmProviderId(
|
const persistedVoiceLlmProvider = parseVoiceLlmProviderId(
|
||||||
persisted.features?.voiceMode?.llm?.provider
|
persisted.features?.voiceMode?.llm?.provider
|
||||||
@@ -299,7 +290,6 @@ export function loadConfig(
|
|||||||
voiceTtsProvider,
|
voiceTtsProvider,
|
||||||
...(sherpaOnnx ? { sherpaOnnx } : {}),
|
...(sherpaOnnx ? { sherpaOnnx } : {}),
|
||||||
},
|
},
|
||||||
openrouterApiKey,
|
|
||||||
voiceLlmProvider,
|
voiceLlmProvider,
|
||||||
voiceLlmProviderExplicit,
|
voiceLlmProviderExplicit,
|
||||||
voiceLlmModel,
|
voiceLlmModel,
|
||||||
|
|||||||
@@ -574,16 +574,16 @@ describe("daemon client E2E", () => {
|
|||||||
120000
|
120000
|
||||||
);
|
);
|
||||||
|
|
||||||
test.runIf(Boolean(process.env.OPENROUTER_API_KEY))(
|
test(
|
||||||
"streams session activity logs and chunks",
|
"does not process non-voice LLM turns via OpenRouter",
|
||||||
async () => {
|
async () => {
|
||||||
await ctx.client.setVoiceConversation(false);
|
await ctx.client.setVoiceConversation(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;
|
||||||
@@ -591,9 +591,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) => {
|
||||||
@@ -602,13 +599,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 () => {
|
||||||
@@ -618,9 +613,14 @@ describe("daemon client E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await ctx.client.sendUserMessage("Say 'hello' and nothing else");
|
await ctx.client.sendUserMessage("Say 'hello' and nothing else");
|
||||||
await completion;
|
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(
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -41,7 +42,6 @@ const SherpaOnnxProviderSchema = z
|
|||||||
const ProvidersSchema = z
|
const ProvidersSchema = z
|
||||||
.object({
|
.object({
|
||||||
openai: ProviderCredentialsSchema.optional(),
|
openai: ProviderCredentialsSchema.optional(),
|
||||||
openrouter: ProviderCredentialsSchema.optional(),
|
|
||||||
sherpaOnnx: SherpaOnnxProviderSchema.optional(),
|
sherpaOnnx: SherpaOnnxProviderSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -74,9 +74,7 @@ const FeatureVoiceModeSchema = z
|
|||||||
.object({
|
.object({
|
||||||
llm: z
|
llm: z
|
||||||
.object({
|
.object({
|
||||||
provider: z
|
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),
|
||||||
.enum(["openrouter", "local-agent", "claude", "codex", "opencode"])
|
|
||||||
.optional(),
|
|
||||||
model: z.string().min(1).optional(),
|
model: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { readFile, mkdir, writeFile, stat } from "fs/promises";
|
import { mkdir, stat } from "fs/promises";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify, inspect } from "util";
|
import { promisify } from "util";
|
||||||
import { join, resolve, sep } from "path";
|
import { join, resolve, sep } from "path";
|
||||||
import invariant from "tiny-invariant";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { streamText, stepCountIs } from "ai";
|
|
||||||
import type { ToolSet } from "ai";
|
import type { ToolSet } from "ai";
|
||||||
import type { ModelMessage } from "@ai-sdk/provider-utils";
|
|
||||||
import {
|
|
||||||
createOpenRouter,
|
|
||||||
OpenRouterProviderOptions,
|
|
||||||
} from "@openrouter/ai-sdk-provider";
|
|
||||||
import {
|
import {
|
||||||
serializeAgentStreamEvent,
|
serializeAgentStreamEvent,
|
||||||
type AgentSnapshotPayload,
|
type AgentSnapshotPayload,
|
||||||
@@ -29,8 +22,6 @@ import {
|
|||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
|
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
|
||||||
import { getSystemPrompt } from "./agent/system-prompt.js";
|
|
||||||
import { getAllTools } from "./agent/llm-openai.js";
|
|
||||||
import { TTSManager } from "./agent/tts-manager.js";
|
import { TTSManager } from "./agent/tts-manager.js";
|
||||||
import { STTManager } from "./agent/stt-manager.js";
|
import { STTManager } from "./agent/stt-manager.js";
|
||||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||||
@@ -48,8 +39,6 @@ import { experimental_createMCPClient } from "ai";
|
|||||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
|
|
||||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||||
type VoiceLlmProvider = "openrouter" | "local-agent" | "claude" | "codex" | "opencode";
|
|
||||||
type VoiceAgentProvider = Exclude<VoiceLlmProvider, "openrouter" | "local-agent">;
|
|
||||||
type VoiceSpeakHandler = (params: { text: string; callerAgentId: string; signal?: AbortSignal }) => Promise<void>;
|
type VoiceSpeakHandler = (params: { text: string; callerAgentId: string; signal?: AbortSignal }) => Promise<void>;
|
||||||
type VoiceCallerContext = {
|
type VoiceCallerContext = {
|
||||||
childAgentDefaultLabels?: Record<string, string>;
|
childAgentDefaultLabels?: Record<string, string>;
|
||||||
@@ -144,16 +133,6 @@ const RESTART_EXIT_DELAY_MS = 250;
|
|||||||
* Uses Claude Haiku for speed and cost efficiency.
|
* Uses Claude Haiku for speed and cost efficiency.
|
||||||
*/
|
*/
|
||||||
const AUTO_GEN_MODEL = "haiku";
|
const AUTO_GEN_MODEL = "haiku";
|
||||||
const VOICE_AGENT_FALLBACK_ORDER: VoiceAgentProvider[] = ["claude", "codex", "opencode"];
|
|
||||||
const VOICE_AGENT_DEFAULT_MODE: Record<VoiceAgentProvider, string> = {
|
|
||||||
claude: "default",
|
|
||||||
codex: "read-only",
|
|
||||||
opencode: "default",
|
|
||||||
};
|
|
||||||
const VOICE_AGENT_DEFAULT_MODEL: Partial<Record<VoiceAgentProvider, string>> = {
|
|
||||||
claude: "haiku",
|
|
||||||
codex: "gpt-5.2-mini",
|
|
||||||
};
|
|
||||||
const VOICE_AGENT_SYSTEM_INSTRUCTION = [
|
const VOICE_AGENT_SYSTEM_INSTRUCTION = [
|
||||||
"You are the Paseo voice assistant.",
|
"You are the Paseo voice assistant.",
|
||||||
"The user cannot see your chat messages or tool calls.",
|
"The user cannot see your chat messages or tool calls.",
|
||||||
@@ -210,18 +189,6 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round(
|
|||||||
);
|
);
|
||||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||||
|
|
||||||
/**
|
|
||||||
* Type for present_artifact tool arguments
|
|
||||||
*/
|
|
||||||
interface PresentArtifactArgs {
|
|
||||||
type: "markdown" | "diff" | "image" | "code";
|
|
||||||
source:
|
|
||||||
| { type: "file"; path: string }
|
|
||||||
| { type: "command_output"; command: string }
|
|
||||||
| { type: "text"; text: string };
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AudioBufferState {
|
interface AudioBufferState {
|
||||||
chunks: Buffer[];
|
chunks: Buffer[];
|
||||||
format: string;
|
format: string;
|
||||||
@@ -335,7 +302,6 @@ export class Session {
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
// Conversation history
|
// Conversation history
|
||||||
private messages: ModelMessage[] = [];
|
|
||||||
private turnIndex = 0;
|
private turnIndex = 0;
|
||||||
|
|
||||||
// Per-session managers
|
// Per-session managers
|
||||||
@@ -368,12 +334,10 @@ export class Session {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
private readonly terminalManager: TerminalManager | null;
|
private readonly terminalManager: TerminalManager | null;
|
||||||
private terminalSubscriptions: Map<string, () => void> = new Map();
|
private terminalSubscriptions: Map<string, () => void> = new Map();
|
||||||
private readonly openrouterApiKey: string | null;
|
private readonly voiceLlmProvider: AgentProvider | null;
|
||||||
private readonly voiceLlmProvider: VoiceLlmProvider | null;
|
private readonly voiceLlmModeId: string | null;
|
||||||
private readonly voiceLlmProviderExplicit: boolean;
|
private readonly voiceLlmProviderExplicit: boolean;
|
||||||
private readonly voiceLlmDefaultProvider: VoiceAgentProvider | null;
|
|
||||||
private readonly voiceLlmModel: string | null;
|
private readonly voiceLlmModel: string | null;
|
||||||
private readonly voiceLlmAvailability: Record<VoiceAgentProvider, boolean> | null;
|
|
||||||
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
|
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
|
||||||
private readonly registerVoiceSpeakHandler?: (
|
private readonly registerVoiceSpeakHandler?: (
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@@ -401,12 +365,10 @@ export class Session {
|
|||||||
tts: TextToSpeechProvider | null,
|
tts: TextToSpeechProvider | null,
|
||||||
terminalManager: TerminalManager | null,
|
terminalManager: TerminalManager | null,
|
||||||
voice?: {
|
voice?: {
|
||||||
openrouterApiKey?: string | null;
|
voiceLlmProvider?: AgentProvider | null;
|
||||||
voiceLlmProvider?: VoiceLlmProvider | null;
|
voiceLlmModeId?: string | null;
|
||||||
voiceLlmProviderExplicit?: boolean;
|
voiceLlmProviderExplicit?: boolean;
|
||||||
voiceLlmDefaultProvider?: VoiceAgentProvider | null;
|
|
||||||
voiceLlmModel?: string | null;
|
voiceLlmModel?: string | null;
|
||||||
voiceLlmAvailability?: Record<VoiceAgentProvider, boolean> | null;
|
|
||||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||||
},
|
},
|
||||||
voiceBridge?: {
|
voiceBridge?: {
|
||||||
@@ -430,12 +392,10 @@ export class Session {
|
|||||||
this.agentStorage = agentStorage;
|
this.agentStorage = agentStorage;
|
||||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||||
this.terminalManager = terminalManager;
|
this.terminalManager = terminalManager;
|
||||||
this.openrouterApiKey = voice?.openrouterApiKey ?? null;
|
|
||||||
this.voiceLlmProvider = voice?.voiceLlmProvider ?? null;
|
this.voiceLlmProvider = voice?.voiceLlmProvider ?? null;
|
||||||
|
this.voiceLlmModeId = voice?.voiceLlmModeId ?? null;
|
||||||
this.voiceLlmProviderExplicit = voice?.voiceLlmProviderExplicit ?? false;
|
this.voiceLlmProviderExplicit = voice?.voiceLlmProviderExplicit ?? false;
|
||||||
this.voiceLlmDefaultProvider = voice?.voiceLlmDefaultProvider ?? null;
|
|
||||||
this.voiceLlmModel = voice?.voiceLlmModel ?? null;
|
this.voiceLlmModel = voice?.voiceLlmModel ?? null;
|
||||||
this.voiceLlmAvailability = voice?.voiceLlmAvailability ?? null;
|
|
||||||
this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null;
|
this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null;
|
||||||
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
|
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
|
||||||
this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler;
|
this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler;
|
||||||
@@ -467,28 +427,6 @@ export class Session {
|
|||||||
this.sessionLogger.info("Session created");
|
this.sessionLogger.info("Session created");
|
||||||
}
|
}
|
||||||
|
|
||||||
private escapeXmlText(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
}
|
|
||||||
|
|
||||||
private escapeXmlAttribute(value: string): string {
|
|
||||||
return this.escapeXmlText(value)
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatVoiceTranscriptionForLLM(text: string): string {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
const focusedAgentId = this.clientActivity?.focusedAgentId ?? null;
|
|
||||||
const focusedAttr = focusedAgentId
|
|
||||||
? ` focused-agent-id="${this.escapeXmlAttribute(focusedAgentId)}"`
|
|
||||||
: "";
|
|
||||||
return `<voice-transcription${focusedAttr}>${this.escapeXmlText(trimmed)}</voice-transcription>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the client's current activity state
|
* Get the client's current activity state
|
||||||
*/
|
*/
|
||||||
@@ -1195,19 +1133,6 @@ export class Session {
|
|||||||
voiceConversationId: string,
|
voiceConversationId: string,
|
||||||
requestId: string
|
requestId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.voiceLlmProvider === "openrouter") {
|
|
||||||
this.voiceAssistantAgentId = null;
|
|
||||||
this.messages = [];
|
|
||||||
this.emit({
|
|
||||||
type: "voice_conversation_loaded",
|
|
||||||
payload: {
|
|
||||||
voiceConversationId,
|
|
||||||
messageCount: 0,
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.voiceAssistantAgentId = voiceConversationId;
|
this.voiceAssistantAgentId = voiceConversationId;
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -1459,36 +1384,19 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.isVoiceMode = true;
|
this.isVoiceMode = true;
|
||||||
if (this.voiceLlmProvider !== "openrouter") {
|
this.voiceAssistantAgentId = voiceConversationId;
|
||||||
this.voiceAssistantAgentId = voiceConversationId;
|
|
||||||
this.sessionLogger.info(
|
|
||||||
{ voiceAssistantAgentId: this.voiceAssistantAgentId },
|
|
||||||
"Voice conversation enabled (agent-backed)"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenRouter voice mode is always ephemeral.
|
|
||||||
this.voiceAssistantAgentId = null;
|
|
||||||
this.messages = [];
|
|
||||||
|
|
||||||
this.sessionLogger.info(
|
this.sessionLogger.info(
|
||||||
{ messageCount: this.messages.length },
|
{ voiceAssistantAgentId: this.voiceAssistantAgentId },
|
||||||
"Voice conversation enabled"
|
"Voice conversation enabled (agent-backed)"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isVoiceMode = false;
|
this.isVoiceMode = false;
|
||||||
if (this.voiceLlmProvider !== "openrouter") {
|
this.sessionLogger.info(
|
||||||
this.sessionLogger.info(
|
{ voiceAssistantAgentId: this.voiceAssistantAgentId },
|
||||||
{ voiceAssistantAgentId: this.voiceAssistantAgentId },
|
"Voice conversation disabled (agent-backed)"
|
||||||
"Voice conversation disabled (agent-backed)"
|
);
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.voiceAssistantAgentId = null;
|
|
||||||
this.sessionLogger.info("Voice conversation disabled");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4342,10 +4250,7 @@ export class Session {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to conversation
|
// Process through LLM (voice path is agent-backed only)
|
||||||
this.messages.push({ role: "user", content: text });
|
|
||||||
|
|
||||||
// Process through LLM (TTS enabled in voice mode for voice conversations)
|
|
||||||
this.currentStreamPromise = this.processWithLLM(this.isVoiceMode, text);
|
this.currentStreamPromise = this.processWithLLM(this.isVoiceMode, text);
|
||||||
await this.currentStreamPromise;
|
await this.currentStreamPromise;
|
||||||
}
|
}
|
||||||
@@ -4612,14 +4517,6 @@ export class Session {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to conversation
|
|
||||||
this.messages.push({
|
|
||||||
role: "user",
|
|
||||||
content: this.isVoiceMode
|
|
||||||
? this.formatVoiceTranscriptionForLLM(result.text)
|
|
||||||
: result.text,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set phase to LLM and process (TTS enabled in voice mode for voice conversations)
|
// Set phase to LLM and process (TTS enabled in voice mode for voice conversations)
|
||||||
this.clearSpeechInProgress("transcription complete");
|
this.clearSpeechInProgress("transcription complete");
|
||||||
this.setPhase("llm");
|
this.setPhase("llm");
|
||||||
@@ -4642,52 +4539,6 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the effective voice LLM provider.
|
|
||||||
* - explicit provider => strict
|
|
||||||
* - local-agent / unset => fallback order
|
|
||||||
*/
|
|
||||||
private resolveVoiceAgentProvider(): VoiceAgentProvider {
|
|
||||||
const configured = this.voiceLlmProvider;
|
|
||||||
const availability = this.voiceLlmAvailability ?? {
|
|
||||||
claude: true,
|
|
||||||
codex: true,
|
|
||||||
opencode: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (configured === "openrouter") {
|
|
||||||
throw new Error("voiceLlmProvider=openrouter cannot be used in local-agent flow");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configured === "claude" || configured === "codex" || configured === "opencode") {
|
|
||||||
if (!availability[configured]) {
|
|
||||||
throw new Error(`Configured voice LLM provider '${configured}' is unavailable`);
|
|
||||||
}
|
|
||||||
return configured;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackOrder =
|
|
||||||
this.voiceLlmDefaultProvider && availability[this.voiceLlmDefaultProvider]
|
|
||||||
? [this.voiceLlmDefaultProvider, ...VOICE_AGENT_FALLBACK_ORDER.filter((id) => id !== this.voiceLlmDefaultProvider)]
|
|
||||||
: VOICE_AGENT_FALLBACK_ORDER;
|
|
||||||
|
|
||||||
for (const provider of fallbackOrder) {
|
|
||||||
if (availability[provider]) {
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("No local voice LLM provider is available (claude/codex/opencode)");
|
|
||||||
}
|
|
||||||
|
|
||||||
private getVoiceAgentModel(provider: VoiceAgentProvider): string | undefined {
|
|
||||||
const configured = this.voiceLlmModel?.trim();
|
|
||||||
if (configured) {
|
|
||||||
return configured;
|
|
||||||
}
|
|
||||||
return VOICE_AGENT_DEFAULT_MODEL[provider];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureVoiceAssistantAgent(): Promise<string> {
|
private async ensureVoiceAssistantAgent(): Promise<string> {
|
||||||
if (this.voiceAssistantAgentId) {
|
if (this.voiceAssistantAgentId) {
|
||||||
const existing = this.agentManager.getAgent(this.voiceAssistantAgentId);
|
const existing = this.agentManager.getAgent(this.voiceAssistantAgentId);
|
||||||
@@ -4706,7 +4557,10 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = this.resolveVoiceAgentProvider();
|
const provider = this.voiceLlmProvider;
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error("Voice LLM provider is not configured");
|
||||||
|
}
|
||||||
const voiceAgentId = this.voiceAssistantAgentId ?? uuidv4();
|
const voiceAgentId = this.voiceAssistantAgentId ?? uuidv4();
|
||||||
const cwd = join(this.paseoHome, "voice-agent-workspace");
|
const cwd = join(this.paseoHome, "voice-agent-workspace");
|
||||||
await mkdir(cwd, { recursive: true });
|
await mkdir(cwd, { recursive: true });
|
||||||
@@ -4716,11 +4570,11 @@ export class Session {
|
|||||||
throw new Error("Voice MCP stdio bridge is not configured");
|
throw new Error("Voice MCP stdio bridge is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = this.getVoiceAgentModel(provider);
|
const model = this.voiceLlmModel?.trim() || undefined;
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
provider,
|
provider,
|
||||||
cwd,
|
cwd,
|
||||||
modeId: VOICE_AGENT_DEFAULT_MODE[provider],
|
modeId: this.voiceLlmModeId ?? "default",
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
paseo: buildVoiceAgentMcpServerConfig({
|
paseo: buildVoiceAgentMcpServerConfig({
|
||||||
@@ -4872,372 +4726,22 @@ export class Session {
|
|||||||
* Process user message through LLM with streaming and tool execution
|
* Process user message through LLM with streaming and tool execution
|
||||||
*/
|
*/
|
||||||
private async processWithLLM(enableTTS: boolean, latestUserText?: string): Promise<void> {
|
private async processWithLLM(enableTTS: boolean, latestUserText?: string): Promise<void> {
|
||||||
if (enableTTS && this.voiceLlmProvider !== "openrouter") {
|
try {
|
||||||
const text =
|
if (!enableTTS) {
|
||||||
typeof latestUserText === "string" && latestUserText.trim().length > 0
|
this.sessionLogger.warn("Ignoring non-voice processWithLLM call; voice is agent-only");
|
||||||
? latestUserText
|
return;
|
||||||
: (() => {
|
}
|
||||||
const lastUser = [...this.messages]
|
const normalized = (latestUserText ?? "").trim();
|
||||||
.reverse()
|
|
||||||
.find((message) => message.role === "user");
|
|
||||||
if (!lastUser) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return typeof lastUser.content === "string"
|
|
||||||
? lastUser.content
|
|
||||||
: JSON.stringify(lastUser.content);
|
|
||||||
})();
|
|
||||||
const normalized = text.trim();
|
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.processWithVoiceAgent(normalized);
|
await this.processWithVoiceAgent(normalized);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let assistantResponse = "";
|
|
||||||
let pendingTTS: Promise<void> | null = null;
|
|
||||||
let textBuffer = "";
|
|
||||||
let sawTextDelta = false;
|
|
||||||
|
|
||||||
const flushTextBuffer = () => {
|
|
||||||
if (textBuffer.length > 0) {
|
|
||||||
// TTS handling (capture mode at generation time for drift protection)
|
|
||||||
if (enableTTS && !this.speechInProgress) {
|
|
||||||
const modeAtGeneration = this.isVoiceMode;
|
|
||||||
pendingTTS = this.ttsManager.generateAndWaitForPlayback(
|
|
||||||
textBuffer,
|
|
||||||
(msg) => this.emit(msg),
|
|
||||||
this.abortController.signal,
|
|
||||||
modeAtGeneration
|
|
||||||
);
|
|
||||||
} else if (enableTTS && this.speechInProgress) {
|
|
||||||
this.sessionLogger.debug("Skipping TTS chunk while speech in progress");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit activity log
|
|
||||||
this.emit({
|
|
||||||
type: "activity_log",
|
|
||||||
payload: {
|
|
||||||
id: uuidv4(),
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: "assistant",
|
|
||||||
content: textBuffer,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
textBuffer = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Debug: dump conversation before LLM call
|
|
||||||
await this.dumpConversation();
|
|
||||||
|
|
||||||
const openrouterApiKey =
|
|
||||||
this.openrouterApiKey ?? process.env.OPENROUTER_API_KEY ?? null;
|
|
||||||
invariant(
|
|
||||||
openrouterApiKey,
|
|
||||||
"OpenRouter API key is required (set providers.openrouter.apiKey in config.json or OPENROUTER_API_KEY)"
|
|
||||||
);
|
|
||||||
|
|
||||||
const openrouter = createOpenRouter({
|
|
||||||
apiKey: openrouterApiKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for agent MCP to initialize if needed
|
|
||||||
if (!this.agentTools) {
|
|
||||||
this.sessionLogger.debug("Waiting for agent MCP initialization...");
|
|
||||||
const startTime = Date.now();
|
|
||||||
while (!this.agentTools && Date.now() - startTime < 5000) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
if (!this.agentTools) {
|
|
||||||
this.sessionLogger.info("Agent MCP tools unavailable; continuing with default tool set");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTools = getAllTools(this.agentTools ?? undefined);
|
|
||||||
|
|
||||||
const result = await streamText({
|
|
||||||
model: openrouter(this.voiceLlmModel ?? "anthropic/claude-haiku-4.5"),
|
|
||||||
system: getSystemPrompt(),
|
|
||||||
providerOptions: {
|
|
||||||
openrouter: {
|
|
||||||
transforms: ["middle-out"], // Compress prompts that are > context size.
|
|
||||||
} as OpenRouterProviderOptions,
|
|
||||||
},
|
|
||||||
messages: this.messages,
|
|
||||||
tools: allTools,
|
|
||||||
abortSignal: this.abortController.signal,
|
|
||||||
onFinish: async (event) => {
|
|
||||||
const newMessages = event.response.messages;
|
|
||||||
if (newMessages.length > 0) {
|
|
||||||
this.messages.push(...newMessages);
|
|
||||||
this.sessionLogger.debug(
|
|
||||||
{ messageCount: newMessages.length },
|
|
||||||
`onFinish - saved message with ${newMessages.length} steps`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onChunk: async ({ chunk }) => {
|
|
||||||
if (chunk.type === "text-delta") {
|
|
||||||
sawTextDelta = true;
|
|
||||||
// Accumulate text in buffer
|
|
||||||
textBuffer += chunk.text;
|
|
||||||
assistantResponse += chunk.text;
|
|
||||||
|
|
||||||
// Emit chunk for UI streaming
|
|
||||||
this.emit({
|
|
||||||
type: "assistant_chunk",
|
|
||||||
payload: { chunk: chunk.text },
|
|
||||||
});
|
|
||||||
} else if (chunk.type === "tool-call") {
|
|
||||||
// Flush accumulated text as a segment before tool call
|
|
||||||
flushTextBuffer();
|
|
||||||
|
|
||||||
// Wait for pending TTS before executing tool
|
|
||||||
if (pendingTTS) {
|
|
||||||
this.sessionLogger.debug(
|
|
||||||
{ toolName: chunk.toolName },
|
|
||||||
`Waiting for TTS before executing ${chunk.toolName}`
|
|
||||||
);
|
|
||||||
await pendingTTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle present_artifact tool specially
|
|
||||||
if (chunk.toolName === "present_artifact") {
|
|
||||||
await this.handlePresentArtifact(
|
|
||||||
chunk.toolCallId,
|
|
||||||
chunk.input as PresentArtifactArgs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit tool call activity log
|
|
||||||
this.emit({
|
|
||||||
type: "activity_log",
|
|
||||||
payload: {
|
|
||||||
id: chunk.toolCallId,
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: "tool_call",
|
|
||||||
content: `Calling ${chunk.toolName}`,
|
|
||||||
metadata: {
|
|
||||||
toolCallId: chunk.toolCallId,
|
|
||||||
toolName: chunk.toolName,
|
|
||||||
arguments: chunk.input,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (chunk.type === "tool-result") {
|
|
||||||
// Check if this is a create_agent result
|
|
||||||
if (chunk.toolName === "create_agent" && chunk.output) {
|
|
||||||
const result = chunk.output as any;
|
|
||||||
if (result.structuredContent?.agentId) {
|
|
||||||
const agentId = result.structuredContent.agentId;
|
|
||||||
this.emit({
|
|
||||||
type: "status",
|
|
||||||
payload: {
|
|
||||||
status: "agent_created",
|
|
||||||
agentId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit tool result event
|
|
||||||
this.emit({
|
|
||||||
type: "activity_log",
|
|
||||||
payload: {
|
|
||||||
id: chunk.toolCallId,
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: "tool_result",
|
|
||||||
content: `Tool ${chunk.toolName} completed`,
|
|
||||||
metadata: {
|
|
||||||
toolCallId: chunk.toolCallId,
|
|
||||||
toolName: chunk.toolName,
|
|
||||||
result: chunk.output,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: async (error) => {
|
|
||||||
this.sessionLogger.error({ err: error }, "Stream error");
|
|
||||||
|
|
||||||
this.emit({
|
|
||||||
type: "activity_log",
|
|
||||||
payload: {
|
|
||||||
id: uuidv4(),
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: "error",
|
|
||||||
content: `Stream error: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
stopWhen: stepCountIs(10),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Consume the fullStream to handle tool-error chunks
|
|
||||||
for await (const part of result.fullStream) {
|
|
||||||
if (part.type === "tool-error") {
|
|
||||||
this.emit({
|
|
||||||
type: "activity_log",
|
|
||||||
payload: {
|
|
||||||
id: part.toolCallId,
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: "error",
|
|
||||||
content: `Tool ${part.toolName} failed: ${
|
|
||||||
part.error instanceof Error
|
|
||||||
? part.error.message
|
|
||||||
: String(part.error)
|
|
||||||
}`,
|
|
||||||
metadata: {
|
|
||||||
toolCallId: part.toolCallId,
|
|
||||||
toolName: part.toolName,
|
|
||||||
error: part.error,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sawTextDelta) {
|
|
||||||
let fallbackText = "";
|
|
||||||
try {
|
|
||||||
fallbackText = (await result.text).trim();
|
|
||||||
} catch {
|
|
||||||
fallbackText = "";
|
|
||||||
}
|
|
||||||
if (fallbackText.length > 0) {
|
|
||||||
textBuffer += fallbackText;
|
|
||||||
assistantResponse += fallbackText;
|
|
||||||
this.emit({
|
|
||||||
type: "assistant_chunk",
|
|
||||||
payload: { chunk: fallbackText },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush any remaining text at the end
|
|
||||||
flushTextBuffer();
|
|
||||||
|
|
||||||
// Note: Message is saved by onFinish callback with proper tool calls
|
|
||||||
|
|
||||||
// Now wait for any pending TTS, but don't fail if it times out
|
|
||||||
if (pendingTTS) {
|
|
||||||
try {
|
|
||||||
await pendingTTS;
|
|
||||||
} catch (ttsError) {
|
|
||||||
this.sessionLogger.error(
|
|
||||||
{ err: ttsError },
|
|
||||||
"TTS playback failed (message already saved)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Note: Partial messages are saved by onAbort callback with proper tool calls
|
|
||||||
|
|
||||||
// Check if this is an abort error
|
|
||||||
const isAbortError =
|
|
||||||
error instanceof Error && error.name === "AbortError";
|
|
||||||
|
|
||||||
// Only emit error log for non-abort errors
|
|
||||||
if (!isAbortError) {
|
|
||||||
this.emit({
|
|
||||||
type: "activity_log",
|
|
||||||
payload: {
|
|
||||||
id: uuidv4(),
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: "error",
|
|
||||||
content: `Error: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't re-throw abort errors (they're expected during interruptions)
|
|
||||||
if (isAbortError) {
|
|
||||||
this.sessionLogger.debug("Stream aborted (partial response saved)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-throw unexpected errors
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
// Increment turn index for next LLM call
|
|
||||||
this.turnIndex++;
|
this.turnIndex++;
|
||||||
|
|
||||||
// Clear the stream promise tracker
|
|
||||||
this.currentStreamPromise = null;
|
this.currentStreamPromise = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle present_artifact tool execution
|
|
||||||
*/
|
|
||||||
private async handlePresentArtifact(
|
|
||||||
toolCallId: string,
|
|
||||||
args: PresentArtifactArgs
|
|
||||||
): Promise<void> {
|
|
||||||
let content: string;
|
|
||||||
let isBase64 = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (args.source.type === "file") {
|
|
||||||
const fileBuffer = await readFile(args.source.path);
|
|
||||||
content = fileBuffer.toString("base64");
|
|
||||||
isBase64 = true;
|
|
||||||
} else if (args.source.type === "command_output") {
|
|
||||||
const { stdout } = await execAsync(args.source.command, {
|
|
||||||
encoding: "buffer",
|
|
||||||
});
|
|
||||||
content = stdout.toString("base64");
|
|
||||||
isBase64 = true;
|
|
||||||
} else if (args.source.type === "text") {
|
|
||||||
content = args.source.text;
|
|
||||||
isBase64 = false;
|
|
||||||
} else {
|
|
||||||
content = "[Unknown source type]";
|
|
||||||
isBase64 = false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.sessionLogger.error(
|
|
||||||
{ err: error },
|
|
||||||
"Failed to resolve artifact source"
|
|
||||||
);
|
|
||||||
content = `[Error: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}]`;
|
|
||||||
isBase64 = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit artifact message
|
|
||||||
this.emit({
|
|
||||||
type: "artifact",
|
|
||||||
payload: {
|
|
||||||
type: args.type,
|
|
||||||
id: toolCallId,
|
|
||||||
title: args.title,
|
|
||||||
content,
|
|
||||||
isBase64,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit activity log for artifact
|
|
||||||
this.emit({
|
|
||||||
type: "activity_log",
|
|
||||||
payload: {
|
|
||||||
id: toolCallId,
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: "system",
|
|
||||||
content: `${args.type} artifact: ${args.title}`,
|
|
||||||
metadata: { artifactId: toolCallId, artifactType: args.type },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle abort request from client
|
* Handle abort request from client
|
||||||
*/
|
*/
|
||||||
@@ -5445,38 +4949,6 @@ export class Session {
|
|||||||
this.onMessage(msg);
|
this.onMessage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Debug helper: dump conversation to disk
|
|
||||||
*/
|
|
||||||
private async dumpConversation(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const dumpDir = join(process.cwd(), ".debug.conversations");
|
|
||||||
await mkdir(dumpDir, { recursive: true });
|
|
||||||
|
|
||||||
const filename = `${this.sessionId}-${this.turnIndex}.json`;
|
|
||||||
const filepath = join(dumpDir, filename);
|
|
||||||
|
|
||||||
const dump = {
|
|
||||||
voiceAssistantAgentId: this.voiceAssistantAgentId,
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
turnIndex: this.turnIndex,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
messages: this.messages,
|
|
||||||
};
|
|
||||||
|
|
||||||
await writeFile(filepath, inspect(dump, { depth: null }), "utf-8");
|
|
||||||
this.sessionLogger.debug(
|
|
||||||
{ filepath },
|
|
||||||
`Dumped conversation to ${filepath}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.sessionLogger.error(
|
|
||||||
{ err: error },
|
|
||||||
"Failed to dump conversation"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up session resources
|
* Clean up session resources
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ type TestPaseoDaemonOptions = {
|
|||||||
cleanup?: boolean;
|
cleanup?: boolean;
|
||||||
openai?: PaseoOpenAIConfig;
|
openai?: PaseoOpenAIConfig;
|
||||||
speech?: PaseoSpeechConfig;
|
speech?: PaseoSpeechConfig;
|
||||||
openrouterApiKey?: string | null;
|
|
||||||
voiceLlmProvider?: PaseoDaemonConfig["voiceLlmProvider"];
|
voiceLlmProvider?: PaseoDaemonConfig["voiceLlmProvider"];
|
||||||
voiceLlmProviderExplicit?: boolean;
|
voiceLlmProviderExplicit?: boolean;
|
||||||
voiceLlmModel?: string | null;
|
voiceLlmModel?: string | null;
|
||||||
@@ -82,7 +81,6 @@ 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: options.openrouterApiKey ?? null,
|
|
||||||
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
||||||
voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false,
|
voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false,
|
||||||
voiceLlmModel: options.voiceLlmModel ?? null,
|
voiceLlmModel: options.voiceLlmModel ?? null,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { PushService } from "./push/push-service.js";
|
|||||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||||
|
|
||||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||||
type VoiceAgentProvider = "claude" | "codex" | "opencode";
|
|
||||||
type VoiceMcpStdioConfig = {
|
type VoiceMcpStdioConfig = {
|
||||||
command: string;
|
command: string;
|
||||||
baseArgs: string[];
|
baseArgs: string[];
|
||||||
@@ -79,12 +78,10 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
stt?: SpeechToTextProvider | null;
|
stt?: SpeechToTextProvider | null;
|
||||||
} | null;
|
} | null;
|
||||||
private readonly voice: {
|
private readonly voice: {
|
||||||
openrouterApiKey?: string | null;
|
voiceLlmProvider?: AgentProvider | null;
|
||||||
voiceLlmProvider?: "openrouter" | "local-agent" | "claude" | "codex" | "opencode" | null;
|
voiceLlmModeId?: string | null;
|
||||||
voiceLlmProviderExplicit?: boolean;
|
voiceLlmProviderExplicit?: boolean;
|
||||||
voiceLlmDefaultProvider?: VoiceAgentProvider | null;
|
|
||||||
voiceLlmModel?: string | null;
|
voiceLlmModel?: string | null;
|
||||||
voiceLlmAvailability?: Record<VoiceAgentProvider, boolean> | null;
|
|
||||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||||
} | null;
|
} | null;
|
||||||
private readonly voiceSpeakHandlers = new Map<
|
private readonly voiceSpeakHandlers = new Map<
|
||||||
@@ -114,12 +111,10 @@ 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;
|
||||||
voiceLlmProvider?: "openrouter" | "local-agent" | "claude" | "codex" | "opencode" | null;
|
voiceLlmModeId?: string | null;
|
||||||
voiceLlmProviderExplicit?: boolean;
|
voiceLlmProviderExplicit?: boolean;
|
||||||
voiceLlmDefaultProvider?: VoiceAgentProvider | null;
|
|
||||||
voiceLlmModel?: string | null;
|
voiceLlmModel?: string | null;
|
||||||
voiceLlmAvailability?: Record<VoiceAgentProvider, boolean> | null;
|
|
||||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||||
},
|
},
|
||||||
dictation?: {
|
dictation?: {
|
||||||
|
|||||||
@@ -86,8 +86,44 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"openrouter": {
|
"sherpaOnnx": {
|
||||||
"$ref": "#/definitions/PaseoConfigV1/properties/providers/properties/openai"
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"modelsDir": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"autoDownload": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"stt": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"preset": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"tts": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"preset": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"speakerId": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -104,7 +140,8 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openai"
|
"openai",
|
||||||
|
"local"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
@@ -129,7 +166,9 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openrouter"
|
"claude",
|
||||||
|
"codex",
|
||||||
|
"opencode"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
@@ -145,7 +184,8 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openai"
|
"openai",
|
||||||
|
"local"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
@@ -161,7 +201,8 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openai"
|
"openai",
|
||||||
|
"local"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -92,6 +91,7 @@ function Configuration() {
|
|||||||
"features": {
|
"features": {
|
||||||
"dictation": { "stt": { "provider": "local" } },
|
"dictation": { "stt": { "provider": "local" } },
|
||||||
"voiceMode": {
|
"voiceMode": {
|
||||||
|
"llm": { "provider": "claude", "model": "haiku" },
|
||||||
"stt": { "provider": "local" },
|
"stt": { "provider": "local" },
|
||||||
"tts": { "provider": "local" }
|
"tts": { "provider": "local" }
|
||||||
}
|
}
|
||||||
@@ -106,6 +106,13 @@ function Configuration() {
|
|||||||
}
|
}
|
||||||
}`}
|
}`}
|
||||||
</pre>
|
</pre>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
Voice LLM orchestration is agents-only. Set{' '}
|
||||||
|
<code className="font-mono">features.voiceMode.llm.provider</code> to one of{' '}
|
||||||
|
<code className="font-mono">claude</code>, <code className="font-mono">codex</code>, or{' '}
|
||||||
|
<code className="font-mono">opencode</code>. If set, startup is strict and fails if unavailable.
|
||||||
|
If omitted, Paseo picks the first available voice-enabled agent provider.
|
||||||
|
</p>
|
||||||
<p className="text-white/60 leading-relaxed">
|
<p className="text-white/60 leading-relaxed">
|
||||||
Local voice uses ONNX models (sherpa-onnx + PocketTTS). Default presets are
|
Local voice uses ONNX models (sherpa-onnx + PocketTTS). Default presets are
|
||||||
<code className="font-mono">parakeet-tdt-0.6b-v3-int8</code> for STT and
|
<code className="font-mono">parakeet-tdt-0.6b-v3-int8</code> for STT and
|
||||||
@@ -152,7 +159,8 @@ function Configuration() {
|
|||||||
<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_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_SHERPA_ONNX_MODELS_DIR</code> and <code className="font-mono">PASEO_SHERPA_ONNX_AUTO_DOWNLOAD</code> — control local model directory and download behavior</li>
|
<li><code className="font-mono">PASEO_SHERPA_ONNX_MODELS_DIR</code> and <code className="font-mono">PASEO_SHERPA_ONNX_AUTO_DOWNLOAD</code> — control local model directory and download behavior</li>
|
||||||
<li><code className="font-mono">PASEO_SHERPA_STT_PRESET</code> and <code className="font-mono">PASEO_SHERPA_TTS_PRESET</code> — override local STT/TTS model presets</li>
|
<li><code className="font-mono">PASEO_SHERPA_STT_PRESET</code> and <code className="font-mono">PASEO_SHERPA_TTS_PRESET</code> — override local STT/TTS model presets</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user