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.
- package/.env.example +8 -1
- package/dist/bridge-llm.d.ts +22 -0
- package/dist/bridge-llm.js +39 -0
- package/dist/claude-handler.d.ts +6 -0
- package/dist/claude-handler.js +43 -1
- package/dist/claude-llm.d.ts +128 -0
- package/dist/claude-llm.js +623 -0
- package/dist/codex-llm.d.ts +40 -0
- package/dist/codex-llm.js +144 -0
- package/dist/config.d.ts +227 -1
- package/dist/config.js +775 -8
- package/dist/conversation-brain.d.ts +92 -0
- package/dist/conversation-brain.js +360 -0
- package/dist/fast-brain.d.ts +122 -0
- package/dist/fast-brain.js +1404 -0
- package/dist/index.js +1997 -322
- package/dist/prompts.d.ts +19 -0
- package/dist/prompts.js +610 -0
- package/dist/session-access.d.ts +399 -0
- package/dist/session-access.js +775 -0
- package/dist/smithery-proxy.d.ts +57 -0
- package/dist/smithery-proxy.js +195 -0
- package/dist/status-manager.d.ts +90 -0
- package/dist/status-manager.js +187 -0
- package/dist/voice-io.d.ts +70 -0
- package/dist/voice-io.js +152 -0
- package/package.json +17 -6
|
@@ -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
|
+
}
|