osborn 0.1.6 → 0.5.3

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.
@@ -0,0 +1,1404 @@
1
+ /**
2
+ * Fast Brain Agent — Middle-tier intelligence for the Voice AI System
3
+ *
4
+ * A fast intermediary between the realtime voice model and the Claude SDK agent.
5
+ * Uses direct API calls for ~2 second responses.
6
+ *
7
+ * Capabilities:
8
+ * - Read/write session files (spec.md + library/)
9
+ * - Web search for quick factual lookups
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
13
+ *
14
+ * Key constraint: The fast brain NEVER calls ask_agent. The realtime model is always the router.
15
+ *
16
+ * Auth chain (tried in order):
17
+ * 1. ANTHROPIC_API_KEY env var → Anthropic SDK (Haiku)
18
+ * 2. ANTHROPIC_AUTH_TOKEN env var → Anthropic SDK (Haiku)
19
+ * 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
+ */
24
+ import Anthropic from '@anthropic-ai/sdk';
25
+ import { GoogleGenAI } from '@google/genai';
26
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
27
+ import { dirname, basename } from 'path';
28
+ 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';
30
+ import { getRecentToolResults, readSessionHistory, getSubagentTranscripts, getConversationText, getSessionTranscripts, searchSessionJsonl, getSessionStats } from './session-access.js';
31
+ // ============================================================
32
+ // Content extraction — pulls useful snippets from tool responses
33
+ // ============================================================
34
+ /**
35
+ * Extract useful content snippets from tool responses, truncated by tool type.
36
+ * Returns null for tools with no useful content (Write confirmations, etc.)
37
+ */
38
+ export function extractToolContent(toolName, toolInput, toolResponse) {
39
+ if (!toolResponse)
40
+ return null;
41
+ const response = typeof toolResponse === 'string' ? toolResponse : JSON.stringify(toolResponse);
42
+ if (response.length < 20)
43
+ return null; // Skip trivial responses
44
+ switch (toolName) {
45
+ case 'Read':
46
+ return `[File: ${basename(toolInput?.file_path || 'unknown')}]\n${response.slice(0, 600)}`;
47
+ case 'Bash':
48
+ return `[Command: ${(toolInput?.command || '').slice(0, 80)}]\n${response.slice(0, 400)}`;
49
+ case 'Grep':
50
+ return `[Search: "${toolInput?.pattern}"]\n${response.slice(0, 600)}`;
51
+ case 'WebSearch':
52
+ return `[Web: "${toolInput?.query}"]\n${response.slice(0, 800)}`;
53
+ case 'WebFetch':
54
+ return `[Page: ${toolInput?.url}]\n${response.slice(0, 800)}`;
55
+ case 'Glob':
56
+ return `[Files matching: ${toolInput?.pattern}]\n${response.slice(0, 400)}`;
57
+ case 'Write':
58
+ case 'Edit':
59
+ return null; // Skip write confirmations
60
+ default:
61
+ if (toolName.startsWith('mcp__'))
62
+ return `[${toolName}]\n${response.slice(0, 500)}`;
63
+ return null;
64
+ }
65
+ }
66
+ let provider = 'none';
67
+ let anthropicClient = null;
68
+ let geminiClient = null;
69
+ let initialized = false;
70
+ // Model IDs — configurable per provider
71
+ const ANTHROPIC_FAST_MODEL = 'claude-haiku-4-5-20251001';
72
+ const GEMINI_FAST_MODEL = 'gemini-2.0-flash';
73
+ /** No-op — history is now sourced live from agent.chatCtx, passed per-call */
74
+ export function clearFastBrainHistory() {
75
+ console.log('🧠 Fast brain: conversation history cleared (no-op — sourced from chatCtx)');
76
+ }
77
+ function initProvider() {
78
+ if (initialized)
79
+ return;
80
+ initialized = true;
81
+ // 1. ANTHROPIC_API_KEY
82
+ const apiKey = process.env.ANTHROPIC_API_KEY;
83
+ if (apiKey) {
84
+ anthropicClient = new Anthropic({ apiKey });
85
+ provider = 'anthropic';
86
+ console.log('🧠 Fast brain: using Anthropic API (ANTHROPIC_API_KEY)');
87
+ return;
88
+ }
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;
96
+ }
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 });
103
+ provider = 'gemini';
104
+ console.log(`🧠 Fast brain: using Gemini Flash fallback (${GEMINI_FAST_MODEL})`);
105
+ return;
106
+ }
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
+ }
112
+ // ============================================================
113
+ // Tool execution (shared across providers)
114
+ // ============================================================
115
+ function executeTool(toolName, toolInput, workspace, sessionId, workingDir) {
116
+ try {
117
+ switch (toolName) {
118
+ case 'read_file': {
119
+ const relPath = toolInput.path;
120
+ if (relPath.includes('..'))
121
+ return 'Error: path traversal not allowed';
122
+ const fullPath = `${workspace}/${relPath}`;
123
+ if (!existsSync(fullPath))
124
+ return `File not found: ${relPath}`;
125
+ const content = readFileSync(fullPath, 'utf-8');
126
+ return content || '(empty file)';
127
+ }
128
+ case 'write_file': {
129
+ const relPath = toolInput.path;
130
+ const content = toolInput.content;
131
+ if (relPath.includes('..'))
132
+ return 'Error: path traversal not allowed';
133
+ const fullPath = `${workspace}/${relPath}`;
134
+ mkdirSync(dirname(fullPath), { recursive: true });
135
+ writeFileSync(fullPath, content, 'utf-8');
136
+ console.log(`📝 Fast brain wrote ${relPath} (${content.length} chars)`);
137
+ return `Written: ${relPath} (${content.length} chars)`;
138
+ }
139
+ case 'list_library': {
140
+ const libraryDir = `${workspace}/library`;
141
+ if (!existsSync(libraryDir))
142
+ return 'Library is empty — no research files yet.';
143
+ try {
144
+ const items = readdirSync(libraryDir);
145
+ return items.length > 0 ? items.join('\n') : 'Library is empty — no research files yet.';
146
+ }
147
+ catch {
148
+ return 'Library is empty — no research files yet.';
149
+ }
150
+ }
151
+ case 'read_agent_results': {
152
+ if (!sessionId || !workingDir)
153
+ return 'Error: no active research session';
154
+ const lastN = toolInput.lastN || 40;
155
+ const toolFilter = toolInput.toolFilter;
156
+ const results = getRecentToolResults(sessionId, workingDir, lastN, { toolNameFilter: toolFilter });
157
+ if (results.length === 0)
158
+ return `No tool results found${toolFilter ? ` for tools: ${toolFilter.join(', ')}` : ''}.`;
159
+ return `[${results.length} results${toolFilter ? ` filtered by: ${toolFilter.join(', ')}` : ''}]\n\n` +
160
+ results.map(tr => {
161
+ const inputPreview = JSON.stringify(tr.toolInput).substring(0, 200);
162
+ return `[${tr.toolName}: ${inputPreview}]\n${tr.resultContent}`;
163
+ }).join('\n\n---\n\n');
164
+ }
165
+ case 'read_agent_text': {
166
+ if (!sessionId || !workingDir)
167
+ return 'Error: no active research session';
168
+ const lastN = toolInput.lastN || 60;
169
+ const opts = lastN === 0
170
+ ? { types: ['assistant'] }
171
+ : { lastN, types: ['assistant'] };
172
+ const messages = readSessionHistory(sessionId, workingDir, opts);
173
+ const texts = messages.filter(m => m.text && m.text.length > 20);
174
+ if (texts.length === 0)
175
+ return 'No agent reasoning text found in JSONL.';
176
+ return `[${texts.length} agent messages]\n\n` + texts.map(m => m.text).join('\n\n---\n\n');
177
+ }
178
+ case 'read_subagents': {
179
+ if (!sessionId || !workingDir)
180
+ return 'Error: no active research session';
181
+ const transcripts = getSubagentTranscripts(sessionId, workingDir);
182
+ if (transcripts.length === 0)
183
+ return 'No sub-agent transcripts found.';
184
+ return transcripts.map(sa => {
185
+ const texts = sa.messages
186
+ .filter(m => m.text && m.text.length > 20)
187
+ .map(m => `[${m.type}] ${m.text}`);
188
+ return `=== Sub-agent ${sa.taskId} (${sa.messages.length} msgs) ===\n${texts.join('\n')}`;
189
+ }).join('\n\n');
190
+ }
191
+ case 'search_jsonl': {
192
+ if (!sessionId || !workingDir)
193
+ return 'Error: no active research session';
194
+ const keyword = toolInput.keyword;
195
+ if (!keyword)
196
+ return 'Error: keyword is required';
197
+ const maxResults = toolInput.maxResults || 20;
198
+ const results = searchSessionJsonl(sessionId, workingDir, keyword, { maxResults });
199
+ if (results.length === 0)
200
+ return `No matches for "${keyword}" in agent JSONL.`;
201
+ return results.map(r => `[${r.type}${r.timestamp ? ` @ ${r.timestamp}` : ''}] ${r.text}`).join('\n\n---\n\n');
202
+ }
203
+ case 'read_conversation': {
204
+ if (!sessionId || !workingDir)
205
+ return 'Error: no active research session';
206
+ const lastN = toolInput.lastN || 30;
207
+ const exchanges = getConversationText(sessionId, workingDir, lastN, 2000);
208
+ if (exchanges.length === 0)
209
+ return 'No conversation history found.';
210
+ return exchanges.map(e => `${e.role}: ${e.text}`).join('\n\n');
211
+ }
212
+ case 'get_session_stats': {
213
+ if (!sessionId || !workingDir)
214
+ return 'Error: no active research session';
215
+ const stats = getSessionStats(sessionId, workingDir);
216
+ if (!stats)
217
+ return 'No session data found.';
218
+ const toolList = Object.entries(stats.toolBreakdown)
219
+ .sort(([, a], [, b]) => b - a)
220
+ .map(([name, count]) => ` ${name}: ${count}`)
221
+ .join('\n');
222
+ return `Session Stats:
223
+ Total messages: ${stats.totalMessages}
224
+ User messages: ${stats.userMessages}
225
+ Agent messages: ${stats.assistantMessages}
226
+ Tool calls: ${stats.toolUseCount}
227
+ Tool results: ${stats.toolResultCount}
228
+ Sub-agents: ${stats.subagentCount}
229
+ File size: ${(stats.fileSizeBytes / 1024).toFixed(1)} KB
230
+ Time range: ${stats.firstTimestamp || '?'} → ${stats.lastTimestamp || '?'}
231
+
232
+ Tool breakdown:
233
+ ${toolList}`;
234
+ }
235
+ case 'deep_read_results': {
236
+ if (!sessionId || !workingDir)
237
+ return 'Error: no active research session';
238
+ const toolFilter = toolInput.toolFilter;
239
+ const allResults = getRecentToolResults(sessionId, workingDir, 0, { toolNameFilter: toolFilter });
240
+ if (allResults.length === 0)
241
+ return `No tool results found${toolFilter ? ` for tools: ${toolFilter.join(', ')}` : ''}.`;
242
+ return `[${allResults.length} total results${toolFilter ? ` filtered by: ${toolFilter.join(', ')}` : ' (all tools)'}]\n\n` +
243
+ allResults.map(tr => {
244
+ const inputPreview = JSON.stringify(tr.toolInput).substring(0, 200);
245
+ return `[${tr.toolName}: ${inputPreview}]\n${tr.resultContent}`;
246
+ }).join('\n\n---\n\n');
247
+ }
248
+ case 'deep_read_text': {
249
+ if (!sessionId || !workingDir)
250
+ return 'Error: no active research session';
251
+ const allMessages = readSessionHistory(sessionId, workingDir, {
252
+ types: ['assistant']
253
+ });
254
+ const allTexts = allMessages.filter(m => m.text && m.text.length > 20);
255
+ if (allTexts.length === 0)
256
+ return 'No agent reasoning text found in JSONL.';
257
+ return `[${allTexts.length} total agent messages across entire session]\n\n` + allTexts.map(m => m.text).join('\n\n---\n\n');
258
+ }
259
+ case 'get_full_transcript': {
260
+ if (!sessionId || !workingDir)
261
+ return 'Error: no active research session';
262
+ const transcripts = getSessionTranscripts(sessionId, workingDir);
263
+ const agentTexts = transcripts.agent.messages
264
+ .filter(m => m.text && m.text.length > 20)
265
+ .map(m => `[${m.type}${m.toolName ? ': ' + m.toolName : ''}] ${m.text}`);
266
+ let output = `=== Agent Transcript (${transcripts.agent.messages.length} msgs, ${transcripts.agent.fileSize} bytes) ===\n${agentTexts.join('\n\n')}`;
267
+ if (transcripts.subagents.length > 0) {
268
+ const subTexts = transcripts.subagents.map(sa => {
269
+ const texts = sa.messages.filter(m => m.text).map(m => `[${m.type}] ${m.text}`);
270
+ return `=== Sub-agent ${sa.taskId} ===\n${texts.join('\n')}`;
271
+ });
272
+ output += '\n\n' + subTexts.join('\n\n');
273
+ }
274
+ return output;
275
+ }
276
+ default:
277
+ return `Unknown tool: ${toolName}`;
278
+ }
279
+ }
280
+ catch (err) {
281
+ return `Tool error: ${err.message}`;
282
+ }
283
+ }
284
+ // ============================================================
285
+ // Anthropic tool definitions
286
+ // ============================================================
287
+ function buildAnthropicTools() {
288
+ return [
289
+ {
290
+ name: 'read_file',
291
+ description: 'Read a file from the session workspace. Use relative paths like "spec.md" or "library/react-guide.md".',
292
+ input_schema: {
293
+ type: 'object',
294
+ properties: {
295
+ path: { type: 'string', description: 'Relative path within session workspace' }
296
+ },
297
+ required: ['path']
298
+ }
299
+ },
300
+ {
301
+ name: 'write_file',
302
+ description: 'Write or update a file in the session workspace. For spec.md, always read first then write the complete updated content.',
303
+ input_schema: {
304
+ type: 'object',
305
+ properties: {
306
+ path: { type: 'string', description: 'Relative path within session workspace' },
307
+ content: { type: 'string', description: 'The complete file content to write' }
308
+ },
309
+ required: ['path', 'content']
310
+ }
311
+ },
312
+ {
313
+ name: 'list_library',
314
+ description: 'List all files in the research library directory.',
315
+ input_schema: { type: 'object', properties: {} }
316
+ },
317
+ {
318
+ name: 'read_agent_results',
319
+ description: 'Read the research agent\'s FULL memory — complete untruncated tool outputs including entire file contents the agent read, full bash command outputs, web search results, and web page fetches. This is the agent\'s raw data. Use this FIRST when asked about anything the agent just researched. Default: last 40 results.',
320
+ input_schema: {
321
+ type: 'object',
322
+ properties: {
323
+ lastN: { type: 'number', description: 'Number of recent results to return (default: 40, max: 80)' }
324
+ }
325
+ }
326
+ },
327
+ {
328
+ name: 'read_agent_text',
329
+ description: 'Read the research agent\'s reasoning, analysis, and conclusions from JSONL. Contains the agent\'s step-by-step thinking, synthesis of findings, comparisons, and recommendations. Use this alongside read_agent_results to get the COMPLETE picture of what the agent researched and concluded. Default: last 60 messages.',
330
+ input_schema: {
331
+ type: 'object',
332
+ properties: {
333
+ lastN: { type: 'number', description: 'Number of recent text messages to return (default: 60, max: 100)' }
334
+ }
335
+ }
336
+ },
337
+ {
338
+ name: 'read_subagents',
339
+ description: 'Read all sub-agent (parallel Task) transcripts. Contains the detailed work done by sub-agents spawned during research. Use when the main agent delegated parts of the research to sub-agents working in parallel.',
340
+ input_schema: { type: 'object', properties: {} }
341
+ },
342
+ {
343
+ name: 'search_jsonl',
344
+ description: 'Search the agent\'s JSONL transcript for a keyword. Returns matching entries across all tool results, agent reasoning, and conversation history. Use to find specific mentions of a topic, file, function, or concept.',
345
+ input_schema: {
346
+ type: 'object',
347
+ properties: {
348
+ keyword: { type: 'string', description: 'The keyword to search for (case-insensitive)' },
349
+ maxResults: { type: 'number', description: 'Maximum number of results (default: 20)' }
350
+ },
351
+ required: ['keyword']
352
+ }
353
+ },
354
+ {
355
+ name: 'read_conversation',
356
+ description: 'Read the user/assistant conversation exchange history. Shows what the user asked and what the agent responded, without tool call details. Use for understanding conversation flow, user intent, and what was discussed.',
357
+ input_schema: {
358
+ type: 'object',
359
+ properties: {
360
+ lastN: { type: 'number', description: 'Number of recent exchanges to return (default: 30)' }
361
+ }
362
+ }
363
+ },
364
+ {
365
+ name: 'get_full_transcript',
366
+ description: 'Read the COMPLETE agent transcript + all sub-agent transcripts. This is the most comprehensive view of everything the agent did — use when targeted tools (read_agent_results, read_agent_text) aren\'t enough and you need the full picture. Large output.',
367
+ input_schema: { type: 'object', properties: {} }
368
+ },
369
+ {
370
+ name: 'get_session_stats',
371
+ description: 'Get session statistics: total messages, tool call counts by name, sub-agent count, data size, time range. Use this to understand how much data is in the session before deciding whether to use deep tools.',
372
+ input_schema: { type: 'object', properties: {} }
373
+ },
374
+ {
375
+ name: 'deep_read_results',
376
+ description: 'Read ALL tool results across the ENTIRE session — not just recent ones. Returns every file read, bash output, web search, web fetch, etc. Use toolFilter to narrow by tool type. Use this for generating detailed analyses, overviews, diagrams, answering specific questions requiring full context, or when the user wants comprehensive details.',
377
+ input_schema: {
378
+ type: 'object',
379
+ properties: {
380
+ toolFilter: {
381
+ type: 'array',
382
+ items: { type: 'string' },
383
+ description: 'Only return results from these tools. E.g., ["Read"] for file reads, ["WebSearch","WebFetch"] for web data, ["Bash"] for commands, ["Grep","Glob"] for code searches. Omit for all tools.'
384
+ }
385
+ }
386
+ }
387
+ },
388
+ {
389
+ name: 'deep_read_text',
390
+ 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
+ input_schema: { type: 'object', properties: {} }
392
+ }
393
+ ];
394
+ }
395
+ const ANTHROPIC_WEB_SEARCH = {
396
+ type: 'web_search_20250305',
397
+ name: 'web_search',
398
+ max_uses: 3,
399
+ };
400
+ // ============================================================
401
+ // Gemini tool definitions
402
+ // ============================================================
403
+ function buildGeminiTools() {
404
+ // NOTE: Gemini API does NOT allow combining functionDeclarations with googleSearch
405
+ // in the same request (400 "Tool use with function calling is unsupported").
406
+ // Web search is implemented as a custom function that makes a separate grounded API call.
407
+ return [
408
+ {
409
+ functionDeclarations: [
410
+ {
411
+ name: 'read_file',
412
+ description: 'Read a file from the session workspace. Use relative paths like "spec.md" or "library/react-guide.md".',
413
+ parameters: {
414
+ type: 'object',
415
+ properties: {
416
+ path: { type: 'string', description: 'Relative path within session workspace' }
417
+ },
418
+ required: ['path']
419
+ }
420
+ },
421
+ {
422
+ name: 'write_file',
423
+ description: 'Write or update a file in the session workspace. For spec.md, always read first then write the complete updated content.',
424
+ parameters: {
425
+ type: 'object',
426
+ properties: {
427
+ path: { type: 'string', description: 'Relative path within session workspace' },
428
+ content: { type: 'string', description: 'The complete file content to write' }
429
+ },
430
+ required: ['path', 'content']
431
+ }
432
+ },
433
+ {
434
+ name: 'list_library',
435
+ description: 'List all files in the research library directory.',
436
+ parameters: { type: 'object', properties: {} }
437
+ },
438
+ {
439
+ name: 'web_search',
440
+ description: 'Search the web for current information. Use for factual questions like "current version of X", "what is X", definitions, etc.',
441
+ parameters: {
442
+ type: 'object',
443
+ properties: {
444
+ query: { type: 'string', description: 'The search query' }
445
+ },
446
+ required: ['query']
447
+ }
448
+ },
449
+ {
450
+ name: 'read_agent_results',
451
+ description: 'Read the research agent\'s FULL memory — complete untruncated tool outputs including entire file contents the agent read, full bash command outputs, web search results, and web page fetches. This is the agent\'s raw data. Use this FIRST when asked about anything the agent just researched. Default: last 40 results.',
452
+ parameters: {
453
+ type: 'object',
454
+ properties: {
455
+ lastN: { type: 'number', description: 'Number of recent results to return (default: 40, max: 60)' }
456
+ }
457
+ }
458
+ },
459
+ {
460
+ name: 'read_agent_text',
461
+ description: 'Read the research agent\'s reasoning, analysis, and conclusions from JSONL. Contains the agent\'s step-by-step thinking, synthesis of findings, comparisons, and recommendations. Use this alongside read_agent_results to get the COMPLETE picture of what the agent researched and concluded. Default: last 60 messages.',
462
+ parameters: {
463
+ type: 'object',
464
+ properties: {
465
+ lastN: { type: 'number', description: 'Number of recent text messages to return (default: 60, max: 100)' }
466
+ }
467
+ }
468
+ },
469
+ {
470
+ name: 'read_subagents',
471
+ description: 'Read all sub-agent (parallel Task) transcripts. Contains the detailed work done by sub-agents spawned during research.',
472
+ parameters: { type: 'object', properties: {} }
473
+ },
474
+ {
475
+ name: 'search_jsonl',
476
+ description: 'Search the agent\'s JSONL transcript for a keyword. Returns matching entries across all tool results, agent reasoning, and conversation history.',
477
+ parameters: {
478
+ type: 'object',
479
+ properties: {
480
+ keyword: { type: 'string', description: 'The keyword to search for (case-insensitive)' },
481
+ maxResults: { type: 'number', description: 'Maximum number of results (default: 20)' }
482
+ },
483
+ required: ['keyword']
484
+ }
485
+ },
486
+ {
487
+ name: 'read_conversation',
488
+ description: 'Read the user/assistant conversation exchange history. Shows what the user asked and what the agent responded.',
489
+ parameters: {
490
+ type: 'object',
491
+ properties: {
492
+ lastN: { type: 'number', description: 'Number of recent exchanges to return (default: 30)' }
493
+ }
494
+ }
495
+ },
496
+ {
497
+ name: 'get_full_transcript',
498
+ description: 'Read the COMPLETE agent transcript + all sub-agent transcripts. Most comprehensive view — use when targeted tools aren\'t enough.',
499
+ parameters: { type: 'object', properties: {} }
500
+ },
501
+ {
502
+ name: 'get_session_stats',
503
+ description: 'Get session statistics: total messages, tool call counts by name, sub-agent count, data size, time range. Use to understand how much data is in the session before using deep tools.',
504
+ parameters: { type: 'object', properties: {} }
505
+ },
506
+ {
507
+ name: 'deep_read_results',
508
+ description: 'Read ALL tool results across the ENTIRE session — not just recent ones. Use toolFilter to narrow by tool type. For detailed analyses, overviews, diagrams, specific questions requiring full context.',
509
+ parameters: {
510
+ type: 'object',
511
+ properties: {
512
+ toolFilter: {
513
+ type: 'array',
514
+ items: { type: 'string' },
515
+ description: 'Only return results from these tools. E.g., ["Read"] for file reads, ["WebSearch","WebFetch"] for web data. Omit for all.'
516
+ }
517
+ }
518
+ }
519
+ },
520
+ {
521
+ name: 'deep_read_text',
522
+ description: 'Read ALL agent reasoning across the ENTIRE session. For comprehensive overviews or detailed explanations of what the agent found throughout the session.',
523
+ parameters: { type: 'object', properties: {} }
524
+ }
525
+ ]
526
+ }
527
+ ];
528
+ }
529
+ /**
530
+ * Perform a web search via Gemini's Google Search grounding.
531
+ * This is called as a separate API request because Gemini doesn't allow
532
+ * combining googleSearch with functionDeclarations in the same request.
533
+ */
534
+ async function geminiWebSearch(query) {
535
+ try {
536
+ const ai = geminiClient;
537
+ const response = await ai.models.generateContent({
538
+ model: GEMINI_FAST_MODEL,
539
+ contents: [{ role: 'user', parts: [{ text: query }] }],
540
+ config: {
541
+ tools: [{ googleSearch: {} }],
542
+ }
543
+ });
544
+ return response.text || 'No web results found.';
545
+ }
546
+ catch (err) {
547
+ console.error('❌ Gemini web search failed:', err);
548
+ return `Web search failed: ${err.message}`;
549
+ }
550
+ }
551
+ // ============================================================
552
+ // Anthropic Q&A implementation
553
+ // ============================================================
554
+ async function askViaAnthropic(question, workspace, researchContext, sessionId, workingDir, chatHistory) {
555
+ const client = anthropicClient;
556
+ const tools = buildAnthropicTools();
557
+ const userContent = researchContext
558
+ ? `${question}\n\n[LIVE RESEARCH CONTEXT — the research agent is currently working]\n${researchContext}`
559
+ : question;
560
+ // Build messages from live voice conversation history (from agent.chatCtx)
561
+ const messages = [];
562
+ if (chatHistory && chatHistory.length > 0) {
563
+ for (const turn of chatHistory) {
564
+ messages.push({ role: turn.role, content: turn.text });
565
+ }
566
+ }
567
+ messages.push({ role: 'user', content: userContent });
568
+ const allTools = [...tools, ANTHROPIC_WEB_SEARCH];
569
+ for (let i = 0; i < 10; i++) {
570
+ const response = await client.messages.create({
571
+ model: ANTHROPIC_FAST_MODEL,
572
+ max_tokens: 10000,
573
+ system: FAST_BRAIN_SYSTEM_PROMPT,
574
+ tools: allTools,
575
+ messages,
576
+ });
577
+ if (response.stop_reason === 'end_turn') {
578
+ const textBlock = response.content.find((b) => b.type === 'text');
579
+ return textBlock?.text || 'No answer found.';
580
+ }
581
+ const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
582
+ if (toolUseBlocks.length === 0 && response.stop_reason !== 'tool_use') {
583
+ const textBlock = response.content.find((b) => b.type === 'text');
584
+ return textBlock?.text || 'No answer found.';
585
+ }
586
+ messages.push({ role: 'assistant', content: response.content });
587
+ if (toolUseBlocks.length > 0) {
588
+ const toolResults = toolUseBlocks.map(toolUse => ({
589
+ type: 'tool_result',
590
+ tool_use_id: toolUse.id,
591
+ content: executeTool(toolUse.name, toolUse.input, workspace, sessionId, workingDir),
592
+ }));
593
+ messages.push({ role: 'user', content: toolResults });
594
+ }
595
+ }
596
+ return 'Fast brain reached maximum tool iterations. Try ask_agent for a deeper search.';
597
+ }
598
+ // ============================================================
599
+ // Gemini Q&A implementation
600
+ // ============================================================
601
+ async function askViaGemini(question, workspace, researchContext, sessionId, workingDir, chatHistory) {
602
+ const ai = geminiClient;
603
+ const tools = buildGeminiTools();
604
+ const userContent = researchContext
605
+ ? `${question}\n\n[LIVE RESEARCH CONTEXT — the research agent is currently working]\n${researchContext}`
606
+ : 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
+ });
615
+ }
616
+ }
617
+ contents.push({ role: 'user', parts: [{ text: userContent }] });
618
+ for (let i = 0; i < 10; i++) {
619
+ const response = await ai.models.generateContent({
620
+ model: GEMINI_FAST_MODEL,
621
+ contents,
622
+ config: {
623
+ systemInstruction: FAST_BRAIN_SYSTEM_PROMPT,
624
+ tools,
625
+ }
626
+ });
627
+ const functionCalls = response.functionCalls;
628
+ if (!functionCalls || functionCalls.length === 0) {
629
+ return response.text || 'No answer found.';
630
+ }
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)
636
+ const functionResponses = await Promise.all(functionCalls.map(async (call) => {
637
+ let result;
638
+ if (call.name === 'web_search') {
639
+ result = await geminiWebSearch(call.args?.query || question);
640
+ }
641
+ else {
642
+ result = executeTool(call.name, call.args || {}, workspace, sessionId, workingDir);
643
+ }
644
+ return {
645
+ functionResponse: {
646
+ name: call.name,
647
+ response: { result }
648
+ }
649
+ };
650
+ }));
651
+ contents.push({ role: 'user', parts: functionResponses });
652
+ }
653
+ return 'Fast brain reached maximum tool iterations. Try ask_agent for a deeper search.';
654
+ }
655
+ // ============================================================
656
+ // askHaiku — Main Q&A function (dispatches to provider)
657
+ // ============================================================
658
+ /**
659
+ * Ask the fast brain a question with access to session files and web search.
660
+ * Returns an answer or "NEEDS_DEEPER_RESEARCH: ..." for escalation.
661
+ *
662
+ * Auth chain: Anthropic (API key → auth token → Keychain OAuth) → Gemini Flash fallback
663
+ *
664
+ * @param researchContext - Optional snapshot of the live research log.
665
+ * ~2 second response time for most queries.
666
+ */
667
+ export async function askHaiku(workingDir, sessionId, question, researchContext, chatHistory) {
668
+ initProvider();
669
+ if (provider === 'none') {
670
+ return 'NEEDS_DEEPER_RESEARCH: Fast brain unavailable (no API key). Try ask_agent instead.';
671
+ }
672
+ const workspace = getSessionWorkspace(workingDir, sessionId);
673
+ if (provider === 'anthropic') {
674
+ return askViaAnthropic(question, workspace, researchContext, sessionId, workingDir, chatHistory);
675
+ }
676
+ else {
677
+ return askViaGemini(question, workspace, researchContext, sessionId, workingDir, chatHistory);
678
+ }
679
+ }
680
+ // ============================================================
681
+ // processResearchChunk — Incremental content processing during research
682
+ // ============================================================
683
+ /**
684
+ * Process a batch of research content chunks through the fast brain.
685
+ * Updates spec.md and library/ files incrementally during research.
686
+ *
687
+ * @param isRefinement - true for the final post-research consolidation pass (higher token budget)
688
+ */
689
+ export async function processResearchChunk(workingDir, sessionId, task, contentChunks, isRefinement) {
690
+ initProvider();
691
+ if (provider === 'none')
692
+ return null;
693
+ if (contentChunks.length === 0)
694
+ return null;
695
+ // Prevent concurrent spec writes
696
+ if (specUpdateInProgress) {
697
+ console.log('⏸️ processResearchChunk: spec update already in progress, skipping');
698
+ return null;
699
+ }
700
+ specUpdateInProgress = true;
701
+ try {
702
+ const workspace = getSessionWorkspace(workingDir, sessionId);
703
+ const specPath = `${workspace}/spec.md`;
704
+ if (!existsSync(specPath)) {
705
+ console.log('⚠️ processResearchChunk: spec.md not found, skipping');
706
+ return null;
707
+ }
708
+ const currentSpec = readFileSync(specPath, 'utf-8');
709
+ const libraryDir = `${workspace}/library`;
710
+ // Only read library files during refinement pass (final consolidation)
711
+ // Mid-research: skip library entirely to stay fast and avoid file proliferation
712
+ let existingSection = '';
713
+ if (isRefinement) {
714
+ const existingFiles = listLibraryFiles(workingDir, sessionId);
715
+ const existingContents = [];
716
+ for (const file of existingFiles) {
717
+ const filePath = `${libraryDir}/${file}`;
718
+ if (existsSync(filePath)) {
719
+ try {
720
+ const content = readFileSync(filePath, 'utf-8');
721
+ existingContents.push(`--- ${file} ---\n${content}`);
722
+ }
723
+ catch { /* skip */ }
724
+ }
725
+ }
726
+ existingSection = existingContents.length > 0
727
+ ? `\n\nExisting library/ files:\n${existingContents.join('\n\n')}`
728
+ : '';
729
+ }
730
+ // No content capping — models handle 200K+ tokens (Haiku) / 1M+ (Gemini Flash)
731
+ const chunksText = contentChunks.join('\n\n---\n\n');
732
+ // Use different prompts: mid-research = spec only, refinement = spec + library
733
+ const systemPrompt = isRefinement ? REFINEMENT_PROCESS_SYSTEM : CHUNK_PROCESS_SYSTEM;
734
+ const userMessage = `Research task: "${task}"
735
+
736
+ Current spec.md:
737
+ \`\`\`markdown
738
+ ${currentSpec}
739
+ \`\`\`
740
+ ${existingSection}
741
+
742
+ Content chunks from research:
743
+ ${chunksText}
744
+
745
+ Return ONLY valid JSON — no code fences, no explanation.`;
746
+ let responseText = null;
747
+ if (provider === 'anthropic') {
748
+ const response = await anthropicClient.messages.create({
749
+ model: ANTHROPIC_FAST_MODEL,
750
+ max_tokens: isRefinement ? 20000 : 10000,
751
+ system: systemPrompt,
752
+ messages: [{ role: 'user', content: userMessage }]
753
+ });
754
+ responseText = response.content[0].type === 'text' ? response.content[0].text : null;
755
+ }
756
+ else {
757
+ const response = await geminiClient.models.generateContent({
758
+ model: GEMINI_FAST_MODEL,
759
+ contents: userMessage,
760
+ config: { systemInstruction: systemPrompt }
761
+ });
762
+ responseText = response.text || null;
763
+ }
764
+ if (!responseText)
765
+ return null;
766
+ // Parse JSON response — multi-strategy for robustness
767
+ const parsed = parseChunkResponse(responseText);
768
+ if (!parsed)
769
+ return null;
770
+ let updatedSpec = null;
771
+ const writtenFiles = [];
772
+ // Write spec.md
773
+ if (parsed.spec && typeof parsed.spec === 'string' && parsed.spec.length > 50) {
774
+ writeFileSync(specPath, parsed.spec, 'utf-8');
775
+ updatedSpec = parsed.spec;
776
+ const label = isRefinement ? 'refinement pass' : 'chunk';
777
+ console.log(`📋 Fast brain processed research ${label} — spec.md updated (${parsed.spec.length} chars)`);
778
+ }
779
+ // Write library files — ONLY during refinement pass (prevents file proliferation)
780
+ if (isRefinement && parsed.library && Array.isArray(parsed.library) && parsed.library.length > 0) {
781
+ mkdirSync(libraryDir, { recursive: true });
782
+ for (const file of parsed.library) {
783
+ if (!file.filename || !file.content)
784
+ continue;
785
+ const safeName = file.filename.replace(/[^a-zA-Z0-9._-]/g, '-');
786
+ const filePath = `${libraryDir}/${safeName}`;
787
+ writeFileSync(filePath, file.content, 'utf-8');
788
+ console.log(`📝 Fast brain wrote library/${safeName} (${file.content.length} chars)`);
789
+ writtenFiles.push(safeName);
790
+ }
791
+ }
792
+ const label = isRefinement ? 'refinement' : `${contentChunks.length} content items`;
793
+ console.log(`📋 Fast brain processed research chunk (${label})`);
794
+ return { spec: updatedSpec, libraryFiles: writtenFiles };
795
+ }
796
+ catch (err) {
797
+ console.error('❌ processResearchChunk failed:', err);
798
+ return null;
799
+ }
800
+ finally {
801
+ specUpdateInProgress = false;
802
+ }
803
+ }
804
+ // Simple lock to prevent concurrent spec writes during research
805
+ let specUpdateInProgress = false;
806
+ // ============================================================
807
+ // parseChunkResponse — Robust JSON parsing for LLM output
808
+ // ============================================================
809
+ /**
810
+ * Multi-strategy JSON parser for LLM chunk processing responses.
811
+ * Handles code fences, control characters, and raw markdown fallbacks.
812
+ *
813
+ * Strategies (tried in order):
814
+ * 1. Direct JSON.parse after stripping code fences
815
+ * 2. Control character stripping (newlines, tabs in string values)
816
+ * 3. Regex extraction of spec field from malformed JSON
817
+ * 4. Raw markdown detection (LLM returned spec directly instead of JSON)
818
+ */
819
+ function parseChunkResponse(responseText) {
820
+ const cleaned = responseText.replace(/^```json?\s*/i, '').replace(/\s*```$/i, '').trim();
821
+ // Strategy 1: Direct parse
822
+ try {
823
+ return JSON.parse(cleaned);
824
+ }
825
+ catch { /* continue */ }
826
+ // Strategy 2: Strip control characters in string values
827
+ try {
828
+ const sanitized = cleaned.replace(/[\x00-\x1f]/g, (ch) => {
829
+ if (ch === '\n')
830
+ return '\\n';
831
+ if (ch === '\r')
832
+ return '\\r';
833
+ if (ch === '\t')
834
+ return '\\t';
835
+ return '';
836
+ });
837
+ return JSON.parse(sanitized);
838
+ }
839
+ catch { /* continue */ }
840
+ // Strategy 3: Regex extract spec field
841
+ const specMatch = cleaned.match(/"spec"\s*:\s*"((?:[^"\\]|\\.)*)"/s);
842
+ if (specMatch) {
843
+ try {
844
+ const specContent = JSON.parse(`"${specMatch[1]}"`);
845
+ return { spec: specContent };
846
+ }
847
+ catch { /* continue */ }
848
+ }
849
+ // Strategy 4: Raw markdown — LLM returned spec directly
850
+ if (cleaned.startsWith('#') && cleaned.includes('## ')) {
851
+ console.log('⚠️ parseChunkResponse: detected raw markdown, treating as spec');
852
+ return { spec: cleaned };
853
+ }
854
+ console.error('⚠️ parseChunkResponse: all strategies failed');
855
+ return null;
856
+ }
857
+ // ============================================================
858
+ // augmentResearchResult — Fast brain adds spec context to agent results (NO summarization)
859
+ // ============================================================
860
+ /**
861
+ * Augment agent SDK research results with context from spec.md.
862
+ * Passes ALL specific details through verbatim — only ADDS context annotations.
863
+ * The voice model downstream handles summarization for speech.
864
+ *
865
+ * Falls back to returning the original result if the fast brain is unavailable.
866
+ */
867
+ export async function augmentResearchResult(workingDir, sessionId, task, agentResult) {
868
+ initProvider();
869
+ if (provider === 'none')
870
+ return agentResult;
871
+ try {
872
+ // Read spec for context
873
+ const specContent = readSessionSpec(workingDir, sessionId);
874
+ const libraryFiles = listLibraryFiles(workingDir, sessionId);
875
+ const specSection = specContent
876
+ ? `\n\nCurrent spec.md:\n${specContent}`
877
+ : '';
878
+ const libSection = libraryFiles.length > 0
879
+ ? `\n\nLibrary files available: ${libraryFiles.join(', ')}`
880
+ : '';
881
+ const userMessage = `Research task: "${task}"
882
+
883
+ Agent findings:
884
+ ${agentResult}
885
+ ${specSection}${libSection}
886
+
887
+ Augment the agent's findings with relevant context from the spec. Pass ALL details through verbatim.`;
888
+ let responseText = null;
889
+ if (provider === 'anthropic') {
890
+ const response = await anthropicClient.messages.create({
891
+ model: ANTHROPIC_FAST_MODEL,
892
+ max_tokens: 16000,
893
+ system: AUGMENT_RESULT_SYSTEM,
894
+ messages: [{ role: 'user', content: userMessage }]
895
+ });
896
+ responseText = response.content[0].type === 'text' ? response.content[0].text : null;
897
+ }
898
+ else {
899
+ const response = await geminiClient.models.generateContent({
900
+ model: GEMINI_FAST_MODEL,
901
+ contents: userMessage,
902
+ config: { systemInstruction: AUGMENT_RESULT_SYSTEM }
903
+ });
904
+ responseText = response.text || null;
905
+ }
906
+ if (!responseText || responseText.length < agentResult.length * 0.5) {
907
+ // If augmented result is suspiciously shorter, the LLM likely summarized — use original
908
+ console.log('⚠️ augmentResearchResult: augmented result too short, using original');
909
+ return agentResult;
910
+ }
911
+ console.log(`🔄 augmentResearchResult: augmented ${agentResult.length} → ${responseText.length} chars`);
912
+ return responseText;
913
+ }
914
+ catch (err) {
915
+ console.error('❌ augmentResearchResult failed:', err);
916
+ return agentResult; // Fallback to original on error
917
+ }
918
+ }
919
+ // ============================================================
920
+ // updateSpecFromJSONL — Post-research spec consolidation via JSONL
921
+ // ============================================================
922
+ /**
923
+ * Update spec.md and library/ files after research completes.
924
+ * Reads FULL untruncated data directly from Claude Agent SDK JSONL files
925
+ * instead of receiving pre-truncated content chunks.
926
+ *
927
+ * Data sources:
928
+ * - getRecentToolResults() — last 30 full tool results (Read, Bash, WebSearch, etc.)
929
+ * - readSessionHistory() — last 50 assistant messages (agent reasoning/analysis)
930
+ * - getSubagentTranscripts() — all sub-agent findings
931
+ *
932
+ * Returns { spec, libraryFiles } or null if update failed.
933
+ */
934
+ export async function updateSpecFromJSONL(workingDir, sessionId, task, researchLog) {
935
+ initProvider();
936
+ if (provider === 'none')
937
+ return null;
938
+ try {
939
+ // 1. Read FULL data from JSONL — no truncation
940
+ const toolResults = getRecentToolResults(sessionId, workingDir, 30);
941
+ const assistantMessages = readSessionHistory(sessionId, workingDir, {
942
+ lastN: 50,
943
+ types: ['assistant']
944
+ });
945
+ const subagents = getSubagentTranscripts(sessionId, workingDir);
946
+ // 2. Build comprehensive content from FULL data
947
+ const contentChunks = [];
948
+ // Tool results — full content
949
+ if (toolResults.length > 0) {
950
+ const toolContent = toolResults.map(tr => {
951
+ const inputPreview = JSON.stringify(tr.toolInput).substring(0, 200);
952
+ return `[${tr.toolName}: ${inputPreview}]\n${tr.resultContent}`;
953
+ }).join('\n\n---\n\n');
954
+ contentChunks.push(toolContent);
955
+ }
956
+ // Agent reasoning — full text
957
+ const agentTexts = assistantMessages
958
+ .filter(m => m.text && m.text.length > 20)
959
+ .map(m => `[Agent reasoning]\n${m.text}`);
960
+ if (agentTexts.length > 0) {
961
+ contentChunks.push(agentTexts.join('\n\n'));
962
+ }
963
+ // Sub-agent findings — full text
964
+ if (subagents.length > 0) {
965
+ const subagentContent = subagents.map(sa => {
966
+ const findings = sa.messages
967
+ .filter(m => m.type === 'assistant' && m.text)
968
+ .map(m => `[Sub-agent ${sa.taskId}]\n${m.text}`);
969
+ return findings.join('\n\n');
970
+ }).filter(c => c.length > 0);
971
+ if (subagentContent.length > 0) {
972
+ contentChunks.push(subagentContent.join('\n\n'));
973
+ }
974
+ }
975
+ // Research log summary
976
+ if (researchLog.length > 0) {
977
+ contentChunks.push(`[Research log summary]\n${researchLog.slice(0, 25).join('\n')}`);
978
+ }
979
+ if (contentChunks.length === 0) {
980
+ console.log('⚠️ updateSpecFromJSONL: no content found in JSONL');
981
+ return null;
982
+ }
983
+ const totalChars = contentChunks.reduce((sum, c) => sum + c.length, 0);
984
+ console.log(`📖 updateSpecFromJSONL: read ${toolResults.length} tool results, ${agentTexts.length} agent messages, ${subagents.length} sub-agents (${totalChars} total chars)`);
985
+ // 3. Pass to processResearchChunk with isRefinement=true
986
+ return processResearchChunk(workingDir, sessionId, task, contentChunks, true);
987
+ }
988
+ catch (err) {
989
+ console.error('❌ updateSpecFromJSONL failed:', err);
990
+ return null;
991
+ }
992
+ }
993
+ // ============================================================
994
+ // Fire-and-forget: Question Writer — writes user question to spec BEFORE agent starts
995
+ // ============================================================
996
+ /**
997
+ * Fire-and-forget: Write a user question to spec.md Open Questions > From User
998
+ * before the agent starts researching. Ensures every escalated question is tracked.
999
+ *
1000
+ * Uses a simple LLM call to fuzzy-match existing questions and avoid duplicates.
1001
+ * Skips if spec.md doesn't exist yet or no provider is available.
1002
+ */
1003
+ export async function writeQuestionToSpec(workingDir, sessionId, question) {
1004
+ initProvider();
1005
+ if (provider === 'none')
1006
+ return;
1007
+ try {
1008
+ const workspace = getSessionWorkspace(workingDir, sessionId);
1009
+ const specPath = `${workspace}/spec.md`;
1010
+ if (!existsSync(specPath))
1011
+ return;
1012
+ const currentSpec = readFileSync(specPath, 'utf-8');
1013
+ // Quick check: if the question (or something very similar) is already in the spec, skip
1014
+ const normalizedQ = question.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim();
1015
+ if (normalizedQ.length < 10)
1016
+ return; // Too short to track
1017
+ const systemPrompt = `You manage the "Open Questions" section of a research spec file.
1018
+
1019
+ Given the current spec.md and a new user question, decide:
1020
+ 1. Is this question (or something very similar) already tracked? If yes, output: SKIP
1021
+ 2. If not, output the COMPLETE updated spec.md with the question added under "## Open Questions > ### From User (unanswered)" as a checkbox: - [ ] Question
1022
+
1023
+ Rules:
1024
+ - Add a timestamp: (asked ${new Date().toLocaleTimeString()})
1025
+ - Do NOT modify any other section of the spec
1026
+ - Do NOT mark existing questions as answered
1027
+ - Output ONLY the full spec.md content or the word SKIP — nothing else`;
1028
+ const userMessage = `Current spec.md:\n\`\`\`\n${currentSpec}\n\`\`\`\n\nNew user question to track:\n"${question}"`;
1029
+ let responseText = null;
1030
+ if (provider === 'anthropic') {
1031
+ const response = await anthropicClient.messages.create({
1032
+ model: ANTHROPIC_FAST_MODEL,
1033
+ max_tokens: 8000,
1034
+ system: systemPrompt,
1035
+ messages: [{ role: 'user', content: userMessage }]
1036
+ });
1037
+ responseText = response.content[0].type === 'text' ? response.content[0].text : null;
1038
+ }
1039
+ else {
1040
+ const response = await geminiClient.models.generateContent({
1041
+ model: GEMINI_FAST_MODEL,
1042
+ contents: userMessage,
1043
+ config: { systemInstruction: systemPrompt }
1044
+ });
1045
+ responseText = response.text || null;
1046
+ }
1047
+ if (!responseText || responseText.trim() === 'SKIP') {
1048
+ console.log(`📝 writeQuestionToSpec: question already tracked or skipped`);
1049
+ return;
1050
+ }
1051
+ // Strip code fences if present
1052
+ let updatedSpec = responseText.trim();
1053
+ if (updatedSpec.startsWith('```')) {
1054
+ updatedSpec = updatedSpec.replace(/^```(?:markdown)?\n?/, '').replace(/\n?```$/, '');
1055
+ }
1056
+ // Sanity check: updated spec should be at least as long as current spec
1057
+ if (updatedSpec.length >= currentSpec.length * 0.8) {
1058
+ writeFileSync(specPath, updatedSpec, 'utf-8');
1059
+ console.log(`📝 writeQuestionToSpec: added question to spec (${updatedSpec.length} chars)`);
1060
+ }
1061
+ }
1062
+ catch (err) {
1063
+ console.error('❌ writeQuestionToSpec failed:', err);
1064
+ }
1065
+ }
1066
+ // ============================================================
1067
+ // Fire-and-forget: Answer Checker — checks agent output against open questions
1068
+ // ============================================================
1069
+ // Debounce guard: prevent flooding during rapid tool_result sequences
1070
+ let answerCheckTimer = null;
1071
+ let pendingAnswerCheck = null;
1072
+ /**
1073
+ * Fire-and-forget: Check if substantial agent output answers any open questions in spec.md.
1074
+ * Debounced (3s) to prevent flooding during rapid tool_result sequences.
1075
+ *
1076
+ * When a question is answered, marks it with [x] and moves the answer to Findings.
1077
+ */
1078
+ export async function checkOutputAgainstQuestions(workingDir, sessionId, output, outputType) {
1079
+ // Store the latest check request (newer output replaces older)
1080
+ pendingAnswerCheck = { workingDir, sessionId, output, outputType };
1081
+ // Debounce: only fire after 3s of quiet
1082
+ if (answerCheckTimer)
1083
+ return;
1084
+ answerCheckTimer = setTimeout(async () => {
1085
+ answerCheckTimer = null;
1086
+ const check = pendingAnswerCheck;
1087
+ pendingAnswerCheck = null;
1088
+ if (!check)
1089
+ return;
1090
+ await executeAnswerCheck(check.workingDir, check.sessionId, check.output, check.outputType);
1091
+ }, 3000);
1092
+ }
1093
+ async function executeAnswerCheck(workingDir, sessionId, output, outputType) {
1094
+ initProvider();
1095
+ if (provider === 'none')
1096
+ return;
1097
+ try {
1098
+ const workspace = getSessionWorkspace(workingDir, sessionId);
1099
+ const specPath = `${workspace}/spec.md`;
1100
+ if (!existsSync(specPath))
1101
+ return;
1102
+ const currentSpec = readFileSync(specPath, 'utf-8');
1103
+ // Quick check: are there any open questions?
1104
+ if (!currentSpec.includes('- [ ]')) {
1105
+ return; // No open questions to check against
1106
+ }
1107
+ const systemPrompt = `You check if research output answers any open questions in a spec file.
1108
+
1109
+ Given the current spec.md and a piece of agent output (${outputType}), decide:
1110
+ 1. Does this output answer (fully or partially) any "- [ ]" questions in "## Open Questions"?
1111
+ 2. If YES: output the COMPLETE updated spec.md with:
1112
+ - Answered questions marked: - [x] Question → Brief answer summary (from research)
1113
+ - Key findings added to "## Findings & Resources" section
1114
+ 3. If NO questions are answered: output NONE
1115
+
1116
+ Rules:
1117
+ - Only mark a question answered if the output CLEARLY provides the answer
1118
+ - Keep the answer summary brief (1-2 sentences)
1119
+ - Do NOT modify questions that aren't answered by this output
1120
+ - Do NOT remove or rewrite existing Findings
1121
+ - Output ONLY the full spec.md content or the word NONE — nothing else`;
1122
+ // Truncate output to avoid overwhelming the model on very large tool results
1123
+ const truncatedOutput = output.length > 15000 ? output.substring(0, 15000) + '\n[... truncated]' : output;
1124
+ const userMessage = `Current spec.md:\n\`\`\`\n${currentSpec}\n\`\`\`\n\nAgent output (${outputType}):\n\`\`\`\n${truncatedOutput}\n\`\`\``;
1125
+ let responseText = null;
1126
+ if (provider === 'anthropic') {
1127
+ const response = await anthropicClient.messages.create({
1128
+ model: ANTHROPIC_FAST_MODEL,
1129
+ max_tokens: 8000,
1130
+ system: systemPrompt,
1131
+ messages: [{ role: 'user', content: userMessage }]
1132
+ });
1133
+ responseText = response.content[0].type === 'text' ? response.content[0].text : null;
1134
+ }
1135
+ else {
1136
+ const response = await geminiClient.models.generateContent({
1137
+ model: GEMINI_FAST_MODEL,
1138
+ contents: userMessage,
1139
+ config: { systemInstruction: systemPrompt }
1140
+ });
1141
+ responseText = response.text || null;
1142
+ }
1143
+ if (!responseText || responseText.trim() === 'NONE') {
1144
+ return;
1145
+ }
1146
+ // Strip code fences if present
1147
+ let updatedSpec = responseText.trim();
1148
+ if (updatedSpec.startsWith('```')) {
1149
+ updatedSpec = updatedSpec.replace(/^```(?:markdown)?\n?/, '').replace(/\n?```$/, '');
1150
+ }
1151
+ // Sanity check
1152
+ if (updatedSpec.length >= currentSpec.length * 0.8) {
1153
+ writeFileSync(specPath, updatedSpec, 'utf-8');
1154
+ console.log(`✅ checkOutputAgainstQuestions: marked question(s) as answered in spec (${updatedSpec.length} chars)`);
1155
+ }
1156
+ }
1157
+ catch (err) {
1158
+ console.error('❌ checkOutputAgainstQuestions failed:', err);
1159
+ }
1160
+ }
1161
+ // ============================================================
1162
+ // contextualizeResearchUpdate — Fast brain generates natural voice updates during research
1163
+ // ============================================================
1164
+ /**
1165
+ * Generate a natural, contextualized voice update from raw research events.
1166
+ * Called by scheduleResearchBatch() instead of injecting raw events directly.
1167
+ *
1168
+ * Returns a natural 1-2 sentence update, or null if nothing interesting to say.
1169
+ * 3-second timeout — returns null if the LLM is too slow.
1170
+ */
1171
+ export async function contextualizeResearchUpdate(workingDir, sessionId, task, batchEvents, researchLog) {
1172
+ initProvider();
1173
+ if (provider === 'none')
1174
+ return null;
1175
+ try {
1176
+ const specContent = readSessionSpec(workingDir, sessionId);
1177
+ const specTruncated = specContent ? specContent.substring(0, 1500) : '';
1178
+ // Read last 5 tool results for what was just found
1179
+ const recentResults = getRecentToolResults(sessionId, workingDir, 5);
1180
+ const resultsSummary = recentResults.map(tr => {
1181
+ const inputPreview = JSON.stringify(tr.toolInput).substring(0, 100);
1182
+ const resultPreview = tr.resultContent.substring(0, 200);
1183
+ return `[${tr.toolName}: ${inputPreview}] ${resultPreview}`;
1184
+ }).join('\n');
1185
+ const userMessage = `Research question: "${task}"
1186
+
1187
+ Recent events: ${batchEvents.slice(-10).join('. ')}
1188
+
1189
+ Research log (${researchLog.length} total steps): ${researchLog.slice(-15).join('. ')}
1190
+
1191
+ Recent findings:
1192
+ ${resultsSummary}
1193
+
1194
+ ${specTruncated ? `Spec context:\n${specTruncated}` : ''}`;
1195
+ const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), 3000));
1196
+ let responsePromise;
1197
+ if (provider === 'anthropic') {
1198
+ responsePromise = anthropicClient.messages.create({
1199
+ model: ANTHROPIC_FAST_MODEL,
1200
+ max_tokens: 200,
1201
+ system: CONTEXTUALIZE_UPDATE_SYSTEM,
1202
+ messages: [{ role: 'user', content: userMessage }]
1203
+ }).then(r => r.content[0].type === 'text' ? r.content[0].text : null);
1204
+ }
1205
+ else {
1206
+ responsePromise = geminiClient.models.generateContent({
1207
+ model: GEMINI_FAST_MODEL,
1208
+ contents: userMessage,
1209
+ config: { systemInstruction: CONTEXTUALIZE_UPDATE_SYSTEM }
1210
+ }).then(r => r.text || null);
1211
+ }
1212
+ const result = await Promise.race([responsePromise, timeoutPromise]);
1213
+ if (!result || result.trim() === 'NOTHING')
1214
+ return null;
1215
+ return result.trim();
1216
+ }
1217
+ catch (err) {
1218
+ console.error('❌ contextualizeResearchUpdate failed:', err);
1219
+ return null;
1220
+ }
1221
+ }
1222
+ // ============================================================
1223
+ // generateProactivePrompt — Fast brain generates conversation during research silence
1224
+ // ============================================================
1225
+ /**
1226
+ * Generate a proactive conversational prompt to keep the user engaged during research.
1227
+ * Called periodically (every 15s) during active research.
1228
+ *
1229
+ * Can ask open questions, discuss implications of findings, or give progress with depth.
1230
+ * Returns null/NOTHING if nothing interesting to say.
1231
+ * 3-second timeout.
1232
+ */
1233
+ export async function generateProactivePrompt(workingDir, sessionId, task, researchLog, previousPrompts) {
1234
+ initProvider();
1235
+ if (provider === 'none')
1236
+ return null;
1237
+ try {
1238
+ const specContent = readSessionSpec(workingDir, sessionId);
1239
+ const specTruncated = specContent ? specContent.substring(0, 2000) : '';
1240
+ // Read recent discoveries from JSONL
1241
+ const recentResults = getRecentToolResults(sessionId, workingDir, 8);
1242
+ const resultsSummary = recentResults.map(tr => {
1243
+ const inputPreview = JSON.stringify(tr.toolInput).substring(0, 100);
1244
+ const resultPreview = tr.resultContent.substring(0, 300);
1245
+ return `[${tr.toolName}: ${inputPreview}] ${resultPreview}`;
1246
+ }).join('\n');
1247
+ // Read recent agent reasoning
1248
+ const recentText = readSessionHistory(sessionId, workingDir, {
1249
+ lastN: 5,
1250
+ types: ['assistant']
1251
+ });
1252
+ const reasoningSummary = recentText
1253
+ .filter(m => m.text && m.text.length > 20)
1254
+ .map(m => m.text.substring(0, 300))
1255
+ .join('\n');
1256
+ const userMessage = `Research question: "${task}"
1257
+
1258
+ Research progress (${researchLog.length} steps so far): ${researchLog.slice(-10).join('. ')}
1259
+
1260
+ Recent findings:
1261
+ ${resultsSummary}
1262
+
1263
+ Agent reasoning:
1264
+ ${reasoningSummary}
1265
+
1266
+ ${specTruncated ? `Session spec:\n${specTruncated}` : ''}
1267
+
1268
+ Previous things already said (DO NOT repeat):
1269
+ ${previousPrompts.length > 0 ? previousPrompts.join('\n') : '(none yet)'}`;
1270
+ const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), 3000));
1271
+ let responsePromise;
1272
+ if (provider === 'anthropic') {
1273
+ responsePromise = anthropicClient.messages.create({
1274
+ model: ANTHROPIC_FAST_MODEL,
1275
+ max_tokens: 200,
1276
+ system: PROACTIVE_PROMPT_SYSTEM,
1277
+ messages: [{ role: 'user', content: userMessage }]
1278
+ }).then(r => r.content[0].type === 'text' ? r.content[0].text : null);
1279
+ }
1280
+ else {
1281
+ responsePromise = geminiClient.models.generateContent({
1282
+ model: GEMINI_FAST_MODEL,
1283
+ contents: userMessage,
1284
+ config: { systemInstruction: PROACTIVE_PROMPT_SYSTEM }
1285
+ }).then(r => r.text || null);
1286
+ }
1287
+ const result = await Promise.race([responsePromise, timeoutPromise]);
1288
+ if (!result || result.trim() === 'NOTHING')
1289
+ return null;
1290
+ return result.trim();
1291
+ }
1292
+ catch (err) {
1293
+ console.error('❌ generateProactivePrompt failed:', err);
1294
+ return null;
1295
+ }
1296
+ }
1297
+ // ============================================================
1298
+ // generateVisualDocument — Fast brain generates structured visual documents
1299
+ // ============================================================
1300
+ /**
1301
+ * Generate a structured visual document (comparison table, Mermaid diagram,
1302
+ * analysis, or summary) from research findings.
1303
+ *
1304
+ * Reads spec.md, JSONL results, and library for context.
1305
+ * Writes the result to library/ and returns the filename + content.
1306
+ */
1307
+ export async function generateVisualDocument(workingDir, sessionId, request, documentType) {
1308
+ initProvider();
1309
+ if (provider === 'none')
1310
+ return null;
1311
+ try {
1312
+ const workspace = getSessionWorkspace(workingDir, sessionId);
1313
+ const specContent = readSessionSpec(workingDir, sessionId) || '';
1314
+ const libraryFiles = listLibraryFiles(workingDir, sessionId);
1315
+ // Read library contents for context
1316
+ const libraryDir = `${workspace}/library`;
1317
+ const libraryContents = [];
1318
+ for (const file of libraryFiles.slice(0, 5)) {
1319
+ const filePath = `${libraryDir}/${file}`;
1320
+ if (existsSync(filePath)) {
1321
+ try {
1322
+ const content = readFileSync(filePath, 'utf-8');
1323
+ libraryContents.push(`--- ${file} ---\n${content.substring(0, 3000)}`);
1324
+ }
1325
+ catch { /* skip */ }
1326
+ }
1327
+ }
1328
+ // Read recent JSONL results for raw data
1329
+ const toolResults = getRecentToolResults(sessionId, workingDir, 20);
1330
+ const toolResultsSummary = toolResults.map(tr => {
1331
+ const inputPreview = JSON.stringify(tr.toolInput).substring(0, 150);
1332
+ return `[${tr.toolName}: ${inputPreview}]\n${tr.resultContent.substring(0, 1000)}`;
1333
+ }).join('\n\n---\n\n');
1334
+ const userMessage = `Document request: "${request}"
1335
+ Document type: ${documentType}
1336
+
1337
+ Session spec:
1338
+ ${specContent}
1339
+
1340
+ ${libraryContents.length > 0 ? `Library files:\n${libraryContents.join('\n\n')}` : ''}
1341
+
1342
+ Recent research data:
1343
+ ${toolResultsSummary}
1344
+
1345
+ Return JSON: {"fileName": "descriptive-name.md", "content": "full markdown content"}`;
1346
+ let responseText = null;
1347
+ if (provider === 'anthropic') {
1348
+ const response = await anthropicClient.messages.create({
1349
+ model: ANTHROPIC_FAST_MODEL,
1350
+ max_tokens: 16000,
1351
+ system: VISUAL_DOCUMENT_SYSTEM,
1352
+ messages: [{ role: 'user', content: userMessage }]
1353
+ });
1354
+ responseText = response.content[0].type === 'text' ? response.content[0].text : null;
1355
+ }
1356
+ else {
1357
+ const response = await geminiClient.models.generateContent({
1358
+ model: GEMINI_FAST_MODEL,
1359
+ contents: userMessage,
1360
+ config: { systemInstruction: VISUAL_DOCUMENT_SYSTEM }
1361
+ });
1362
+ responseText = response.text || null;
1363
+ }
1364
+ if (!responseText)
1365
+ return null;
1366
+ // Parse JSON response
1367
+ const cleaned = responseText.replace(/^```json?\s*/i, '').replace(/\s*```$/i, '').trim();
1368
+ let parsed;
1369
+ try {
1370
+ parsed = JSON.parse(cleaned);
1371
+ }
1372
+ catch {
1373
+ // Try to extract from malformed response
1374
+ const fnMatch = cleaned.match(/"fileName"\s*:\s*"([^"]+)"/);
1375
+ const ctMatch = cleaned.match(/"content"\s*:\s*"((?:[^"\\]|\\.)*)"/s);
1376
+ if (fnMatch && ctMatch) {
1377
+ try {
1378
+ parsed = { fileName: fnMatch[1], content: JSON.parse(`"${ctMatch[1]}"`) };
1379
+ }
1380
+ catch {
1381
+ console.error('⚠️ generateVisualDocument: failed to parse response');
1382
+ return null;
1383
+ }
1384
+ }
1385
+ else {
1386
+ return null;
1387
+ }
1388
+ }
1389
+ if (!parsed.fileName || !parsed.content)
1390
+ return null;
1391
+ // Write to library
1392
+ const safeName = parsed.fileName.replace(/[^a-zA-Z0-9._-]/g, '-');
1393
+ const libraryPath = `${workspace}/library`;
1394
+ mkdirSync(libraryPath, { recursive: true });
1395
+ const filePath = `${libraryPath}/${safeName}`;
1396
+ writeFileSync(filePath, parsed.content, 'utf-8');
1397
+ console.log(`📊 generateVisualDocument: wrote ${safeName} (${parsed.content.length} chars)`);
1398
+ return { fileName: safeName, content: parsed.content };
1399
+ }
1400
+ catch (err) {
1401
+ console.error('❌ generateVisualDocument failed:', err);
1402
+ return null;
1403
+ }
1404
+ }