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.
- package/dist/config.d.ts +6 -46
- package/dist/config.js +20 -56
- package/dist/index.js +94 -849
- package/dist/pipeline-fastbrain.d.ts +10 -17
- package/dist/pipeline-fastbrain.js +269 -246
- package/dist/turn-detector-shim.js +6 -2
- package/dist/voice-io.d.ts +3 -34
- package/dist/voice-io.js +30 -76
- package/package.json +3 -5
- package/tests/autocompact-pct.test.ts +0 -77
- package/tests/voice-io-stt-config.test.ts +0 -177
|
@@ -1,171 +1,159 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pipeline-fastbrain.ts — Pipeline Fast Brain (Agent with
|
|
2
|
+
* pipeline-fastbrain.ts — Pipeline Fast Brain (Agent with tool loop)
|
|
3
3
|
*
|
|
4
|
-
* Uses
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
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
|
-
*
|
|
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
|
|
21
|
-
const
|
|
22
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
async function
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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.
|
|
308
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
305
309
|
if (!apiKey) {
|
|
306
|
-
return { script:
|
|
310
|
+
return { script: 'Search system not available right now.', type: 'acknowledgment', toolsUsed: [] };
|
|
307
311
|
}
|
|
308
|
-
// Reset
|
|
312
|
+
// Reset on session change
|
|
309
313
|
if (persistentSessionId !== sessionId) {
|
|
310
|
-
|
|
314
|
+
persistentMessages = [];
|
|
311
315
|
persistentSessionId = sessionId;
|
|
312
316
|
console.log(`🧠⚡ [pipeline-fb] New session: ${sessionId.substring(0, 8)}`);
|
|
313
317
|
}
|
|
314
|
-
// Prune
|
|
315
|
-
if (
|
|
316
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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?.
|
|
368
|
-
console.warn('Pipeline fast brain:
|
|
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);
|