osborn 0.9.180 → 0.9.182

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.
@@ -1,171 +1,159 @@
1
1
  /**
2
- * pipeline-fastbrain.ts — Pipeline Fast Brain (Agent with AFC)
2
+ * pipeline-fastbrain.ts — Pipeline Fast Brain (Agent with tool loop)
3
3
  *
4
- * Uses Gemini Flash as an AGENT with Automatic Function Calling (AFC).
5
- * One generateContent() call handles everything:
6
- * - Gemini decides IF it needs to search (skips for greetings/follow-ups)
7
- * - Gemini decides WHAT to search (smart phrase selection)
8
- * - Gemini can multi-step: search → not enough → refine → search again
9
- * - AFC handles the tool loop internally (up to 3 rounds)
4
+ * Uses OpenRouter (OpenAI-compatible) instead of Google Gemini.
5
+ * Manual tool loop replaces Gemini's Automatic Function Calling (AFC).
6
+ * - Model decides IF it needs to search (skips for greetings/follow-ups)
7
+ * - Model decides WHAT to search (smart phrase selection)
8
+ * - Model can multi-step: search → not enough → refine → search again
10
9
  *
11
10
  * Tools:
12
11
  * search_session — ripgrep the summary index + read full content via byte offsets
13
- *
14
- * No separate phrase extraction call. No manual tool loop. One API invocation.
12
+ * get_recent — latest N index entries + full content
13
+ * emergency_stop — kill and restart the main agent
15
14
  */
16
- import { GoogleGenAI } from '@google/genai';
17
15
  // ============================================================
18
16
  // CONSTANTS
19
17
  // ============================================================
20
- const GEMINI_MODEL = 'gemini-2.5-flash'; // 0.9.67: was gemini-2.0-flash — 404 deprecated by Google
21
- const TIMEOUT_MS = 20_000; // AFC needs time for tool calls + processing + synthesis
22
- const MAX_AFC_CALLS = 4;
18
+ const OPENROUTER_MODEL = 'deepseek/deepseek-chat-v3-5';
19
+ const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
20
+ const TIMEOUT_MS = 20_000;
21
+ const MAX_TOOL_ROUNDS = 4;
23
22
  // ============================================================
24
- // PERSISTENT STATE
23
+ // PERSISTENT STATE (OpenAI message format)
25
24
  // ============================================================
26
- let persistentContents = [];
25
+ let persistentMessages = [];
27
26
  let persistentSessionId = null;
28
- /** Clear the pipeline fast brain session (call on disconnect/reconnect) */
29
27
  export function clearPipelineFastBrainSession() {
30
- persistentContents = [];
28
+ persistentMessages = [];
31
29
  persistentSessionId = null;
32
30
  }
33
- /** No-op — kept for backward compatibility with index.ts import */
34
31
  export async function prewarmBM25Index(_sessionId, _workingDir) { }
35
- function createSearchTool(sessionId, workingDir, sessionBaseDir, agentControl) {
36
- let searchCount = 0;
37
- const callableTool = {
38
- async tool() {
39
- return {
40
- functionDeclarations: [
41
- {
42
- name: 'search_session',
43
- description: 'Search session history by keywords. Returns summaries + full untruncated content. Use for questions about what was discussed, decided, researched, or built.',
44
- parameters: {
45
- type: 'OBJECT',
46
- properties: {
47
- phrases: {
48
- type: 'ARRAY',
49
- items: { type: 'STRING' },
50
- description: '2-3 word search phrases, lowercase. Include one phrase per topic.',
51
- },
52
- },
53
- required: ['phrases'],
32
+ // ============================================================
33
+ // TOOL DEFINITIONS (OpenAI function calling format)
34
+ // ============================================================
35
+ function buildTools(hasAgentControl) {
36
+ const tools = [
37
+ {
38
+ type: 'function',
39
+ function: {
40
+ name: 'search_session',
41
+ description: 'Search session history by keywords. Returns summaries + full untruncated content. Use for questions about what was discussed, decided, researched, or built.',
42
+ parameters: {
43
+ type: 'object',
44
+ properties: {
45
+ phrases: {
46
+ type: 'array',
47
+ items: { type: 'string' },
48
+ description: '2-3 word search phrases, lowercase. Include one phrase per topic.',
54
49
  },
55
50
  },
56
- {
57
- name: 'get_recent',
58
- description: 'Get the most recent session activity with full content. Use for: "where did we leave off?", "what just happened?", "what are we working on?", or any question about recent/current work.',
59
- parameters: {
60
- type: 'OBJECT',
61
- properties: {
62
- count: {
63
- type: 'NUMBER',
64
- description: 'Number of recent entries. Default 20, max 50.',
65
- },
66
- },
51
+ required: ['phrases'],
52
+ },
53
+ },
54
+ },
55
+ {
56
+ type: 'function',
57
+ function: {
58
+ name: 'get_recent',
59
+ description: 'Get the most recent session activity with full content. Use for: "where did we leave off?", "what just happened?", "what are we working on?", or any question about recent/current work.',
60
+ parameters: {
61
+ type: 'object',
62
+ properties: {
63
+ count: {
64
+ type: 'number',
65
+ description: 'Number of recent entries. Default 20, max 50.',
67
66
  },
68
67
  },
69
- ...(agentControl ? [{
70
- name: 'emergency_stop',
71
- description: [
72
- 'Kill and restart the main agent with new instructions.',
73
- 'Call when the user clearly wants the agent to STOP what a DESTRUCTIVE or ALTERING action:',
74
- ' - Destructive actions: write, edit, delete, install, deploy, push, modify files/data',
75
- ' - Wrong direction: agent is doing something the user didn\'t ask for or explicitly rejects',
76
- 'User signals: "stop", "don\'t", "cancel", "wait no", "not that", "no no no", "I said stop".',
77
- 'NEVER call for: research, reading, exploring, searching, fetching, or casual conversation, questions about what the agent is doing, or research the user initiated.',
78
- 'When in doubt about whether to stop: check get_recent first to see what the agent is actually doing. ',
79
- 'Priority: how destructive/unrecoverable the action is > how strongly the user signals.'
80
- ].join(' '),
81
- parameters: {
82
- type: 'OBJECT',
83
- properties: {
84
- reason: {
85
- type: 'STRING',
86
- description: 'What destructive action is being stopped and what the user wants instead. Use their exact words.',
87
- },
88
- },
89
- required: ['reason'],
90
- },
91
- }] : []),
92
- ],
93
- };
94
- },
95
- async callTool(functionCalls) {
96
- const results = [];
97
- for (const call of functionCalls) {
98
- if (call.name === 'search_session') {
99
- searchCount++;
100
- const phrases = call.args?.phrases || [];
101
- if (phrases.length === 0) {
102
- results.push({ functionResponse: { name: 'search_session', response: { result: 'No phrases provided' } } });
103
- continue;
104
- }
105
- console.log(`🧠⚡ [pipeline-fb] AFC search: [${phrases.join(', ')}]`);
106
- const searchResult = await executeSearch(phrases, sessionId, workingDir);
107
- results.push({ functionResponse: { name: 'search_session', response: { result: searchResult } } });
108
- }
109
- else if (call.name === 'get_recent') {
110
- searchCount++;
111
- const count = Math.min(Math.max(call.args?.count || 20, 5), 50);
112
- console.log(`🧠⚡ [pipeline-fb] AFC get_recent: ${count}`);
113
- const recent = await getRecentEntries(sessionId, workingDir, undefined, count);
114
- results.push({ functionResponse: { name: 'get_recent', response: { result: recent } } });
115
- }
116
- else if (call.name === 'emergency_stop' && agentControl) {
117
- const reason = call.args?.reason || 'user requested stop';
118
- console.log(`🧠⚡ [pipeline-fb] AFC emergency_stop: ${reason}`);
119
- // Gather context
120
- const recentUserMessages = agentControl.getRecentUserMessages(10);
121
- const recentActivity = await getRecentEntries(sessionId, workingDir, undefined, 10);
122
- // Kill the destructive process and restart with new instructions
123
- agentControl.abort();
124
- const restartPrompt = [
125
- `[EMERGENCY STOP] The user stopped your previous action.`,
126
- ``,
127
- `Reason: ${reason}`,
128
- ``,
129
- `Recent user messages:`,
130
- ...recentUserMessages.map((m, i) => ` ${i + 1}. ${m}`),
131
- ``,
132
- `What was happening before the stop:`,
133
- recentActivity.substring(0, 2000),
134
- ``,
135
- `RESPOND IMMEDIATELY with speech:`,
136
- `1. Acknowledge what you were doing and that you've stopped`,
137
- `2. If the user gave a new direction, confirm what you'll do instead`,
138
- `3. If unclear, ask what they'd like to do next`,
139
- `Do NOT silently do tool calls — speak first.`,
140
- ].join('\n');
141
- agentControl.sendPrompt(restartPrompt);
142
- results.push({ functionResponse: { name: 'emergency_stop', response: { result: `Agent stopped and restarted. Reason: ${reason}` } } });
143
- }
144
- }
145
- return results;
68
+ },
69
+ },
146
70
  },
147
- };
148
- return { tool: callableTool, searchCount, getSearchCount: () => searchCount };
71
+ ];
72
+ if (hasAgentControl) {
73
+ tools.push({
74
+ type: 'function',
75
+ function: {
76
+ name: 'emergency_stop',
77
+ description: [
78
+ 'Kill and restart the main agent with new instructions.',
79
+ 'Call when the user clearly wants the agent to STOP a DESTRUCTIVE or ALTERING action:',
80
+ ' - Destructive actions: write, edit, delete, install, deploy, push, modify files/data',
81
+ ' - Wrong direction: agent is doing something the user didn\'t ask for or explicitly rejects',
82
+ 'User signals: "stop", "don\'t", "cancel", "wait no", "not that", "no no no", "I said stop".',
83
+ 'NEVER call for: research, reading, exploring, searching, fetching, or casual conversation.',
84
+ 'When in doubt: check get_recent first to see what the agent is actually doing.',
85
+ ].join(' '),
86
+ parameters: {
87
+ type: 'object',
88
+ properties: {
89
+ reason: {
90
+ type: 'string',
91
+ description: 'What destructive action is being stopped and what the user wants instead.',
92
+ },
93
+ },
94
+ required: ['reason'],
95
+ },
96
+ },
97
+ });
98
+ }
99
+ return tools;
149
100
  }
150
- /**
151
- * Execute a search: ripgrep the summary index, then read full content via byte offsets.
152
- */
153
- async function executeSearch(phrases, sessionId, workingDir, _sessionBaseDir) {
101
+ // ============================================================
102
+ // TOOL EXECUTION
103
+ // ============================================================
104
+ async function executeTool(name, args, sessionId, workingDir, agentControl) {
105
+ if (name === 'search_session') {
106
+ const phrases = args?.phrases || [];
107
+ if (phrases.length === 0)
108
+ return 'No phrases provided';
109
+ console.log(`🧠⚡ [pipeline-fb] search: [${phrases.join(', ')}]`);
110
+ return executeSearch(phrases, sessionId, workingDir);
111
+ }
112
+ else if (name === 'get_recent') {
113
+ const count = Math.min(Math.max(args?.count || 20, 5), 50);
114
+ console.log(`🧠⚡ [pipeline-fb] get_recent: ${count}`);
115
+ return getRecentEntries(sessionId, workingDir, undefined, count);
116
+ }
117
+ else if (name === 'emergency_stop' && agentControl) {
118
+ const reason = args?.reason || 'user requested stop';
119
+ console.log(`🧠⚡ [pipeline-fb] emergency_stop: ${reason}`);
120
+ const recentUserMessages = agentControl.getRecentUserMessages(10);
121
+ const recentActivity = await getRecentEntries(sessionId, workingDir, undefined, 10);
122
+ agentControl.abort();
123
+ agentControl.sendPrompt([
124
+ `[EMERGENCY STOP] The user stopped your previous action.`,
125
+ ``,
126
+ `Reason: ${reason}`,
127
+ ``,
128
+ `Recent user messages:`,
129
+ ...recentUserMessages.map((m, i) => ` ${i + 1}. ${m}`),
130
+ ``,
131
+ `What was happening before the stop:`,
132
+ recentActivity.substring(0, 2000),
133
+ ``,
134
+ `RESPOND IMMEDIATELY with speech:`,
135
+ `1. Acknowledge what you were doing and that you've stopped`,
136
+ `2. If the user gave a new direction, confirm what you'll do instead`,
137
+ `3. If unclear, ask what they'd like to do next`,
138
+ `Do NOT silently do tool calls — speak first.`,
139
+ ].join('\n'));
140
+ return `Agent stopped and restarted. Reason: ${reason}`;
141
+ }
142
+ return 'Unknown tool';
143
+ }
144
+ // ============================================================
145
+ // SEARCH HELPERS
146
+ // ============================================================
147
+ async function executeSearch(phrases, sessionId, workingDir) {
154
148
  const { ripgrepSearch } = await import('./jsonl-search.js');
155
149
  const { getIndexPath, readFullContent } = await import('./summary-index.js');
156
150
  const indexPath = getIndexPath(sessionId, workingDir);
157
151
  if (indexPath) {
158
- // ── Fast path: search summary index + targeted byte-offset reads ──
159
152
  const sections = [];
160
153
  const matchedRefs = [];
161
154
  const seenLines = new Set();
162
- let totalMatches = 0;
163
155
  for (const phrase of phrases.slice(0, 6)) {
164
- const results = ripgrepSearch(indexPath, phrase, {
165
- maxResults: 8,
166
- fromEnd: true,
167
- contextLines: 0,
168
- });
156
+ const results = ripgrepSearch(indexPath, phrase, { maxResults: 8, fromEnd: true, contextLines: 0 });
169
157
  const newResults = results.filter((r) => {
170
158
  const key = `${r.lineNumber}`;
171
159
  if (seenLines.has(key))
@@ -178,44 +166,29 @@ async function executeSearch(phrases, sessionId, workingDir, _sessionBaseDir) {
178
166
  for (const r of newResults) {
179
167
  const parts = r.content.split('|');
180
168
  if (parts.length >= 6) {
181
- matchedRefs.push({
182
- lineNum: parseInt(parts[0], 10),
183
- byteOffset: parseInt(parts[1], 10),
184
- source: parts[3],
185
- });
169
+ matchedRefs.push({ lineNum: parseInt(parts[0], 10), byteOffset: parseInt(parts[1], 10), source: parts[3] });
186
170
  sections.push(r.content);
187
171
  }
188
172
  }
189
- totalMatches += newResults.length;
190
173
  }
191
174
  }
192
- // Read full content for matched entries (byte-offset reads, ~0.5ms each)
193
175
  if (matchedRefs.length > 0) {
194
176
  try {
195
177
  const fullTexts = readFullContent(matchedRefs, sessionId, workingDir, undefined, 2000);
196
- if (fullTexts.length > 0) {
178
+ if (fullTexts.length > 0)
197
179
  sections.push('', `[FULL CONTENT — ${fullTexts.length} entries]`, ...fullTexts);
198
- }
199
180
  }
200
181
  catch { }
201
182
  }
202
- if (sections.length === 0) {
203
- return `No matches for: ${phrases.join(', ')}`;
204
- }
205
- return sections.join('\n');
183
+ return sections.length === 0 ? `No matches for: ${phrases.join(', ')}` : sections.join('\n');
206
184
  }
207
- // ── Fallback: raw JSONL search ──
208
185
  const { getSessionPaths } = await import('./session-access.js');
209
186
  const paths = getSessionPaths(sessionId, workingDir);
210
187
  if (!paths.exists)
211
188
  return 'No session files found';
212
189
  const sections = [];
213
190
  for (const phrase of phrases.slice(0, 4)) {
214
- const results = ripgrepSearch(paths.conversation, phrase, {
215
- maxResults: 5,
216
- fromEnd: true,
217
- contextLines: 0,
218
- });
191
+ const results = ripgrepSearch(paths.conversation, phrase, { maxResults: 5, fromEnd: true, contextLines: 0 });
219
192
  if (results.length > 0) {
220
193
  sections.push(`["${phrase}" — ${results.length} matches]`);
221
194
  sections.push(...results.map((r) => `L${r.lineNumber}: ${r.content}`));
@@ -223,42 +196,28 @@ async function executeSearch(phrases, sessionId, workingDir, _sessionBaseDir) {
223
196
  }
224
197
  return sections.length > 0 ? sections.join('\n') : `No matches for: ${phrases.join(', ')}`;
225
198
  }
226
- /**
227
- * Get the most recent N entries from the index + their full content.
228
- * Reads last N lines of search-index.txt, then byte-offset reads for full text.
229
- */
230
- async function getRecentEntries(sessionId, workingDir, _sessionBaseDir, // deprecated — workingDir used for index path
231
- count) {
199
+ async function getRecentEntries(sessionId, workingDir, _, count) {
232
200
  const { readFileSync } = await import('fs');
233
201
  const { getIndexPath, readFullContent } = await import('./summary-index.js');
234
202
  const indexPath = getIndexPath(sessionId, workingDir);
235
203
  if (!indexPath)
236
204
  return 'Index not built yet.';
237
- // Read last N lines
238
205
  const content = readFileSync(indexPath, 'utf-8');
239
- const allLines = content.split('\n').filter(Boolean);
240
- const recentLines = allLines.slice(-count);
241
- // Parse refs for full content reads
206
+ const recentLines = content.split('\n').filter(Boolean).slice(-count);
242
207
  const refs = [];
243
208
  const summaries = [`[RECENT — last ${recentLines.length} entries]`];
244
209
  for (const line of recentLines) {
245
210
  summaries.push(line);
246
211
  const parts = line.split('|');
247
212
  if (parts.length >= 6) {
248
- refs.push({
249
- lineNum: parseInt(parts[0], 10),
250
- byteOffset: parseInt(parts[1], 10),
251
- source: parts[3],
252
- });
213
+ refs.push({ lineNum: parseInt(parts[0], 10), byteOffset: parseInt(parts[1], 10), source: parts[3] });
253
214
  }
254
215
  }
255
- // Read full content for each entry
256
216
  if (refs.length > 0) {
257
217
  try {
258
218
  const fullTexts = readFullContent(refs, sessionId, workingDir, undefined, 1500);
259
- if (fullTexts.length > 0) {
219
+ if (fullTexts.length > 0)
260
220
  summaries.push('', `[FULL CONTENT — ${fullTexts.length} entries]`, ...fullTexts);
261
- }
262
221
  }
263
222
  catch { }
264
223
  }
@@ -268,104 +227,168 @@ count) {
268
227
  // SYSTEM PROMPT
269
228
  // ============================================================
270
229
  function buildSystemPrompt(chatHistory, researchContext) {
271
- const parts = [];
272
- parts.push(
273
- // CONTEXT
274
- `You are a fast memory recall agent for a voice AI assistant called Osborn.`, `You search the user's conversation history — their questions, the assistant's answers,`, `tool calls, research findings, and decisions — stored as indexed session files.`, `Tools: search_session (keyword search) and get_recent (latest activity).`, ``,
275
- // OBJECTIVE
276
- `== OBJECTIVE ==`, `Answer from session history. Search first for any recall question.`, `Greetings/thanks/confirmations: respond directly, no search.`, `Tasks needing live code analysis or new research: respond with [RESEARCH_NEEDED]`, ``,
277
- // STYLE
278
- `== STYLE ==`, `1-3 sentences. Grounded in results. Never fabricate.`, `If not found after thorough searching: "I didn't find that in the session history."`, ``,
279
- // AUDIENCE
280
- `== AUDIENCE ==`, `A user having a conversation and asking questions based on past context and research/task intentions via voice. Questions may be casual, rambling,`, `or use vague references ("that thing", "the error"). Interpret intent, not just words.`, ``,
281
- // RESULTS FORMAT
282
- `== RESULTS FORMAT ==`, `Each line: lineNum|byteOffset|timestamp|source|msgType|summary`, ` source: "main" = conversation, "agent-XXXX" = sub-agent research`, `Full content sections have complete untruncated text.`, ``,
283
- // SEARCH STRATEGY
284
- `== HOW TO SEARCH ==`, `You are searching a CONVERSATION, not a database. Think about what words people`, `ACTUALLY USED when this topic came up — not how the user is phrasing it now.`, ``, `PHRASES: 1-4 words each, multiple phrases per call.`, ` Short precise terms beat long phrases. "error" finds more than "error we got".`, ` Single words work great: "BM25", "latency", "crash", "watcher".`, ` Longer user questions = more clues. Mine them for specific nouns and names.`, ` e.g. "can you check the file sizes and see if the watcher is running"`, ` → ["file size", "watcher", "indexer", "running"]`, ``, `RETRIES (4 rounds — use them before giving up):`, ` 1: Specific terms from the question.`, ` 2: Think about how the conversation would READ when this was discussed.`, ` What would the assistant have said? What would the user have asked?`, ` 3: Related terms — names, tools, files that would appear near the topic.`, ` 4: Broad single words — cast a wide net.`, ` Only say "didn't find" after 3+ failed rounds.`, ``, `FOLLOW-UPS: "why?", "what about that?", "the other one?" — check your recent`, ` conversation to find the topic, then search for THAT topic specifically.`, ``, `⚠ Your own prior answers may have errors. Trust search results over your memory.`);
230
+ const parts = [
231
+ `You are a fast memory recall agent for a voice AI assistant called Osborn.`,
232
+ `You search the user's conversation history — their questions, the assistant's answers,`,
233
+ `tool calls, research findings, and decisions — stored as indexed session files.`,
234
+ `Tools: search_session (keyword search) and get_recent (latest activity).`,
235
+ ``,
236
+ `== OBJECTIVE ==`,
237
+ `Answer from session history. Search first for any recall question.`,
238
+ `Greetings/thanks/confirmations: respond directly, no search.`,
239
+ `Tasks needing live code analysis or new research: respond with [RESEARCH_NEEDED]`,
240
+ ``,
241
+ `== STYLE ==`,
242
+ `1-3 sentences. Grounded in results. Never fabricate.`,
243
+ `If not found after thorough searching: "I didn't find that in the session history."`,
244
+ ``,
245
+ `== AUDIENCE ==`,
246
+ `A user having a conversation via voice. Questions may be casual, rambling,`,
247
+ `or use vague references ("that thing", "the error"). Interpret intent, not just words.`,
248
+ ``,
249
+ `== RESULTS FORMAT ==`,
250
+ `Each line: lineNum|byteOffset|timestamp|source|msgType|summary`,
251
+ ` source: "main" = conversation, "agent-XXXX" = sub-agent research`,
252
+ `Full content sections have complete untruncated text.`,
253
+ ``,
254
+ `== HOW TO SEARCH ==`,
255
+ `Think about what words people ACTUALLY USED when this topic came up.`,
256
+ `PHRASES: 1-4 words each, multiple phrases per call.`,
257
+ ` Short precise terms beat long phrases. "error" finds more than "error we got".`,
258
+ `RETRIES (4 rounds — use them before giving up):`,
259
+ ` 1: Specific terms from the question.`,
260
+ ` 2: Think about how the conversation would READ when this was discussed.`,
261
+ ` 3: Related terms — names, tools, files that would appear near the topic.`,
262
+ ` 4: Broad single words — cast a wide net.`,
263
+ ` Only say "didn't find" after 3+ failed rounds.`,
264
+ `⚠ Your own prior answers may have errors. Trust search results over your memory.`,
265
+ ];
285
266
  if (chatHistory && chatHistory.length > 0) {
286
267
  parts.push(``, `== RECENT CONVERSATION ==`);
287
268
  for (const turn of chatHistory.slice(-6)) {
288
269
  parts.push(`${turn.role}: ${turn.content.substring(0, 200)}`);
289
270
  }
290
271
  }
291
- if (researchContext) {
272
+ if (researchContext)
292
273
  parts.push(``, `== ACTIVE RESEARCH ==`, researchContext);
293
- }
294
274
  return parts.join('\n');
295
275
  }
296
276
  // ============================================================
277
+ // OPENROUTER CALL
278
+ // ============================================================
279
+ async function callOpenRouter(messages, tools, apiKey) {
280
+ const resp = await fetch(OPENROUTER_URL, {
281
+ method: 'POST',
282
+ headers: {
283
+ 'Authorization': `Bearer ${apiKey}`,
284
+ 'Content-Type': 'application/json',
285
+ 'X-OpenRouter-Title': 'Osborn Fast Brain',
286
+ },
287
+ body: JSON.stringify({
288
+ model: OPENROUTER_MODEL,
289
+ messages,
290
+ tools,
291
+ tool_choice: 'auto',
292
+ }),
293
+ signal: AbortSignal.timeout(TIMEOUT_MS),
294
+ });
295
+ if (!resp.ok) {
296
+ const body = await resp.text().catch(() => '');
297
+ throw new Error(`OpenRouter ${resp.status}: ${body.substring(0, 200)}`);
298
+ }
299
+ return resp.json();
300
+ }
301
+ // ============================================================
297
302
  // MAIN FUNCTION
298
303
  // ============================================================
299
304
  export async function askPipelineFastBrain(workingDir, sessionId, question, opts) {
300
- // Skip when no real session yet
301
305
  if (!sessionId || sessionId === 'pending') {
302
306
  return { script: 'Session is still initializing.', type: 'acknowledgment', toolsUsed: [] };
303
307
  }
304
- const apiKey = process.env.GOOGLE_API_KEY;
308
+ const apiKey = process.env.OPENROUTER_API_KEY;
305
309
  if (!apiKey) {
306
- return { script: "Search system not available right now.", type: 'acknowledgment', toolsUsed: [] };
310
+ return { script: 'Search system not available right now.', type: 'acknowledgment', toolsUsed: [] };
307
311
  }
308
- // Reset persistent state if session changed
312
+ // Reset on session change
309
313
  if (persistentSessionId !== sessionId) {
310
- persistentContents = [];
314
+ persistentMessages = [];
311
315
  persistentSessionId = sessionId;
312
316
  console.log(`🧠⚡ [pipeline-fb] New session: ${sessionId.substring(0, 8)}`);
313
317
  }
314
- // Prune persistent history (keep last 12)
315
- if (persistentContents.length > 12) {
316
- persistentContents = persistentContents.slice(-12);
318
+ // Prune history (keep last 12 turns)
319
+ if (persistentMessages.length > 24) {
320
+ persistentMessages = persistentMessages.slice(-24);
317
321
  }
322
+ const systemPrompt = buildSystemPrompt(opts?.chatHistory, opts?.researchContext);
323
+ const sessionBaseDir = opts?.sessionBaseDir || workingDir;
324
+ const tools = buildTools(!!opts?.agentControl);
325
+ const toolsUsed = [];
326
+ // Build messages: system + persistent history + new user message
327
+ const messages = [
328
+ { role: 'system', content: systemPrompt },
329
+ ...persistentMessages,
330
+ { role: 'user', content: question },
331
+ ];
318
332
  try {
319
- const ai = new GoogleGenAI({ apiKey });
320
- const systemPrompt = buildSystemPrompt(opts?.chatHistory, opts?.researchContext);
321
- const sessionBaseDir = opts?.sessionBaseDir || workingDir;
322
- // Create the search tool for this session
323
- const { tool: searchTool, getSearchCount } = createSearchTool(sessionId, workingDir, sessionBaseDir, opts?.agentControl);
324
- // Add question to persistent history
325
- persistentContents.push({ role: 'user', parts: [{ text: question }] });
326
- // Single generateContent call — AFC handles the tool loop automatically
327
- const apiCall = ai.models.generateContent({
328
- model: GEMINI_MODEL,
329
- contents: persistentContents,
330
- config: {
331
- systemInstruction: systemPrompt,
332
- tools: [searchTool],
333
- automaticFunctionCalling: { maximumRemoteCalls: MAX_AFC_CALLS },
334
- },
335
- });
336
- // Real timeout via Promise.race
337
- const timeoutRace = new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS));
338
- const response = await Promise.race([apiCall, timeoutRace]);
339
- if (!response) {
340
- persistentContents.pop();
341
- console.warn(`Pipeline fast brain: timed out after ${TIMEOUT_MS}ms`);
342
- return { script: 'Search took too long.', type: 'error', toolsUsed: [] };
343
- }
344
- const text = response.text;
345
- if (text) {
346
- persistentContents.push({ role: 'model', parts: [{ text }] });
347
- }
348
- const toolsUsed = getSearchCount() > 0 ? ['search_session'] : [];
349
- console.log(`🧠⚡ [pipeline-fb] AFC: ${getSearchCount()} searches, answer: "${(text || '').substring(0, 80)}"`);
350
- if (!text?.trim()) {
351
- return {
352
- script: "I didn't find that in the session history.",
353
- type: 'answer',
354
- toolsUsed,
355
- };
356
- }
357
- if (text.includes('[RESEARCH_NEEDED]')) {
358
- return {
359
- script: text.replace('[RESEARCH_NEEDED]', '').trim() || 'This needs deeper research.',
360
- type: 'research_needed',
361
- toolsUsed,
362
- };
333
+ let rounds = 0;
334
+ while (rounds < MAX_TOOL_ROUNDS) {
335
+ rounds++;
336
+ const data = await callOpenRouter(messages, tools, apiKey);
337
+ const choice = data.choices?.[0];
338
+ const msg = choice?.message;
339
+ if (!msg)
340
+ break;
341
+ // Add assistant message to context
342
+ messages.push(msg);
343
+ const calls = msg.tool_calls;
344
+ if (!calls || calls.length === 0) {
345
+ // Final text response
346
+ const text = (msg.content || '').trim();
347
+ // Update persistent history with this exchange
348
+ persistentMessages.push({ role: 'user', content: question });
349
+ if (text)
350
+ persistentMessages.push({ role: 'assistant', content: text });
351
+ console.log(`🧠⚡ [pipeline-fb] ${toolsUsed.length} searches, answer: "${text.substring(0, 80)}"`);
352
+ if (!text)
353
+ return { script: "I didn't find that in the session history.", type: 'answer', toolsUsed };
354
+ if (text.includes('[RESEARCH_NEEDED]')) {
355
+ return { script: text.replace('[RESEARCH_NEEDED]', '').trim() || 'This needs deeper research.', type: 'research_needed', toolsUsed };
356
+ }
357
+ return { script: text, type: 'answer', toolsUsed };
358
+ }
359
+ // Execute tool calls
360
+ for (const call of calls) {
361
+ const name = call.function?.name;
362
+ let args = {};
363
+ try {
364
+ args = JSON.parse(call.function?.arguments || '{}');
365
+ }
366
+ catch { }
367
+ const result = await executeTool(name, args, sessionId, workingDir, opts?.agentControl);
368
+ if (name === 'search_session' || name === 'get_recent')
369
+ toolsUsed.push(name);
370
+ messages.push({
371
+ role: 'tool',
372
+ tool_call_id: call.id,
373
+ content: result,
374
+ });
375
+ }
363
376
  }
364
- return { script: text.trim(), type: 'answer', toolsUsed };
377
+ // Max rounds hit — extract whatever text we have
378
+ const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant' && m.content);
379
+ const fallback = lastAssistant?.content?.trim() || "I didn't find that in the session history.";
380
+ persistentMessages.push({ role: 'user', content: question });
381
+ if (fallback)
382
+ persistentMessages.push({ role: 'assistant', content: fallback });
383
+ return { script: fallback, type: 'answer', toolsUsed };
365
384
  }
366
385
  catch (err) {
367
- if (err?.status === 429 || err?.message?.includes('429') || err?.message?.includes('RESOURCE_EXHAUSTED')) {
368
- console.warn('Pipeline fast brain: 429 rate limited');
386
+ if (err?.name === 'TimeoutError' || err?.message?.includes('timeout')) {
387
+ console.warn('Pipeline fast brain: timed out');
388
+ return { script: 'Search took too long.', type: 'error', toolsUsed: [] };
389
+ }
390
+ if (err?.message?.includes('429')) {
391
+ console.warn('Pipeline fast brain: rate limited');
369
392
  return { script: 'Memory search is cooling down.', type: 'error', toolsUsed: [] };
370
393
  }
371
394
  console.error('Pipeline fast brain error:', err?.message);