osborn 0.5.2 → 0.5.5
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/.claude/settings.local.json +9 -0
- package/.claude/skills/markdown-to-pdf/SKILL.md +29 -0
- package/.claude/skills/pdf-to-markdown/SKILL.md +28 -0
- package/.claude/skills/playwright-browser/SKILL.md +75 -0
- package/.claude/skills/youtube-transcript/SKILL.md +24 -0
- package/dist/claude-llm.d.ts +29 -1
- package/dist/claude-llm.js +346 -79
- package/dist/config.d.ts +6 -2
- package/dist/config.js +6 -1
- package/dist/fast-brain.d.ts +124 -12
- package/dist/fast-brain.js +1361 -96
- package/dist/index-3-2-26-legacy.d.ts +1 -0
- package/dist/index-3-2-26-legacy.js +2233 -0
- package/dist/index.js +889 -394
- package/dist/jsonl-search.d.ts +66 -0
- package/dist/jsonl-search.js +274 -0
- package/dist/leagcyprompts2.d.ts +0 -0
- package/dist/leagcyprompts2.js +573 -0
- package/dist/pipeline-direct-llm.d.ts +77 -0
- package/dist/pipeline-direct-llm.js +216 -0
- package/dist/pipeline-fastbrain.d.ts +45 -0
- package/dist/pipeline-fastbrain.js +367 -0
- package/dist/prompts-2-25-26.d.ts +0 -0
- package/dist/prompts-2-25-26.js +518 -0
- package/dist/prompts-3-2-26.d.ts +78 -0
- package/dist/prompts-3-2-26.js +1319 -0
- package/dist/prompts.d.ts +83 -8
- package/dist/prompts.js +1990 -374
- package/dist/session-access.d.ts +60 -2
- package/dist/session-access.js +172 -2
- package/dist/summary-index.d.ts +87 -0
- package/dist/summary-index.js +570 -0
- package/dist/turn-detector-shim.d.ts +24 -0
- package/dist/turn-detector-shim.js +83 -0
- package/dist/voice-io.d.ts +9 -3
- package/dist/voice-io.js +39 -20
- package/package.json +18 -11
package/dist/index.js
CHANGED
|
@@ -5,13 +5,20 @@ import { Room, RoomEvent } from '@livekit/rtc-node';
|
|
|
5
5
|
import { AccessToken } from 'livekit-server-sdk';
|
|
6
6
|
// Initialize logger before anything else
|
|
7
7
|
initializeLogger({ pretty: true, level: 'info' });
|
|
8
|
+
// Prevent MaxListenersExceededWarning on AbortSignal from Claude SDK query() calls
|
|
9
|
+
// Each resumed query() adds listeners to the shared signal; default limit is 10
|
|
10
|
+
import { setMaxListeners } from 'node:events';
|
|
11
|
+
setMaxListeners(50);
|
|
8
12
|
import { createServer } from 'http';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { loadConfig, getMcpServers, getEnabledMcpServerNames, getVoiceMode, getRealtimeConfig, getDirectConfig, listSessions, getMostRecentSessionId, sessionExists, cleanupOrphanedMetadata, getSessionSummary, getConversationHistory, ensureSessionWorkspace, getMcpServerStatusList, buildMcpServersForKeys, listWorkspaceArtifacts } from './config.js';
|
|
16
|
+
import { createSTT, createTTS, createRealtimeModelFromConfig, DIRECT_MODE_STT, DIRECT_MODE_TTS } from './voice-io.js';
|
|
11
17
|
import { createClaudeLLM } from './claude-llm.js';
|
|
18
|
+
import { clearPipelineFastBrainSession, prewarmBM25Index } from './pipeline-fastbrain.js';
|
|
12
19
|
import { createSmitheryProxy, destroySmitheryProxy, parseSmitheryUrl, isSmitheryUrl, SmitheryAuthorizationError } from './smithery-proxy.js';
|
|
13
|
-
import { askHaiku, updateSpecFromJSONL } from './fast-brain.js';
|
|
14
|
-
import { DIRECT_MODE_PROMPT, getRealtimeInstructions,
|
|
20
|
+
import { askHaiku, askFastBrain, updateSpecFromJSONL, processResearchCompletion, handleResearchBatch, prepareBriefingScript, prepareRecoveryScript, writeQuestionToSpec, checkOutputAgainstQuestions, generateProactivePrompt, clearFastBrainSession } from './fast-brain.js';
|
|
21
|
+
import { DIRECT_MODE_PROMPT, getRealtimeInstructions, getScriptInjection, getProactiveInjection, getNotificationInjection } from './prompts.js';
|
|
15
22
|
import { MCP_CATALOG } from './config.js';
|
|
16
23
|
import { llm } from '@livekit/agents';
|
|
17
24
|
import { z } from 'zod';
|
|
@@ -28,6 +35,32 @@ import { z } from 'zod';
|
|
|
28
35
|
// - Voice LLM with tool calling (ask_agent, respond_permission)
|
|
29
36
|
// - Routes tasks to Claude agents for execution
|
|
30
37
|
// ============================================================
|
|
38
|
+
// Load skills list with name + description for frontend display
|
|
39
|
+
function loadSkillsList(agentDir) {
|
|
40
|
+
const skillsDir = join(agentDir, '.claude', 'skills');
|
|
41
|
+
if (!existsSync(skillsDir))
|
|
42
|
+
return [];
|
|
43
|
+
const skills = [];
|
|
44
|
+
try {
|
|
45
|
+
for (const skillName of readdirSync(skillsDir)) {
|
|
46
|
+
const skillFile = join(skillsDir, skillName, 'SKILL.md');
|
|
47
|
+
if (existsSync(skillFile)) {
|
|
48
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
49
|
+
// Extract title from first # heading, or use folder name
|
|
50
|
+
const titleMatch = content.match(/^#\s+(?:Skill:\s*)?(.+)/m);
|
|
51
|
+
const name = titleMatch ? titleMatch[1].trim() : skillName;
|
|
52
|
+
// Extract description from first paragraph after heading
|
|
53
|
+
const descMatch = content.match(/^#[^\n]+\n+([^\n#]+)/m);
|
|
54
|
+
const description = descMatch ? descMatch[1].trim() : '';
|
|
55
|
+
skills.push({ name, description });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
console.warn('⚠️ Failed to load skills list:', err);
|
|
61
|
+
}
|
|
62
|
+
return skills;
|
|
63
|
+
}
|
|
31
64
|
// Generate a short, user-friendly room code
|
|
32
65
|
function generateRoomCode() {
|
|
33
66
|
const chars = 'abcdefghjkmnpqrstuvwxyz23456789';
|
|
@@ -70,6 +103,22 @@ process.on('unhandledRejection', (reason) => {
|
|
|
70
103
|
console.log('⚠️ OpenAI active response collision (will retry on next listening state)');
|
|
71
104
|
return;
|
|
72
105
|
}
|
|
106
|
+
// LiveKit SDK internal error after participant disconnect — safe to suppress
|
|
107
|
+
if (msg.includes("reading 'source'") || msg.includes("reading 'type'")) {
|
|
108
|
+
console.log('⚠️ Post-disconnect cleanup error (harmless)');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// generateReply timeout — realtime LLM called a tool instead of speaking (toolChoice:'none' ignored)
|
|
112
|
+
// or Superseded — new generateReply cancelled a pending one
|
|
113
|
+
if (msg.includes('generateReply timed out') || msg.includes('generation_created') || msg.includes('Superseded')) {
|
|
114
|
+
console.log('⚠️ generateReply failed:', msg.substring(0, 80));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// AdaptiveInterruptionDetector crash — LiveKit Cloud returns string instead of JSON.
|
|
118
|
+
// SDK handles this internally (retries → VAD fallback). Suppress residual noise.
|
|
119
|
+
if (msg.includes('interruption prediction') || msg.includes('AdaptiveInterruptionDetector')) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
73
122
|
console.error('❌ Unhandled Rejection:', msg);
|
|
74
123
|
});
|
|
75
124
|
process.on('uncaughtException', (error) => {
|
|
@@ -148,48 +197,6 @@ function startApiServer(workingDir, port) {
|
|
|
148
197
|
* Gemini has smaller context limits — cap at 10 exchanges with 500 char content.
|
|
149
198
|
* OpenAI handles full history (30 exchanges, 2000 char content).
|
|
150
199
|
*/
|
|
151
|
-
function buildContextBriefing(summary, history, provider) {
|
|
152
|
-
const isGemini = provider === 'gemini';
|
|
153
|
-
// Gemini: last 10 exchanges capped at 500 chars. OpenAI: full history.
|
|
154
|
-
const maxExchanges = isGemini ? 10 : history.length;
|
|
155
|
-
const maxContentLen = isGemini ? 500 : 2000;
|
|
156
|
-
const trimmedHistory = history.slice(-maxExchanges);
|
|
157
|
-
const lines = [
|
|
158
|
-
`Session ID: ${summary.sessionId.substring(0, 8)}`,
|
|
159
|
-
`Total messages: ${summary.messageCount}`,
|
|
160
|
-
'',
|
|
161
|
-
'=== SESSION CONVERSATION HISTORY ==='
|
|
162
|
-
];
|
|
163
|
-
for (const exchange of trimmedHistory) {
|
|
164
|
-
const content = exchange.content.length > maxContentLen
|
|
165
|
-
? exchange.content.substring(0, maxContentLen) + '...'
|
|
166
|
-
: exchange.content;
|
|
167
|
-
lines.push(`${exchange.role === 'user' ? 'User' : 'Assistant'}: ${content}`);
|
|
168
|
-
lines.push('');
|
|
169
|
-
}
|
|
170
|
-
return lines.join('\n');
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Read spec.md and format it for the realtime voice model.
|
|
174
|
-
* Truncates to avoid bloating the context window.
|
|
175
|
-
* Returns null if spec doesn't exist or session ID isn't available.
|
|
176
|
-
*/
|
|
177
|
-
function getSpecForVoiceModel(workingDir, sessionId) {
|
|
178
|
-
if (!sessionId)
|
|
179
|
-
return null;
|
|
180
|
-
const specContent = readSessionSpec(workingDir, sessionId);
|
|
181
|
-
if (!specContent)
|
|
182
|
-
return null;
|
|
183
|
-
const MAX = 3000;
|
|
184
|
-
if (specContent.length <= MAX)
|
|
185
|
-
return specContent;
|
|
186
|
-
const truncated = specContent.substring(0, MAX);
|
|
187
|
-
const lastHeading = truncated.lastIndexOf('\n## ');
|
|
188
|
-
if (lastHeading > MAX * 0.5) {
|
|
189
|
-
return truncated.substring(0, lastHeading) + '\n\n[... truncated — call read_spec for full content]';
|
|
190
|
-
}
|
|
191
|
-
return truncated + '\n\n[... truncated]';
|
|
192
|
-
}
|
|
193
200
|
/**
|
|
194
201
|
* Load full session conversation history into the realtime model's ChatContext.
|
|
195
202
|
* This gives the model persistent memory of what was discussed/researched,
|
|
@@ -251,8 +258,20 @@ async function main() {
|
|
|
251
258
|
if (enabledMcpNames.length > 0) {
|
|
252
259
|
console.log(`🔌 Enabled MCP servers: ${enabledMcpNames.join(', ')}`);
|
|
253
260
|
}
|
|
254
|
-
|
|
255
|
-
|
|
261
|
+
// Two directory concepts:
|
|
262
|
+
// 1. workingDir (cwd) — where Claude Code operates. Configurable per-session.
|
|
263
|
+
// Priority: OSBORN_CWD env > config.workingDirectory > process.cwd()
|
|
264
|
+
// 2. sessionBaseDir — where session artifacts live (spec.md, library/).
|
|
265
|
+
// Always the Osborn agent install directory (where this process started).
|
|
266
|
+
// This ensures .osborn/sessions/ doesn't scatter across random directories.
|
|
267
|
+
const sessionBaseDir = process.cwd(); // Always the Osborn install dir
|
|
268
|
+
const defaultWorkingDir = process.env.OSBORN_CWD || config.workingDirectory || process.cwd();
|
|
269
|
+
let workingDir = defaultWorkingDir;
|
|
270
|
+
console.log(`📂 Working directory (cwd): ${workingDir}`);
|
|
271
|
+
console.log(`📂 Session base directory: ${sessionBaseDir}`);
|
|
272
|
+
if (process.env.OSBORN_CWD) {
|
|
273
|
+
console.log(` (cwd from OSBORN_CWD env var)`);
|
|
274
|
+
}
|
|
256
275
|
console.log(`🔬 Mode: RESEARCH`);
|
|
257
276
|
// Determine voice mode
|
|
258
277
|
const voiceMode = getVoiceMode(config);
|
|
@@ -305,6 +324,7 @@ async function main() {
|
|
|
305
324
|
const room = new Room();
|
|
306
325
|
room.setMaxListeners(50); // Prevent MaxListenersExceeded warnings on reconnect
|
|
307
326
|
// Track state
|
|
327
|
+
let pendingSessionClose = null; // Tracks async session close for reconnect safety
|
|
308
328
|
let currentSession = null;
|
|
309
329
|
let currentAgent = null; // For updateChatCtx() context injection
|
|
310
330
|
let currentLLM = null;
|
|
@@ -313,6 +333,9 @@ async function main() {
|
|
|
313
333
|
let userState = 'listening'; // Track user speech state for queue safety
|
|
314
334
|
let currentVoiceMode = voiceMode; // Track active voice mode for data handlers
|
|
315
335
|
let currentProvider = realtimeConfig.provider; // Track active realtime provider
|
|
336
|
+
// Track the active resume session ID across scopes (ParticipantConnected + DataReceived)
|
|
337
|
+
// Updated by resume_session, session_selected, continue_session, switch_session handlers
|
|
338
|
+
let currentResumeSessionId;
|
|
316
339
|
// Task deduplication guard - prevents Gemini re-execution loops
|
|
317
340
|
let lastTaskRequest = '';
|
|
318
341
|
let lastTaskTime = 0;
|
|
@@ -320,8 +343,78 @@ async function main() {
|
|
|
320
343
|
let haikuInFlight = null;
|
|
321
344
|
// Background research state - tracks async ask_agent execution
|
|
322
345
|
let activeResearch = null;
|
|
346
|
+
// Persist last completed research context so follow-up questions can reference it
|
|
347
|
+
// (activeResearch is set to null on completion — this preserves the context)
|
|
348
|
+
let lastCompletedResearch = null;
|
|
323
349
|
// No manual queuing — the Claude SDK handles sequential queries internally
|
|
324
350
|
// ============================================================
|
|
351
|
+
// Interruption Tracking (Content Ledger)
|
|
352
|
+
// ============================================================
|
|
353
|
+
// When user interrupts TTS, LiveKit truncates chatCtx to what was spoken.
|
|
354
|
+
// We capture the spoken text (synchronizedTranscript) and on the next user
|
|
355
|
+
// message, read Claude's full output from JSONL + inject context so Claude
|
|
356
|
+
// knows what was heard vs lost. Claude decides: side question → answer +
|
|
357
|
+
// continue, or redirect → follow new direction.
|
|
358
|
+
// Current SpeechHandle from session.say() — only the latest one matters
|
|
359
|
+
let currentSpeechHandle = null;
|
|
360
|
+
// Last interruption context — gathered at interrupt time, consumed when user's message arrives
|
|
361
|
+
let lastInterruption = null;
|
|
362
|
+
/**
|
|
363
|
+
* Called when a SpeechHandle finishes (interrupted or not).
|
|
364
|
+
* If interrupted: gather spoken text + JSONL context. Does NOT send to Claude yet —
|
|
365
|
+
* that happens when the user's transcribed message arrives via chat().
|
|
366
|
+
*/
|
|
367
|
+
async function handleSpeechDone(handle, fullText) {
|
|
368
|
+
if (!handle.interrupted) {
|
|
369
|
+
lastInterruption = null;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// fullText is what was being spoken when interrupted (passed from tts_say handler).
|
|
373
|
+
// No word-level cutoff for say() — only generateReply pipeline has that — but Claude
|
|
374
|
+
// knows its own output from JSONL, so the full block is enough context.
|
|
375
|
+
console.log(`🔇 Speech interrupted. Was speaking: "${fullText.substring(0, 80)}..."`);
|
|
376
|
+
// Read last 10 assistant messages from JSONL (Claude's full untruncated output).
|
|
377
|
+
// SessionMessage.text is pre-joined from all text content blocks.
|
|
378
|
+
let recentMessages = '';
|
|
379
|
+
const sessionId = currentLLM?.sessionId;
|
|
380
|
+
if (sessionId) {
|
|
381
|
+
try {
|
|
382
|
+
const { readSessionHistory } = await import('./session-access.js');
|
|
383
|
+
const history = readSessionHistory(sessionId, workingDir, {
|
|
384
|
+
lastN: 10,
|
|
385
|
+
types: ['assistant'],
|
|
386
|
+
});
|
|
387
|
+
recentMessages = history
|
|
388
|
+
.filter((m) => m.text)
|
|
389
|
+
.map((m) => m.text)
|
|
390
|
+
.join('\n---\n');
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
console.warn('⚠️ Failed to read JSONL for interruption context:', err);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Store — consumed when user's next message arrives via chat()
|
|
397
|
+
lastInterruption = { spokenText: fullText, recentMessages, timestamp: Date.now() };
|
|
398
|
+
console.log(`📋 Interruption context stored (text: ${fullText.length} chars, JSONL: ${recentMessages.length} chars)`);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Callback for PipelineDirectLLM — returns pending interruption context and clears it.
|
|
402
|
+
* Called in chat() when user's transcribed message arrives.
|
|
403
|
+
* PipelineDirectLLM enriches the user message with this context before sending to Claude.
|
|
404
|
+
*/
|
|
405
|
+
function getAndConsumeInterruptionContext() {
|
|
406
|
+
if (!lastInterruption)
|
|
407
|
+
return null;
|
|
408
|
+
// Expire after 60s — user may have waited too long
|
|
409
|
+
if (Date.now() - lastInterruption.timestamp > 60_000) {
|
|
410
|
+
lastInterruption = null;
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const ctx = { spokenText: lastInterruption.spokenText, recentMessages: lastInterruption.recentMessages };
|
|
414
|
+
lastInterruption = null;
|
|
415
|
+
return ctx;
|
|
416
|
+
}
|
|
417
|
+
// ============================================================
|
|
325
418
|
// Unified Voice Injection Queue
|
|
326
419
|
// ============================================================
|
|
327
420
|
// ALL system injections (research updates, completions, notifications, errors)
|
|
@@ -354,43 +447,62 @@ async function main() {
|
|
|
354
447
|
console.log(`⏸️ Voice queue: ${voiceQueue.length} items waiting (user speaking)`);
|
|
355
448
|
return;
|
|
356
449
|
}
|
|
450
|
+
// Don't inject while fast brain tool call is in flight — the tool response will
|
|
451
|
+
// race with our generateReply, causing Gemini to drop our content and only speak
|
|
452
|
+
// the tool response. Wait for the tool call to complete first.
|
|
453
|
+
if (haikuInFlight) {
|
|
454
|
+
console.log(`⏸️ Voice queue: ${voiceQueue.length} items waiting (fast brain in flight: "${haikuInFlight.question.substring(0, 40)}...")`);
|
|
455
|
+
return; // Will be retried when haikuInFlight clears (see tool execute handler)
|
|
456
|
+
}
|
|
357
457
|
isProcessingQueue = true;
|
|
358
|
-
//
|
|
359
|
-
|
|
458
|
+
// Batch ALL queued items into one generateReply call
|
|
459
|
+
const items = voiceQueue.splice(0);
|
|
460
|
+
const batchedInstruction = items.length === 1
|
|
461
|
+
? items[0]
|
|
462
|
+
: items.join('\n\n---\n\n');
|
|
463
|
+
console.log(`📡 Voice queue: processing ${items.length} batched items (${batchedInstruction.length} chars)`);
|
|
464
|
+
// Safety timeout: if agent_state_changed never fires (edge case — e.g. Gemini
|
|
465
|
+
// WebSocket drops, or state machine hangs). 15s gives the model time to process.
|
|
360
466
|
setTimeout(() => {
|
|
361
467
|
if (isProcessingQueue) {
|
|
362
|
-
console.log('⚠️ Voice queue:
|
|
468
|
+
console.log('⚠️ Voice queue: safety timeout — clearing guard');
|
|
363
469
|
isProcessingQueue = false;
|
|
364
470
|
if (voiceQueue.length > 0 && agentState === 'listening') {
|
|
365
471
|
processVoiceQueue();
|
|
366
472
|
}
|
|
367
473
|
}
|
|
368
|
-
},
|
|
369
|
-
// Batch ALL queued items into one generateReply call
|
|
370
|
-
const items = voiceQueue.splice(0);
|
|
371
|
-
const batchedInstruction = items.length === 1
|
|
372
|
-
? items[0]
|
|
373
|
-
: items.join('\n\n---\n\n');
|
|
374
|
-
console.log(`📡 Voice queue: processing ${items.length} batched items (${batchedInstruction.length} chars)`);
|
|
474
|
+
}, 15000);
|
|
375
475
|
try {
|
|
376
476
|
// Skip interrupt for Gemini — disrupts Gemini's state machine, causing it to
|
|
377
477
|
// never transition back to 'listening' (hangs in speaking state indefinitely)
|
|
378
478
|
if (currentProvider !== 'gemini') {
|
|
379
479
|
currentSession.interrupt();
|
|
380
480
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
481
|
+
if (currentProvider === 'gemini') {
|
|
482
|
+
// LiveKit SDK v1.0.51: generateReply({ instructions }) sends a system turn +
|
|
483
|
+
// synthetic "." user turn. After Gemini processes a tool call in this flow,
|
|
484
|
+
// autoToolReplyGeneration does NOT trigger continuation (system-only limitation).
|
|
485
|
+
// Using userInput instead makes it a "user-initiated" request where auto-continuation
|
|
486
|
+
// works. The ask_fast_brain injection bypass handles [SCRIPT]/[PROACTIVE]/[NOTIFICATION]
|
|
487
|
+
// prefixes and returns the content directly as a tool response.
|
|
488
|
+
currentSession.generateReply({
|
|
489
|
+
userInput: batchedInstruction,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
// OpenAI respects toolChoice:'none' — speaks instructions directly
|
|
494
|
+
currentSession.generateReply({
|
|
495
|
+
instructions: batchedInstruction,
|
|
496
|
+
toolChoice: 'none',
|
|
497
|
+
});
|
|
498
|
+
}
|
|
385
499
|
// Model transitions to thinking/speaking after this call.
|
|
386
500
|
// When it returns to 'listening', agent_state_changed triggers processVoiceQueue() again.
|
|
387
501
|
// Also inject into chatCtx as persistent context so the model remembers across turns
|
|
388
502
|
injectIntoChatCtx(batchedInstruction);
|
|
389
503
|
}
|
|
390
504
|
catch (err) {
|
|
391
|
-
console.log('⚠️ Voice queue generateReply failed
|
|
392
|
-
// Do NOT re-queue — re-queuing causes infinite retry cascades
|
|
393
|
-
// The frontend still has the updates via claude_output events
|
|
505
|
+
console.log('⚠️ Voice queue generateReply failed:', err);
|
|
394
506
|
isProcessingQueue = false;
|
|
395
507
|
}
|
|
396
508
|
// isProcessingQueue is cleared when agent_state_changed fires
|
|
@@ -418,6 +530,32 @@ async function main() {
|
|
|
418
530
|
console.log('⚠️ ChatCtx injection failed:', err);
|
|
419
531
|
}
|
|
420
532
|
}
|
|
533
|
+
// Extract recent voice conversation turns from the realtime LLM's in-memory ChatContext.
|
|
534
|
+
// Replaces the internal conversationHistory array in fast-brain.ts.
|
|
535
|
+
function getChatHistory(maxTurns = 20) {
|
|
536
|
+
if (!currentAgent)
|
|
537
|
+
return [];
|
|
538
|
+
try {
|
|
539
|
+
const items = currentAgent.chatCtx.items;
|
|
540
|
+
const turns = [];
|
|
541
|
+
for (const item of items) {
|
|
542
|
+
if (item.type !== 'message')
|
|
543
|
+
continue;
|
|
544
|
+
const msg = item;
|
|
545
|
+
if (msg.role !== 'user' && msg.role !== 'assistant')
|
|
546
|
+
continue;
|
|
547
|
+
const text = msg.textContent ?? '';
|
|
548
|
+
if (!text.trim())
|
|
549
|
+
continue;
|
|
550
|
+
turns.push({ role: msg.role, text: text.trim() });
|
|
551
|
+
}
|
|
552
|
+
return turns.slice(-maxTurns);
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
console.log('⚠️ getChatHistory: failed to read chatCtx:', err);
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
}
|
|
421
559
|
// Research event batching — debounce rapid-fire tool events into a single voice queue entry
|
|
422
560
|
let researchBatchTimer = null;
|
|
423
561
|
function scheduleResearchBatch() {
|
|
@@ -437,10 +575,63 @@ async function main() {
|
|
|
437
575
|
isStreaming: true,
|
|
438
576
|
agentRole: 'research-progress',
|
|
439
577
|
});
|
|
440
|
-
//
|
|
441
|
-
|
|
578
|
+
// Route through fast brain — it decides whether to speak (usually silent)
|
|
579
|
+
if (activeResearch.voiceUpdateCount < 2) {
|
|
580
|
+
const voiceSid = currentLLM?.sessionId;
|
|
581
|
+
if (voiceSid) {
|
|
582
|
+
const chatHistory = getChatHistory(10);
|
|
583
|
+
handleResearchBatch(workingDir, voiceSid, lastTaskRequest || '', updates, activeResearch.researchLog, chatHistory, sessionBaseDir)
|
|
584
|
+
.then(script => {
|
|
585
|
+
if (script && activeResearch) {
|
|
586
|
+
activeResearch.voiceUpdateCount++;
|
|
587
|
+
queueVoiceInjection(getScriptInjection(script));
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
.catch(() => { }); // Silent fail — updates are optional
|
|
591
|
+
}
|
|
592
|
+
}
|
|
442
593
|
}, 8000); // 8s debounce: reduces voice queue flooding during research
|
|
443
594
|
}
|
|
595
|
+
// Proactive conversational loop — keeps conversation alive during research
|
|
596
|
+
let proactiveTimer = null;
|
|
597
|
+
let proactivePromptHistory = [];
|
|
598
|
+
const PROACTIVE_INTERVAL = 15000; // 15 seconds (offset from 8s batch timer)
|
|
599
|
+
const MAX_PROACTIVE_PROMPTS = 2; // Cap per research task (reduced from 4 to minimize realtime LLM tokens)
|
|
600
|
+
function startProactiveLoop(task, sessionId) {
|
|
601
|
+
stopProactiveLoop();
|
|
602
|
+
proactivePromptHistory = [];
|
|
603
|
+
let proactiveCount = 0;
|
|
604
|
+
proactiveTimer = setInterval(async () => {
|
|
605
|
+
if (!activeResearch) {
|
|
606
|
+
stopProactiveLoop();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (proactiveCount >= MAX_PROACTIVE_PROMPTS)
|
|
610
|
+
return;
|
|
611
|
+
if (agentState !== 'listening' || userState === 'speaking')
|
|
612
|
+
return;
|
|
613
|
+
if (researchBatchTimer)
|
|
614
|
+
return; // Don't collide with batch updates
|
|
615
|
+
if (isProcessingQueue)
|
|
616
|
+
return; // Don't collide with voice queue
|
|
617
|
+
try {
|
|
618
|
+
const prompt = await generateProactivePrompt(workingDir, sessionId, task, activeResearch.researchLog, proactivePromptHistory, sessionBaseDir);
|
|
619
|
+
if (prompt && prompt !== 'NOTHING') {
|
|
620
|
+
proactivePromptHistory.push(prompt);
|
|
621
|
+
proactiveCount++;
|
|
622
|
+
queueVoiceInjection(getProactiveInjection(prompt));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch { } // Silent fail — proactive prompts are optional
|
|
626
|
+
}, PROACTIVE_INTERVAL);
|
|
627
|
+
}
|
|
628
|
+
function stopProactiveLoop() {
|
|
629
|
+
if (proactiveTimer) {
|
|
630
|
+
clearInterval(proactiveTimer);
|
|
631
|
+
proactiveTimer = null;
|
|
632
|
+
}
|
|
633
|
+
proactivePromptHistory = [];
|
|
634
|
+
}
|
|
444
635
|
// Helper to send data to frontend (with size limit handling)
|
|
445
636
|
const MAX_MESSAGE_SIZE = 60000;
|
|
446
637
|
async function sendToFrontend(data) {
|
|
@@ -490,28 +681,40 @@ async function main() {
|
|
|
490
681
|
}
|
|
491
682
|
}
|
|
492
683
|
// Create DIRECT session (STT + Claude Agent SDK + TTS)
|
|
493
|
-
async function createDirectSession(resumeSessionId) {
|
|
684
|
+
async function createDirectSession(resumeSessionId, llmOverride) {
|
|
494
685
|
console.log('🎯 Creating direct session...');
|
|
495
|
-
const stt = createSTT(
|
|
496
|
-
const tts = createTTS(
|
|
497
|
-
|
|
498
|
-
//
|
|
499
|
-
|
|
686
|
+
const stt = createSTT(DIRECT_MODE_STT);
|
|
687
|
+
const tts = createTTS(DIRECT_MODE_TTS);
|
|
688
|
+
// Create Claude LLM wrapper — direct mode uses speech-optimized system prompt
|
|
689
|
+
// skipTTSQueue: bypass LiveKit's BufferedTokenStream, use session.say() instead
|
|
690
|
+
// llmOverride: pipeline mode passes PipelineDirectLLM which wraps its own ClaudeLLM
|
|
691
|
+
const directLLM = llmOverride || createClaudeLLM({
|
|
500
692
|
workingDirectory: workingDir,
|
|
693
|
+
sessionBaseDir,
|
|
501
694
|
mcpServers,
|
|
502
695
|
resumeSessionId,
|
|
696
|
+
voiceMode: 'direct',
|
|
697
|
+
skipTTSQueue: true,
|
|
503
698
|
});
|
|
504
699
|
currentLLM = directLLM;
|
|
505
700
|
// For resumed sessions, eagerly create workspace (we know the real ID)
|
|
506
701
|
if (resumeSessionId) {
|
|
507
|
-
const workspace = ensureSessionWorkspace(
|
|
702
|
+
const workspace = ensureSessionWorkspace(sessionBaseDir, resumeSessionId);
|
|
508
703
|
console.log(`📁 Session workspace (resumed): ${workspace}`);
|
|
509
704
|
}
|
|
510
705
|
// For new sessions, create workspace when SDK assigns real session ID
|
|
511
706
|
directLLM.events.once('session_id', ({ sessionId }) => {
|
|
512
|
-
const workspace = ensureSessionWorkspace(
|
|
707
|
+
const workspace = ensureSessionWorkspace(sessionBaseDir, sessionId);
|
|
513
708
|
console.log(`📁 Session workspace created: ${workspace}`);
|
|
709
|
+
// Pipeline mode: pre-warm BM25 index so first fast brain query is fast
|
|
710
|
+
if (currentVoiceMode === 'pipeline') {
|
|
711
|
+
prewarmBM25Index(sessionId, workingDir).catch(() => { });
|
|
712
|
+
}
|
|
514
713
|
});
|
|
714
|
+
// Also pre-warm for resumed sessions (sessionId already known)
|
|
715
|
+
if (resumeSessionId && currentVoiceMode === 'pipeline') {
|
|
716
|
+
prewarmBM25Index(resumeSessionId, workingDir).catch(() => { });
|
|
717
|
+
}
|
|
515
718
|
// Wire up MCP server changes to frontend
|
|
516
719
|
directLLM.events.on('mcp_servers_changed', (data) => {
|
|
517
720
|
console.log(`🔌 MCP servers changed: ${data.enabledKeys.join(', ') || 'none'}`);
|
|
@@ -595,6 +798,50 @@ async function main() {
|
|
|
595
798
|
currentSession.say?.(ttsMessage).catch(() => { });
|
|
596
799
|
}
|
|
597
800
|
});
|
|
801
|
+
// Wire up TTS say — bypass LiveKit's BufferedTokenStream, speak directly via session.say()
|
|
802
|
+
// Each text block from Claude gets spoken immediately as it arrives, no internal buffering
|
|
803
|
+
directLLM.events.on('tts_say', (data) => {
|
|
804
|
+
// Guard: session must be alive — TTS errors can kill the session while background query runs
|
|
805
|
+
if (!currentSession) {
|
|
806
|
+
console.warn(`⚠️ tts_say fired but currentSession is null — text dropped: "${data.text?.substring(0, 60)}"`);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (!data.text?.trim()) {
|
|
810
|
+
console.log(`🔇 tts_say fired but text is empty — skipping`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const sayId = Date.now(); // simple ID to correlate start/end logs
|
|
814
|
+
console.log(`🗣️ [${sayId}] session.say START (${data.text.length} chars): "${data.text.substring(0, 60)}..."`);
|
|
815
|
+
try {
|
|
816
|
+
const handle = currentSession.say(data.text);
|
|
817
|
+
if (handle && typeof handle.addDoneCallback === 'function') {
|
|
818
|
+
// SpeechHandle — track it and register interruption callback
|
|
819
|
+
currentSpeechHandle = handle;
|
|
820
|
+
handle.addDoneCallback((sh) => {
|
|
821
|
+
if (sh.interrupted) {
|
|
822
|
+
console.log(`🔇 [${sayId}] session.say INTERRUPTED`);
|
|
823
|
+
handleSpeechDone(sh, data.text);
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
console.log(`✅ [${sayId}] session.say DONE`);
|
|
827
|
+
if (currentSpeechHandle === sh)
|
|
828
|
+
lastInterruption = null;
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
console.log(`🗣️ [${sayId}] session.say queued (SpeechHandle tracked)`);
|
|
832
|
+
}
|
|
833
|
+
else if (handle && typeof handle.then === 'function') {
|
|
834
|
+
// Promise-based fallback (older SDK path)
|
|
835
|
+
handle
|
|
836
|
+
.then(() => console.log(`✅ [${sayId}] session.say DONE`))
|
|
837
|
+
.catch((err) => console.error(`❌ [${sayId}] session.say FAILED:`, err?.message || err));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
// Catch synchronous "AgentSession is not running" errors
|
|
842
|
+
console.warn(`⚠️ [${sayId}] session.say threw — session likely dead: ${err?.message}`);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
598
845
|
// Wire up session resume failure - notify frontend when SDK creates new session instead
|
|
599
846
|
directLLM.events.on('session_resume_failed', (data) => {
|
|
600
847
|
console.error(`❌ Session resume failed: ${data.requestedSessionId} → ${data.actualSessionId}`);
|
|
@@ -613,17 +860,18 @@ async function main() {
|
|
|
613
860
|
});
|
|
614
861
|
});
|
|
615
862
|
// Create the Agent with instructions, STT, LLM, TTS
|
|
863
|
+
// VAD (Silero ONNX) removed — caused 2-5s inference lag on CPU, making interruption detection worse
|
|
864
|
+
// Turn detection is server-side (Deepgram endpointing), interruptions handled by STT
|
|
616
865
|
const agent = new voice.Agent({
|
|
617
866
|
instructions: DIRECT_MODE_PROMPT,
|
|
618
867
|
stt,
|
|
619
868
|
llm: directLLM,
|
|
620
869
|
tts,
|
|
621
|
-
|
|
622
|
-
turnDetection: 'vad',
|
|
870
|
+
turnDetection: 'stt',
|
|
623
871
|
});
|
|
624
|
-
// Create the session (no longer passes STT/LLM/TTS here)
|
|
625
872
|
const session = new voice.AgentSession({
|
|
626
|
-
turnDetection: '
|
|
873
|
+
turnDetection: 'stt',
|
|
874
|
+
preemptiveGeneration: false, // Only fire LLM on final committed transcript, not partial preemptives
|
|
627
875
|
});
|
|
628
876
|
return { session, agent };
|
|
629
877
|
}
|
|
@@ -639,18 +887,19 @@ async function main() {
|
|
|
639
887
|
// Create Claude LLM for tool execution (research tasks)
|
|
640
888
|
realtimeClaudeHandler = createClaudeLLM({
|
|
641
889
|
workingDirectory: workingDir,
|
|
890
|
+
sessionBaseDir,
|
|
642
891
|
mcpServers,
|
|
643
892
|
resumeSessionId,
|
|
644
893
|
});
|
|
645
894
|
currentLLM = realtimeClaudeHandler;
|
|
646
895
|
// For resumed sessions, eagerly create workspace (we know the real ID)
|
|
647
896
|
if (resumeSessionId) {
|
|
648
|
-
const workspace = ensureSessionWorkspace(
|
|
897
|
+
const workspace = ensureSessionWorkspace(sessionBaseDir, resumeSessionId);
|
|
649
898
|
console.log(`📁 Session workspace (resumed): ${workspace}`);
|
|
650
899
|
}
|
|
651
900
|
// For new sessions, create workspace when SDK assigns real session ID
|
|
652
901
|
realtimeClaudeHandler.events.once('session_id', ({ sessionId }) => {
|
|
653
|
-
const workspace = ensureSessionWorkspace(
|
|
902
|
+
const workspace = ensureSessionWorkspace(sessionBaseDir, sessionId);
|
|
654
903
|
console.log(`📁 Session workspace created: ${workspace}`);
|
|
655
904
|
});
|
|
656
905
|
// Wire up MCP server changes to frontend
|
|
@@ -693,8 +942,11 @@ async function main() {
|
|
|
693
942
|
});
|
|
694
943
|
});
|
|
695
944
|
// Stream Claude's research text to frontend as progress updates
|
|
945
|
+
// Skips during active research to avoid duplication with per-task onText handler
|
|
696
946
|
realtimeClaudeHandler.events.on('assistant_text', (data) => {
|
|
697
947
|
if (data.text && data.text.trim()) {
|
|
948
|
+
if (activeResearch)
|
|
949
|
+
return;
|
|
698
950
|
sendToFrontend({
|
|
699
951
|
type: 'claude_output',
|
|
700
952
|
text: data.text,
|
|
@@ -747,71 +999,24 @@ async function main() {
|
|
|
747
999
|
checkpointId: data.checkpointId,
|
|
748
1000
|
});
|
|
749
1001
|
});
|
|
750
|
-
// Extract priority content from research results — preserves URLs, code blocks, and key details
|
|
751
|
-
function extractPriorityContent(result, maxChars = 4000) {
|
|
752
|
-
if (result.length <= maxChars)
|
|
753
|
-
return result;
|
|
754
|
-
// Extract URLs (preserve for voice relay)
|
|
755
|
-
const urlRegex = /https?:\/\/[^\s\)\"\'>\]]+/g;
|
|
756
|
-
const urls = [...new Set(result.match(urlRegex) || [])];
|
|
757
|
-
// Extract code blocks (first 2, up to 400 chars each)
|
|
758
|
-
const codeBlockRegex = /```[\s\S]*?```/g;
|
|
759
|
-
const codeBlocks = [];
|
|
760
|
-
let match;
|
|
761
|
-
while ((match = codeBlockRegex.exec(result)) !== null && codeBlocks.length < 2) {
|
|
762
|
-
const block = match[0].length > 400 ? match[0].substring(0, 397) + '```' : match[0];
|
|
763
|
-
codeBlocks.push(block);
|
|
764
|
-
}
|
|
765
|
-
// Build sections
|
|
766
|
-
const sections = [];
|
|
767
|
-
// Take the first ~2500 chars of narrative (intro + main findings)
|
|
768
|
-
const narrativeEnd = Math.min(result.length, 2500);
|
|
769
|
-
const narrativeTruncated = result.substring(0, narrativeEnd);
|
|
770
|
-
const lastPeriod = narrativeTruncated.lastIndexOf('.');
|
|
771
|
-
const narrative = lastPeriod > narrativeEnd * 0.6
|
|
772
|
-
? narrativeTruncated.substring(0, lastPeriod + 1)
|
|
773
|
-
: narrativeTruncated;
|
|
774
|
-
sections.push(narrative);
|
|
775
|
-
// Append conclusion (last ~500 chars) if result is long enough
|
|
776
|
-
if (result.length > 3000) {
|
|
777
|
-
const tail = result.substring(result.length - 500);
|
|
778
|
-
const firstPeriod = tail.indexOf('.');
|
|
779
|
-
const conclusion = firstPeriod > 0 ? tail.substring(firstPeriod + 1).trim() : tail.trim();
|
|
780
|
-
if (conclusion.length > 50) {
|
|
781
|
-
sections.push(`\n\n[CONCLUSION]\n${conclusion}`);
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
// Append code blocks if not already in the narrative
|
|
785
|
-
if (codeBlocks.length > 0) {
|
|
786
|
-
const codeSection = codeBlocks.filter(cb => !narrative.includes(cb));
|
|
787
|
-
if (codeSection.length > 0) {
|
|
788
|
-
sections.push(`\n\n[CODE EXAMPLES]\n${codeSection.join('\n\n')}`);
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
// Append URLs if not already in the narrative
|
|
792
|
-
const newUrls = urls.filter(u => !narrative.includes(u));
|
|
793
|
-
if (newUrls.length > 0) {
|
|
794
|
-
sections.push(`\n\n[LINKS]\n${newUrls.slice(0, 5).join('\n')}`);
|
|
795
|
-
}
|
|
796
|
-
let assembled = sections.join('');
|
|
797
|
-
// Final safety truncation if assembled exceeds maxChars
|
|
798
|
-
if (assembled.length > maxChars) {
|
|
799
|
-
const truncated = assembled.substring(0, maxChars);
|
|
800
|
-
const lp = truncated.lastIndexOf('.');
|
|
801
|
-
assembled = lp > maxChars * 0.7 ? truncated.substring(0, lp + 1) : truncated + '...';
|
|
802
|
-
}
|
|
803
|
-
return assembled;
|
|
804
|
-
}
|
|
805
1002
|
// Extracted research execution — called by ask_agent, SDK handles queuing internally
|
|
806
1003
|
function executeResearch(task) {
|
|
807
1004
|
sendToFrontend({ type: 'system', text: `Executing: ${task}` });
|
|
808
|
-
//
|
|
1005
|
+
// Fire-and-forget: write user question to spec.md BEFORE agent starts
|
|
1006
|
+
const questionSid = currentLLM?.sessionId || resumeSessionId;
|
|
1007
|
+
if (questionSid) {
|
|
1008
|
+
writeQuestionToSpec(sessionBaseDir, questionSid, task).catch(err => console.error('❌ writeQuestionToSpec failed:', err));
|
|
1009
|
+
}
|
|
1010
|
+
// Clean up previous research UI tracking — but let the SDK query complete in background.
|
|
1011
|
+
// The SDK has an internal queue: new query() calls enqueue behind running ones.
|
|
1012
|
+
// Old research results land in JSONL and fast brain can access them later.
|
|
809
1013
|
if (activeResearch) {
|
|
810
|
-
activeResearch.cleanup();
|
|
1014
|
+
activeResearch.cleanup(); // Remove event listeners so UI tracks new task
|
|
811
1015
|
if (researchBatchTimer) {
|
|
812
1016
|
clearTimeout(researchBatchTimer);
|
|
813
1017
|
researchBatchTimer = null;
|
|
814
1018
|
}
|
|
1019
|
+
// NOTE: NOT aborting — old SDK process continues writing to JSONL
|
|
815
1020
|
}
|
|
816
1021
|
// Set up research log batching — events push to queue for state-driven injection
|
|
817
1022
|
const researchLog = [];
|
|
@@ -861,12 +1066,30 @@ async function main() {
|
|
|
861
1066
|
pendingUpdates.push(entry);
|
|
862
1067
|
scheduleResearchBatch();
|
|
863
1068
|
};
|
|
1069
|
+
const ANSWER_CHECK_THRESHOLD = 300; // chars — only check substantial outputs
|
|
864
1070
|
const onToolResult = (data) => {
|
|
865
1071
|
// Only log to researchLog for the final summary — don't push to pendingUpdates
|
|
866
1072
|
// This prevents redundant "Reading config.ts. Read done." voice updates
|
|
867
1073
|
researchLog.push(`${data.name} completed`);
|
|
868
|
-
//
|
|
869
|
-
//
|
|
1074
|
+
// Fire-and-forget: check if substantial tool results answer any spec questions
|
|
1075
|
+
// Note: PostToolUse emits { name, input, response } — use data.response (not data.result)
|
|
1076
|
+
const resultText = typeof data.response === 'string' ? data.response : JSON.stringify(data.response || '');
|
|
1077
|
+
if (resultText.length > ANSWER_CHECK_THRESHOLD) {
|
|
1078
|
+
const sid = currentLLM?.sessionId || resumeSessionId;
|
|
1079
|
+
if (sid)
|
|
1080
|
+
checkOutputAgainstQuestions(sessionBaseDir, sid, resultText, 'tool_result').catch(() => { });
|
|
1081
|
+
}
|
|
1082
|
+
// When AskUserQuestion completes, the user's answer is a decision — track it in spec
|
|
1083
|
+
if (data.name === 'AskUserQuestion' && data.response) {
|
|
1084
|
+
const sid = currentLLM?.sessionId || resumeSessionId;
|
|
1085
|
+
if (sid) {
|
|
1086
|
+
const questionText = JSON.stringify(data.input?.questions || data.input || {});
|
|
1087
|
+
const answerText = typeof data.response === 'string' ? data.response : JSON.stringify(data.response);
|
|
1088
|
+
const specUpdate = `User answered a clarifying question during research.\nQuestion: ${questionText}\nAnswer: ${answerText}\nRecord this as a user decision in spec.md.`;
|
|
1089
|
+
askHaiku(workingDir, sid, specUpdate, undefined, undefined, undefined, sessionBaseDir).catch(err => console.error('❌ Failed to record AskUserQuestion answer in spec:', err));
|
|
1090
|
+
console.log(`📝 AskUserQuestion answer forwarded to fast brain for spec tracking`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
870
1093
|
};
|
|
871
1094
|
const onText = (data) => {
|
|
872
1095
|
if (data.text?.trim()) {
|
|
@@ -876,31 +1099,57 @@ async function main() {
|
|
|
876
1099
|
researchLog.push(firstSentence);
|
|
877
1100
|
pendingUpdates.push(firstSentence);
|
|
878
1101
|
scheduleResearchBatch();
|
|
879
|
-
//
|
|
880
|
-
|
|
1102
|
+
// Fire-and-forget: check if substantial agent reasoning answers any spec questions
|
|
1103
|
+
if (text.length > ANSWER_CHECK_THRESHOLD) {
|
|
1104
|
+
const sid = currentLLM?.sessionId || resumeSessionId;
|
|
1105
|
+
if (sid)
|
|
1106
|
+
checkOutputAgainstQuestions(sessionBaseDir, sid, text, 'assistant_text').catch(() => { });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
// Capture the SDK's requestId for this query — identifies this research task
|
|
1111
|
+
// in the JSONL file for targeted retrieval by fast brain
|
|
1112
|
+
let sdkRequestId = null;
|
|
1113
|
+
const onQueryRequestId = (data) => {
|
|
1114
|
+
if (!sdkRequestId && data.requestId) {
|
|
1115
|
+
sdkRequestId = data.requestId;
|
|
1116
|
+
console.log(`📋 [research] SDK requestId: ${sdkRequestId}`);
|
|
881
1117
|
}
|
|
882
1118
|
};
|
|
883
1119
|
realtimeClaudeHandler.events.on('tool_use', onToolUse);
|
|
884
1120
|
realtimeClaudeHandler.events.on('tool_result', onToolResult);
|
|
885
1121
|
realtimeClaudeHandler.events.on('assistant_text', onText);
|
|
1122
|
+
realtimeClaudeHandler.events.on('query_request_id', onQueryRequestId);
|
|
886
1123
|
const cleanupListeners = () => {
|
|
887
1124
|
realtimeClaudeHandler?.events.off('tool_use', onToolUse);
|
|
888
1125
|
realtimeClaudeHandler?.events.off('tool_result', onToolResult);
|
|
889
1126
|
realtimeClaudeHandler?.events.off('assistant_text', onText);
|
|
1127
|
+
realtimeClaudeHandler?.events.off('query_request_id', onQueryRequestId);
|
|
890
1128
|
};
|
|
1129
|
+
// Create AbortController for this research task — abort on disconnect/cleanup
|
|
1130
|
+
const researchAbortController = new AbortController();
|
|
891
1131
|
// Track active research — updates drain when model enters 'listening' state
|
|
892
|
-
|
|
1132
|
+
const thisResearch = {
|
|
893
1133
|
researchLog,
|
|
894
1134
|
pendingUpdates,
|
|
895
1135
|
cleanup: cleanupListeners,
|
|
896
1136
|
voiceUpdateCount: 0,
|
|
1137
|
+
abortController: researchAbortController,
|
|
897
1138
|
};
|
|
1139
|
+
activeResearch = thisResearch;
|
|
1140
|
+
// Start proactive conversational loop
|
|
1141
|
+
const proactiveSid = currentLLM?.sessionId || resumeSessionId;
|
|
1142
|
+
if (proactiveSid) {
|
|
1143
|
+
startProactiveLoop(task, proactiveSid);
|
|
1144
|
+
}
|
|
898
1145
|
// Run research in the background (non-blocking)
|
|
1146
|
+
// Pass AbortController so research can be stopped on disconnect
|
|
899
1147
|
const researchPromise = (async () => {
|
|
900
1148
|
const stream = realtimeClaudeHandler.chat({
|
|
901
1149
|
chatCtx: {
|
|
902
1150
|
items: [{ type: 'message', role: 'user', content: [task] }],
|
|
903
1151
|
},
|
|
1152
|
+
abortController: researchAbortController,
|
|
904
1153
|
});
|
|
905
1154
|
let result = '';
|
|
906
1155
|
for await (const chunk of stream) {
|
|
@@ -912,66 +1161,94 @@ async function main() {
|
|
|
912
1161
|
})();
|
|
913
1162
|
// Handle completion asynchronously
|
|
914
1163
|
researchPromise.then(async (result) => {
|
|
915
|
-
|
|
1164
|
+
// Check if aborted — empty result means clean abort, skip pipeline
|
|
1165
|
+
if (researchAbortController.signal.aborted || !result.trim()) {
|
|
1166
|
+
console.log(`🛑 [realtime] Research aborted or empty: ${task.substring(0, 60)}`);
|
|
1167
|
+
cleanupListeners();
|
|
1168
|
+
if (activeResearch === thisResearch) {
|
|
1169
|
+
activeResearch = null;
|
|
1170
|
+
}
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
const isStillCurrent = activeResearch === thisResearch;
|
|
1174
|
+
console.log(`✅ [realtime] Research complete (${result.length} chars${isStillCurrent ? '' : ', superseded by newer task'})`);
|
|
916
1175
|
// Clean up
|
|
917
1176
|
cleanupListeners();
|
|
918
|
-
// Send to frontend
|
|
919
|
-
|
|
1177
|
+
// Send raw result to frontend as a log entry (not assistant_response — that's reserved
|
|
1178
|
+
// for the voice model's spoken response, avoiding duplication in chat)
|
|
1179
|
+
await sendToFrontend({ type: 'claude_output', text: result, isStreaming: false, agentRole: 'research-result' });
|
|
920
1180
|
const resultPreview = result.length > 150
|
|
921
1181
|
? result.substring(0, 150) + '...'
|
|
922
1182
|
: result;
|
|
923
1183
|
await sendToFrontend({ type: 'task_completed', task, resultPreview });
|
|
924
|
-
//
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1184
|
+
// Only modify global state if we're still the current research task.
|
|
1185
|
+
// If a newer task replaced us, don't clobber its timers/state.
|
|
1186
|
+
if (isStillCurrent) {
|
|
1187
|
+
if (researchBatchTimer) {
|
|
1188
|
+
clearTimeout(researchBatchTimer);
|
|
1189
|
+
researchBatchTimer = null;
|
|
1190
|
+
}
|
|
1191
|
+
stopProactiveLoop();
|
|
1192
|
+
}
|
|
1193
|
+
// Preserve research context for follow-up questions
|
|
1194
|
+
lastCompletedResearch = {
|
|
1195
|
+
task,
|
|
1196
|
+
researchLog: [...researchLog],
|
|
1197
|
+
completedAt: Date.now(),
|
|
1198
|
+
};
|
|
1199
|
+
// Only clear activeResearch if we're still the current task
|
|
1200
|
+
if (isStillCurrent) {
|
|
1201
|
+
activeResearch = null;
|
|
935
1202
|
}
|
|
936
|
-
|
|
937
|
-
// Send final results to frontend for visibility
|
|
1203
|
+
// Send research_task_complete to frontend for inline chat tracking
|
|
938
1204
|
await sendToFrontend({
|
|
939
|
-
type: '
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
agentRole: 'research-progress',
|
|
1205
|
+
type: 'research_task_complete',
|
|
1206
|
+
task,
|
|
1207
|
+
summary: result.substring(0, 500),
|
|
943
1208
|
});
|
|
944
|
-
//
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1209
|
+
// Route through fast brain to generate a teleprompter script from the findings
|
|
1210
|
+
// Fast brain reads full JSONL and writes a spoken monologue
|
|
1211
|
+
const voiceSid = currentLLM?.sessionId || resumeSessionId;
|
|
1212
|
+
const chatHistory = getChatHistory(10);
|
|
1213
|
+
console.log(`📡 [realtime] Generating teleprompter script via fast brain (result: ${result.length} chars, agentState: ${agentState})`);
|
|
1214
|
+
// Create sendToChat for research completion to send structured data to frontend
|
|
1215
|
+
const completionSendToChat = (text) => {
|
|
1216
|
+
sendToFrontend({ type: 'assistant_response', text });
|
|
1217
|
+
};
|
|
1218
|
+
if (voiceSid) {
|
|
1219
|
+
processResearchCompletion(workingDir, voiceSid, task, result, chatHistory, completionSendToChat, sessionBaseDir)
|
|
1220
|
+
.then(script => {
|
|
1221
|
+
queueVoiceInjection(getScriptInjection(script));
|
|
1222
|
+
})
|
|
1223
|
+
.catch(() => {
|
|
1224
|
+
// Fallback: use truncated result directly if fast brain fails
|
|
1225
|
+
queueVoiceInjection(getScriptInjection(result.substring(0, 500)));
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
else {
|
|
1229
|
+
queueVoiceInjection(getScriptInjection(result.substring(0, 500)));
|
|
1230
|
+
}
|
|
950
1231
|
// Fire-and-forget JSONL-based refinement pass via fast brain
|
|
951
1232
|
// Reads FULL untruncated data from JSONL — no content buffer, no truncation
|
|
952
1233
|
const postResearchSessionId = currentLLM?.sessionId || resumeSessionId;
|
|
953
1234
|
if (postResearchSessionId) {
|
|
954
|
-
updateSpecFromJSONL(workingDir, postResearchSessionId, task, researchLog)
|
|
1235
|
+
updateSpecFromJSONL(workingDir, postResearchSessionId, task, researchLog, sessionBaseDir)
|
|
955
1236
|
.then(updateResult => {
|
|
956
1237
|
if (!updateResult)
|
|
957
1238
|
return;
|
|
958
1239
|
// Notify frontend about spec.md update
|
|
959
1240
|
if (updateResult.spec) {
|
|
960
|
-
const specPath = `${
|
|
1241
|
+
const specPath = `${sessionBaseDir}/.osborn/sessions/${postResearchSessionId}/spec.md`;
|
|
961
1242
|
sendToFrontend({
|
|
962
1243
|
type: 'research_artifact_updated',
|
|
963
1244
|
filePath: specPath,
|
|
964
1245
|
fileName: 'spec.md',
|
|
965
1246
|
});
|
|
966
|
-
|
|
967
|
-
if (truncated) {
|
|
968
|
-
injectIntoChatCtx(`[UPDATED SESSION SPEC]\n${truncated}`);
|
|
969
|
-
console.log(`📋 Re-injected spec.md into ChatCtx after fast brain update (${truncated.length} chars)`);
|
|
970
|
-
}
|
|
1247
|
+
// Voice model is a teleprompter — fast brain reads spec directly, no ChatCtx injection needed
|
|
971
1248
|
}
|
|
972
1249
|
// Notify frontend about each library file written by the fast brain
|
|
973
1250
|
for (const libFile of updateResult.libraryFiles) {
|
|
974
|
-
const libPath = `${
|
|
1251
|
+
const libPath = `${sessionBaseDir}/.osborn/sessions/${postResearchSessionId}/library/${libFile}`;
|
|
975
1252
|
sendToFrontend({
|
|
976
1253
|
type: 'research_artifact_updated',
|
|
977
1254
|
filePath: libPath,
|
|
@@ -981,177 +1258,148 @@ async function main() {
|
|
|
981
1258
|
});
|
|
982
1259
|
}
|
|
983
1260
|
}).catch(async (err) => {
|
|
984
|
-
console.error(`❌ [realtime] Research failed:`, err);
|
|
985
1261
|
// Clean up
|
|
986
1262
|
cleanupListeners();
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
researchBatchTimer
|
|
1263
|
+
const isStillCurrent = activeResearch === thisResearch;
|
|
1264
|
+
if (isStillCurrent) {
|
|
1265
|
+
if (researchBatchTimer) {
|
|
1266
|
+
clearTimeout(researchBatchTimer);
|
|
1267
|
+
researchBatchTimer = null;
|
|
1268
|
+
}
|
|
1269
|
+
stopProactiveLoop();
|
|
1270
|
+
activeResearch = null;
|
|
990
1271
|
}
|
|
991
|
-
|
|
1272
|
+
// If aborted (user disconnected), log quietly
|
|
1273
|
+
if (researchAbortController.signal.aborted) {
|
|
1274
|
+
console.log(`🛑 [realtime] Research aborted: ${task.substring(0, 60)}`);
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
console.error(`❌ [realtime] Research failed:`, err);
|
|
992
1278
|
// Queue error notification — will be spoken when model is available
|
|
993
|
-
queueVoiceInjection(`
|
|
1279
|
+
queueVoiceInjection(getNotificationInjection(`Research encountered an error: ${err.message}. You could try asking again.`));
|
|
994
1280
|
});
|
|
995
1281
|
// Return immediately to unblock the voice model
|
|
996
1282
|
return 'Research started. I\'ll relay findings as they come in — you can keep talking to the user while I work.';
|
|
997
1283
|
}
|
|
998
1284
|
// Create tools for the realtime voice LLM
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
- Fetching and analyzing web pages, articles, blog posts, YouTube transcripts
|
|
1005
|
-
- Reading and summarizing documentation, papers, or reference materials
|
|
1006
|
-
- Exploring and analyzing codebases, configs, architecture
|
|
1007
|
-
- Comparing options, tools, approaches — with tradeoffs and recommendations
|
|
1008
|
-
- Running bash commands, testing implementations
|
|
1009
|
-
- Using MCP tools (GitHub, YouTube, and other external tools)
|
|
1010
|
-
- Saving findings to the session library and updating the spec
|
|
1011
|
-
- Any question requiring research, analysis, verification, or deeper reasoning
|
|
1012
|
-
|
|
1013
|
-
Reformulate the user's spoken request into a clear, specific task.
|
|
1014
|
-
The more context you include (topic, constraints, what they want to learn), the better the results.
|
|
1015
|
-
If the user wants specific details (examples, URLs, comparisons, step-by-step breakdown), mention that in your request.`,
|
|
1285
|
+
// The realtime model is a thin teleprompter — only 2 tools:
|
|
1286
|
+
// 1. ask_fast_brain: ALL user questions route here (the fast brain decides everything)
|
|
1287
|
+
// 2. respond_permission: voice permission flow for Claude SDK blocked operations
|
|
1288
|
+
const askFastBrainTool = llm.tool({
|
|
1289
|
+
description: `Ask your brain. Call this for EVERY user message — greetings, questions, decisions, requests, everything. No exceptions. Returns what you should say.`,
|
|
1016
1290
|
parameters: z.object({
|
|
1017
|
-
|
|
1018
|
-
}),
|
|
1019
|
-
execute: async ({ request: task }) => {
|
|
1020
|
-
console.log(`\n🔨 [realtime] Task: "${task}"`);
|
|
1021
|
-
// Guard: if ask_haiku is currently handling a similar question, skip ask_agent
|
|
1022
|
-
// This prevents the double-calling pattern where Gemini fires both in rapid succession
|
|
1023
|
-
if (haikuInFlight && (Date.now() - haikuInFlight.time) < 8000) {
|
|
1024
|
-
console.log(`⏭️ Skipping ask_agent — ask_haiku is already handling: "${haikuInFlight.question.substring(0, 60)}"`);
|
|
1025
|
-
return 'The fast brain is already looking into this. Wait for its answer first.';
|
|
1026
|
-
}
|
|
1027
|
-
// Deduplication guard: prevent re-execution of same task within 10s
|
|
1028
|
-
const now = Date.now();
|
|
1029
|
-
if (task === lastTaskRequest && (now - lastTaskTime) < 10000) {
|
|
1030
|
-
console.log('⏭️ Skipping duplicate task (within 10s window)');
|
|
1031
|
-
return 'This task was just completed. The results were already relayed.';
|
|
1032
|
-
}
|
|
1033
|
-
lastTaskRequest = task;
|
|
1034
|
-
lastTaskTime = now;
|
|
1035
|
-
return executeResearch(task);
|
|
1036
|
-
},
|
|
1037
|
-
});
|
|
1038
|
-
const respondPermissionTool = llm.tool({
|
|
1039
|
-
description: `Respond to a permission request. Call after hearing user's response.`,
|
|
1040
|
-
parameters: z.object({
|
|
1041
|
-
response: z.enum(['allow', 'deny', 'always_allow']),
|
|
1042
|
-
}),
|
|
1043
|
-
execute: async ({ response }) => {
|
|
1044
|
-
if (!realtimeClaudeHandler?.hasPendingPermission()) {
|
|
1045
|
-
return 'No pending permission.';
|
|
1046
|
-
}
|
|
1047
|
-
const pending = realtimeClaudeHandler.getPendingPermission();
|
|
1048
|
-
const allow = response === 'allow' || response === 'always_allow';
|
|
1049
|
-
realtimeClaudeHandler.respondToPermission(allow);
|
|
1050
|
-
await sendToFrontend({ type: 'permission_response', response, toolName: pending?.toolName });
|
|
1051
|
-
return `Permission ${response} for ${pending?.toolName || 'tool'}.`;
|
|
1052
|
-
},
|
|
1053
|
-
});
|
|
1054
|
-
const readSpecTool = llm.tool({
|
|
1055
|
-
description: `Read the session spec (spec.md) — shared state between you and your backend agent.
|
|
1056
|
-
Use when: checking decisions, reading open questions to ask the user, understanding architecture/context, seeing what research has been saved. Updated by your backend agent during research.`,
|
|
1057
|
-
parameters: z.object({}),
|
|
1058
|
-
execute: async () => {
|
|
1059
|
-
const sessionId = currentLLM?.sessionId || resumeSessionId;
|
|
1060
|
-
if (!sessionId)
|
|
1061
|
-
return 'No session spec yet — session is still initializing.';
|
|
1062
|
-
const specContent = readSessionSpec(workingDir, sessionId);
|
|
1063
|
-
if (!specContent)
|
|
1064
|
-
return 'Spec is empty — no research done yet.';
|
|
1065
|
-
const libraryFiles = listLibraryFiles(workingDir, sessionId);
|
|
1066
|
-
const libSection = libraryFiles.length > 0
|
|
1067
|
-
? `\n\n[LIBRARY FILES: ${libraryFiles.join(', ')}]`
|
|
1068
|
-
: '';
|
|
1069
|
-
const MAX = 4000;
|
|
1070
|
-
const content = specContent.length > MAX
|
|
1071
|
-
? specContent.substring(0, MAX) + '\n\n[... truncated]'
|
|
1072
|
-
: specContent;
|
|
1073
|
-
return content + libSection;
|
|
1074
|
-
},
|
|
1075
|
-
});
|
|
1076
|
-
const askHaikuTool = llm.tool({
|
|
1077
|
-
description: `Ask your fast brain — a quick knowledge assistant with access to session files and web search (~2 seconds).
|
|
1078
|
-
|
|
1079
|
-
Use for:
|
|
1080
|
-
- Questions answerable from the session spec or research library (much faster than ask_agent)
|
|
1081
|
-
- Quick web lookups for simple factual questions (definitions, current versions, basic how-to)
|
|
1082
|
-
- Recording user decisions: "User decided: [decision]. Update the spec."
|
|
1083
|
-
- Recording user preferences: "User prefers: [preference]. Update the spec."
|
|
1084
|
-
- Checking what research has been done on a topic
|
|
1085
|
-
- Reading specific library files for details
|
|
1086
|
-
|
|
1087
|
-
Do NOT use for: deep research, code analysis, multi-file codebase exploration, complex investigations → use ask_agent.
|
|
1088
|
-
If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to look deeper, then call ask_agent with the context it provides.`,
|
|
1089
|
-
parameters: z.object({
|
|
1090
|
-
question: z.string().describe('The question to ask or instruction to execute'),
|
|
1291
|
+
question: z.string().describe('The user\'s question or statement'),
|
|
1091
1292
|
}),
|
|
1092
1293
|
execute: async ({ question }) => {
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1294
|
+
// INJECTION BYPASS: When Gemini receives a system injection via generateReply(),
|
|
1295
|
+
// it calls ask_fast_brain with the injection content (Gemini always calls tools).
|
|
1296
|
+
// For Gemini: this is the INTENDED path — we deliberately don't set toolChoice:'none'
|
|
1297
|
+
// so the tool call goes through and we return the content as a tool response.
|
|
1298
|
+
// For OpenAI: this is a fallback guard — OpenAI normally speaks instructions directly
|
|
1299
|
+
// with toolChoice:'none', but if it somehow calls the tool, we handle it here.
|
|
1300
|
+
const injectionMatch = question.match(/\[(SCRIPT|PROACTIVE|NOTIFICATION)\]\s*([\s\S]*)/);
|
|
1301
|
+
if (injectionMatch) {
|
|
1302
|
+
const content = injectionMatch[2].trim();
|
|
1303
|
+
console.log(`⚡ [fast brain] BYPASS: injection [${injectionMatch[1]}] → returning content directly (${content.length} chars)`);
|
|
1304
|
+
return content || question;
|
|
1305
|
+
}
|
|
1306
|
+
// Use pending sessionId for fresh sessions where SDK hasn't assigned one yet
|
|
1307
|
+
const sessionId = currentLLM?.sessionId || currentResumeSessionId || resumeSessionId || 'pending';
|
|
1096
1308
|
console.log(`🧠 [fast brain] Question: "${question.substring(0, 80)}..."`);
|
|
1097
|
-
// Track in-flight state
|
|
1309
|
+
// Track in-flight state
|
|
1098
1310
|
haikuInFlight = { question, time: Date.now() };
|
|
1099
|
-
// Build
|
|
1100
|
-
// This is a READ of the existing researchLog array — safe, no race conditions
|
|
1311
|
+
// Build research context — from active research or last completed research
|
|
1101
1312
|
let researchContext;
|
|
1102
1313
|
if (activeResearch && activeResearch.researchLog.length > 0) {
|
|
1103
1314
|
const recentLog = activeResearch.researchLog.slice(-15);
|
|
1104
1315
|
researchContext = `Research topic: "${lastTaskRequest || 'unknown'}"\nSteps completed (${activeResearch.researchLog.length} total, showing last ${recentLog.length}):\n${recentLog.join('\n')}`;
|
|
1105
1316
|
}
|
|
1317
|
+
else if (lastCompletedResearch && (Date.now() - lastCompletedResearch.completedAt) < 600000) {
|
|
1318
|
+
// Include context from last completed research (within 10 minutes)
|
|
1319
|
+
const recentLog = lastCompletedResearch.researchLog.slice(-15);
|
|
1320
|
+
researchContext = `[COMPLETED RESEARCH] Topic: "${lastCompletedResearch.task}"\nSteps completed (${lastCompletedResearch.researchLog.length} total, showing last ${recentLog.length}):\n${recentLog.join('\n')}\n\n(Research completed — results are in JSONL and spec.md. Answer from those, do NOT trigger new research on this topic.)`;
|
|
1321
|
+
}
|
|
1322
|
+
const callbacks = {
|
|
1323
|
+
triggerResearch: (task) => {
|
|
1324
|
+
// Deduplication guard
|
|
1325
|
+
const now = Date.now();
|
|
1326
|
+
if (task === lastTaskRequest && (now - lastTaskTime) < 10000) {
|
|
1327
|
+
console.log('⏭️ Skipping duplicate research task (within 10s window)');
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
lastTaskRequest = task;
|
|
1331
|
+
lastTaskTime = now;
|
|
1332
|
+
executeResearch(task);
|
|
1333
|
+
},
|
|
1334
|
+
queueVoice: (script) => {
|
|
1335
|
+
queueVoiceInjection(getScriptInjection(script));
|
|
1336
|
+
},
|
|
1337
|
+
sendToFrontend: (data) => {
|
|
1338
|
+
sendToFrontend(data);
|
|
1339
|
+
},
|
|
1340
|
+
};
|
|
1106
1341
|
try {
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
});
|
|
1342
|
+
const chatHistory = getChatHistory(20);
|
|
1343
|
+
const result = await askFastBrain(workingDir, sessionId, question, {
|
|
1344
|
+
chatHistory,
|
|
1345
|
+
researchContext,
|
|
1346
|
+
callbacks,
|
|
1347
|
+
sessionBaseDir,
|
|
1348
|
+
});
|
|
1349
|
+
haikuInFlight = null;
|
|
1350
|
+
// Voice queue items may have been held while fast brain was in flight — retry now
|
|
1351
|
+
if (voiceQueue.length > 0) {
|
|
1352
|
+
setTimeout(() => processVoiceQueue(), 500);
|
|
1119
1353
|
}
|
|
1120
|
-
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
if (activeResearch && (question.toLowerCase().includes('
|
|
1124
|
-
question.toLowerCase().includes('
|
|
1125
|
-
question.toLowerCase().includes('update the spec') ||
|
|
1126
|
-
question.toLowerCase().includes('also check') ||
|
|
1354
|
+
console.log(`🧠 [fast brain] Response type: ${result.type}, script: ${result.script.length} chars`);
|
|
1355
|
+
// If this was a user direction during active research,
|
|
1356
|
+
// pass it to the agent SDK so it picks up the context
|
|
1357
|
+
if (activeResearch && result.type === 'recorded' && (question.toLowerCase().includes('decided') ||
|
|
1358
|
+
question.toLowerCase().includes('prefers') ||
|
|
1127
1359
|
question.toLowerCase().includes('focus on') ||
|
|
1128
1360
|
question.toLowerCase().includes('redirect'))) {
|
|
1129
|
-
console.log(`📨 [fast brain] Passing user direction to agent SDK queue
|
|
1130
|
-
|
|
1131
|
-
// at the start of its next query and will see the updated direction
|
|
1132
|
-
executeResearch(`[USER DIRECTION during active research] ${question}. The user's spec.md has been updated with this. Acknowledge briefly and incorporate into your current research context.`);
|
|
1361
|
+
console.log(`📨 [fast brain] Passing user direction to agent SDK queue`);
|
|
1362
|
+
executeResearch(`[USER DIRECTION during active research] ${question}. The user's spec.md has been updated. Acknowledge briefly and incorporate.`);
|
|
1133
1363
|
}
|
|
1134
|
-
return
|
|
1364
|
+
return result.script;
|
|
1135
1365
|
}
|
|
1136
1366
|
catch (err) {
|
|
1137
|
-
haikuInFlight = null;
|
|
1367
|
+
haikuInFlight = null;
|
|
1368
|
+
// Voice queue items may have been held while fast brain was in flight — retry now
|
|
1369
|
+
if (voiceQueue.length > 0) {
|
|
1370
|
+
setTimeout(() => processVoiceQueue(), 500);
|
|
1371
|
+
}
|
|
1138
1372
|
console.error('❌ Fast brain failed:', err);
|
|
1139
|
-
return '
|
|
1373
|
+
return 'I\'m having trouble processing that. Could you try again?';
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
});
|
|
1377
|
+
const respondPermissionTool = llm.tool({
|
|
1378
|
+
description: `Respond to a permission request. Call after hearing user's response.`,
|
|
1379
|
+
parameters: z.object({
|
|
1380
|
+
response: z.enum(['allow', 'deny', 'always_allow']),
|
|
1381
|
+
}),
|
|
1382
|
+
execute: async ({ response }) => {
|
|
1383
|
+
if (!realtimeClaudeHandler?.hasPendingPermission()) {
|
|
1384
|
+
return 'No pending permission.';
|
|
1140
1385
|
}
|
|
1386
|
+
const pending = realtimeClaudeHandler.getPendingPermission();
|
|
1387
|
+
const allow = response === 'allow' || response === 'always_allow';
|
|
1388
|
+
realtimeClaudeHandler.respondToPermission(allow);
|
|
1389
|
+
await sendToFrontend({ type: 'permission_response', response, toolName: pending?.toolName });
|
|
1390
|
+
return `Permission ${response} for ${pending?.toolName || 'tool'}.`;
|
|
1141
1391
|
},
|
|
1142
1392
|
});
|
|
1143
1393
|
// Instructions for realtime voice LLM
|
|
1144
1394
|
const realtimeInstructions = getRealtimeInstructions(workingDir);
|
|
1145
1395
|
// Create realtime model
|
|
1146
1396
|
const realtimeModel = createRealtimeModelFromConfig(rtConfig, realtimeInstructions);
|
|
1147
|
-
// Create the Agent with
|
|
1397
|
+
// Create the Agent with MINIMAL tools — fast brain handles all routing
|
|
1148
1398
|
const agent = new voice.Agent({
|
|
1149
1399
|
instructions: realtimeInstructions,
|
|
1150
1400
|
llm: realtimeModel,
|
|
1151
1401
|
tools: {
|
|
1152
|
-
|
|
1153
|
-
ask_haiku: askHaikuTool,
|
|
1154
|
-
read_spec: readSpecTool,
|
|
1402
|
+
ask_fast_brain: askFastBrainTool,
|
|
1155
1403
|
respond_permission: respondPermissionTool,
|
|
1156
1404
|
},
|
|
1157
1405
|
});
|
|
@@ -1171,31 +1419,51 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1171
1419
|
// Clean up active research and voice queue
|
|
1172
1420
|
voiceQueue.length = 0;
|
|
1173
1421
|
isProcessingQueue = false;
|
|
1422
|
+
currentSpeechHandle = null;
|
|
1423
|
+
lastInterruption = null;
|
|
1174
1424
|
if (researchBatchTimer) {
|
|
1175
1425
|
clearTimeout(researchBatchTimer);
|
|
1176
1426
|
researchBatchTimer = null;
|
|
1177
1427
|
}
|
|
1428
|
+
stopProactiveLoop();
|
|
1178
1429
|
if (activeResearch) {
|
|
1430
|
+
activeResearch.abortController.abort();
|
|
1179
1431
|
activeResearch.cleanup();
|
|
1180
1432
|
activeResearch = null;
|
|
1181
1433
|
}
|
|
1434
|
+
lastCompletedResearch = null;
|
|
1182
1435
|
currentSession = null;
|
|
1183
1436
|
currentAgent = null;
|
|
1184
1437
|
currentLLM = null;
|
|
1438
|
+
clearFastBrainSession();
|
|
1439
|
+
clearPipelineFastBrainSession();
|
|
1185
1440
|
});
|
|
1186
1441
|
room.on(RoomEvent.ParticipantConnected, async (participant) => {
|
|
1187
1442
|
console.log(`\n👤 User joined: ${participant.identity}`);
|
|
1443
|
+
// Wait for previous session's byte stream handler to fully deregister.
|
|
1444
|
+
// Quick reconnects (< ~6s) crash with "byte stream handler already set" without this.
|
|
1445
|
+
if (pendingSessionClose) {
|
|
1446
|
+
console.log('⏳ Waiting for previous session to fully close...');
|
|
1447
|
+
await pendingSessionClose;
|
|
1448
|
+
}
|
|
1188
1449
|
// Clean up any existing session before creating a new one
|
|
1189
1450
|
voiceQueue.length = 0;
|
|
1190
1451
|
isProcessingQueue = false;
|
|
1452
|
+
currentSpeechHandle = null;
|
|
1453
|
+
lastInterruption = null;
|
|
1191
1454
|
if (researchBatchTimer) {
|
|
1192
1455
|
clearTimeout(researchBatchTimer);
|
|
1193
1456
|
researchBatchTimer = null;
|
|
1194
1457
|
}
|
|
1458
|
+
stopProactiveLoop();
|
|
1459
|
+
clearFastBrainSession();
|
|
1460
|
+
clearPipelineFastBrainSession();
|
|
1195
1461
|
if (activeResearch) {
|
|
1462
|
+
activeResearch.abortController.abort();
|
|
1196
1463
|
activeResearch.cleanup();
|
|
1197
1464
|
activeResearch = null;
|
|
1198
1465
|
}
|
|
1466
|
+
lastCompletedResearch = null;
|
|
1199
1467
|
if (currentSession) {
|
|
1200
1468
|
console.log('🧹 Cleaning up previous session...');
|
|
1201
1469
|
try {
|
|
@@ -1218,7 +1486,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1218
1486
|
try {
|
|
1219
1487
|
const metadata = JSON.parse(participant.metadata || '{}');
|
|
1220
1488
|
console.log(`📋 Participant metadata:`, metadata);
|
|
1221
|
-
if (metadata.voiceArch === 'realtime' || metadata.voiceArch === 'direct') {
|
|
1489
|
+
if (metadata.voiceArch === 'realtime' || metadata.voiceArch === 'direct' || metadata.voiceArch === 'pipeline') {
|
|
1222
1490
|
sessionVoiceMode = metadata.voiceArch;
|
|
1223
1491
|
console.log(`🎙️ Using voice mode from frontend: ${sessionVoiceMode}`);
|
|
1224
1492
|
}
|
|
@@ -1235,6 +1503,15 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1235
1503
|
preSelectedSessionId = metadata.sessionId;
|
|
1236
1504
|
console.log(`📂 Pre-selected session from frontend: ${preSelectedSessionId}`);
|
|
1237
1505
|
}
|
|
1506
|
+
// Read working directory override from frontend
|
|
1507
|
+
if (metadata.workingDirectory && typeof metadata.workingDirectory === 'string' && metadata.workingDirectory.length > 0) {
|
|
1508
|
+
workingDir = metadata.workingDirectory;
|
|
1509
|
+
console.log(`📂 Working directory from frontend: ${workingDir}`);
|
|
1510
|
+
}
|
|
1511
|
+
else {
|
|
1512
|
+
// Reset to default for new connections (in case previous session changed it)
|
|
1513
|
+
workingDir = defaultWorkingDir;
|
|
1514
|
+
}
|
|
1238
1515
|
}
|
|
1239
1516
|
catch (err) {
|
|
1240
1517
|
console.log('⚠️ Could not parse participant metadata, using config voiceMode:', voiceMode);
|
|
@@ -1244,6 +1521,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1244
1521
|
currentProvider = sessionRealtimeProvider;
|
|
1245
1522
|
// Resume session ID — only set when resuming an existing session
|
|
1246
1523
|
const resumeSessionId = preSelectedSessionId || undefined;
|
|
1524
|
+
currentResumeSessionId = resumeSessionId;
|
|
1247
1525
|
if (resumeSessionId) {
|
|
1248
1526
|
console.log(`🆔 Resuming session: ${resumeSessionId}`);
|
|
1249
1527
|
}
|
|
@@ -1261,6 +1539,46 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1261
1539
|
session = result.session;
|
|
1262
1540
|
agent = result.agent;
|
|
1263
1541
|
}
|
|
1542
|
+
else if (sessionVoiceMode === 'pipeline') {
|
|
1543
|
+
console.log(`🎯 PIPELINE MODE: Claude SDK + parallel Gemini fast brain observer`);
|
|
1544
|
+
// Pipeline mode = direct mode underneath + parallel fast brain
|
|
1545
|
+
// Fast brain runs in PipelineDirectLLM.chat() — fires Gemini alongside Claude
|
|
1546
|
+
const { createPipelineDirectLLM } = await import('./pipeline-direct-llm.js');
|
|
1547
|
+
const pipelineLLM = createPipelineDirectLLM({
|
|
1548
|
+
workingDirectory: workingDir,
|
|
1549
|
+
sessionBaseDir,
|
|
1550
|
+
mcpServers,
|
|
1551
|
+
resumeSessionId,
|
|
1552
|
+
voiceMode: 'direct',
|
|
1553
|
+
skipTTSQueue: true,
|
|
1554
|
+
getChatHistory: () => getChatHistory(20).map(t => ({ role: t.role, content: t.text })),
|
|
1555
|
+
getResearchContext: () => {
|
|
1556
|
+
if (activeResearch?.researchLog.length) {
|
|
1557
|
+
return `Research: "${lastTaskRequest}"\n${activeResearch.researchLog.slice(-15).join('\n')}`;
|
|
1558
|
+
}
|
|
1559
|
+
if (lastCompletedResearch && Date.now() - lastCompletedResearch.completedAt < 600000) {
|
|
1560
|
+
return `[COMPLETED] "${lastCompletedResearch.task}"\n${lastCompletedResearch.researchLog.slice(-15).join('\n')}`;
|
|
1561
|
+
}
|
|
1562
|
+
},
|
|
1563
|
+
getAndConsumeInterruptionContext,
|
|
1564
|
+
onFastBrainResult: (result) => {
|
|
1565
|
+
console.log(`🧠⚡ [FAST_BRAIN ${result.type.toUpperCase()} +${result.elapsedMs}ms]: "${result.answer.substring(0, 60)}"`);
|
|
1566
|
+
sendToFrontend({
|
|
1567
|
+
type: 'fast_brain_response',
|
|
1568
|
+
text: result.answer,
|
|
1569
|
+
responseType: result.type,
|
|
1570
|
+
elapsedMs: result.elapsedMs,
|
|
1571
|
+
question: result.question,
|
|
1572
|
+
toolsUsed: result.toolsUsed,
|
|
1573
|
+
agentRole: 'pipeline-fast-brain',
|
|
1574
|
+
});
|
|
1575
|
+
},
|
|
1576
|
+
});
|
|
1577
|
+
// Pass pipelineLLM to createDirectSession so it uses it instead of creating a new ClaudeLLM
|
|
1578
|
+
const result = await createDirectSession(resumeSessionId, pipelineLLM);
|
|
1579
|
+
session = result.session;
|
|
1580
|
+
agent = result.agent;
|
|
1581
|
+
}
|
|
1264
1582
|
else {
|
|
1265
1583
|
console.log(`🎯 DIRECT MODE: Claude Agent SDK with full coding capabilities`);
|
|
1266
1584
|
const result = await createDirectSession(resumeSessionId);
|
|
@@ -1273,7 +1591,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1273
1591
|
// Session event wiring — extracted into function for auto-recovery
|
|
1274
1592
|
// ============================================================
|
|
1275
1593
|
let lastRecoveryTime = 0;
|
|
1276
|
-
const MIN_RECOVERY_INTERVAL =
|
|
1594
|
+
const MIN_RECOVERY_INTERVAL = 3000; // 3 seconds between recovery attempts
|
|
1277
1595
|
function wireSessionEvents(sess, agt) {
|
|
1278
1596
|
// Transcript dedup state (reset per wiring)
|
|
1279
1597
|
let lastSentUserTranscript = '';
|
|
@@ -1286,6 +1604,10 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1286
1604
|
return;
|
|
1287
1605
|
if (normalized === '<noise>' || normalized.toLowerCase() === 'thank you')
|
|
1288
1606
|
return;
|
|
1607
|
+
// Filter out voice injection content that appears as user transcript
|
|
1608
|
+
// (Gemini v1.0.51: userInput in generateReply creates a user conversation item)
|
|
1609
|
+
if (normalized.startsWith('[SCRIPT]') || normalized.startsWith('[PROACTIVE]') || normalized.startsWith('[NOTIFICATION]'))
|
|
1610
|
+
return;
|
|
1289
1611
|
console.log(`📝 User (${source}): "${transcript.substring(0, 60)}..."`);
|
|
1290
1612
|
sendToFrontend({ type: 'user_transcript', text: transcript });
|
|
1291
1613
|
lastSentUserTranscript = normalized;
|
|
@@ -1342,6 +1664,10 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1342
1664
|
sess.on('user_state_changed', (ev) => {
|
|
1343
1665
|
userState = ev.newState;
|
|
1344
1666
|
console.log(`👤 User state: ${ev.newState}`);
|
|
1667
|
+
// When user stops speaking, retry voice queue — items may be waiting
|
|
1668
|
+
if (ev.newState === 'listening' && voiceQueue.length > 0) {
|
|
1669
|
+
setTimeout(() => processVoiceQueue(), 500);
|
|
1670
|
+
}
|
|
1345
1671
|
});
|
|
1346
1672
|
// FALLBACK: playout_completed
|
|
1347
1673
|
sess.on('playout_completed', (ev) => {
|
|
@@ -1358,13 +1684,153 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1358
1684
|
console.log('⚠️ OpenAI active response collision — queue will retry on next listening state');
|
|
1359
1685
|
return;
|
|
1360
1686
|
}
|
|
1687
|
+
// TTS abort from user interruption is normal — not an error
|
|
1688
|
+
if (msg.includes('Request was aborted') || msg.includes('APIUserAbortError') || msg.includes('aborted')) {
|
|
1689
|
+
console.log('⚠️ LLM request aborted (user interrupted)');
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1361
1692
|
console.error('❌ Session error:', ev.error);
|
|
1362
1693
|
});
|
|
1363
|
-
//
|
|
1694
|
+
// Capture voice mode at session creation — prevents state confusion
|
|
1695
|
+
// if currentVoiceMode changes between session start and crash recovery
|
|
1696
|
+
const sessionVoiceMode = currentVoiceMode;
|
|
1697
|
+
// Close handler with auto-recovery for crashes (both realtime and direct modes)
|
|
1364
1698
|
sess.on('close', async (ev) => {
|
|
1365
1699
|
console.log('🚪 Session closed:', ev.reason);
|
|
1700
|
+
// TTS abort from user interruption — SDK already killed the session internally,
|
|
1701
|
+
// so we MUST recover (can't just reset state — STT pipeline is dead).
|
|
1702
|
+
// Log it distinctly so we know it's an interrupt recovery, not a real crash.
|
|
1703
|
+
const errorMsg = ev.error?.message || ev.error?.error?.message || '';
|
|
1704
|
+
const isTTSAbort = errorMsg.includes('aborted') || errorMsg.includes('APIUserAbortError');
|
|
1705
|
+
if (isTTSAbort) {
|
|
1706
|
+
console.log('⚠️ TTS abort from user interruption — recovering session (SDK killed it internally)');
|
|
1707
|
+
}
|
|
1708
|
+
// Auto-recover from crashes in direct/pipeline mode (includes TTS abort)
|
|
1709
|
+
if ((ev.reason === 'error' || ev.reason === 'disconnected') && (sessionVoiceMode === 'direct' || sessionVoiceMode === 'pipeline')) {
|
|
1710
|
+
const now = Date.now();
|
|
1711
|
+
if (now - lastRecoveryTime < MIN_RECOVERY_INTERVAL) {
|
|
1712
|
+
console.log(`⚠️ Recovery too frequent — scheduling retry in ${MIN_RECOVERY_INTERVAL}ms`);
|
|
1713
|
+
setTimeout(async () => {
|
|
1714
|
+
// Re-check: if session was already recovered or user left, skip
|
|
1715
|
+
if (currentSession || !room.remoteParticipants.size)
|
|
1716
|
+
return;
|
|
1717
|
+
console.log('🔄 Retrying direct mode recovery after guard interval...');
|
|
1718
|
+
// Trigger recovery by emitting a synthetic close
|
|
1719
|
+
sess.emit('close', { reason: 'error' });
|
|
1720
|
+
}, MIN_RECOVERY_INTERVAL);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
lastRecoveryTime = now;
|
|
1724
|
+
console.log(`🔄 Auto-recovering direct mode session (reason: ${ev.reason})...`);
|
|
1725
|
+
// Clean up dead session — match realtime recovery's thoroughness
|
|
1726
|
+
try {
|
|
1727
|
+
sess.removeAllListeners();
|
|
1728
|
+
}
|
|
1729
|
+
catch { }
|
|
1730
|
+
currentSession = null;
|
|
1731
|
+
currentAgent = null;
|
|
1732
|
+
// Clear stale state from crashed session
|
|
1733
|
+
voiceQueue.length = 0;
|
|
1734
|
+
isProcessingQueue = false;
|
|
1735
|
+
haikuInFlight = null;
|
|
1736
|
+
if (researchBatchTimer) {
|
|
1737
|
+
clearTimeout(researchBatchTimer);
|
|
1738
|
+
researchBatchTimer = null;
|
|
1739
|
+
}
|
|
1740
|
+
stopProactiveLoop();
|
|
1741
|
+
if (activeResearch) {
|
|
1742
|
+
activeResearch.abortController.abort();
|
|
1743
|
+
activeResearch.cleanup();
|
|
1744
|
+
activeResearch = null;
|
|
1745
|
+
}
|
|
1746
|
+
try {
|
|
1747
|
+
// Reuse existing session ID so Claude SDK resumes where it left off
|
|
1748
|
+
const recoverySessionId = currentLLM?.sessionId || resumeSessionId;
|
|
1749
|
+
// Stop old index watcher if it exists
|
|
1750
|
+
if (currentLLM && 'stopIndexWatcher' in currentLLM) {
|
|
1751
|
+
currentLLM.stopIndexWatcher();
|
|
1752
|
+
}
|
|
1753
|
+
let result;
|
|
1754
|
+
if (sessionVoiceMode === 'pipeline') {
|
|
1755
|
+
// Pipeline mode: recreate PipelineDirectLLM wrapper with fast brain
|
|
1756
|
+
console.log('🔄 Rebuilding pipeline mode (PipelineDirectLLM + fast brain)...');
|
|
1757
|
+
const { createPipelineDirectLLM } = await import('./pipeline-direct-llm.js');
|
|
1758
|
+
const pipelineLLM = createPipelineDirectLLM({
|
|
1759
|
+
workingDirectory: workingDir,
|
|
1760
|
+
sessionBaseDir,
|
|
1761
|
+
mcpServers,
|
|
1762
|
+
resumeSessionId: recoverySessionId,
|
|
1763
|
+
voiceMode: 'direct',
|
|
1764
|
+
skipTTSQueue: true,
|
|
1765
|
+
getChatHistory: () => getChatHistory(20).map(t => ({ role: t.role, content: t.text })),
|
|
1766
|
+
getResearchContext: () => {
|
|
1767
|
+
if (activeResearch?.researchLog.length) {
|
|
1768
|
+
return `Research: "${lastTaskRequest}"\n${activeResearch.researchLog.slice(-15).join('\n')}`;
|
|
1769
|
+
}
|
|
1770
|
+
if (lastCompletedResearch && Date.now() - lastCompletedResearch.completedAt < 600000) {
|
|
1771
|
+
return `[COMPLETED] "${lastCompletedResearch.task}"\n${lastCompletedResearch.researchLog.slice(-15).join('\n')}`;
|
|
1772
|
+
}
|
|
1773
|
+
},
|
|
1774
|
+
getAndConsumeInterruptionContext,
|
|
1775
|
+
onFastBrainResult: (r) => {
|
|
1776
|
+
console.log(`🧠⚡ [FAST_BRAIN ${r.type.toUpperCase()} +${r.elapsedMs}ms]: "${r.answer.substring(0, 60)}"`);
|
|
1777
|
+
sendToFrontend({
|
|
1778
|
+
type: 'fast_brain_response', text: r.answer, responseType: r.type,
|
|
1779
|
+
elapsedMs: r.elapsedMs, question: r.question, toolsUsed: r.toolsUsed,
|
|
1780
|
+
agentRole: 'pipeline-fast-brain',
|
|
1781
|
+
});
|
|
1782
|
+
},
|
|
1783
|
+
});
|
|
1784
|
+
result = await createDirectSession(recoverySessionId, pipelineLLM);
|
|
1785
|
+
}
|
|
1786
|
+
else {
|
|
1787
|
+
result = await createDirectSession(recoverySessionId);
|
|
1788
|
+
}
|
|
1789
|
+
const newSession = result.session;
|
|
1790
|
+
const newAgent = result.agent;
|
|
1791
|
+
currentSession = newSession;
|
|
1792
|
+
currentAgent = newAgent;
|
|
1793
|
+
// Re-wire event listeners on the new session
|
|
1794
|
+
wireSessionEvents(newSession, newAgent);
|
|
1795
|
+
await newSession.start({ agent: newAgent, room });
|
|
1796
|
+
// Sync state
|
|
1797
|
+
agentState = 'listening';
|
|
1798
|
+
sendToFrontend({ type: 'agent_state', state: 'listening' });
|
|
1799
|
+
// Resume Claude session if one was active
|
|
1800
|
+
if (currentLLM?.sessionId) {
|
|
1801
|
+
currentLLM.setContinueSession(true);
|
|
1802
|
+
}
|
|
1803
|
+
console.log('✅ Direct mode auto-recovery complete');
|
|
1804
|
+
// Notify user via TTS
|
|
1805
|
+
try {
|
|
1806
|
+
const recoveredId = currentLLM?.sessionId || recoverySessionId;
|
|
1807
|
+
if (recoveredId) {
|
|
1808
|
+
const conversationHistory = await getConversationHistory(recoveredId, workingDir, 10);
|
|
1809
|
+
const historyForScript = conversationHistory.map(e => ({ role: e.role, text: e.content }));
|
|
1810
|
+
const script = await prepareRecoveryScript(historyForScript);
|
|
1811
|
+
// Direct mode: use session.say() for recovery notification
|
|
1812
|
+
newSession.say(script, { allowInterruptions: true });
|
|
1813
|
+
}
|
|
1814
|
+
else {
|
|
1815
|
+
newSession.say('Voice session was briefly interrupted but I\'m back. What were we working on?', { allowInterruptions: true });
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
catch (err) {
|
|
1819
|
+
console.log('⚠️ Failed to generate recovery script:', err);
|
|
1820
|
+
try {
|
|
1821
|
+
newSession.say('I\'m back after a brief interruption. What were we working on?', { allowInterruptions: true });
|
|
1822
|
+
}
|
|
1823
|
+
catch { }
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
catch (err) {
|
|
1827
|
+
console.error('❌ Direct mode auto-recovery failed:', err);
|
|
1828
|
+
sendToFrontend({ type: 'agent_state', state: 'error' });
|
|
1829
|
+
}
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1366
1832
|
// Auto-recover from crashes in realtime mode
|
|
1367
|
-
if (ev.reason === 'error' &&
|
|
1833
|
+
if (ev.reason === 'error' && sessionVoiceMode === 'realtime') {
|
|
1368
1834
|
const now = Date.now();
|
|
1369
1835
|
if (now - lastRecoveryTime < MIN_RECOVERY_INTERVAL) {
|
|
1370
1836
|
console.log('⚠️ Recovery too frequent — skipping to prevent loop');
|
|
@@ -1387,7 +1853,9 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1387
1853
|
clearTimeout(researchBatchTimer);
|
|
1388
1854
|
researchBatchTimer = null;
|
|
1389
1855
|
}
|
|
1856
|
+
stopProactiveLoop();
|
|
1390
1857
|
if (activeResearch) {
|
|
1858
|
+
activeResearch.abortController.abort();
|
|
1391
1859
|
activeResearch.cleanup();
|
|
1392
1860
|
activeResearch = null;
|
|
1393
1861
|
}
|
|
@@ -1411,29 +1879,23 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1411
1879
|
if (currentLLM?.sessionId) {
|
|
1412
1880
|
currentLLM.setContinueSession(true);
|
|
1413
1881
|
}
|
|
1414
|
-
//
|
|
1882
|
+
// Generate recovery script via fast brain
|
|
1415
1883
|
const recoveredSessionId = currentLLM?.sessionId || recoverySessionId;
|
|
1416
1884
|
if (recoveredSessionId) {
|
|
1417
1885
|
try {
|
|
1418
|
-
const
|
|
1419
|
-
const
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
console.log('📋 Injected conversation context into recovered session');
|
|
1424
|
-
}
|
|
1425
|
-
else {
|
|
1426
|
-
queueVoiceInjection('[NOTIFICATION] The voice session was briefly interrupted but has been recovered. Ask the user if they can hear you and continue where you left off. Do NOT call any tools.');
|
|
1427
|
-
}
|
|
1886
|
+
const conversationHistory = await getConversationHistory(recoveredSessionId, workingDir, 10);
|
|
1887
|
+
const historyForScript = conversationHistory.map(e => ({ role: e.role, text: e.content }));
|
|
1888
|
+
const script = await prepareRecoveryScript(historyForScript);
|
|
1889
|
+
queueVoiceInjection(getScriptInjection(script));
|
|
1890
|
+
console.log('📋 Injected recovery script into recovered session');
|
|
1428
1891
|
}
|
|
1429
1892
|
catch (err) {
|
|
1430
|
-
console.log('⚠️ Failed to
|
|
1431
|
-
queueVoiceInjection('
|
|
1893
|
+
console.log('⚠️ Failed to generate recovery script:', err);
|
|
1894
|
+
queueVoiceInjection(getNotificationInjection('Voice session was briefly interrupted but I\'m back. What were we working on?'));
|
|
1432
1895
|
}
|
|
1433
1896
|
}
|
|
1434
1897
|
else {
|
|
1435
|
-
|
|
1436
|
-
queueVoiceInjection('[NOTIFICATION] The voice session was briefly interrupted but has been recovered. Ask the user if they can hear you and continue where you left off. Do NOT call any tools.');
|
|
1898
|
+
queueVoiceInjection(getNotificationInjection('Voice session was briefly interrupted but I\'m back. What were we working on?'));
|
|
1437
1899
|
}
|
|
1438
1900
|
console.log('✅ Auto-recovery complete');
|
|
1439
1901
|
}
|
|
@@ -1481,6 +1943,8 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1481
1943
|
preSelectedSessionId,
|
|
1482
1944
|
mcpServers: getMcpServerStatusList(config),
|
|
1483
1945
|
enabledMcpServers: enabledMcpNames,
|
|
1946
|
+
workingDirectory: workingDir,
|
|
1947
|
+
skills: loadSkillsList(sessionBaseDir),
|
|
1484
1948
|
});
|
|
1485
1949
|
};
|
|
1486
1950
|
const readyInterval = setInterval(sendReady, 2000);
|
|
@@ -1499,8 +1963,8 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1499
1963
|
// For direct mode: use say() which goes through the configured TTS
|
|
1500
1964
|
const greetViaVoice = async (text) => {
|
|
1501
1965
|
if (sessionVoiceMode === 'realtime') {
|
|
1502
|
-
//
|
|
1503
|
-
await session.generateReply({
|
|
1966
|
+
// Use instructions (not userInput) to avoid system text appearing as user transcript
|
|
1967
|
+
await session.generateReply({ instructions: getScriptInjection(text) });
|
|
1504
1968
|
}
|
|
1505
1969
|
else {
|
|
1506
1970
|
await session.say(text);
|
|
@@ -1521,7 +1985,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1521
1985
|
success: true,
|
|
1522
1986
|
});
|
|
1523
1987
|
// Send existing workspace artifacts to frontend (session-scoped)
|
|
1524
|
-
const preArtifacts = listWorkspaceArtifacts(
|
|
1988
|
+
const preArtifacts = listWorkspaceArtifacts(sessionBaseDir, preSelectedSessionId);
|
|
1525
1989
|
if (preArtifacts.length > 0) {
|
|
1526
1990
|
console.log(`📁 Sending ${preArtifacts.length} workspace artifacts to frontend`);
|
|
1527
1991
|
await sendToFrontend({
|
|
@@ -1535,18 +1999,14 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1535
1999
|
}))
|
|
1536
2000
|
});
|
|
1537
2001
|
}
|
|
1538
|
-
//
|
|
2002
|
+
// Generate briefing script via fast brain
|
|
1539
2003
|
if (summary) {
|
|
1540
2004
|
loadSessionHistoryIntoChatCtx(currentAgent, conversationHistory, currentProvider);
|
|
1541
|
-
const contextBriefing = buildContextBriefing(summary, conversationHistory, currentProvider);
|
|
1542
|
-
const specContent = getSpecForVoiceModel(workingDir, preSelectedSessionId);
|
|
1543
|
-
const specSection = specContent
|
|
1544
|
-
? `\n\n=== SESSION SPEC ===\n${specContent}\n=== END SPEC ===\nCheck "Open Questions" — if any are unanswered, ask the user about them.`
|
|
1545
|
-
: '';
|
|
1546
2005
|
try {
|
|
1547
2006
|
if (sessionVoiceMode === 'realtime') {
|
|
1548
|
-
const
|
|
1549
|
-
await
|
|
2007
|
+
const historyForScript = conversationHistory.map(e => ({ role: e.role, text: e.content }));
|
|
2008
|
+
const script = await prepareBriefingScript(sessionBaseDir, preSelectedSessionId, historyForScript);
|
|
2009
|
+
await session.generateReply({ instructions: getScriptInjection(script) });
|
|
1550
2010
|
}
|
|
1551
2011
|
else {
|
|
1552
2012
|
await session.say("Welcome back! Ready to continue our previous conversation.");
|
|
@@ -1566,7 +2026,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1566
2026
|
// No sessions at all (or new session chosen) — greet as new user
|
|
1567
2027
|
try {
|
|
1568
2028
|
console.log('👋 Sending greeting...');
|
|
1569
|
-
await greetViaVoice("
|
|
2029
|
+
await greetViaVoice("Hey! I'm Osborn, your AI research assistant. What are you working on today?");
|
|
1570
2030
|
console.log('✅ Greeting sent');
|
|
1571
2031
|
}
|
|
1572
2032
|
catch (err) {
|
|
@@ -1580,11 +2040,41 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1580
2040
|
});
|
|
1581
2041
|
room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
1582
2042
|
console.log(`👋 User left: ${participant.identity}`);
|
|
2043
|
+
// Full cleanup — stop all background work to avoid accumulating API usage
|
|
2044
|
+
voiceQueue.length = 0;
|
|
2045
|
+
isProcessingQueue = false;
|
|
2046
|
+
currentSpeechHandle = null;
|
|
2047
|
+
lastInterruption = null;
|
|
2048
|
+
if (researchBatchTimer) {
|
|
2049
|
+
clearTimeout(researchBatchTimer);
|
|
2050
|
+
researchBatchTimer = null;
|
|
2051
|
+
}
|
|
2052
|
+
stopProactiveLoop();
|
|
2053
|
+
if (activeResearch) {
|
|
2054
|
+
activeResearch.abortController.abort();
|
|
2055
|
+
activeResearch.cleanup();
|
|
2056
|
+
activeResearch = null;
|
|
2057
|
+
}
|
|
1583
2058
|
if (currentSession) {
|
|
1584
|
-
currentSession
|
|
2059
|
+
const sessionToClose = currentSession;
|
|
1585
2060
|
currentSession = null;
|
|
1586
|
-
|
|
2061
|
+
// Track async close so new connections can wait for byte stream handler to be released
|
|
2062
|
+
pendingSessionClose = (async () => {
|
|
2063
|
+
try {
|
|
2064
|
+
await sessionToClose.close();
|
|
2065
|
+
}
|
|
2066
|
+
catch { }
|
|
2067
|
+
try {
|
|
2068
|
+
sessionToClose.removeAllListeners();
|
|
2069
|
+
}
|
|
2070
|
+
catch { }
|
|
2071
|
+
pendingSessionClose = null;
|
|
2072
|
+
})();
|
|
1587
2073
|
}
|
|
2074
|
+
currentAgent = null;
|
|
2075
|
+
currentLLM = null;
|
|
2076
|
+
clearFastBrainSession();
|
|
2077
|
+
clearPipelineFastBrainSession();
|
|
1588
2078
|
console.log('⏳ Waiting for new user...\n');
|
|
1589
2079
|
});
|
|
1590
2080
|
room.on(RoomEvent.DataReceived, async (payload, participant, kind, topic) => {
|
|
@@ -1641,20 +2131,21 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1641
2131
|
}
|
|
1642
2132
|
}
|
|
1643
2133
|
else if (data.type === 'resume_session' && currentLLM) {
|
|
1644
|
-
//
|
|
2134
|
+
// Lightweight: set resume ID and send artifacts to frontend only
|
|
2135
|
+
// Context injection (generateReply) happens in session_selected handler
|
|
2136
|
+
// to avoid double generateReply calls that cause timeouts
|
|
1645
2137
|
const sessionId = data.sessionId;
|
|
1646
2138
|
if (sessionId && sessionExists(sessionId, workingDir)) {
|
|
1647
2139
|
currentLLM.setResumeSessionId(sessionId);
|
|
2140
|
+
currentResumeSessionId = sessionId;
|
|
1648
2141
|
console.log(`🔄 Will resume session: ${sessionId}`);
|
|
1649
|
-
const summary = await getSessionSummary(sessionId, workingDir);
|
|
1650
|
-
const conversationHistory = await getConversationHistory(sessionId, workingDir, 30);
|
|
1651
2142
|
await sendToFrontend({
|
|
1652
2143
|
type: 'session_resume_set',
|
|
1653
2144
|
sessionId,
|
|
1654
2145
|
success: true,
|
|
1655
2146
|
});
|
|
1656
2147
|
// Send existing session artifacts to frontend (session-scoped)
|
|
1657
|
-
const artifacts = listWorkspaceArtifacts(
|
|
2148
|
+
const artifacts = listWorkspaceArtifacts(sessionBaseDir, sessionId);
|
|
1658
2149
|
if (artifacts.length > 0) {
|
|
1659
2150
|
console.log(`📁 Sending ${artifacts.length} session artifacts to frontend`);
|
|
1660
2151
|
await sendToFrontend({
|
|
@@ -1668,27 +2159,6 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1668
2159
|
}))
|
|
1669
2160
|
});
|
|
1670
2161
|
}
|
|
1671
|
-
if (currentSession && summary) {
|
|
1672
|
-
loadSessionHistoryIntoChatCtx(currentAgent, conversationHistory, currentProvider);
|
|
1673
|
-
const contextBriefing = buildContextBriefing(summary, conversationHistory, currentProvider);
|
|
1674
|
-
const specContent = getSpecForVoiceModel(workingDir, sessionId);
|
|
1675
|
-
const specSection = specContent
|
|
1676
|
-
? `\n\n=== SESSION SPEC ===\n${specContent}\n=== END SPEC ===\nCheck "Open Questions" — if any are unanswered, ask the user about them.`
|
|
1677
|
-
: '';
|
|
1678
|
-
console.log('📋 Injecting session context into voice agent...');
|
|
1679
|
-
try {
|
|
1680
|
-
if (currentVoiceMode === 'realtime') {
|
|
1681
|
-
const contextPrompt = `[SESSION RESUMED] The user chose to continue a previous research session. Here's the context:\n${contextBriefing}${specSection}\n\nBriefly acknowledge the previous session. If there are open questions in the spec, ask the most important one. Otherwise ask what they'd like to continue with.`;
|
|
1682
|
-
await currentSession.generateReply({ instructions: contextPrompt });
|
|
1683
|
-
}
|
|
1684
|
-
else {
|
|
1685
|
-
await currentSession.say("Ready to continue our previous conversation.");
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
catch (err) {
|
|
1689
|
-
console.log('⚠️ Context injection failed:', err);
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
2162
|
}
|
|
1693
2163
|
else {
|
|
1694
2164
|
console.error(`❌ Session not found: ${sessionId}`);
|
|
@@ -1704,6 +2174,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1704
2174
|
const recentId = await getMostRecentSessionId(workingDir);
|
|
1705
2175
|
if (recentId) {
|
|
1706
2176
|
currentLLM.setResumeSessionId(recentId);
|
|
2177
|
+
currentResumeSessionId = recentId;
|
|
1707
2178
|
console.log(`🔄 Continuing most recent session: ${recentId}`);
|
|
1708
2179
|
const summary = await getSessionSummary(recentId, workingDir);
|
|
1709
2180
|
const conversationHistory = await getConversationHistory(recentId, workingDir, 30);
|
|
@@ -1713,7 +2184,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1713
2184
|
success: true,
|
|
1714
2185
|
});
|
|
1715
2186
|
// Send existing session artifacts to frontend (session-scoped)
|
|
1716
|
-
const artifacts = listWorkspaceArtifacts(
|
|
2187
|
+
const artifacts = listWorkspaceArtifacts(sessionBaseDir, recentId);
|
|
1717
2188
|
if (artifacts.length > 0) {
|
|
1718
2189
|
console.log(`📁 Sending ${artifacts.length} session artifacts to frontend`);
|
|
1719
2190
|
await sendToFrontend({
|
|
@@ -1729,16 +2200,12 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1729
2200
|
}
|
|
1730
2201
|
if (currentSession && summary) {
|
|
1731
2202
|
loadSessionHistoryIntoChatCtx(currentAgent, conversationHistory, currentProvider);
|
|
1732
|
-
const contextBriefing = buildContextBriefing(summary, conversationHistory, currentProvider);
|
|
1733
|
-
const specContent = getSpecForVoiceModel(workingDir, recentId);
|
|
1734
|
-
const specSection = specContent
|
|
1735
|
-
? `\n\n=== SESSION SPEC ===\n${specContent}\n=== END SPEC ===\nCheck "Open Questions" — if any are unanswered, ask the user about them.`
|
|
1736
|
-
: '';
|
|
1737
2203
|
console.log('📋 Injecting session context into voice agent...');
|
|
1738
2204
|
try {
|
|
1739
2205
|
if (currentVoiceMode === 'realtime') {
|
|
1740
|
-
const
|
|
1741
|
-
await
|
|
2206
|
+
const historyForScript = conversationHistory.map(e => ({ role: e.role, text: e.content }));
|
|
2207
|
+
const script = await prepareBriefingScript(sessionBaseDir, recentId, historyForScript);
|
|
2208
|
+
await currentSession.generateReply({ instructions: getScriptInjection(script) });
|
|
1742
2209
|
}
|
|
1743
2210
|
else {
|
|
1744
2211
|
await currentSession.say("Continuing where we left off.");
|
|
@@ -1769,6 +2236,9 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1769
2236
|
// Step 2: Reset LLM state and configure for new session
|
|
1770
2237
|
currentLLM.resetForSessionSwitch();
|
|
1771
2238
|
currentLLM.setResumeSessionId(sessionId);
|
|
2239
|
+
currentResumeSessionId = sessionId;
|
|
2240
|
+
clearFastBrainSession();
|
|
2241
|
+
clearPipelineFastBrainSession();
|
|
1772
2242
|
console.log(`🔄 Switched to session: ${sessionId}`);
|
|
1773
2243
|
// Step 3: Send full context to frontend (including conversation history)
|
|
1774
2244
|
await sendToFrontend({
|
|
@@ -1779,7 +2249,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1779
2249
|
conversationHistory,
|
|
1780
2250
|
});
|
|
1781
2251
|
// Step 3.5: Send existing session artifacts to frontend (session-scoped)
|
|
1782
|
-
const switchArtifacts = listWorkspaceArtifacts(
|
|
2252
|
+
const switchArtifacts = listWorkspaceArtifacts(sessionBaseDir, sessionId);
|
|
1783
2253
|
if (switchArtifacts.length > 0) {
|
|
1784
2254
|
console.log(`📁 Sending ${switchArtifacts.length} session artifacts to frontend`);
|
|
1785
2255
|
await sendToFrontend({
|
|
@@ -1793,14 +2263,14 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1793
2263
|
}))
|
|
1794
2264
|
});
|
|
1795
2265
|
}
|
|
1796
|
-
// Step 4: Voice agent acknowledges context
|
|
2266
|
+
// Step 4: Voice agent acknowledges context via fast brain
|
|
1797
2267
|
if (currentSession && summary) {
|
|
1798
2268
|
loadSessionHistoryIntoChatCtx(currentAgent, conversationHistory, currentProvider);
|
|
1799
|
-
const contextBriefing = buildContextBriefing(summary, conversationHistory, currentProvider);
|
|
1800
2269
|
try {
|
|
1801
2270
|
if (currentVoiceMode === 'realtime') {
|
|
1802
|
-
const
|
|
1803
|
-
await
|
|
2271
|
+
const historyForScript = conversationHistory.map(e => ({ role: e.role, text: e.content }));
|
|
2272
|
+
const briefingScript = await prepareBriefingScript(sessionBaseDir, sessionId, historyForScript, 'switch');
|
|
2273
|
+
queueVoiceInjection(getScriptInjection(briefingScript));
|
|
1804
2274
|
}
|
|
1805
2275
|
else {
|
|
1806
2276
|
const acknowledgment = summary.lastMessages.length > 0
|
|
@@ -1834,7 +2304,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1834
2304
|
else if (data.type === 'get_session_artifacts') {
|
|
1835
2305
|
const sessionId = data.sessionId;
|
|
1836
2306
|
if (sessionId) {
|
|
1837
|
-
const artifacts = listWorkspaceArtifacts(
|
|
2307
|
+
const artifacts = listWorkspaceArtifacts(sessionBaseDir, sessionId);
|
|
1838
2308
|
console.log(`📁 Sending ${artifacts.length} session artifacts for ${sessionId.substring(0, 8)}`);
|
|
1839
2309
|
await sendToFrontend({
|
|
1840
2310
|
type: 'session_artifacts',
|
|
@@ -1871,7 +2341,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1871
2341
|
const fs = await import('fs');
|
|
1872
2342
|
const fileName = filePath.split('/').pop() || '';
|
|
1873
2343
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
1874
|
-
const isImage = ['png', 'jpg', 'jpeg', '
|
|
2344
|
+
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext);
|
|
1875
2345
|
if (isImage) {
|
|
1876
2346
|
const base64 = fs.readFileSync(filePath, 'base64');
|
|
1877
2347
|
await sendToFrontend({ type: 'research_artifact_content', filePath, content: base64, fileName, isImage: true, mimeType: `image/${ext}` });
|
|
@@ -1970,12 +2440,40 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1970
2440
|
enabledKeys,
|
|
1971
2441
|
});
|
|
1972
2442
|
}
|
|
2443
|
+
else if (data.type === 'get_skills') {
|
|
2444
|
+
await sendToFrontend({
|
|
2445
|
+
type: 'skills_status',
|
|
2446
|
+
skills: loadSkillsList(sessionBaseDir),
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
else if (data.type === 'skill_add') {
|
|
2450
|
+
const skillName = (data.name || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
2451
|
+
const skillContent = (data.content || '').trim();
|
|
2452
|
+
if (!skillName || !skillContent) {
|
|
2453
|
+
await sendToFrontend({ type: 'skill_add_result', success: false, error: 'Name and content are required' });
|
|
2454
|
+
}
|
|
2455
|
+
else {
|
|
2456
|
+
try {
|
|
2457
|
+
const skillDir = join(sessionBaseDir, '.claude', 'skills', skillName);
|
|
2458
|
+
mkdirSync(skillDir, { recursive: true });
|
|
2459
|
+
writeFileSync(join(skillDir, 'SKILL.md'), skillContent, 'utf-8');
|
|
2460
|
+
console.log(`📚 Skill added: ${skillName}`);
|
|
2461
|
+
const skills = loadSkillsList(sessionBaseDir);
|
|
2462
|
+
await sendToFrontend({ type: 'skill_add_result', success: true, skills });
|
|
2463
|
+
}
|
|
2464
|
+
catch (err) {
|
|
2465
|
+
console.error('❌ Failed to add skill:', err);
|
|
2466
|
+
await sendToFrontend({ type: 'skill_add_result', success: false, error: String(err) });
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
1973
2470
|
else if (data.type === 'session_selected') {
|
|
1974
2471
|
const sessionId = data.sessionId;
|
|
1975
2472
|
console.log(`🚪 Session gate completed: ${sessionId ? `resume ${sessionId}` : 'fresh start'}`);
|
|
1976
2473
|
if (sessionId && currentLLM && sessionExists(sessionId, workingDir)) {
|
|
1977
2474
|
// Resume the selected session
|
|
1978
2475
|
currentLLM.setResumeSessionId(sessionId);
|
|
2476
|
+
currentResumeSessionId = sessionId;
|
|
1979
2477
|
console.log(`🔄 Resuming session: ${sessionId}`);
|
|
1980
2478
|
// Fetch context and greet with it
|
|
1981
2479
|
const summary = await getSessionSummary(sessionId, workingDir);
|
|
@@ -1986,7 +2484,7 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
1986
2484
|
success: true,
|
|
1987
2485
|
});
|
|
1988
2486
|
// Send existing session artifacts to frontend (session-scoped)
|
|
1989
|
-
const gateArtifacts = listWorkspaceArtifacts(
|
|
2487
|
+
const gateArtifacts = listWorkspaceArtifacts(sessionBaseDir, sessionId);
|
|
1990
2488
|
if (gateArtifacts.length > 0) {
|
|
1991
2489
|
console.log(`📁 Sending ${gateArtifacts.length} session artifacts to frontend`);
|
|
1992
2490
|
await sendToFrontend({
|
|
@@ -2000,18 +2498,14 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
2000
2498
|
}))
|
|
2001
2499
|
});
|
|
2002
2500
|
}
|
|
2003
|
-
// Load full session history and greet with context
|
|
2501
|
+
// Load full session history and greet with context via fast brain
|
|
2004
2502
|
if (currentSession && summary) {
|
|
2005
2503
|
loadSessionHistoryIntoChatCtx(currentAgent, conversationHistory, currentProvider);
|
|
2006
|
-
const contextBriefing = buildContextBriefing(summary, conversationHistory, currentProvider);
|
|
2007
|
-
const specContent = getSpecForVoiceModel(workingDir, sessionId);
|
|
2008
|
-
const specSection = specContent
|
|
2009
|
-
? `\n\n=== SESSION SPEC ===\n${specContent}\n=== END SPEC ===\nCheck "Open Questions" — if any are unanswered, ask the user about them.`
|
|
2010
|
-
: '';
|
|
2011
2504
|
try {
|
|
2012
2505
|
if (currentVoiceMode === 'realtime') {
|
|
2013
|
-
const
|
|
2014
|
-
await
|
|
2506
|
+
const historyForScript = conversationHistory.map(e => ({ role: e.role, text: e.content }));
|
|
2507
|
+
const briefingScript = await prepareBriefingScript(sessionBaseDir, sessionId, historyForScript, 'resume');
|
|
2508
|
+
queueVoiceInjection(getScriptInjection(briefingScript));
|
|
2015
2509
|
}
|
|
2016
2510
|
else {
|
|
2017
2511
|
await currentSession.say("Welcome back! Ready to continue our previous conversation.");
|
|
@@ -2023,12 +2517,13 @@ If the fast brain responds with NEEDS_DEEPER_RESEARCH, tell the user you need to
|
|
|
2023
2517
|
}
|
|
2024
2518
|
}
|
|
2025
2519
|
else {
|
|
2026
|
-
// Fresh start -
|
|
2520
|
+
// Fresh start - greet via voice queue (not userInput, which creates a user transcript)
|
|
2521
|
+
currentResumeSessionId = undefined;
|
|
2027
2522
|
console.log('🆕 Starting fresh session');
|
|
2028
2523
|
if (currentSession) {
|
|
2029
2524
|
try {
|
|
2030
2525
|
if (currentVoiceMode === 'realtime') {
|
|
2031
|
-
|
|
2526
|
+
queueVoiceInjection(getScriptInjection("Hey! I'm Osborn, your AI research assistant. What are you working on today?"));
|
|
2032
2527
|
}
|
|
2033
2528
|
else {
|
|
2034
2529
|
await currentSession.say("Hey! I'm Osborn. What are you working on?");
|