osborn 0.5.3 → 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.
Files changed (37) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.claude/skills/markdown-to-pdf/SKILL.md +29 -0
  3. package/.claude/skills/pdf-to-markdown/SKILL.md +28 -0
  4. package/.claude/skills/playwright-browser/SKILL.md +75 -0
  5. package/.claude/skills/youtube-transcript/SKILL.md +24 -0
  6. package/dist/claude-llm.d.ts +29 -1
  7. package/dist/claude-llm.js +334 -78
  8. package/dist/config.d.ts +5 -1
  9. package/dist/config.js +4 -1
  10. package/dist/fast-brain.d.ts +70 -16
  11. package/dist/fast-brain.js +662 -99
  12. package/dist/index-3-2-26-legacy.d.ts +1 -0
  13. package/dist/index-3-2-26-legacy.js +2233 -0
  14. package/dist/index.js +752 -423
  15. package/dist/jsonl-search.d.ts +66 -0
  16. package/dist/jsonl-search.js +274 -0
  17. package/dist/leagcyprompts2.d.ts +0 -0
  18. package/dist/leagcyprompts2.js +573 -0
  19. package/dist/pipeline-direct-llm.d.ts +77 -0
  20. package/dist/pipeline-direct-llm.js +216 -0
  21. package/dist/pipeline-fastbrain.d.ts +45 -0
  22. package/dist/pipeline-fastbrain.js +367 -0
  23. package/dist/prompts-2-25-26.d.ts +0 -0
  24. package/dist/prompts-2-25-26.js +518 -0
  25. package/dist/prompts-3-2-26.d.ts +78 -0
  26. package/dist/prompts-3-2-26.js +1319 -0
  27. package/dist/prompts.d.ts +83 -12
  28. package/dist/prompts.js +1991 -588
  29. package/dist/session-access.d.ts +24 -0
  30. package/dist/session-access.js +74 -0
  31. package/dist/summary-index.d.ts +87 -0
  32. package/dist/summary-index.js +570 -0
  33. package/dist/turn-detector-shim.d.ts +24 -0
  34. package/dist/turn-detector-shim.js +83 -0
  35. package/dist/voice-io.d.ts +9 -3
  36. package/dist/voice-io.js +39 -20
  37. package/package.json +13 -10
@@ -1,32 +1,34 @@
1
1
  /**
2
- * Fast Brain Agent Middle-tier intelligence for the Voice AI System
2
+ * Fast Brain — Central Orchestrator for the Voice AI System
3
3
  *
4
- * A fast intermediary between the realtime voice model and the Claude SDK agent.
5
- * Uses direct API calls for ~2 second responses.
4
+ * The sole intelligence layer between the user and all backend capabilities.
5
+ * The realtime voice model is a thin teleprompter — it speaks what this module returns.
6
6
  *
7
7
  * Capabilities:
8
8
  * - Read/write session files (spec.md + library/)
9
9
  * - Web search for quick factual lookups
10
10
  * - Record user decisions and preferences into spec.md
11
- * - Post-research: synthesize findings into spec.md
12
- * - Escalate to ask_agent when deeper research is needed
11
+ * - Trigger deep research (via callbacks to index.ts)
12
+ * - Generate teleprompter scripts for ALL voice output
13
+ * - Post-research: synthesize findings from JSONL into spec.md + voice scripts
14
+ * - Generate visual documents (comparison, diagram, analysis, summary)
13
15
  *
14
- * Key constraint: The fast brain NEVER calls ask_agent. The realtime model is always the router.
16
+ * Central function: askFastBrain() ALL user questions route here.
17
+ * It returns a FastBrainResponse with a teleprompter script the voice model reads verbatim.
15
18
  *
16
19
  * Auth chain (tried in order):
17
20
  * 1. ANTHROPIC_API_KEY env var → Anthropic SDK (Haiku)
18
21
  * 2. ANTHROPIC_AUTH_TOKEN env var → Anthropic SDK (Haiku)
19
22
  * 3. GOOGLE_API_KEY env var → Gemini Flash fallback
20
- *
21
- * Note: Claude Code OAuth (macOS Keychain) was tested but Anthropic's Messages API
22
- * rejects OAuth tokens with 401 "OAuth authentication is currently not supported."
23
23
  */
24
24
  import Anthropic from '@anthropic-ai/sdk';
25
+ import { query as sdkQuery, tool as sdkTool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
25
26
  import { GoogleGenAI } from '@google/genai';
26
27
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
27
28
  import { dirname, basename } from 'path';
29
+ import { z } from 'zod';
28
30
  import { getSessionWorkspace, readSessionSpec, listLibraryFiles } from './config.js';
29
- import { FAST_BRAIN_SYSTEM_PROMPT, CHUNK_PROCESS_SYSTEM, REFINEMENT_PROCESS_SYSTEM, AUGMENT_RESULT_SYSTEM, CONTEXTUALIZE_UPDATE_SYSTEM, PROACTIVE_PROMPT_SYSTEM, VISUAL_DOCUMENT_SYSTEM } from './prompts.js';
31
+ import { FAST_BRAIN_SYSTEM_PROMPT, CHUNK_PROCESS_SYSTEM, REFINEMENT_PROCESS_SYSTEM, AUGMENT_RESULT_SYSTEM, CONTEXTUALIZE_UPDATE_SYSTEM, PROACTIVE_PROMPT_SYSTEM, VISUAL_DOCUMENT_SYSTEM, RESEARCH_COMPLETION_SYSTEM, buildFastBrainSdkPrompt } from './prompts.js';
30
32
  import { getRecentToolResults, readSessionHistory, getSubagentTranscripts, getConversationText, getSessionTranscripts, searchSessionJsonl, getSessionStats } from './session-access.js';
31
33
  // ============================================================
32
34
  // Content extraction — pulls useful snippets from tool responses
@@ -70,55 +72,81 @@ let initialized = false;
70
72
  // Model IDs — configurable per provider
71
73
  const ANTHROPIC_FAST_MODEL = 'claude-haiku-4-5-20251001';
72
74
  const GEMINI_FAST_MODEL = 'gemini-2.0-flash';
73
- /** No-op history is now sourced live from agent.chatCtx, passed per-call */
75
+ // Agent SDK session tracking resume across voice questions for context continuity
76
+ let fastBrainSessionId = null;
77
+ // Gemini Chat session — persists across voice questions for context continuity.
78
+ // The Chat object auto-manages full conversation history (messages + tool calls).
79
+ // Cleared on disconnect/reconnect/session switch via clearFastBrainSession().
80
+ let geminiChat = null;
81
+ const MAX_FAST_BRAIN_HISTORY = 30;
82
+ let fastBrainHistory = [];
83
+ /** Clear fast brain session state — call on disconnect/reconnect/session switch */
84
+ export function clearFastBrainSession() {
85
+ fastBrainSessionId = null;
86
+ geminiChat = null;
87
+ fastBrainHistory = [];
88
+ console.log('🧠 Fast brain: session cleared (SDK + Gemini chat + Anthropic history)');
89
+ }
90
+ /** @deprecated Use clearFastBrainSession() instead */
74
91
  export function clearFastBrainHistory() {
75
- console.log('🧠 Fast brain: conversation history cleared (no-op — sourced from chatCtx)');
92
+ clearFastBrainSession();
76
93
  }
77
94
  function initProvider() {
78
95
  if (initialized)
79
96
  return;
80
97
  initialized = true;
81
- // 1. ANTHROPIC_API_KEY
98
+ // Initialize fallback clients (Gemini for fallback, Anthropic direct API if key available)
99
+ const googleKey = process.env.GOOGLE_API_KEY;
100
+ if (googleKey) {
101
+ geminiClient = new GoogleGenAI({ apiKey: googleKey });
102
+ }
82
103
  const apiKey = process.env.ANTHROPIC_API_KEY;
83
104
  if (apiKey) {
84
105
  anthropicClient = new Anthropic({ apiKey });
85
- provider = 'anthropic';
86
- console.log('🧠 Fast brain: using Anthropic API (ANTHROPIC_API_KEY)');
87
- return;
88
106
  }
89
- // 2. ANTHROPIC_AUTH_TOKEN (if user sets it explicitly)
90
- const authToken = process.env.ANTHROPIC_AUTH_TOKEN;
91
- if (authToken) {
92
- anthropicClient = new Anthropic({ authToken });
93
- provider = 'anthropic';
94
- console.log('🧠 Fast brain: using Anthropic API (ANTHROPIC_AUTH_TOKEN)');
95
- return;
107
+ else {
108
+ const authToken = process.env.ANTHROPIC_AUTH_TOKEN;
109
+ if (authToken) {
110
+ anthropicClient = new Anthropic({ authToken });
111
+ }
96
112
  }
97
- // NOTE: Claude Code OAuth (macOS Keychain) was tested but Anthropic's Messages API
98
- // returns 401 "OAuth authentication is currently not supported." cannot reuse it.
99
- // 3. Gemini Flash fallback (uses GOOGLE_API_KEY already in .env)
100
- const googleKey = process.env.GOOGLE_API_KEY;
101
- if (googleKey) {
102
- geminiClient = new GoogleGenAI({ apiKey: googleKey });
113
+ // PRIMARY: Gemini Flash fastest (~1-2s), handles 1M tokens, no cold start.
114
+ // Agent SDK Haiku is too slow (~10-15s) due to CLI process spawn + session overhead.
115
+ if (geminiClient) {
103
116
  provider = 'gemini';
104
- console.log(`🧠 Fast brain: using Gemini Flash fallback (${GEMINI_FAST_MODEL})`);
105
- return;
117
+ console.log(`🧠 Fast brain: using Gemini Flash (primary) — fastest response time`);
118
+ if (anthropicClient) {
119
+ console.log(`🧠 Fast brain: Direct Anthropic API available as fallback`);
120
+ }
121
+ }
122
+ else if (anthropicClient) {
123
+ provider = 'anthropic';
124
+ console.log(`🧠 Fast brain: using Anthropic API (primary) — no Gemini key available`);
125
+ }
126
+ else {
127
+ // Last resort: Agent SDK is slow but functional
128
+ provider = 'agent-sdk';
129
+ console.log(`🧠 Fast brain: using Claude Agent SDK (fallback) — no API keys available`);
106
130
  }
107
- // No provider available
108
- provider = 'none';
109
- console.error('⚠️ Fast brain: no API key available — fast brain disabled');
110
- console.error(' Set ANTHROPIC_API_KEY or GOOGLE_API_KEY in agent/.env');
111
131
  }
112
132
  // ============================================================
113
133
  // Tool execution (shared across providers)
114
134
  // ============================================================
115
- function executeTool(toolName, toolInput, workspace, sessionId, workingDir) {
135
+ // Track whether send_to_chat was called during a fast brain conversation.
136
+ // If the LLM calls send_to_chat but returns no text, we use a fallback
137
+ // instead of "No answer found."
138
+ let sendToChatCalledThisTurn = false;
139
+ function executeTool(toolName, toolInput, workspace, sessionId, workingDir, sendToChat) {
116
140
  try {
117
141
  switch (toolName) {
118
142
  case 'read_file': {
119
143
  const relPath = toolInput.path;
120
144
  if (relPath.includes('..'))
121
145
  return 'Error: path traversal not allowed';
146
+ const ext = relPath.toLowerCase().split('.').pop() || '';
147
+ const BINARY_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'mp3', 'wav', 'mp4', 'mov'];
148
+ if (BINARY_EXTS.includes(ext))
149
+ return `Binary file (${ext}) — cannot read as text.`;
122
150
  const fullPath = `${workspace}/${relPath}`;
123
151
  if (!existsSync(fullPath))
124
152
  return `File not found: ${relPath}`;
@@ -273,6 +301,18 @@ ${toolList}`;
273
301
  }
274
302
  return output;
275
303
  }
304
+ case 'send_to_chat': {
305
+ const text = toolInput.text;
306
+ if (!text)
307
+ return 'Error: text is required';
308
+ if (sendToChat) {
309
+ console.log(`💬 [fast brain] send_to_chat: ${text.substring(0, 80)}...`);
310
+ sendToChat(text);
311
+ sendToChatCalledThisTurn = true;
312
+ return `Sent to chat successfully. Now return a brief spoken summary — do NOT repeat the content you just sent.`;
313
+ }
314
+ return 'Error: chat sending not available';
315
+ }
276
316
  default:
277
317
  return `Unknown tool: ${toolName}`;
278
318
  }
@@ -389,6 +429,17 @@ function buildAnthropicTools() {
389
429
  name: 'deep_read_text',
390
430
  description: 'Read ALL agent reasoning and analysis across the ENTIRE session — not just recent messages. Returns every piece of thinking, synthesis, comparison, and recommendation the agent produced. Use this for generating comprehensive overviews or when the user asks for detailed explanations of what the agent found.',
391
431
  input_schema: { type: 'object', properties: {} }
432
+ },
433
+ {
434
+ name: 'send_to_chat',
435
+ description: 'Send formatted content to the user\'s chat panel. Use for URLs, links, lists, prices, code snippets, or anything that\'s better read than spoken. The content appears as a chat message in the frontend. You should STILL speak a brief summary — use this tool for the detailed/visual content.',
436
+ input_schema: {
437
+ type: 'object',
438
+ properties: {
439
+ text: { type: 'string', description: 'The formatted text to display in chat. Supports markdown.' }
440
+ },
441
+ required: ['text']
442
+ }
392
443
  }
393
444
  ];
394
445
  }
@@ -521,6 +572,17 @@ function buildGeminiTools() {
521
572
  name: 'deep_read_text',
522
573
  description: 'Read ALL agent reasoning across the ENTIRE session. For comprehensive overviews or detailed explanations of what the agent found throughout the session.',
523
574
  parameters: { type: 'object', properties: {} }
575
+ },
576
+ {
577
+ name: 'send_to_chat',
578
+ description: 'Send formatted content to the user\'s chat panel. Use for URLs, links, lists, prices, code snippets, or anything better read than spoken. Still speak a brief summary — use this for the detailed/visual content.',
579
+ parameters: {
580
+ type: 'object',
581
+ properties: {
582
+ text: { type: 'string', description: 'The formatted text to display in chat. Supports markdown.' }
583
+ },
584
+ required: ['text']
585
+ }
524
586
  }
525
587
  ]
526
588
  }
@@ -549,23 +611,150 @@ async function geminiWebSearch(query) {
549
611
  }
550
612
  }
551
613
  // ============================================================
552
- // Anthropic Q&A implementation
614
+ // Agent SDK Q&A implementation — replaces direct Anthropic API for Q&A
553
615
  // ============================================================
554
- async function askViaAnthropic(question, workspace, researchContext, sessionId, workingDir, chatHistory) {
616
+ /**
617
+ * Create an in-process MCP server with the send_to_chat tool for the Agent SDK fast brain.
618
+ */
619
+ function createFastBrainMcpServer(sendToChat) {
620
+ const tools = [];
621
+ if (sendToChat) {
622
+ tools.push(sdkTool('send_to_chat', 'Send formatted content to the user\'s chat panel. Use for URLs, links, lists, prices, code snippets, tables, or anything better read than spoken. Supports markdown. You should STILL speak a brief summary — the chat content is supplementary.', { text: z.string().describe('The formatted text to display in chat. Supports markdown.') }, async ({ text }) => {
623
+ sendToChat(text);
624
+ sendToChatCalledThisTurn = true;
625
+ return { content: [{ type: 'text', text: 'Sent to chat. Now give a brief spoken summary of what you sent.' }] };
626
+ }));
627
+ }
628
+ return createSdkMcpServer({
629
+ name: 'osborn-fast-brain',
630
+ version: '1.0.0',
631
+ tools,
632
+ });
633
+ }
634
+ /**
635
+ * Ask via Claude Agent SDK — the agent traverses JSONL files natively using Read/Grep/Glob.
636
+ * Falls back to Gemini on timeout or error.
637
+ */
638
+ async function askViaAgentSdk(question, workspace, researchContext, sessionId, workingDir, chatHistory, sendToChat, sessionBaseDir) {
639
+ sendToChatCalledThisTurn = false;
640
+ // Build the prompt with conversation context
641
+ let prompt = question;
642
+ if (researchContext) {
643
+ prompt += `\n\n[LIVE RESEARCH CONTEXT — the deep research agent is currently working]\n${researchContext}`;
644
+ }
645
+ if (chatHistory && chatHistory.length > 0) {
646
+ const historyStr = chatHistory.slice(-15).map(t => `${t.role}: ${t.text}`).join('\n');
647
+ prompt = `[Recent voice conversation]\n${historyStr}\n\n[Current question]\n${prompt}`;
648
+ }
649
+ // Create MCP server for send_to_chat
650
+ const mcpServer = createFastBrainMcpServer(sendToChat);
651
+ // Build system prompt with computed paths
652
+ const systemPrompt = buildFastBrainSdkPrompt(workingDir || workspace, sessionId || '', sessionBaseDir || workingDir || workspace);
653
+ // Tools: Read/Write/Edit for files, Grep/Glob for search, WebSearch/WebFetch for web
654
+ const toolNames = ['Read', 'Write', 'Edit', 'Grep', 'Glob', 'WebSearch', 'WebFetch'];
655
+ const mcpToolPatterns = sendToChat ? ['mcp__osborn-fast-brain__*'] : [];
656
+ const options = {
657
+ model: ANTHROPIC_FAST_MODEL,
658
+ cwd: workingDir,
659
+ systemPrompt,
660
+ maxTurns: 8,
661
+ tools: toolNames,
662
+ allowedTools: [...toolNames, ...mcpToolPatterns],
663
+ mcpServers: { 'osborn-fast-brain': mcpServer },
664
+ };
665
+ if (fastBrainSessionId) {
666
+ options.resume = fastBrainSessionId;
667
+ }
668
+ // Run with 15s timeout — falls back to Gemini on timeout
669
+ const TIMEOUT_MS = 15000;
670
+ let timeoutHandle;
671
+ const timeoutPromise = new Promise((_, reject) => {
672
+ timeoutHandle = setTimeout(() => reject(new Error('fast-brain-timeout')), TIMEOUT_MS);
673
+ });
674
+ const queryPromise = (async () => {
675
+ let result = '';
676
+ try {
677
+ for await (const message of sdkQuery({ prompt, options })) {
678
+ if (message.type === 'result') {
679
+ result = message.result || '';
680
+ }
681
+ // Capture session ID eagerly — even if we timeout, next call can resume
682
+ if (message.type === 'assistant' && message.session_id) {
683
+ const sid = message.session_id;
684
+ if (sid !== fastBrainSessionId) {
685
+ fastBrainSessionId = sid;
686
+ console.log(`🧠 Fast brain session: ${sid.substring(0, 12)}... (${options.resume ? 'resumed' : 'new'})`);
687
+ }
688
+ }
689
+ }
690
+ }
691
+ catch (err) {
692
+ console.error('❌ Agent SDK query error:', err);
693
+ throw err;
694
+ }
695
+ clearTimeout(timeoutHandle);
696
+ return result;
697
+ })();
698
+ try {
699
+ const result = await Promise.race([queryPromise, timeoutPromise]);
700
+ if (!result || result.trim().length === 0) {
701
+ if (sendToChatCalledThisTurn)
702
+ return "I've sent the details to your chat panel.";
703
+ return 'No answer found.';
704
+ }
705
+ console.log(`🧠 Agent SDK fast brain: ${result.length} chars (session: ${fastBrainSessionId?.substring(0, 8) || 'new'})`);
706
+ return result;
707
+ }
708
+ catch (err) {
709
+ clearTimeout(timeoutHandle);
710
+ if (err.message === 'fast-brain-timeout') {
711
+ console.log('⏱️ Agent SDK fast brain timed out (15s), falling back to Gemini');
712
+ }
713
+ else {
714
+ console.error('❌ Agent SDK fast brain error:', err.message || err);
715
+ }
716
+ // Fall back to Gemini if available
717
+ if (geminiClient) {
718
+ console.log('🔄 Falling back to Gemini fast brain');
719
+ return askViaGemini(question, workspace, researchContext, sessionId, workingDir, chatHistory, sendToChat);
720
+ }
721
+ // Fall back to direct Anthropic API if no Gemini
722
+ if (anthropicClient) {
723
+ console.log('🔄 Falling back to direct Anthropic API');
724
+ return askViaAnthropic(question, workspace, researchContext, sessionId, workingDir, chatHistory, sendToChat);
725
+ }
726
+ return 'Fast brain unavailable. Try asking me to research it.';
727
+ }
728
+ }
729
+ // ============================================================
730
+ // Direct Anthropic API Q&A — kept as fallback for Agent SDK failures
731
+ // ============================================================
732
+ async function askViaAnthropic(question, workspace, researchContext, sessionId, workingDir, chatHistory, sendToChat) {
555
733
  const client = anthropicClient;
556
734
  const tools = buildAnthropicTools();
735
+ sendToChatCalledThisTurn = false;
557
736
  const userContent = researchContext
558
737
  ? `${question}\n\n[LIVE RESEARCH CONTEXT — the research agent is currently working]\n${researchContext}`
559
738
  : question;
560
- // Build messages from live voice conversation history (from agent.chatCtx)
739
+ // Build messages: persistent fast brain history + live voice history + current question
561
740
  const messages = [];
741
+ // 1. Inject persistent fast brain history (prior exchanges from this session)
742
+ for (const exchange of fastBrainHistory) {
743
+ messages.push({ role: 'user', content: exchange.question });
744
+ messages.push({ role: 'assistant', content: exchange.answer });
745
+ }
746
+ // 2. Inject live voice conversation history (from agent.chatCtx — what user/model actually said)
562
747
  if (chatHistory && chatHistory.length > 0) {
563
748
  for (const turn of chatHistory) {
564
749
  messages.push({ role: turn.role, content: turn.text });
565
750
  }
566
751
  }
752
+ // 3. Current question
567
753
  messages.push({ role: 'user', content: userContent });
568
754
  const allTools = [...tools, ANTHROPIC_WEB_SEARCH];
755
+ const noAnswerFallback = () => sendToChatCalledThisTurn
756
+ ? "I've sent the details to your chat panel."
757
+ : 'No answer found.';
569
758
  for (let i = 0; i < 10; i++) {
570
759
  const response = await client.messages.create({
571
760
  model: ANTHROPIC_FAST_MODEL,
@@ -576,70 +765,97 @@ async function askViaAnthropic(question, workspace, researchContext, sessionId,
576
765
  });
577
766
  if (response.stop_reason === 'end_turn') {
578
767
  const textBlock = response.content.find((b) => b.type === 'text');
579
- return textBlock?.text || 'No answer found.';
768
+ const answer = textBlock?.text || noAnswerFallback();
769
+ // Persist this exchange for future calls
770
+ fastBrainHistory.push({ question: userContent, answer });
771
+ if (fastBrainHistory.length > MAX_FAST_BRAIN_HISTORY)
772
+ fastBrainHistory.shift();
773
+ return answer;
580
774
  }
581
775
  const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
582
776
  if (toolUseBlocks.length === 0 && response.stop_reason !== 'tool_use') {
583
777
  const textBlock = response.content.find((b) => b.type === 'text');
584
- return textBlock?.text || 'No answer found.';
778
+ const answer = textBlock?.text || noAnswerFallback();
779
+ fastBrainHistory.push({ question: userContent, answer });
780
+ if (fastBrainHistory.length > MAX_FAST_BRAIN_HISTORY)
781
+ fastBrainHistory.shift();
782
+ return answer;
585
783
  }
586
784
  messages.push({ role: 'assistant', content: response.content });
587
785
  if (toolUseBlocks.length > 0) {
588
786
  const toolResults = toolUseBlocks.map(toolUse => ({
589
787
  type: 'tool_result',
590
788
  tool_use_id: toolUse.id,
591
- content: executeTool(toolUse.name, toolUse.input, workspace, sessionId, workingDir),
789
+ content: executeTool(toolUse.name, toolUse.input, workspace, sessionId, workingDir, sendToChat),
592
790
  }));
593
791
  messages.push({ role: 'user', content: toolResults });
594
792
  }
595
793
  }
794
+ if (sendToChatCalledThisTurn) {
795
+ const answer = "I've sent the full details to your chat. Let me know if you want to dive deeper into anything.";
796
+ fastBrainHistory.push({ question: userContent, answer });
797
+ if (fastBrainHistory.length > MAX_FAST_BRAIN_HISTORY)
798
+ fastBrainHistory.shift();
799
+ return answer;
800
+ }
596
801
  return 'Fast brain reached maximum tool iterations. Try ask_agent for a deeper search.';
597
802
  }
598
803
  // ============================================================
599
804
  // Gemini Q&A implementation
600
805
  // ============================================================
601
- async function askViaGemini(question, workspace, researchContext, sessionId, workingDir, chatHistory) {
806
+ async function askViaGemini(question, workspace, researchContext, sessionId, workingDir, chatHistory, sendToChat, sessionBaseDir) {
602
807
  const ai = geminiClient;
603
808
  const tools = buildGeminiTools();
809
+ sendToChatCalledThisTurn = false;
604
810
  const userContent = researchContext
605
811
  ? `${question}\n\n[LIVE RESEARCH CONTEXT — the research agent is currently working]\n${researchContext}`
606
812
  : question;
607
- // Build contents from live voice conversation history (from agent.chatCtx)
608
- const contents = [];
609
- if (chatHistory && chatHistory.length > 0) {
610
- for (const turn of chatHistory) {
611
- contents.push({
612
- role: turn.role === 'assistant' ? 'model' : 'user',
613
- parts: [{ text: turn.text }],
614
- });
813
+ // Create or reuse persistent Gemini Chat session.
814
+ // The Chat object auto-manages full conversation history (messages + tool calls).
815
+ // Cleared on disconnect/reconnect/session switch via clearFastBrainSession().
816
+ if (!geminiChat) {
817
+ // Seed with live voice conversation history so Gemini knows what user/model said
818
+ const history = [];
819
+ if (chatHistory && chatHistory.length > 0) {
820
+ for (const turn of chatHistory) {
821
+ history.push({
822
+ role: turn.role === 'assistant' ? 'model' : 'user',
823
+ parts: [{ text: turn.text }],
824
+ });
825
+ }
615
826
  }
616
- }
617
- contents.push({ role: 'user', parts: [{ text: userContent }] });
618
- for (let i = 0; i < 10; i++) {
619
- const response = await ai.models.generateContent({
827
+ geminiChat = ai.chats.create({
620
828
  model: GEMINI_FAST_MODEL,
621
- contents,
622
829
  config: {
623
830
  systemInstruction: FAST_BRAIN_SYSTEM_PROMPT,
624
831
  tools,
625
- }
832
+ },
833
+ history,
626
834
  });
835
+ console.log(`🧠 Gemini fast brain: new chat session (history: ${history.length} turns)`);
836
+ }
837
+ // Send user message via the persistent chat — history accumulates automatically.
838
+ // The Chat object tracks all messages + tool calls internally.
839
+ let response = await geminiChat.sendMessage({ message: userContent });
840
+ // Tool call loop: execute tools and send results back, up to 10 rounds
841
+ for (let i = 0; i < 10; i++) {
627
842
  const functionCalls = response.functionCalls;
628
843
  if (!functionCalls || functionCalls.length === 0) {
629
- return response.text || 'No answer found.';
844
+ const text = response.text;
845
+ if (text)
846
+ return text;
847
+ if (sendToChatCalledThisTurn)
848
+ return "I've sent the details to your chat panel.";
849
+ return 'No answer found.';
630
850
  }
631
- // Add model response to conversation
632
- if (response.candidates?.[0]?.content) {
633
- contents.push(response.candidates[0].content);
634
- }
635
- // Execute tools and send results back (web_search is async, others are sync)
851
+ // Execute tools
636
852
  const functionResponses = await Promise.all(functionCalls.map(async (call) => {
637
853
  let result;
638
854
  if (call.name === 'web_search') {
639
855
  result = await geminiWebSearch(call.args?.query || question);
640
856
  }
641
857
  else {
642
- result = executeTool(call.name, call.args || {}, workspace, sessionId, workingDir);
858
+ result = executeTool(call.name, call.args || {}, workspace, sessionId, workingDir, sendToChat);
643
859
  }
644
860
  return {
645
861
  functionResponse: {
@@ -648,7 +864,11 @@ async function askViaGemini(question, workspace, researchContext, sessionId, wor
648
864
  }
649
865
  };
650
866
  }));
651
- contents.push({ role: 'user', parts: functionResponses });
867
+ // Send tool results back — chat auto-tracks the full exchange
868
+ response = await geminiChat.sendMessage({ message: functionResponses });
869
+ }
870
+ if (sendToChatCalledThisTurn) {
871
+ return "I've sent the full details to your chat. Let me know if you want to dive deeper into anything.";
652
872
  }
653
873
  return 'Fast brain reached maximum tool iterations. Try ask_agent for a deeper search.';
654
874
  }
@@ -664,19 +884,181 @@ async function askViaGemini(question, workspace, researchContext, sessionId, wor
664
884
  * @param researchContext - Optional snapshot of the live research log.
665
885
  * ~2 second response time for most queries.
666
886
  */
667
- export async function askHaiku(workingDir, sessionId, question, researchContext, chatHistory) {
887
+ export async function askHaiku(workingDir, sessionId, question, researchContext, chatHistory, sendToChat, sessionBaseDir) {
668
888
  initProvider();
669
- if (provider === 'none') {
670
- return 'NEEDS_DEEPER_RESEARCH: Fast brain unavailable (no API key). Try ask_agent instead.';
889
+ // workspace uses sessionBaseDir (Osborn install dir) for spec.md/library
890
+ // workingDir is for JSONL access (matches Claude SDK cwd)
891
+ const wsDir = sessionBaseDir || workingDir;
892
+ const workspace = getSessionWorkspace(wsDir, sessionId);
893
+ // Primary: Gemini Flash (~1-2s) with pre-loaded JSONL context
894
+ // Fallback: Anthropic direct API or Agent SDK (slower but functional)
895
+ if (provider === 'gemini') {
896
+ return askViaGemini(question, workspace, researchContext, sessionId, workingDir, chatHistory, sendToChat, wsDir);
671
897
  }
672
- const workspace = getSessionWorkspace(workingDir, sessionId);
673
- if (provider === 'anthropic') {
674
- return askViaAnthropic(question, workspace, researchContext, sessionId, workingDir, chatHistory);
898
+ else if (provider === 'anthropic' || provider === 'agent-sdk') {
899
+ return askViaAgentSdk(question, workspace, researchContext, sessionId, workingDir, chatHistory, sendToChat, wsDir);
675
900
  }
676
901
  else {
677
- return askViaGemini(question, workspace, researchContext, sessionId, workingDir, chatHistory);
902
+ return 'NEEDS_DEEPER_RESEARCH: Fast brain unavailable (no API key or CLI auth). Try ask_agent instead.';
678
903
  }
679
904
  }
905
+ let researchTaskCounter = 0;
906
+ /**
907
+ * Central orchestrator — ALL user questions from the realtime model come here.
908
+ * Routes to: direct answer, research triggering, decision recording, or document generation.
909
+ * Returns a teleprompter script the voice model reads verbatim.
910
+ */
911
+ export async function askFastBrain(workingDir, sessionId, question, opts) {
912
+ const { chatHistory, researchContext, callbacks } = opts;
913
+ const wsDir = opts.sessionBaseDir || workingDir;
914
+ // Detect document generation requests
915
+ const docMatch = detectDocumentRequest(question);
916
+ if (docMatch) {
917
+ try {
918
+ const result = await generateVisualDocument(workingDir, sessionId, question, docMatch, wsDir);
919
+ if (result) {
920
+ const fullPath = `${wsDir}/.osborn/sessions/${sessionId}/library/${result.fileName}`;
921
+ callbacks.sendToFrontend({
922
+ type: 'research_artifact_updated',
923
+ filePath: fullPath,
924
+ fileName: result.fileName,
925
+ });
926
+ return {
927
+ script: `I've created a ${docMatch} document called ${result.fileName}. You can see it in the files panel.`,
928
+ type: 'answer',
929
+ };
930
+ }
931
+ }
932
+ catch (err) {
933
+ console.error('❌ askFastBrain: document generation failed:', err);
934
+ }
935
+ // Fall through to regular handling if document gen fails
936
+ }
937
+ // Create sendToChat wrapper that sends assistant_response to frontend
938
+ const sendToChat = (text) => {
939
+ callbacks.sendToFrontend({ type: 'assistant_response', text });
940
+ };
941
+ // Core: ask the fast brain LLM
942
+ const answer = await askHaiku(workingDir, sessionId, question, researchContext, chatHistory, sendToChat, wsDir);
943
+ // Parse the response to determine routing
944
+ if (answer.startsWith('RECORDED:') || answer.includes('\nRECORDED:')) {
945
+ // Decision was recorded — extract the confirmation
946
+ const recordedLine = answer.split('\n').find(l => l.startsWith('RECORDED:'));
947
+ const confirmation = recordedLine
948
+ ? recordedLine.replace('RECORDED:', '').trim()
949
+ : 'Got it, noted.';
950
+ // Notify frontend about spec update
951
+ const specPath = `${wsDir}/.osborn/sessions/${sessionId}/spec.md`;
952
+ callbacks.sendToFrontend({
953
+ type: 'research_artifact_updated',
954
+ filePath: specPath,
955
+ fileName: 'spec.md',
956
+ });
957
+ return { script: confirmation, type: 'recorded' };
958
+ }
959
+ // Handle ASK_USER — questions directed at the user (not research tasks)
960
+ if (answer.startsWith('ASK_USER:') || answer.includes('\nASK_USER:')) {
961
+ const askLine = answer.split('\n').find(l => l.includes('ASK_USER:'));
962
+ const userQuestion = askLine
963
+ ? askLine.replace(/^ASK_USER:\s*/, '').trim()
964
+ : answer.replace(/^ASK_USER:\s*/, '').trim();
965
+ return { script: userQuestion, type: 'question' };
966
+ }
967
+ if (answer.includes('NEEDS_DEEPER_RESEARCH')) {
968
+ // Extract the research task context
969
+ const needsLine = answer.split('\n').find(l => l.includes('NEEDS_DEEPER_RESEARCH'));
970
+ const contextLine = answer.split('\n').find(l => l.startsWith('CONTEXT:'));
971
+ const researchTask = needsLine
972
+ ? needsLine.replace(/^(PARTIAL:\s*)?NEEDS_DEEPER_RESEARCH:\s*/, '').trim()
973
+ : question;
974
+ const contextStr = contextLine ? contextLine.replace('CONTEXT:', '').trim() : '';
975
+ // Safety check: if the "research task" looks like a question for the user
976
+ // (ends with ?, asks about preferences/needs, is very short), treat it as ASK_USER instead.
977
+ // This catches the common Gemini bug where clarification questions are formatted as research tasks.
978
+ const taskLower = researchTask.toLowerCase();
979
+ const looksLikeUserQuestion = (researchTask.endsWith('?') && (taskLower.includes('would you') ||
980
+ taskLower.includes('do you') ||
981
+ taskLower.includes('could you') ||
982
+ taskLower.includes('what kind of') ||
983
+ taskLower.includes('which') ||
984
+ taskLower.includes('your needs') ||
985
+ taskLower.includes('your preference') ||
986
+ taskLower.includes('more details') ||
987
+ taskLower.includes('clarif') ||
988
+ taskLower.includes('specify') ||
989
+ taskLower.includes('interested in') ||
990
+ researchTask.length < 80 // Very short "tasks" ending in ? are almost always user questions
991
+ ));
992
+ if (looksLikeUserQuestion) {
993
+ console.log(`🧠 [fast brain] Caught question-as-research-task, redirecting to ASK_USER: "${researchTask.substring(0, 100)}"`);
994
+ return { script: researchTask, type: 'question' };
995
+ }
996
+ const fullTask = contextStr ? `${researchTask}\n\nContext: ${contextStr}` : researchTask;
997
+ // Extract any partial answer (spoken script before NEEDS_DEEPER_RESEARCH)
998
+ const partialMatch = answer.match(/^PARTIAL:\s*([\s\S]*?)(?=\nNEEDS_DEEPER_RESEARCH)/m);
999
+ const partialScript = partialMatch ? partialMatch[1].trim() : '';
1000
+ // Generate a task ID for frontend tracking
1001
+ researchTaskCounter++;
1002
+ const taskId = `research-${researchTaskCounter}-${Date.now()}`;
1003
+ // Trigger research in background
1004
+ callbacks.triggerResearch(fullTask);
1005
+ callbacks.sendToFrontend({
1006
+ type: 'research_task_started',
1007
+ task: researchTask.substring(0, 200),
1008
+ taskId,
1009
+ });
1010
+ // Generate acknowledgment script
1011
+ let script;
1012
+ if (partialScript) {
1013
+ script = `${partialScript} Let me dig deeper on the rest.`;
1014
+ }
1015
+ else {
1016
+ // Generate a contextual ack based on conversation flow
1017
+ script = generateResearchAck(question, chatHistory);
1018
+ }
1019
+ return { script, type: 'research_started' };
1020
+ }
1021
+ // Direct answer — the response IS the teleprompter script
1022
+ return { script: answer, type: 'answer' };
1023
+ }
1024
+ /** Detect if the user's question is an EXPLICIT document generation request.
1025
+ * Must be very specific — don't catch general questions about analysis or comparisons.
1026
+ * Only triggers when the user explicitly asks for a written document/artifact. */
1027
+ function detectDocumentRequest(question) {
1028
+ const q = question.toLowerCase();
1029
+ // Only match explicit document requests — "create a comparison", "make a diagram", "write a summary"
1030
+ // Do NOT match: "compare X and Y", "analyze the code", "give me an overview"
1031
+ const docVerbs = /(create|make|generate|write|build|produce|draw)\s+(a\s+|an\s+|the\s+)?/;
1032
+ if (!docVerbs.test(q))
1033
+ return null;
1034
+ if (q.includes('comparison') || q.includes('comparison table') || q.includes('comparison document'))
1035
+ return 'comparison';
1036
+ if (q.includes('diagram') || q.includes('flow chart') || q.includes('architecture diagram'))
1037
+ return 'diagram';
1038
+ if (q.includes('analysis document') || q.includes('tradeoff document'))
1039
+ return 'analysis';
1040
+ if (q.includes('summary document') || q.includes('overview document'))
1041
+ return 'summary';
1042
+ return null;
1043
+ }
1044
+ /** Generate a natural research acknowledgment based on conversation context */
1045
+ function generateResearchAck(question, chatHistory) {
1046
+ // Use simple heuristics for a natural ack — no LLM call needed
1047
+ const q = question.toLowerCase();
1048
+ if (q.includes('how') && (q.includes('work') || q.includes('implement'))) {
1049
+ return "Let me look into how that works. I'll have the details for you shortly.";
1050
+ }
1051
+ if (q.includes('what') && (q.includes('option') || q.includes('available') || q.includes('choice'))) {
1052
+ return "Let me research the options for you.";
1053
+ }
1054
+ if (q.includes('why') || q.includes('explain')) {
1055
+ return "Good question. Let me dig into that.";
1056
+ }
1057
+ if (q.includes('find') || q.includes('search') || q.includes('look')) {
1058
+ return "On it. Give me a moment to look into that.";
1059
+ }
1060
+ return "Let me research that for you. I'll have findings shortly.";
1061
+ }
680
1062
  // ============================================================
681
1063
  // processResearchChunk — Incremental content processing during research
682
1064
  // ============================================================
@@ -686,7 +1068,7 @@ export async function askHaiku(workingDir, sessionId, question, researchContext,
686
1068
  *
687
1069
  * @param isRefinement - true for the final post-research consolidation pass (higher token budget)
688
1070
  */
689
- export async function processResearchChunk(workingDir, sessionId, task, contentChunks, isRefinement) {
1071
+ export async function processResearchChunk(workingDir, sessionId, task, contentChunks, isRefinement, sessionBaseDir) {
690
1072
  initProvider();
691
1073
  if (provider === 'none')
692
1074
  return null;
@@ -698,8 +1080,9 @@ export async function processResearchChunk(workingDir, sessionId, task, contentC
698
1080
  return null;
699
1081
  }
700
1082
  specUpdateInProgress = true;
1083
+ const wsDir = sessionBaseDir || workingDir;
701
1084
  try {
702
- const workspace = getSessionWorkspace(workingDir, sessionId);
1085
+ const workspace = getSessionWorkspace(wsDir, sessionId);
703
1086
  const specPath = `${workspace}/spec.md`;
704
1087
  if (!existsSync(specPath)) {
705
1088
  console.log('⚠️ processResearchChunk: spec.md not found, skipping');
@@ -711,7 +1094,7 @@ export async function processResearchChunk(workingDir, sessionId, task, contentC
711
1094
  // Mid-research: skip library entirely to stay fast and avoid file proliferation
712
1095
  let existingSection = '';
713
1096
  if (isRefinement) {
714
- const existingFiles = listLibraryFiles(workingDir, sessionId);
1097
+ const existingFiles = listLibraryFiles(wsDir, sessionId);
715
1098
  const existingContents = [];
716
1099
  for (const file of existingFiles) {
717
1100
  const filePath = `${libraryDir}/${file}`;
@@ -744,7 +1127,7 @@ ${chunksText}
744
1127
 
745
1128
  Return ONLY valid JSON — no code fences, no explanation.`;
746
1129
  let responseText = null;
747
- if (provider === 'anthropic') {
1130
+ if (anthropicClient) {
748
1131
  const response = await anthropicClient.messages.create({
749
1132
  model: ANTHROPIC_FAST_MODEL,
750
1133
  max_tokens: isRefinement ? 20000 : 10000,
@@ -753,7 +1136,7 @@ Return ONLY valid JSON — no code fences, no explanation.`;
753
1136
  });
754
1137
  responseText = response.content[0].type === 'text' ? response.content[0].text : null;
755
1138
  }
756
- else {
1139
+ else if (geminiClient) {
757
1140
  const response = await geminiClient.models.generateContent({
758
1141
  model: GEMINI_FAST_MODEL,
759
1142
  contents: userMessage,
@@ -886,7 +1269,7 @@ ${specSection}${libSection}
886
1269
 
887
1270
  Augment the agent's findings with relevant context from the spec. Pass ALL details through verbatim.`;
888
1271
  let responseText = null;
889
- if (provider === 'anthropic') {
1272
+ if (anthropicClient) {
890
1273
  const response = await anthropicClient.messages.create({
891
1274
  model: ANTHROPIC_FAST_MODEL,
892
1275
  max_tokens: 16000,
@@ -895,7 +1278,7 @@ Augment the agent's findings with relevant context from the spec. Pass ALL detai
895
1278
  });
896
1279
  responseText = response.content[0].type === 'text' ? response.content[0].text : null;
897
1280
  }
898
- else {
1281
+ else if (geminiClient) {
899
1282
  const response = await geminiClient.models.generateContent({
900
1283
  model: GEMINI_FAST_MODEL,
901
1284
  contents: userMessage,
@@ -931,7 +1314,7 @@ Augment the agent's findings with relevant context from the spec. Pass ALL detai
931
1314
  *
932
1315
  * Returns { spec, libraryFiles } or null if update failed.
933
1316
  */
934
- export async function updateSpecFromJSONL(workingDir, sessionId, task, researchLog) {
1317
+ export async function updateSpecFromJSONL(workingDir, sessionId, task, researchLog, sessionBaseDir) {
935
1318
  initProvider();
936
1319
  if (provider === 'none')
937
1320
  return null;
@@ -983,7 +1366,7 @@ export async function updateSpecFromJSONL(workingDir, sessionId, task, researchL
983
1366
  const totalChars = contentChunks.reduce((sum, c) => sum + c.length, 0);
984
1367
  console.log(`📖 updateSpecFromJSONL: read ${toolResults.length} tool results, ${agentTexts.length} agent messages, ${subagents.length} sub-agents (${totalChars} total chars)`);
985
1368
  // 3. Pass to processResearchChunk with isRefinement=true
986
- return processResearchChunk(workingDir, sessionId, task, contentChunks, true);
1369
+ return processResearchChunk(workingDir, sessionId, task, contentChunks, true, sessionBaseDir);
987
1370
  }
988
1371
  catch (err) {
989
1372
  console.error('❌ updateSpecFromJSONL failed:', err);
@@ -1027,7 +1410,7 @@ Rules:
1027
1410
  - Output ONLY the full spec.md content or the word SKIP — nothing else`;
1028
1411
  const userMessage = `Current spec.md:\n\`\`\`\n${currentSpec}\n\`\`\`\n\nNew user question to track:\n"${question}"`;
1029
1412
  let responseText = null;
1030
- if (provider === 'anthropic') {
1413
+ if (anthropicClient) {
1031
1414
  const response = await anthropicClient.messages.create({
1032
1415
  model: ANTHROPIC_FAST_MODEL,
1033
1416
  max_tokens: 8000,
@@ -1036,7 +1419,7 @@ Rules:
1036
1419
  });
1037
1420
  responseText = response.content[0].type === 'text' ? response.content[0].text : null;
1038
1421
  }
1039
- else {
1422
+ else if (geminiClient) {
1040
1423
  const response = await geminiClient.models.generateContent({
1041
1424
  model: GEMINI_FAST_MODEL,
1042
1425
  contents: userMessage,
@@ -1123,7 +1506,7 @@ Rules:
1123
1506
  const truncatedOutput = output.length > 15000 ? output.substring(0, 15000) + '\n[... truncated]' : output;
1124
1507
  const userMessage = `Current spec.md:\n\`\`\`\n${currentSpec}\n\`\`\`\n\nAgent output (${outputType}):\n\`\`\`\n${truncatedOutput}\n\`\`\``;
1125
1508
  let responseText = null;
1126
- if (provider === 'anthropic') {
1509
+ if (anthropicClient) {
1127
1510
  const response = await anthropicClient.messages.create({
1128
1511
  model: ANTHROPIC_FAST_MODEL,
1129
1512
  max_tokens: 8000,
@@ -1132,7 +1515,7 @@ Rules:
1132
1515
  });
1133
1516
  responseText = response.content[0].type === 'text' ? response.content[0].text : null;
1134
1517
  }
1135
- else {
1518
+ else if (geminiClient) {
1136
1519
  const response = await geminiClient.models.generateContent({
1137
1520
  model: GEMINI_FAST_MODEL,
1138
1521
  contents: userMessage,
@@ -1168,12 +1551,13 @@ Rules:
1168
1551
  * Returns a natural 1-2 sentence update, or null if nothing interesting to say.
1169
1552
  * 3-second timeout — returns null if the LLM is too slow.
1170
1553
  */
1171
- export async function contextualizeResearchUpdate(workingDir, sessionId, task, batchEvents, researchLog) {
1554
+ export async function contextualizeResearchUpdate(workingDir, sessionId, task, batchEvents, researchLog, chatHistory, sessionBaseDir) {
1172
1555
  initProvider();
1173
1556
  if (provider === 'none')
1174
1557
  return null;
1558
+ const wsDir = sessionBaseDir || workingDir;
1175
1559
  try {
1176
- const specContent = readSessionSpec(workingDir, sessionId);
1560
+ const specContent = readSessionSpec(wsDir, sessionId);
1177
1561
  const specTruncated = specContent ? specContent.substring(0, 1500) : '';
1178
1562
  // Read last 5 tool results for what was just found
1179
1563
  const recentResults = getRecentToolResults(sessionId, workingDir, 5);
@@ -1194,7 +1578,7 @@ ${resultsSummary}
1194
1578
  ${specTruncated ? `Spec context:\n${specTruncated}` : ''}`;
1195
1579
  const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), 3000));
1196
1580
  let responsePromise;
1197
- if (provider === 'anthropic') {
1581
+ if (anthropicClient) {
1198
1582
  responsePromise = anthropicClient.messages.create({
1199
1583
  model: ANTHROPIC_FAST_MODEL,
1200
1584
  max_tokens: 200,
@@ -1202,13 +1586,16 @@ ${specTruncated ? `Spec context:\n${specTruncated}` : ''}`;
1202
1586
  messages: [{ role: 'user', content: userMessage }]
1203
1587
  }).then(r => r.content[0].type === 'text' ? r.content[0].text : null);
1204
1588
  }
1205
- else {
1589
+ else if (geminiClient) {
1206
1590
  responsePromise = geminiClient.models.generateContent({
1207
1591
  model: GEMINI_FAST_MODEL,
1208
1592
  contents: userMessage,
1209
1593
  config: { systemInstruction: CONTEXTUALIZE_UPDATE_SYSTEM }
1210
1594
  }).then(r => r.text || null);
1211
1595
  }
1596
+ else {
1597
+ return null;
1598
+ }
1212
1599
  const result = await Promise.race([responsePromise, timeoutPromise]);
1213
1600
  if (!result || result.trim() === 'NOTHING')
1214
1601
  return null;
@@ -1230,12 +1617,13 @@ ${specTruncated ? `Spec context:\n${specTruncated}` : ''}`;
1230
1617
  * Returns null/NOTHING if nothing interesting to say.
1231
1618
  * 3-second timeout.
1232
1619
  */
1233
- export async function generateProactivePrompt(workingDir, sessionId, task, researchLog, previousPrompts) {
1620
+ export async function generateProactivePrompt(workingDir, sessionId, task, researchLog, previousPrompts, sessionBaseDir) {
1234
1621
  initProvider();
1235
1622
  if (provider === 'none')
1236
1623
  return null;
1624
+ const wsDir = sessionBaseDir || workingDir;
1237
1625
  try {
1238
- const specContent = readSessionSpec(workingDir, sessionId);
1626
+ const specContent = readSessionSpec(wsDir, sessionId);
1239
1627
  const specTruncated = specContent ? specContent.substring(0, 2000) : '';
1240
1628
  // Read recent discoveries from JSONL
1241
1629
  const recentResults = getRecentToolResults(sessionId, workingDir, 8);
@@ -1269,7 +1657,7 @@ Previous things already said (DO NOT repeat):
1269
1657
  ${previousPrompts.length > 0 ? previousPrompts.join('\n') : '(none yet)'}`;
1270
1658
  const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), 3000));
1271
1659
  let responsePromise;
1272
- if (provider === 'anthropic') {
1660
+ if (anthropicClient) {
1273
1661
  responsePromise = anthropicClient.messages.create({
1274
1662
  model: ANTHROPIC_FAST_MODEL,
1275
1663
  max_tokens: 200,
@@ -1277,13 +1665,16 @@ ${previousPrompts.length > 0 ? previousPrompts.join('\n') : '(none yet)'}`;
1277
1665
  messages: [{ role: 'user', content: userMessage }]
1278
1666
  }).then(r => r.content[0].type === 'text' ? r.content[0].text : null);
1279
1667
  }
1280
- else {
1668
+ else if (geminiClient) {
1281
1669
  responsePromise = geminiClient.models.generateContent({
1282
1670
  model: GEMINI_FAST_MODEL,
1283
1671
  contents: userMessage,
1284
1672
  config: { systemInstruction: PROACTIVE_PROMPT_SYSTEM }
1285
1673
  }).then(r => r.text || null);
1286
1674
  }
1675
+ else {
1676
+ return null;
1677
+ }
1287
1678
  const result = await Promise.race([responsePromise, timeoutPromise]);
1288
1679
  if (!result || result.trim() === 'NOTHING')
1289
1680
  return null;
@@ -1304,14 +1695,15 @@ ${previousPrompts.length > 0 ? previousPrompts.join('\n') : '(none yet)'}`;
1304
1695
  * Reads spec.md, JSONL results, and library for context.
1305
1696
  * Writes the result to library/ and returns the filename + content.
1306
1697
  */
1307
- export async function generateVisualDocument(workingDir, sessionId, request, documentType) {
1698
+ export async function generateVisualDocument(workingDir, sessionId, request, documentType, sessionBaseDir) {
1308
1699
  initProvider();
1309
1700
  if (provider === 'none')
1310
1701
  return null;
1702
+ const wsDir = sessionBaseDir || workingDir;
1311
1703
  try {
1312
- const workspace = getSessionWorkspace(workingDir, sessionId);
1313
- const specContent = readSessionSpec(workingDir, sessionId) || '';
1314
- const libraryFiles = listLibraryFiles(workingDir, sessionId);
1704
+ const workspace = getSessionWorkspace(wsDir, sessionId);
1705
+ const specContent = readSessionSpec(wsDir, sessionId) || '';
1706
+ const libraryFiles = listLibraryFiles(wsDir, sessionId);
1315
1707
  // Read library contents for context
1316
1708
  const libraryDir = `${workspace}/library`;
1317
1709
  const libraryContents = [];
@@ -1344,7 +1736,7 @@ ${toolResultsSummary}
1344
1736
 
1345
1737
  Return JSON: {"fileName": "descriptive-name.md", "content": "full markdown content"}`;
1346
1738
  let responseText = null;
1347
- if (provider === 'anthropic') {
1739
+ if (anthropicClient) {
1348
1740
  const response = await anthropicClient.messages.create({
1349
1741
  model: ANTHROPIC_FAST_MODEL,
1350
1742
  max_tokens: 16000,
@@ -1353,7 +1745,7 @@ Return JSON: {"fileName": "descriptive-name.md", "content": "full markdown conte
1353
1745
  });
1354
1746
  responseText = response.content[0].type === 'text' ? response.content[0].text : null;
1355
1747
  }
1356
- else {
1748
+ else if (geminiClient) {
1357
1749
  const response = await geminiClient.models.generateContent({
1358
1750
  model: GEMINI_FAST_MODEL,
1359
1751
  contents: userMessage,
@@ -1402,3 +1794,174 @@ Return JSON: {"fileName": "descriptive-name.md", "content": "full markdown conte
1402
1794
  return null;
1403
1795
  }
1404
1796
  }
1797
+ // ============================================================
1798
+ // processResearchCompletion — Generate teleprompter script from research results
1799
+ // ============================================================
1800
+ /**
1801
+ * Generate a complete teleprompter script from research results.
1802
+ * Replaces augmentResearchResult + extractPriorityContent.
1803
+ * Reads full JSONL and produces a spoken monologue.
1804
+ */
1805
+ export async function processResearchCompletion(workingDir, sessionId, task, agentResult, chatHistory, sendToChat, sessionBaseDir) {
1806
+ initProvider();
1807
+ if (provider === 'none')
1808
+ return agentResult.substring(0, 500);
1809
+ const wsDir = sessionBaseDir || workingDir;
1810
+ try {
1811
+ // Read spec for context
1812
+ const specContent = readSessionSpec(wsDir, sessionId) || '';
1813
+ // Read FULL JSONL data — not truncated. The user waited for this research;
1814
+ // give the completion generator the complete picture.
1815
+ const toolResults = getRecentToolResults(sessionId, workingDir, 30);
1816
+ const toolSummary = toolResults.map(tr => {
1817
+ const inputPreview = JSON.stringify(tr.toolInput).substring(0, 200);
1818
+ return `[${tr.toolName}: ${inputPreview}]\n${tr.resultContent}`;
1819
+ }).join('\n\n---\n\n');
1820
+ // Also read agent reasoning for synthesis context
1821
+ const agentTexts = readSessionHistory(sessionId, workingDir, {
1822
+ lastN: 20,
1823
+ types: ['assistant']
1824
+ }).filter(m => m.text && m.text.length > 30)
1825
+ .map(m => m.text)
1826
+ .join('\n\n');
1827
+ // Read sub-agent findings if any
1828
+ const subagents = getSubagentTranscripts(sessionId, workingDir);
1829
+ const subagentSummary = subagents.length > 0
1830
+ ? subagents.map(sa => {
1831
+ const texts = sa.messages.filter(m => m.text && m.text.length > 30).map(m => m.text);
1832
+ return `[Sub-agent ${sa.taskId}]\n${texts.join('\n')}`;
1833
+ }).join('\n\n')
1834
+ : '';
1835
+ const historyStr = chatHistory
1836
+ ? chatHistory.slice(-10).map(t => `${t.role}: ${t.text}`).join('\n')
1837
+ : '';
1838
+ const userMessage = `Research task: "${task}"
1839
+
1840
+ Agent's headline findings:
1841
+ ${agentResult}
1842
+
1843
+ Full tool outputs (${toolResults.length} results):
1844
+ ${toolSummary}
1845
+
1846
+ ${agentTexts ? `Agent reasoning and analysis:\n${agentTexts.substring(0, 8000)}` : ''}
1847
+
1848
+ ${subagentSummary ? `Sub-agent findings:\n${subagentSummary.substring(0, 4000)}` : ''}
1849
+
1850
+ ${specContent ? `Session spec (for context):\n${specContent.substring(0, 3000)}` : ''}
1851
+
1852
+ ${historyStr ? `Recent conversation (match this vocabulary):\n${historyStr}` : ''}
1853
+
1854
+ Write the spoken monologue now. The user waited for this research — be comprehensive.${sendToChat ? ' If you have structured data (lists, URLs, code, steps), include a CHAT_CONTENT section at the end after a line "---CHAT---" with markdown content to send to the chat panel.' : ''}`;
1855
+ let script = null;
1856
+ if (anthropicClient) {
1857
+ const response = await anthropicClient.messages.create({
1858
+ model: ANTHROPIC_FAST_MODEL,
1859
+ max_tokens: 4000,
1860
+ system: RESEARCH_COMPLETION_SYSTEM,
1861
+ messages: [{ role: 'user', content: userMessage }]
1862
+ });
1863
+ script = response.content[0].type === 'text' ? response.content[0].text : null;
1864
+ }
1865
+ else if (geminiClient) {
1866
+ const response = await geminiClient.models.generateContent({
1867
+ model: GEMINI_FAST_MODEL,
1868
+ contents: userMessage,
1869
+ config: { systemInstruction: RESEARCH_COMPLETION_SYSTEM }
1870
+ });
1871
+ script = response.text || null;
1872
+ }
1873
+ if (!script)
1874
+ return agentResult.substring(0, 500);
1875
+ // Check for chat content section
1876
+ if (sendToChat && script.includes('---CHAT---')) {
1877
+ const parts = script.split('---CHAT---');
1878
+ const spokenPart = parts[0].trim();
1879
+ const chatPart = parts[1]?.trim();
1880
+ if (chatPart) {
1881
+ console.log(`💬 processResearchCompletion: sending ${chatPart.length} chars to chat`);
1882
+ sendToChat(chatPart);
1883
+ }
1884
+ console.log(`🎙️ processResearchCompletion: generated ${spokenPart.length} char script + ${chatPart?.length || 0} char chat content`);
1885
+ return spokenPart;
1886
+ }
1887
+ console.log(`🎙️ processResearchCompletion: generated ${script.length} char script`);
1888
+ return script;
1889
+ }
1890
+ catch (err) {
1891
+ console.error('❌ processResearchCompletion failed:', err);
1892
+ // Fallback: return truncated agent result as-is
1893
+ return agentResult.substring(0, 500);
1894
+ }
1895
+ }
1896
+ // ============================================================
1897
+ // handleResearchBatch — Decide whether research events are worth speaking
1898
+ // ============================================================
1899
+ /**
1900
+ * Process a batch of research events and decide whether to speak.
1901
+ * Replaces contextualizeResearchUpdate — but usually returns null (silent).
1902
+ * Only speaks when something genuinely critical is found.
1903
+ */
1904
+ export async function handleResearchBatch(workingDir, sessionId, task, batchEvents, researchLog, chatHistory, sessionBaseDir) {
1905
+ // Usually: stay silent. The frontend spinner handles visual feedback.
1906
+ // Only speak if the batch contains something genuinely interesting.
1907
+ // Quick heuristic: if fewer than 5 research steps, too early to say anything useful
1908
+ if (researchLog.length < 5)
1909
+ return null;
1910
+ // Check if any event mentions something critical (error, user-impacting finding)
1911
+ const hasCritical = batchEvents.some(e => e.toLowerCase().includes('error') ||
1912
+ e.toLowerCase().includes('warning') ||
1913
+ e.toLowerCase().includes('breaking') ||
1914
+ e.toLowerCase().includes('deprecated'));
1915
+ if (!hasCritical)
1916
+ return null;
1917
+ // Something interesting — generate a brief spoken update via contextualizeResearchUpdate
1918
+ return contextualizeResearchUpdate(workingDir, sessionId, task, batchEvents, researchLog, chatHistory, sessionBaseDir);
1919
+ }
1920
+ // ============================================================
1921
+ // prepareBriefingScript — Session resume/switch spoken briefing
1922
+ // ============================================================
1923
+ /**
1924
+ * Generate a brief spoken script for session resume or switch.
1925
+ * Replaces buildContextBriefing + getSpecForVoiceModel.
1926
+ */
1927
+ export async function prepareBriefingScript(workingDir, sessionId, conversationHistory, type = 'default') {
1928
+ initProvider();
1929
+ // Read spec for context
1930
+ const specContent = readSessionSpec(workingDir, sessionId);
1931
+ if (!specContent && (!conversationHistory || conversationHistory.length === 0)) {
1932
+ return type === 'switch'
1933
+ ? 'Switched sessions. What would you like to work on?'
1934
+ : 'Welcome back. What would you like to work on?';
1935
+ }
1936
+ // Extract goal and last topic from spec
1937
+ const goalMatch = specContent?.match(/## Goal\s*\n([\s\S]*?)(?=\n##|$)/);
1938
+ const goal = goalMatch ? goalMatch[1].trim().substring(0, 200) : '';
1939
+ const prefix = type === 'switch' ? 'Switched over.' : 'Welcome back.';
1940
+ // If we have a goal, generate a brief spoken briefing
1941
+ if (goal) {
1942
+ const lastExchanges = conversationHistory
1943
+ ? conversationHistory.slice(-3).map(e => `${e.role}: ${e.text.substring(0, 100)}`).join('. ')
1944
+ : '';
1945
+ if (lastExchanges) {
1946
+ return `${prefix} We were working on ${goal}. Last time we discussed ${lastExchanges.substring(0, 150)}. Where would you like to pick up?`;
1947
+ }
1948
+ return `${prefix} We were working on ${goal}. Where would you like to pick up?`;
1949
+ }
1950
+ return type === 'switch'
1951
+ ? 'Switched sessions. What would you like to work on?'
1952
+ : 'Session resumed. What would you like to work on?';
1953
+ }
1954
+ // ============================================================
1955
+ // prepareRecoveryScript — Gemini crash recovery spoken script
1956
+ // ============================================================
1957
+ /**
1958
+ * Generate a spoken script after Gemini auto-recovery.
1959
+ * Replaces inline recovery logic in index.ts.
1960
+ */
1961
+ export async function prepareRecoveryScript(conversationHistory) {
1962
+ if (conversationHistory && conversationHistory.length > 0) {
1963
+ const lastTopic = conversationHistory[conversationHistory.length - 1];
1964
+ return `Voice session was briefly interrupted but I'm back. We were talking about ${lastTopic.text.substring(0, 100)}. Where were we?`;
1965
+ }
1966
+ return 'Voice session was briefly interrupted but I\'m back. What were we working on?';
1967
+ }