Based on the git diff showing extensive package-lock.json changes and modifications to the agent system prompt and configuration, here's the commit message:

```
feat: add AI SDK dependencies and refine agent voice interaction config

- Add @ai-sdk packages for enhanced AI capabilities
- Install Deepgram SDK, livekit-client, and werift for voice/WebRTC
- Expand agent system prompt with multi-tool workflows and Claude Code interaction patterns
- Adjust VAD timing (1.2s silence threshold) and turn detection for better voice UX
- Switch TTS to inworld/inworld-tts-1 with Olivia voice
- Configure interruption timing (0.2s detection, no word requirement)
- Add comprehensive guidelines for conversational responses vs structured output
- Document plan mode triggers and Claude response type handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
```
This commit is contained in:
Mohamed Boudra
2025-10-19 15:41:42 +02:00
parent dabcf63ebc
commit 40b199e329
44 changed files with 12183 additions and 1715 deletions
@@ -0,0 +1,173 @@
import { streamText, tool } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod';
import {
listTerminals,
createTerminal,
captureTerminal,
sendText,
renameTerminal,
killTerminal,
} from '../daemon/terminal-manager.js';
let openaiClient: ReturnType<typeof createOpenAI> | null = null;
/**
* Initialize OpenAI client with API key
*/
export function initializeOpenAI(apiKey: string): void {
openaiClient = createOpenAI({ apiKey });
}
/**
* Terminal tools using Vercel AI SDK tool() function
* These are automatically executed when the LLM calls them
*/
export const terminalTools = {
list_terminals: tool({
description: 'List all available terminals in the voice-dev session',
parameters: z.object({}),
execute: async () => {
const terminals = await listTerminals();
return { terminals };
},
}),
create_terminal: tool({
description: 'Create a new terminal with specified name and working directory',
parameters: z.object({
name: z.string().describe('Unique name for the terminal'),
workingDirectory: z.string().describe('Working directory path'),
initialCommand: z.string().optional().describe('Optional command to run on creation'),
}),
execute: async ({ name, workingDirectory, initialCommand }) => {
const terminal = await createTerminal({ name, workingDirectory, initialCommand });
return { terminal };
},
}),
capture_terminal: tool({
description: 'Capture and return the output from a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID (e.g., @123)'),
lines: z.number().optional().describe('Number of lines to capture (default: 200)'),
wait: z.number().optional().describe('Milliseconds to wait before capture'),
}),
execute: async ({ terminalId, lines, wait }) => {
const output = await captureTerminal(terminalId, lines, wait);
return { output };
},
}),
send_text: tool({
description: 'Send text or commands to a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID (e.g., @123)'),
text: z.string().describe('Text to send to the terminal'),
pressEnter: z.boolean().optional().describe('Whether to press Enter after text'),
return_output: z
.object({
lines: z.number().optional(),
wait: z.number().optional(),
})
.optional()
.describe('Capture output after sending'),
}),
execute: async ({ terminalId, text, pressEnter, return_output }) => {
const output = await sendText(terminalId, text, pressEnter, return_output);
return { output };
},
}),
rename_terminal: tool({
description: 'Rename an existing terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID to rename'),
newName: z.string().describe('New unique name for the terminal'),
}),
execute: async ({ terminalId, newName }) => {
await renameTerminal(terminalId, newName);
return { success: true };
},
}),
kill_terminal: tool({
description: 'Close and destroy a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID to kill'),
}),
execute: async ({ terminalId }) => {
await killTerminal(terminalId);
return { success: true };
},
}),
};
/**
* Message interface for conversation
*/
export interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}
/**
* Streaming LLM parameters
*/
export interface StreamLLMParams {
messages: Message[];
onChunk?: (chunk: string) => void;
onToolCall?: (toolName: string, args: any) => void;
onToolResult?: (toolName: string, result: any) => void;
onFinish?: (fullText: string) => void;
}
/**
* Stream LLM response with automatic tool execution
*/
export async function streamLLM(params: StreamLLMParams): Promise<string> {
if (!openaiClient) {
throw new Error('OpenAI client not initialized. Call initializeOpenAI() first.');
}
const result = await streamText({
model: openaiClient('gpt-4o') as any, // Type assertion to work around AI SDK version conflicts
messages: params.messages,
tools: terminalTools,
maxSteps: 5, // Allow up to 5 tool executions per turn
onStepFinish: async (event) => {
// Called after each step (tool call or text generation)
if (event.toolCalls && event.toolCalls.length > 0) {
for (const toolCall of event.toolCalls) {
if (params.onToolCall) {
params.onToolCall(toolCall.toolName, toolCall.args);
}
}
}
if (event.toolResults && event.toolResults.length > 0) {
for (const toolResult of event.toolResults) {
if (params.onToolResult) {
params.onToolResult(toolResult.toolName, toolResult.result);
}
}
}
},
});
let fullText = '';
// Stream text chunks to the caller
for await (const chunk of result.textStream) {
fullText += chunk;
if (params.onChunk) {
params.onChunk(chunk);
}
}
if (params.onFinish) {
params.onFinish(fullText);
}
return fullText;
}
@@ -0,0 +1,173 @@
import { v4 as uuidv4 } from 'uuid';
import { getSystemPrompt } from './system-prompt.js';
import { streamLLM, type Message } from './llm-openai.js';
import type { VoiceAssistantWebSocketServer } from '../websocket-server.js';
interface ConversationContext {
id: string;
messages: Message[];
createdAt: Date;
lastActivity: Date;
}
/**
* Store active conversations (in-memory for now)
* In production, this could be persisted to a database
*/
const conversations = new Map<string, ConversationContext>();
/**
* Create a new conversation with system prompt
*/
export function createConversation(): string {
const id = uuidv4();
conversations.set(id, {
id,
messages: [{ role: 'system', content: getSystemPrompt() }],
createdAt: new Date(),
lastActivity: new Date(),
});
return id;
}
/**
* Get conversation by ID
*/
export function getConversation(id: string): ConversationContext | null {
return conversations.get(id) || null;
}
/**
* Process user message through the LLM orchestrator
* Handles streaming, tool calls, and WebSocket broadcasting
*/
export async function processUserMessage(params: {
conversationId: string;
message: string;
wsServer?: VoiceAssistantWebSocketServer;
enableTTS?: boolean;
}): Promise<string> {
const conversation = conversations.get(params.conversationId);
if (!conversation) {
throw new Error('Conversation not found');
}
// Add user message to context
conversation.messages.push({
role: 'user',
content: params.message,
});
conversation.lastActivity = new Date();
// Broadcast user message to WebSocket clients
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'transcript',
content: params.message,
});
}
let assistantResponse = '';
try {
// Stream LLM response with tool execution
assistantResponse = await streamLLM({
messages: conversation.messages,
onChunk: (chunk) => {
// Broadcast streaming chunks to WebSocket
if (params.wsServer) {
params.wsServer.broadcast({
type: 'assistant_chunk',
payload: { chunk },
});
}
},
onToolCall: (toolName, args) => {
// Broadcast tool call to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'tool_call',
content: `Calling ${toolName}`,
metadata: { toolName, arguments: args },
});
}
},
onToolResult: (toolName, result) => {
// Broadcast tool result to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'tool_result',
content: `Tool ${toolName} completed`,
metadata: { toolName, result },
});
}
},
onFinish: (fullText) => {
// Broadcast complete assistant response
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'assistant',
content: fullText,
});
}
},
});
// Add assistant response to context
conversation.messages.push({
role: 'assistant',
content: assistantResponse,
});
} catch (error: any) {
// Broadcast error to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
content: `Error: ${error.message}`,
});
}
throw error;
}
return assistantResponse;
}
/**
* Clean up old conversations (call periodically)
*/
export function cleanupConversations(maxAgeMinutes: number = 60): void {
const now = new Date();
for (const [id, conv] of conversations.entries()) {
const ageMinutes = (now.getTime() - conv.lastActivity.getTime()) / (1000 * 60);
if (ageMinutes > maxAgeMinutes) {
conversations.delete(id);
}
}
}
/**
* Get conversation statistics
*/
export function getConversationStats(): {
total: number;
conversations: Array<{ id: string; messageCount: number; lastActivity: Date }>;
} {
return {
total: conversations.size,
conversations: Array.from(conversations.values()).map((conv) => ({
id: conv.id,
messageCount: conv.messages.length,
lastActivity: conv.lastActivity,
})),
};
}
@@ -0,0 +1,100 @@
import OpenAI from 'openai';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { v4 as uuidv4 } from 'uuid';
export interface STTConfig {
apiKey: string;
model?: 'whisper-1';
}
export interface TranscriptionResult {
text: string;
language?: string;
duration?: number;
}
let openaiClient: OpenAI | null = null;
let config: STTConfig | null = null;
export function initializeSTT(sttConfig: STTConfig): void {
config = sttConfig;
openaiClient = new OpenAI({
apiKey: sttConfig.apiKey,
});
console.log('✓ STT (OpenAI Whisper) initialized');
}
export async function transcribeAudio(
audioBuffer: Buffer,
format: string
): Promise<TranscriptionResult> {
if (!openaiClient || !config) {
throw new Error('STT not initialized. Call initializeSTT() first.');
}
const startTime = Date.now();
let tempFilePath: string | null = null;
try {
// Map format to file extension
const ext = getFileExtension(format);
// Write audio buffer to temporary file
// OpenAI API requires file upload, not raw buffer
tempFilePath = join(tmpdir(), `audio-${uuidv4()}.${ext}`);
await writeFile(tempFilePath, audioBuffer);
console.log(`[STT] Transcribing audio file: ${tempFilePath} (${audioBuffer.length} bytes)`);
// Call OpenAI Whisper API
const response = await openaiClient.audio.transcriptions.create({
file: await import('fs').then((fs) => fs.createReadStream(tempFilePath!)),
model: config.model || 'whisper-1',
response_format: 'verbose_json', // Get language and duration info
});
const duration = Date.now() - startTime;
console.log(`[STT] Transcription complete in ${duration}ms: "${response.text}"`);
return {
text: response.text,
language: response.language,
duration: duration,
};
} catch (error: any) {
console.error('[STT] Transcription error:', error);
throw new Error(`STT transcription failed: ${error.message}`);
} finally {
// Clean up temporary file
if (tempFilePath) {
try {
await unlink(tempFilePath);
} catch (cleanupError) {
console.warn(`[STT] Failed to clean up temp file: ${tempFilePath}`);
}
}
}
}
function getFileExtension(format: string): string {
// Map mime types or format strings to file extensions
const formatLower = format.toLowerCase();
if (formatLower.includes('webm')) return 'webm';
if (formatLower.includes('ogg')) return 'ogg';
if (formatLower.includes('mp3')) return 'mp3';
if (formatLower.includes('wav')) return 'wav';
if (formatLower.includes('m4a')) return 'm4a';
if (formatLower.includes('mp4')) return 'mp4';
if (formatLower.includes('flac')) return 'flac';
// Default to webm (common browser format)
return 'webm';
}
export function isSTTInitialized(): boolean {
return openaiClient !== null && config !== null;
}
@@ -0,0 +1,22 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load system prompt from agent-prompt.md in package root
export function loadSystemPrompt(): string {
const promptPath = path.join(__dirname, '../../../agent-prompt.md');
return readFileSync(promptPath, 'utf-8');
}
// Cache the loaded prompt
let cachedPrompt: string | null = null;
export function getSystemPrompt(): string {
if (!cachedPrompt) {
cachedPrompt = loadSystemPrompt();
}
return cachedPrompt;
}
@@ -0,0 +1,94 @@
import OpenAI from 'openai';
import { Readable } from 'stream';
export interface TTSConfig {
apiKey: string;
model?: 'tts-1' | 'tts-1-hd';
voice?: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
responseFormat?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
}
export interface SpeechResult {
audio: Buffer;
format: string;
}
let openaiClient: OpenAI | null = null;
let config: TTSConfig | null = null;
export function initializeTTS(ttsConfig: TTSConfig): void {
config = {
model: 'tts-1',
voice: 'alloy',
responseFormat: 'mp3',
...ttsConfig,
};
openaiClient = new OpenAI({
apiKey: ttsConfig.apiKey,
});
console.log(`✓ TTS (OpenAI) initialized with voice: ${config.voice}, model: ${config.model}`);
}
export async function synthesizeSpeech(text: string): Promise<SpeechResult> {
if (!openaiClient || !config) {
throw new Error('TTS not initialized. Call initializeTTS() first.');
}
if (!text || text.trim().length === 0) {
throw new Error('Cannot synthesize empty text');
}
const startTime = Date.now();
try {
console.log(`[TTS] Synthesizing speech: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
// Call OpenAI TTS API with streaming
const response = await openaiClient.audio.speech.create({
model: config.model!,
voice: config.voice!,
input: text,
response_format: config.responseFormat as 'mp3' | 'opus' | 'aac' | 'flac',
});
// Response is a stream, convert to buffer
const audioBuffer = await streamToBuffer(response.body as unknown as Readable);
const duration = Date.now() - startTime;
console.log(`[TTS] Speech synthesis complete in ${duration}ms (${audioBuffer.length} bytes)`);
return {
audio: audioBuffer,
format: config.responseFormat || 'mp3',
};
} catch (error: any) {
console.error('[TTS] Speech synthesis error:', error);
throw new Error(`TTS synthesis failed: ${error.message}`);
}
}
async function streamToBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => {
chunks.push(Buffer.from(chunk));
});
stream.on('end', () => {
resolve(Buffer.concat(chunks));
});
stream.on('error', (error) => {
reject(error);
});
});
}
export function isTTSInitialized(): boolean {
return openaiClient !== null && config !== null;
}
export function getTTSConfig(): TTSConfig | null {
return config;
}