osborn 0.1.1 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,297 +1,449 @@
1
- import { ServerOptions, cli, defineAgent, llm, voice } from '@livekit/agents';
1
+ import { llm, voice, initializeLogger } from '@livekit/agents';
2
2
  import * as openai from '@livekit/agents-plugin-openai';
3
3
  import * as google from '@livekit/agents-plugin-google';
4
+ import { Room, RoomEvent } from '@livekit/rtc-node';
5
+ import { AccessToken } from 'livekit-server-sdk';
4
6
  import { z } from 'zod';
5
- import { fileURLToPath } from 'url';
6
7
  import 'dotenv/config';
8
+ // Initialize logger before anything else
9
+ initializeLogger({ pretty: true, level: 'info' });
7
10
  import { ClaudeHandler } from './claude-handler.js';
8
11
  import { CodexHandler } from './codex-handler.js';
9
- // Global error handlers to catch silent failures
10
- process.on('unhandledRejection', (reason, promise) => {
12
+ import { loadConfig, getMcpServers, getEnabledMcpServerNames } from './config.js';
13
+ // Generate a short, user-friendly room code
14
+ function generateRoomCode() {
15
+ const chars = 'abcdefghjkmnpqrstuvwxyz23456789';
16
+ let code = '';
17
+ for (let i = 0; i < 6; i++) {
18
+ code += chars[Math.floor(Math.random() * chars.length)];
19
+ }
20
+ return code;
21
+ }
22
+ // Parse CLI arguments
23
+ function parseArgs() {
24
+ const args = process.argv.slice(2);
25
+ let roomCode;
26
+ let provider;
27
+ for (let i = 0; i < args.length; i++) {
28
+ if (args[i] === '--room' && args[i + 1]) {
29
+ roomCode = args[i + 1];
30
+ }
31
+ if (args[i] === '--provider' && args[i + 1]) {
32
+ provider = args[i + 1];
33
+ }
34
+ // Short code detection (e.g., `npm run dev abc123`)
35
+ if (!args[i].startsWith('-') && args[i].length >= 4 && args[i].length <= 10 &&
36
+ !['dev', 'start'].includes(args[i])) {
37
+ roomCode = args[i];
38
+ }
39
+ }
40
+ return { roomCode, provider };
41
+ }
42
+ // Global error handlers
43
+ process.on('unhandledRejection', (reason) => {
11
44
  console.error('❌ Unhandled Rejection:', reason);
12
45
  });
13
46
  process.on('uncaughtException', (error) => {
14
47
  console.error('❌ Uncaught Exception:', error);
15
48
  });
16
- // Default provider (can be overridden by participant metadata)
17
- const DEFAULT_PROVIDER = process.env.LLM_PROVIDER || 'openai';
18
- // Debug mode
19
- const DEBUG = process.env.DEBUG_LIVEKIT === 'true';
20
- if (DEBUG) {
21
- console.log('🐛 Debug logging enabled');
22
- }
23
- console.log(`🤖 Default LLM Provider: ${DEFAULT_PROVIDER}`);
24
- // Example MCP server configurations (uncomment to enable)
25
- const MCP_SERVERS = {
26
- // GitHub integration
27
- // 'github': {
28
- // command: 'npx',
29
- // args: ['@modelcontextprotocol/server-github'],
30
- // env: { GITHUB_TOKEN: process.env.GITHUB_TOKEN || '' }
31
- // },
32
- // Filesystem with specific allowed paths
33
- // 'filesystem': {
34
- // command: 'npx',
35
- // args: ['@modelcontextprotocol/server-filesystem'],
36
- // env: { ALLOWED_PATHS: '/Users/newupgrade/Desktop/Developer' }
37
- // },
38
- };
39
- // Pre-initialize Claude handler at module load (before any connections)
40
- console.log('🔥 Pre-initializing Claude Code...');
41
- const claude = new ClaudeHandler({
42
- workingDirectory: '/Users/newupgrade/Desktop/Developer/osborn',
43
- permissionMode: 'default', // Ask for permission on dangerous tools (Bash, Write, Edit)
44
- // Uncomment to enable MCP servers:
45
- // mcpServers: MCP_SERVERS,
46
- });
47
- // Listen for permission requests from Claude
48
- claude.on('permission_request', (req) => {
49
- console.log(`\n⚠️ PERMISSION REQUIRED ⚠️`);
50
- console.log(`🔧 Tool: ${req.toolName}`);
51
- console.log(`📝 Action: ${req.description}`);
52
- console.log(`⏳ Waiting for user response (say: allow, deny, or always allow)...`);
53
- // Send to frontend for UI display
54
- sendToFrontend({
55
- type: 'permission_request',
56
- toolName: req.toolName,
57
- description: req.description,
58
- });
59
- });
60
- // Pre-warm Claude immediately on server start
61
- claude.run('Respond with just: ready')
62
- .then(() => console.log('✅ Claude pre-warmed and ready!'))
63
- .catch((err) => console.log('⚠️ Pre-warm failed:', err.message));
64
- // Track job context and session for data channel
65
- let jobContext = null;
66
- let currentSession = null;
67
- // Track the current coding handler (can be Claude or Codex)
68
- let currentCodingAgent = 'claude';
69
- let codexHandler = null;
70
- // Helper to send data to frontend
71
- async function sendToFrontend(data) {
72
- if (!jobContext)
73
- return;
74
- try {
75
- const encoder = new TextEncoder();
76
- const payload = encoder.encode(JSON.stringify(data));
77
- await jobContext.room.localParticipant?.publishData(payload, {
78
- reliable: true,
79
- topic: 'osborn-updates',
80
- });
49
+ // Main function
50
+ async function main() {
51
+ console.log('\n🤖 Osborn Voice AI Coding Assistant\n');
52
+ // Validate environment
53
+ const livekitUrl = process.env.LIVEKIT_URL;
54
+ const apiKey = process.env.LIVEKIT_API_KEY;
55
+ const apiSecret = process.env.LIVEKIT_API_SECRET;
56
+ if (!livekitUrl || !apiKey || !apiSecret) {
57
+ console.error('❌ Missing required environment variables:');
58
+ if (!livekitUrl)
59
+ console.error(' - LIVEKIT_URL');
60
+ if (!apiKey)
61
+ console.error(' - LIVEKIT_API_KEY');
62
+ if (!apiSecret)
63
+ console.error(' - LIVEKIT_API_SECRET');
64
+ console.error('\nSet these in your .env file or environment.');
65
+ process.exit(1);
81
66
  }
82
- catch (err) {
83
- // Ignore send errors
67
+ // Parse CLI args
68
+ const cliArgs = parseArgs();
69
+ // Load configuration
70
+ console.log('📁 Loading configuration...');
71
+ const config = loadConfig();
72
+ const mcpServers = getMcpServers(config);
73
+ const enabledMcpNames = getEnabledMcpServerNames(config);
74
+ if (enabledMcpNames.length > 0) {
75
+ console.log(`🔌 Enabled MCP servers: ${enabledMcpNames.join(', ')}`);
84
76
  }
85
- }
86
- // Define the run_code tool (works with both Claude and Codex)
87
- const runCodeTool = llm.tool({
88
- description: `Execute coding tasks using the coding agent. Use for:
89
- - Files: read, write, create, edit, list, search
90
- - Directories: current directory, list contents
91
- - Code: fix bugs, refactor, explain, review
92
- - Terminal: run commands, install packages, git
93
- - Project: analyze codebase, make changes
94
- - Web: search the web for information`,
95
- parameters: z.object({
96
- task: z.string().describe('The coding task to execute'),
97
- }),
98
- execute: async ({ task }) => {
99
- const agentName = currentCodingAgent === 'claude' ? 'Claude Code' : 'OpenAI Codex';
100
- console.log(`\n🔨 ${agentName}: "${task}"`);
101
- await sendToFrontend({ type: 'system', text: `Working on: ${task}` });
102
- try {
103
- let result;
104
- if (currentCodingAgent === 'codex' && codexHandler) {
105
- result = await codexHandler.run(task);
106
- }
107
- else {
108
- result = await claude.run(task);
77
+ const workingDir = config.workingDirectory || process.cwd();
78
+ console.log(`📂 Working directory: ${workingDir}`);
79
+ // Determine room code
80
+ const roomCode = cliArgs.roomCode || generateRoomCode();
81
+ const roomName = `osborn-${roomCode}`;
82
+ if (cliArgs.roomCode) {
83
+ console.log(`🔗 Joining room: ${roomCode}`);
84
+ }
85
+ else {
86
+ console.log(`\n✨ Created new room: ${roomCode}`);
87
+ console.log(`\n📋 Share this with the frontend or run:`);
88
+ console.log(` Open: https://osborn.app?room=${roomCode}`);
89
+ console.log(` Or enter code "${roomCode}" in the frontend\n`);
90
+ }
91
+ // Default provider
92
+ const defaultProvider = cliArgs.provider || process.env.LLM_PROVIDER || 'openai';
93
+ console.log(`🎯 Default voice provider: ${defaultProvider}`);
94
+ // ============================================================
95
+ // Initialize Claude Agents (Dual Architecture)
96
+ // ============================================================
97
+ console.log('\n🔥 Initializing Claude agents...');
98
+ // Plan Agent - Read-only, research
99
+ const planAgent = {
100
+ id: 1,
101
+ role: 'plan',
102
+ handler: new ClaudeHandler({
103
+ workingDirectory: workingDir,
104
+ permissionMode: 'plan',
105
+ agentRole: 'plan',
106
+ mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
107
+ }),
108
+ busy: false,
109
+ currentTask: null,
110
+ context: [],
111
+ };
112
+ // Execute Agent - Full access
113
+ const executeAgent = {
114
+ id: 2,
115
+ role: 'execute',
116
+ handler: new ClaudeHandler({
117
+ workingDirectory: workingDir,
118
+ permissionMode: 'default',
119
+ agentRole: 'execute',
120
+ mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
121
+ }),
122
+ busy: false,
123
+ currentTask: null,
124
+ context: [],
125
+ };
126
+ const agentPool = [planAgent, executeAgent];
127
+ // Smart routing
128
+ function routeTask(task) {
129
+ const taskLower = task.toLowerCase();
130
+ const executeKeywords = [
131
+ 'create', 'make', 'build', 'implement', 'add', 'write',
132
+ 'fix', 'update', 'change', 'modify', 'edit', 'refactor',
133
+ 'delete', 'remove', 'run', 'execute', 'install', 'deploy',
134
+ 'commit', 'push', 'test', 'debug', 'start', 'stop',
135
+ ];
136
+ for (const keyword of executeKeywords) {
137
+ if (taskLower.includes(keyword)) {
138
+ if (executeAgent.busy && !planAgent.busy) {
139
+ return planAgent;
140
+ }
141
+ return executeAgent;
109
142
  }
110
- console.log(`✅ Done: ${result.length} chars`);
111
- await sendToFrontend({ type: 'assistant_response', text: result });
112
- return result;
143
+ }
144
+ return planAgent.busy ? executeAgent : planAgent;
145
+ }
146
+ // ============================================================
147
+ // Create Access Token for Agent
148
+ // ============================================================
149
+ console.log('🔑 Creating access token...');
150
+ const token = new AccessToken(apiKey, apiSecret, {
151
+ identity: 'osborn-agent',
152
+ name: 'Osborn AI',
153
+ metadata: JSON.stringify({ type: 'agent', version: '0.1.5' }),
154
+ });
155
+ token.addGrant({
156
+ roomJoin: true,
157
+ room: roomName,
158
+ canPublish: true,
159
+ canSubscribe: true,
160
+ canPublishData: true,
161
+ });
162
+ const jwt = await token.toJwt();
163
+ // ============================================================
164
+ // Connect to Room Directly
165
+ // ============================================================
166
+ console.log('📡 Connecting to LiveKit...');
167
+ const room = new Room();
168
+ // Track state
169
+ let currentSession = null;
170
+ let currentProvider = defaultProvider;
171
+ let currentCodingAgent = 'claude';
172
+ let codexHandler = null;
173
+ let localParticipant = null;
174
+ let agentState = 'initializing';
175
+ // Speech queue
176
+ const speechQueue = [];
177
+ let isSpeaking = false;
178
+ // Helper to send data to frontend
179
+ async function sendToFrontend(data) {
180
+ if (!localParticipant) {
181
+ console.log('⚠️ sendToFrontend: no localParticipant!');
182
+ return;
183
+ }
184
+ try {
185
+ const encoder = new TextEncoder();
186
+ const payload = encoder.encode(JSON.stringify(data));
187
+ await localParticipant.publishData(payload, {
188
+ reliable: true,
189
+ topic: 'osborn-updates',
190
+ });
191
+ console.log(`📤 Sent to frontend: ${data.type}`);
113
192
  }
114
193
  catch (err) {
115
- console.error('❌ Error:', err);
116
- return `Error: ${err.message}`;
194
+ console.error('❌ sendToFrontend error:', err);
117
195
  }
118
- },
119
- });
120
- // Define the permission response tool
121
- const respondPermissionTool = llm.tool({
122
- description: `Respond to a pending permission request from Claude Code.
123
- Use this ONLY when there is a pending permission request.
124
- Call this after hearing the user's response to a permission prompt.`,
125
- parameters: z.object({
126
- response: z.enum(['allow', 'deny', 'always_allow']).describe('The user response: "allow" for one-time approval, "deny" to reject, "always_allow" to permanently allow this tool type'),
127
- }),
128
- execute: async ({ response }) => {
129
- if (!claude.hasPendingPermission()) {
130
- return 'No pending permission request.';
196
+ }
197
+ // Process speech queue
198
+ async function processSpeechQueue() {
199
+ if (isSpeaking || speechQueue.length === 0 || !currentSession)
200
+ return;
201
+ if (agentState !== 'listening')
202
+ return;
203
+ if (currentProvider === 'gemini') {
204
+ // Gemini doesn't support generateReply
205
+ while (speechQueue.length > 0) {
206
+ console.log(`🔊 [Would say] ${speechQueue.shift()}`);
207
+ }
208
+ return;
209
+ }
210
+ isSpeaking = true;
211
+ const message = speechQueue.shift();
212
+ try {
213
+ await Promise.race([
214
+ currentSession.generateReply({ userInput: message }),
215
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
216
+ ]);
217
+ }
218
+ catch {
219
+ // Ignore speech errors
131
220
  }
132
- const pending = claude.getPendingPermission();
133
- claude.respondToPermission(response);
134
- await sendToFrontend({
135
- type: 'permission_response',
136
- response,
137
- toolName: pending?.toolName
221
+ finally {
222
+ isSpeaking = false;
223
+ if (speechQueue.length > 0) {
224
+ setTimeout(processSpeechQueue, 500);
225
+ }
226
+ }
227
+ }
228
+ // Setup agent event handlers
229
+ agentPool.forEach(slot => {
230
+ slot.handler.on('permission_request', (req) => {
231
+ console.log(`\n⚠️ [${slot.role}] PERMISSION: ${req.toolName}`);
232
+ sendToFrontend({
233
+ type: 'permission_request',
234
+ toolName: req.toolName,
235
+ description: req.description,
236
+ agentId: slot.id,
237
+ });
238
+ speechQueue.push(`[Tell user] I need permission to ${req.description}. Say yes, no, or always allow.`);
239
+ processSpeechQueue();
138
240
  });
139
- return `Permission ${response} for ${pending?.toolName || 'tool'}.`;
140
- },
141
- });
142
- // Agent instructions - dynamically includes available tools
143
- const OSBORN_INSTRUCTIONS = `You are Osborn, a voice-enabled AI assistant with coding superpowers.
144
- Keep responses under 50 words. Sound natural and human.
241
+ slot.handler.on('tool_use', (tool) => {
242
+ console.log(`🔧 [${slot.role}] Using: ${tool.name}`);
243
+ });
244
+ slot.handler.on('error', (err) => {
245
+ console.error(`❌ [${slot.role}] Error:`, err);
246
+ });
247
+ });
248
+ // Define tools for voice LLM
249
+ const runCodeTool = llm.tool({
250
+ description: `Execute ANY coding task by delegating to Claude agents. YOU MUST USE THIS for:
251
+ - Reading files ("read package.json", "show me the code")
252
+ - Writing/editing files ("fix this bug", "add a function")
253
+ - Running commands ("run npm test", "git status")
254
+ - Searching code ("find where X is defined")
255
+ - Explaining code ("what does this function do")
145
256
 
146
- AVAILABLE CAPABILITIES via run_code tool:
147
- - Read, Write, Edit, MultiEdit files
148
- - Glob (find files by pattern), Grep (search content)
149
- - Bash (run terminal commands)
150
- - WebSearch (search the web), WebFetch (fetch URLs)
151
- - NotebookEdit (edit Jupyter notebooks)
152
- - Task (delegate complex tasks), TodoWrite (track tasks)
153
- - LSP (code intelligence - go to definition, find references)
257
+ You DON'T need permission to use this - it routes to the right agent automatically.
258
+ Plan Agent = reading/research. Execute Agent = writing (will ask user for permission).`,
259
+ parameters: z.object({
260
+ task: z.string().describe('The coding task to execute'),
261
+ }),
262
+ execute: async ({ task }) => {
263
+ const slot = routeTask(task);
264
+ console.log(`\n🔨 [${slot.role}] Task: "${task}"`);
265
+ await sendToFrontend({ type: 'system', text: `${slot.role} agent: ${task}` });
266
+ slot.busy = true;
267
+ slot.currentTask = task;
268
+ sharedContext.currentFocus = task.substring(0, 50);
269
+ try {
270
+ let result;
271
+ if (currentCodingAgent === 'codex' && codexHandler) {
272
+ result = await codexHandler.run(task);
273
+ }
274
+ else {
275
+ const contextPrefix = slot.context.length > 0
276
+ ? `Context: ${slot.context.slice(-3).join(' | ')}\n\nTask: `
277
+ : '';
278
+ result = await slot.handler.run(contextPrefix + task);
279
+ }
280
+ slot.context.push(`${task.substring(0, 50)} → Done`);
281
+ if (slot.context.length > 10)
282
+ slot.context.shift();
283
+ // Update shared context
284
+ sharedContext.addAction(`${slot.role}: ${task.substring(0, 30)}`);
285
+ // Extract file references from result
286
+ const fileMatches = result.match(/(?:\/[\w\-\.\/]+|src\/[\w\-\.\/]+|\.\/[\w\-\.\/]+)/g);
287
+ if (fileMatches) {
288
+ fileMatches.slice(0, 3).forEach(f => sharedContext.addFile(f));
289
+ }
290
+ console.log(`✅ [${slot.role}] Done`);
291
+ await sendToFrontend({ type: 'assistant_response', text: result });
292
+ // Return a concise summary for the voice LLM
293
+ const summary = result.length > 500
294
+ ? result.substring(0, 500) + '... [truncated for voice]'
295
+ : result;
296
+ return summary;
297
+ }
298
+ catch (err) {
299
+ return `Error: ${err.message}`;
300
+ }
301
+ finally {
302
+ slot.busy = false;
303
+ slot.currentTask = null;
304
+ }
305
+ },
306
+ });
307
+ const respondPermissionTool = llm.tool({
308
+ description: `Respond to a permission request. Call after hearing user's response.`,
309
+ parameters: z.object({
310
+ response: z.enum(['allow', 'deny', 'always_allow']),
311
+ }),
312
+ execute: async ({ response }) => {
313
+ const slot = agentPool.find(s => s.handler.hasPendingPermission());
314
+ if (!slot)
315
+ return 'No pending permission.';
316
+ const pending = slot.handler.getPendingPermission();
317
+ slot.handler.respondToPermission(response);
318
+ await sendToFrontend({ type: 'permission_response', response, toolName: pending?.toolName });
319
+ return `Permission ${response} for ${pending?.toolName || 'tool'}.`;
320
+ },
321
+ });
322
+ // Shared context that both voice and coding agents contribute to
323
+ const sharedContext = {
324
+ recentActions: [],
325
+ discoveredFiles: [],
326
+ currentFocus: null,
327
+ addAction(action) {
328
+ this.recentActions.push(action);
329
+ if (this.recentActions.length > 5)
330
+ this.recentActions.shift();
331
+ },
332
+ addFile(file) {
333
+ if (!this.discoveredFiles.includes(file)) {
334
+ this.discoveredFiles.push(file);
335
+ if (this.discoveredFiles.length > 10)
336
+ this.discoveredFiles.shift();
337
+ }
338
+ },
339
+ getContextSummary() {
340
+ const parts = [];
341
+ if (this.currentFocus)
342
+ parts.push(`Focus: ${this.currentFocus}`);
343
+ if (this.recentActions.length)
344
+ parts.push(`Recent: ${this.recentActions.slice(-3).join(', ')}`);
345
+ if (this.discoveredFiles.length)
346
+ parts.push(`Files: ${this.discoveredFiles.slice(-5).join(', ')}`);
347
+ return parts.join(' | ');
348
+ }
349
+ };
350
+ // Dynamic instructions with working directory context
351
+ const getInstructions = () => `You are Osborn, a voice AI coding assistant.
154
352
 
155
- WHEN TO USE run_code:
156
- - File operations (read, write, create, edit, list, find)
157
- - Code tasks (fix, refactor, explain, review, debug)
158
- - Terminal commands (run, install, test, build, git)
159
- - Web searches (look up documentation, APIs, errors)
160
- - Project analysis (understand codebase, find patterns)
353
+ WORKING DIRECTORY: ${workingDir}
161
354
 
162
- WHEN TO RESPOND DIRECTLY:
163
- - Greetings and small talk
164
- - General knowledge questions
165
- - Clarifying what the user wants
355
+ STYLE: Keep responses SHORT (under 70 words). Sound natural. Say "Got it" when given a task.
166
356
 
167
- PERMISSION HANDLING:
168
- When the coding agent needs permission, you MUST:
169
- 1. Tell the user: "[Agent] wants to [action]. Allow, deny, or always allow?"
170
- 2. When they respond, call respond_permission with their choice
357
+ CAPABILITIES (via run_code tool):
358
+ - Read/write/edit files, search codebase
359
+ - Run terminal commands (npm, git, etc)
360
+ - Fix bugs, refactor, explain code
361
+ - Search web/docs for solutions
171
362
 
172
- Be conversational and helpful. Ask follow-up questions when needed.`;
173
- // Voice assistant with tools
174
- class OsbornAssistant extends voice.Agent {
175
- constructor() {
176
- super({
177
- instructions: OSBORN_INSTRUCTIONS,
178
- tools: {
179
- run_code: runCodeTool,
180
- respond_permission: respondPermissionTool,
181
- },
182
- });
183
- }
184
- }
185
- // Create the appropriate model based on provider
186
- function createModel(provider) {
187
- if (provider === 'gemini') {
188
- console.log('📱 Using Gemini Live API');
189
- console.log('🔑 GOOGLE_API_KEY:', process.env.GOOGLE_API_KEY ? 'set' : 'NOT SET');
190
- // From official docs: https://docs.livekit.io/agents/models/realtime/plugins/gemini/
191
- // Package v1.0.31 uses google.beta.realtime (not google.realtime yet)
192
- const model = new google.beta.realtime.RealtimeModel({
193
- model: 'gemini-2.5-flash-native-audio-preview-12-2025', // From official docs
194
- voice: 'Puck',
195
- instructions: OSBORN_INSTRUCTIONS,
196
- });
197
- console.log('✅ Gemini model created with gemini-2.5-flash-native-audio-preview-12-2025');
198
- return model;
199
- }
200
- else {
201
- console.log('📱 Using OpenAI Realtime API');
202
- console.log('🔑 OPENAI_API_KEY:', process.env.OPENAI_API_KEY ? 'set' : 'NOT SET');
203
- const model = new openai.realtime.RealtimeModel({
204
- voice: 'alloy',
205
- });
206
- console.log('✅ OpenAI model created');
207
- return model;
208
- }
209
- }
210
- // Helper to get provider from participant metadata
211
- function getProviderFromParticipant(metadata) {
212
- if (!metadata)
213
- return DEFAULT_PROVIDER;
214
- try {
215
- const data = JSON.parse(metadata);
216
- return data.provider || DEFAULT_PROVIDER;
217
- }
218
- catch {
219
- return DEFAULT_PROVIDER;
220
- }
221
- }
222
- // Helper to get coding agent from participant metadata
223
- function getCodingAgentFromParticipant(metadata) {
224
- if (!metadata)
225
- return 'claude';
226
- try {
227
- const data = JSON.parse(metadata);
228
- return data.codingAgent || 'claude';
363
+ TWO AGENTS AVAILABLE:
364
+ - Plan Agent: Research, explore, read files (fast, no permissions needed)
365
+ - Execute Agent: Write code, make changes (asks permission for writes)
366
+
367
+ ${sharedContext.getContextSummary() ? `CONTEXT: ${sharedContext.getContextSummary()}` : ''}
368
+
369
+ PERMISSIONS: When you hear permission request, tell user what needs permission and ask "allow, deny, or always allow?" Then call respond_permission.`;
370
+ const INSTRUCTIONS = getInstructions();
371
+ // Voice agent class
372
+ class OsbornVoiceAgent extends voice.Agent {
373
+ constructor() {
374
+ super({
375
+ instructions: INSTRUCTIONS,
376
+ tools: {
377
+ run_code: runCodeTool,
378
+ respond_permission: respondPermissionTool,
379
+ },
380
+ });
381
+ }
229
382
  }
230
- catch {
231
- return 'claude';
383
+ // Create voice model
384
+ function createModel(provider) {
385
+ if (provider === 'gemini') {
386
+ console.log('📱 Using Gemini Live API');
387
+ return new google.beta.realtime.RealtimeModel({
388
+ model: 'gemini-2.5-flash-native-audio-preview-12-2025',
389
+ voice: 'Puck',
390
+ instructions: INSTRUCTIONS,
391
+ });
392
+ }
393
+ else {
394
+ console.log('📱 Using OpenAI Realtime API');
395
+ return new openai.realtime.RealtimeModel({
396
+ voice: 'alloy',
397
+ });
398
+ }
232
399
  }
233
- }
234
- export default defineAgent({
235
- entry: async (ctx) => {
236
- console.log('🚀 Agent starting for room:', ctx.room.name);
237
- jobContext = ctx;
238
- // Claude verbose logging
239
- claude.on('tool_use', (tool) => {
240
- console.log(`\n🔧 Claude Tool Started: ${tool.name}`);
241
- if (tool.input) {
242
- const inputStr = JSON.stringify(tool.input).substring(0, 200);
243
- console.log(` Input: ${inputStr}${inputStr.length >= 200 ? '...' : ''}`);
244
- }
245
- });
246
- claude.on('tool_result', (result) => {
247
- console.log(`✅ Claude Tool Completed: ${result.name || 'unknown'}`);
248
- });
249
- claude.on('text', (text) => {
250
- if (text.length > 0) {
251
- console.log(`💬 Claude says: ${text.substring(0, 100)}${text.length > 100 ? '...' : ''}`);
400
+ // ============================================================
401
+ // Room Event Handlers
402
+ // ============================================================
403
+ room.on(RoomEvent.Connected, () => {
404
+ console.log('✅ Connected to room:', roomName);
405
+ localParticipant = room.localParticipant;
406
+ });
407
+ room.on(RoomEvent.Disconnected, () => {
408
+ console.log('👋 Disconnected from room');
409
+ currentSession = null;
410
+ });
411
+ room.on(RoomEvent.ParticipantConnected, async (participant) => {
412
+ console.log(`\n👤 User joined: ${participant.identity}`);
413
+ // Get provider from participant metadata
414
+ let provider = defaultProvider;
415
+ let codingAgent = 'claude';
416
+ if (participant.metadata) {
417
+ try {
418
+ const meta = JSON.parse(participant.metadata);
419
+ provider = meta.provider || defaultProvider;
420
+ codingAgent = meta.codingAgent || 'claude';
252
421
  }
253
- });
254
- claude.on('error', (err) => {
255
- console.error(`❌ Claude Error:`, err);
256
- });
257
- // Connect FIRST so we can wait for participants
258
- console.log('📡 Connecting to room...');
259
- await ctx.connect();
260
- console.log('✅ Connected to room');
261
- // Wait for a participant to join using LiveKit's built-in method
262
- console.log('⏳ Waiting for participant...');
263
- const participant = await ctx.waitForParticipant();
264
- console.log('👤 Participant joined:', participant.identity);
265
- console.log('📋 Participant metadata:', participant.metadata);
266
- const provider = getProviderFromParticipant(participant.metadata);
267
- const codingAgent = getCodingAgentFromParticipant(participant.metadata);
268
- console.log(`🎯 User selected provider: ${provider}`);
269
- console.log(`🔧 User selected coding agent: ${codingAgent}`);
270
- // Set the current coding agent and initialize if needed
422
+ catch { }
423
+ }
424
+ currentProvider = provider;
271
425
  currentCodingAgent = codingAgent;
426
+ console.log(`🎯 Provider: ${provider}, Agent: ${codingAgent}`);
272
427
  if (codingAgent === 'codex') {
273
- console.log('🔧 Initializing Codex handler...');
274
- codexHandler = new CodexHandler({
275
- workingDirectory: '/Users/newupgrade/Desktop/Developer/osborn',
276
- });
277
- console.log('✅ Codex handler ready');
428
+ codexHandler = new CodexHandler({ workingDirectory: workingDir });
278
429
  }
279
- // Create model based on user's choice
430
+ // Create voice session
280
431
  const model = createModel(provider);
281
- const session = new voice.AgentSession({
282
- llm: model,
283
- });
432
+ const session = new voice.AgentSession({ llm: model });
284
433
  currentSession = session;
285
- // Add session event listeners for debugging
286
- // Using string literals as AgentSessionEventTypes is not directly exported
287
- session.on('user_state_changed', (ev) => {
288
- console.log(`👤 User state: ${ev.oldState} → ${ev.newState}`);
289
- });
434
+ // Session events
290
435
  session.on('agent_state_changed', (ev) => {
291
- console.log(`🤖 Agent state: ${ev.oldState} → ${ev.newState}`);
436
+ agentState = ev.newState;
437
+ console.log(`🤖 State: ${ev.newState}`);
438
+ if (ev.newState === 'listening' && speechQueue.length > 0) {
439
+ processSpeechQueue();
440
+ }
292
441
  });
293
442
  session.on('user_input_transcribed', (ev) => {
294
- console.log(`📝 Transcribed: "${ev.transcript}" (final: ${ev.isFinal})`);
443
+ console.log(`📝 User: "${ev.transcript}"`);
444
+ });
445
+ session.on('user_state_changed', (ev) => {
446
+ console.log(`👤 User state: ${ev.oldState} → ${ev.newState}`);
295
447
  });
296
448
  session.on('error', (ev) => {
297
449
  console.error('❌ Session error:', ev.error);
@@ -299,58 +451,108 @@ export default defineAgent({
299
451
  session.on('close', (ev) => {
300
452
  console.log('🚪 Session closed:', ev.reason);
301
453
  });
302
- ctx.room.on('trackSubscribed', (track, publication, p) => {
303
- console.log(`📥 Track subscribed: ${track.kind} from ${p.identity}`);
304
- });
305
- // Listen for data channel messages from frontend
306
- ctx.room.on('dataReceived', async (payload, participant, kind, topic) => {
307
- if (topic === 'user-input') {
454
+ // Start voice session
455
+ console.log('🎬 Starting voice session...');
456
+ const agent = new OsbornVoiceAgent();
457
+ try {
458
+ await session.start({
459
+ agent,
460
+ room,
461
+ });
462
+ console.log('✅ Voice session started!');
463
+ console.log('🎤 Ready - speak to begin!\n');
464
+ // Send ready signal with persistent retry (frontend might not be subscribed yet)
465
+ console.log('💓 Sending agent_ready signal...');
466
+ let readySent = false;
467
+ const sendReady = async () => {
468
+ if (readySent)
469
+ return;
470
+ await sendToFrontend({ type: 'agent_ready', provider, codingAgent });
471
+ };
472
+ // Keep sending every 2 seconds for 20 seconds total
473
+ const readyInterval = setInterval(sendReady, 2000);
474
+ await sendReady();
475
+ setTimeout(() => {
476
+ clearInterval(readyInterval);
477
+ console.log('✅ agent_ready retries complete');
478
+ }, 20000);
479
+ // Mark as sent when user first speaks (no need to keep sending)
480
+ session.on('input_speech_started', () => {
481
+ readySent = true;
482
+ clearInterval(readyInterval);
483
+ });
484
+ console.log('✅ agent_ready sent (with retries scheduled)');
485
+ // Greet user (OpenAI only)
486
+ if (provider !== 'gemini') {
308
487
  try {
309
- const data = JSON.parse(new TextDecoder().decode(payload));
310
- console.log(`📨 Received from frontend:`, data);
311
- if (data.type === 'permission_response') {
312
- // Handle permission response from UI
313
- if (claude.hasPendingPermission()) {
314
- claude.respondToPermission(data.response);
315
- console.log(`✅ Permission ${data.response} from UI`);
316
- }
317
- }
318
- else if (data.type === 'user_text') {
319
- // Handle text input from frontend
320
- console.log(`📝 Text input: "${data.content}"`);
321
- // Inject text into the session as user input
322
- if (currentSession) {
323
- try {
324
- // Interrupt any current speech first
325
- currentSession.interrupt();
326
- // Generate a reply to the text input
327
- await currentSession.generateReply({
328
- userInput: data.content,
329
- });
330
- console.log(`✅ Injected text to session`);
331
- }
332
- catch (err) {
333
- console.error(`❌ Failed to inject text:`, err);
334
- }
335
- }
336
- }
488
+ await session.generateReply({
489
+ userInput: '[Greet the user: "Hey, I\'m Osborn. What are you working on?"]'
490
+ });
337
491
  }
338
- catch (e) {
339
- // Not JSON, ignore
492
+ catch {
493
+ console.log('⚠️ Greeting skipped');
340
494
  }
341
495
  }
496
+ }
497
+ catch (err) {
498
+ console.error('❌ Failed to start session:', err);
499
+ }
500
+ });
501
+ room.on(RoomEvent.ParticipantDisconnected, (participant) => {
502
+ console.log(`👋 User left: ${participant.identity}`);
503
+ if (currentSession) {
504
+ currentSession.removeAllListeners();
505
+ currentSession = null;
506
+ }
507
+ console.log('⏳ Waiting for new user...\n');
508
+ });
509
+ room.on(RoomEvent.DataReceived, async (payload, participant, kind, topic) => {
510
+ if (topic !== 'user-input')
511
+ return;
512
+ try {
513
+ const data = JSON.parse(new TextDecoder().decode(payload));
514
+ console.log('📨 Data:', data.type);
515
+ if (data.type === 'permission_response') {
516
+ const slot = agentPool.find(s => s.handler.hasPendingPermission());
517
+ if (slot) {
518
+ slot.handler.respondToPermission(data.response);
519
+ console.log(`✅ Permission: ${data.response}`);
520
+ }
521
+ }
522
+ else if (data.type === 'user_text' && currentSession) {
523
+ console.log(`📝 Text: "${data.content}"`);
524
+ currentSession.interrupt();
525
+ await currentSession.generateReply({ userInput: data.content });
526
+ }
527
+ }
528
+ catch { }
529
+ });
530
+ // ============================================================
531
+ // Connect to Room
532
+ // ============================================================
533
+ try {
534
+ await room.connect(livekitUrl, jwt, {
535
+ autoSubscribe: true,
536
+ dynacast: true,
342
537
  });
343
- // Create the agent
344
- const agent = new OsbornAssistant();
345
- // Start session
346
- console.log('🎬 Starting voice session...');
347
- const startTime = Date.now();
348
- await session.start({
349
- agent,
350
- room: ctx.room,
351
- });
352
- console.log(`✅ Session started in ${Date.now() - startTime}ms with ${provider.toUpperCase()} + Claude tools`);
353
- console.log('🎤 Ready for voice input! Speak to start.');
354
- },
355
- });
356
- cli.runApp(new ServerOptions({ agent: fileURLToPath(import.meta.url) }));
538
+ // Set localParticipant immediately after connection
539
+ localParticipant = room.localParticipant;
540
+ console.log('✅ Connected to room:', roomName);
541
+ console.log('\n⏳ Waiting for user to connect...');
542
+ console.log(` Room: ${roomCode}\n`);
543
+ // Warm up agents in background
544
+ console.log('🔥 Warming up agents...');
545
+ Promise.all([
546
+ planAgent.handler.run('ready').then(() => console.log('✅ Plan agent ready')),
547
+ executeAgent.handler.run('ready').then(() => console.log('✅ Execute agent ready')),
548
+ ]).catch(() => { });
549
+ // Keep process alive
550
+ await new Promise(() => { });
551
+ }
552
+ catch (err) {
553
+ console.error('❌ Failed to connect:', err);
554
+ process.exit(1);
555
+ }
556
+ }
557
+ // Run
558
+ main().catch(console.error);