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/claude-handler.d.ts +19 -2
- package/dist/claude-handler.js +160 -34
- package/dist/config.d.ts +33 -0
- package/dist/config.js +127 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +511 -309
- package/package.json +7 -4
package/dist/index.js
CHANGED
|
@@ -1,297 +1,449 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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('❌
|
|
116
|
-
return `Error: ${err.message}`;
|
|
194
|
+
console.error('❌ sendToFrontend error:', err);
|
|
117
195
|
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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(`📝
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
339
|
-
|
|
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
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
console.log('
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
});
|
|
356
|
-
|
|
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);
|