openaxies 1.0.3 → 1.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openaxies",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
package/src/App.js CHANGED
@@ -7,7 +7,7 @@ import { callModel } from './providers/index.js';
7
7
 
8
8
  const h = React.createElement;
9
9
 
10
- const LOGO = [
10
+ const STARTUP = [
11
11
  ' \u2588\u2588\u2588\u2588\u2588\u2588\u2557',
12
12
  ' \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557',
13
13
  ' \u2588\u2588\u2551 \u2588\u2588\u2551',
@@ -15,13 +15,13 @@ const LOGO = [
15
15
  ' \u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d',
16
16
  ];
17
17
 
18
- const COMMANDS = [
19
- { id: 'model', trigger: '/model', desc: 'Switch model' },
20
- { id: 'thoughts', trigger: '/thoughts', desc: 'Toggle thinking' },
21
- { id: 'resume', trigger: '/resume', desc: 'Re-run last query' },
22
- { id: 'clear', trigger: '/clear', desc: 'Clear conversation' },
23
- { id: 'help', trigger: '/help', desc: 'Show commands' },
24
- { id: 'exit', trigger: '/exit', desc: 'Quit' },
18
+ const CMDS = [
19
+ { id: 'model', t: '/model', d: 'Switch model' },
20
+ { id: 'thoughts', t: '/thoughts', d: 'Toggle thinking' },
21
+ { id: 'resume', t: '/resume', d: 'Re-run last query' },
22
+ { id: 'clear', t: '/clear', d: 'Clear conversation' },
23
+ { id: 'help', t: '/help', d: 'Show commands' },
24
+ { id: 'exit', t: '/exit', d: 'Quit' },
25
25
  ];
26
26
 
27
27
  export default function AppRoot() {
@@ -29,304 +29,306 @@ export default function AppRoot() {
29
29
  const { exit } = useApp();
30
30
  const cols = stdout.columns || 80;
31
31
  const rows = stdout.rows || 24;
32
-
33
- const [messages, setMessages] = React.useState([]);
34
- const [activeModel, setActiveModel] = React.useState(DEFAULT_MODEL_ID);
35
- const [input, setInput] = React.useState('');
36
- const [streaming, setStreaming] = React.useState(false);
37
- const [streamText, setStreamText] = React.useState('');
38
- const [thinkingActive, setThinkingActive] = React.useState(false);
39
- const [thinkingText, setThinkingText] = React.useState('');
40
- const [thinkTime, setThinkTime] = React.useState(0);
41
- const [startupDone, setStartupDone] = React.useState(false);
42
- const [showOverlay, setShowOverlay] = React.useState(false);
43
- const [overlayIdx, setOverlayIdx] = React.useState(0);
44
- const [showThought, setShowThought] = React.useState(true);
45
- const [toolEv, setToolEv] = React.useState(null);
46
-
47
- const abortRef = React.useRef(null);
48
- const accumRef = React.useRef('');
49
- const lastQRef = React.useRef('');
32
+ const WID = Math.min(cols - 4, 56);
33
+
34
+ const [msgs, setMsgs] = React.useState([]);
35
+ const [model, setModel] = React.useState(DEFAULT_MODEL_ID);
36
+ const [inp, setInp] = React.useState('');
37
+ const [busy, setBusy] = React.useState(false);
38
+ const [stream, setStream] = React.useState('');
39
+ const [think, setThink] = React.useState(false);
40
+ const [thinkTxt, setThinkTxt] = React.useState('');
41
+ const [thinkSec, setThinkSec] = React.useState(0);
42
+ const [ready, setReady] = React.useState(false);
43
+ const [overlay, setOverlay] = React.useState(false);
44
+ const [ovIdx, setOvIdx] = React.useState(0);
45
+ const [showT, setShowT] = React.useState(true);
46
+ const [tool, setTool] = React.useState(null);
47
+ const [pendingQ, setPendingQ] = React.useState(false);
48
+ const [qText, setQText] = React.useState('');
49
+ const [todos, setTodos] = React.useState([]);
50
+ const [toolCalls, setToolCalls] = React.useState([]);
51
+
52
+ const acRef = React.useRef(null);
53
+ const accRef = React.useRef('');
54
+ const lastRef = React.useRef('');
55
+ const qResolveRef = React.useRef(null);
56
+ const genRef = React.useRef(null);
50
57
 
51
58
  React.useEffect(() => {
52
- if (!thinkingActive) return;
53
- const t = setInterval(() => setThinkTime(p => p + 0.1), 100);
59
+ if (!think) return;
60
+ const t = setInterval(() => setThinkSec(p => p + 0.1), 100);
54
61
  return () => clearInterval(t);
55
- }, [thinkingActive]);
56
-
57
- const model = getModelById(activeModel);
58
- const label = model ? model.label.replace('OpenAxies ', '') : 'llama';
59
- const W = Math.min(cols - 4, 56);
60
-
61
- function strip(t) {
62
- return showThought ? t : t.split('<think>').join('').split('</think>').join('');
62
+ }, [think]);
63
+
64
+ const mi = getModelById(model);
65
+ const label = mi ? mi.label.replace('OpenAxies ', '') : 'llama';
66
+
67
+ function strip(s) { return showT ? s : s.split('<think>').join('').split('</think>').join(''); }
68
+
69
+ function headBox() {
70
+ const R = '\u2502';
71
+ const D = '\u2500';
72
+ const W = WID;
73
+ const hd = function (s) { return R + ' ' + s + ' '.repeat(Math.max(0, W - s.length)) + ' ' + R; };
74
+ return h(Box, { flexShrink: 0, flexDirection: 'column', paddingLeft: 1, paddingRight: 1 },
75
+ h(Text, { color: '#555577' }, '\u250C' + D.repeat(W + 2) + '\u2510'),
76
+ h(Text, { color: '#555577' }, hd('\u203A_ OpenAxies (v1.1.0)')),
77
+ h(Text, { color: '#555577' }, hd('')),
78
+ h(Text, { color: '#555577' }, hd('model: ' + label + ' /model to change')),
79
+ h(Text, { color: '#555577' }, hd('directory: ~')),
80
+ h(Text, { color: '#555577' }, '\u2514' + D.repeat(W + 2) + '\u2518'),
81
+ );
63
82
  }
64
83
 
65
- async function handleSubmit(text) {
66
- const s = typeof text === 'string' ? text.trim() : '';
67
- if (!s || streaming) return;
68
- setStartupDone(true);
69
- lastQRef.current = s;
70
- accumRef.current = '';
71
- setStreaming(true);
72
- setStreamText('');
73
- setThinkingText('');
74
- setThinkTime(0);
75
- setToolEv(null);
76
- setMessages(p => [...p, { role: 'user', content: s }]);
77
- setInput('');
78
-
79
- const m = getModelById(activeModel);
80
- if (!m) { setMessages(p => [...p, { role: 'assistant', content: 'No model loaded.' }]); setStreaming(false); return; }
81
-
84
+ async function submit(s) {
85
+ const t = typeof s === 'string' ? s.trim() : '';
86
+ if (!t || busy) return;
87
+ setReady(true); lastRef.current = t; accRef.current = '';
88
+ setBusy(true); setStream(''); setThinkTxt(''); setThinkSec(0); setTool(null);
89
+ setToolCalls([]);
90
+ setMsgs(p => [...p, { role: 'user', c: t }]);
91
+ setInp('');
92
+
93
+ const m = getModelById(model);
94
+ if (!m) { setMsgs(p => [...p, { role: 'asst', c: 'No model loaded.' }]); setBusy(false); return; }
82
95
  const ac = new AbortController();
83
- abortRef.current = ac;
84
- let thinkOpen = false;
96
+ acRef.current = ac;
97
+ let op = false;
85
98
 
86
99
  try {
87
- const history = messages.map(msg => ({
88
- role: msg.role === 'user' ? 'user' : 'assistant',
89
- content: msg.content || '',
90
- }));
91
- history.push({ role: 'user', content: s });
92
-
93
- for await (const ev of callModel({ id: activeModel }, history, ac.signal)) {
94
- if (ac.signal.aborted) break;
95
-
96
- if (ev.type === 'tool') {
97
- setToolEv({ tool: ev.tool, query: ev.query, sites: ev.sites });
100
+ const hist = msgs.map(x => ({ role: x.role === 'user' ? 'user' : 'assistant', content: x.c || '' }));
101
+ hist.push({ role: 'user', content: t });
102
+
103
+ const gen = callModel({ id: model }, hist, ac.signal);
104
+ genRef.current = gen;
105
+ let result = await gen.next();
106
+
107
+ while (!result.done && !ac.signal.aborted) {
108
+ const ev = result.value;
109
+ if (ev.type === 'question') {
110
+ setPendingQ(true); setQText(ev.text);
111
+ const answer = await new Promise(resolve => { qResolveRef.current = resolve; });
112
+ setPendingQ(false); setQText('');
113
+ result = await gen.next(answer);
98
114
  continue;
99
115
  }
100
-
101
- if (ev.type === 'token' || ev.type === 'thinking') {
116
+ if (ev.type === 'tool_call') {
117
+ setToolCalls(p => [...p, { tool: ev.tool, args: ev.args }]);
118
+ setTool({ tool: ev.tool, query: ev.args?.query || '', sites: 0 });
119
+ result = await gen.next();
120
+ continue;
121
+ }
122
+ if (ev.type === 'tool_result') {
123
+ setToolCalls(p => [...p, { tool: ev.tool, result: ev.result, done: true }]);
124
+ result = await gen.next();
125
+ continue;
126
+ }
127
+ if (ev.type === 'todo_add') {
128
+ setTodos(p => [...p, { text: ev.text, status: 'pending' }]);
129
+ result = await gen.next();
130
+ continue;
131
+ }
132
+ if (ev.type === 'todo_done') {
133
+ setTodos(p => p.map((td, i) => i === ev.id ? { ...td, status: 'done' } : td));
134
+ result = await gen.next();
135
+ continue;
136
+ }
137
+ if (ev.type === 'token') {
102
138
  const c = typeof ev.content === 'string' ? ev.content : '';
103
- if (!c) continue;
104
- if (c.includes('<think>')) thinkOpen = true;
105
- accumRef.current += c;
106
- const clean = strip(accumRef.current);
107
- if (thinkOpen || accumRef.current.includes('<think>')) {
108
- const still = thinkOpen || !accumRef.current.includes('</think>');
109
- if (still) {
110
- setThinkingActive(true);
111
- setThinkingText(clean);
112
- setStreamText('');
113
- } else {
114
- thinkOpen = false;
115
- setThinkingActive(false);
116
- setStreamText(clean);
117
- setThinkingText('');
118
- }
119
- } else {
120
- setStreamText(clean);
121
- }
139
+ if (!c) { result = await gen.next(); continue; }
140
+ if (c.includes('<think>')) op = true;
141
+ accRef.current += c;
142
+ const cl = strip(accRef.current);
143
+ if (op || accRef.current.includes('<think>')) {
144
+ const st = op || !accRef.current.includes('</think>');
145
+ if (st) { setThink(true); setThinkTxt(cl); setStream(''); }
146
+ else { op = false; setThink(false); setStream(cl); setThinkTxt(''); }
147
+ } else setStream(cl);
148
+ result = await gen.next();
149
+ continue;
122
150
  }
123
151
  if (ev.type === 'done' || ev.type === 'timeout') {
124
- if (ev.type === 'timeout' && accumRef.current) accumRef.current += '\n\u2014timed out\u2014';
152
+ if (ev.type === 'timeout' && accRef.current) accRef.current += '\n\u2014timed out\u2014';
125
153
  break;
126
154
  }
155
+ result = await gen.next();
127
156
  }
128
- } catch (err) {
129
- if (!ac.signal.aborted) setMessages(p => [...p, { role: 'assistant', content: 'Error: ' + (err.message || 'connection failed') }]);
157
+ } catch (e) {
158
+ if (!ac.signal.aborted) setMsgs(p => [...p, { role: 'asst', c: 'Error: ' + (e.message || 'connection') }]);
130
159
  } finally {
131
- setStreaming(false);
132
- setThinkingActive(false);
133
- abortRef.current = null;
134
- const final = accumRef.current;
135
- if (final) setMessages(p => [...p, { role: 'assistant', content: strip(final) }]);
136
- setStreamText('');
137
- setThinkingText('');
160
+ setBusy(false); setThink(false); setPendingQ(false);
161
+ acRef.current = null; genRef.current = null;
162
+ const f = accRef.current;
163
+ if (f) setMsgs(p => [...p, { role: 'asst', c: strip(f) }]);
164
+ setStream(''); setThinkTxt('');
138
165
  }
139
166
  }
140
167
 
141
- function cycleModel() {
142
- const models = getModels();
143
- setActiveModel(p => {
144
- let f = false;
145
- for (const m of models) { if (f) return m.id; if (m.id === p) f = true; }
146
- return models[0].id;
147
- });
168
+ function cycle() {
169
+ const ms = getModels();
170
+ setModel(p => { let f = false; for (const m of ms) { if (f) return m.id; if (m.id === p) f = true; } return ms[0].id; });
148
171
  }
149
172
 
150
- function execCmd(item) {
173
+ function cmd(item) {
151
174
  if (!item) return;
152
- const t = item.trigger;
153
- if (t === '/exit') { exit(); return; }
154
- if (t === '/clear') setMessages([]);
155
- if (t === '/model') cycleModel();
156
- if (t === '/thoughts') setShowThought(p => !p);
157
- if (t === '/resume') { const q = lastQRef.current; if (q && !streaming) handleSubmit(q); return; }
158
- if (t === '/help') setMessages(p => [...p, { role: 'assistant', content: 'Commands: /help /clear /model /thoughts /resume /exit' }]);
159
- setInput(''); setShowOverlay(false); setOverlayIdx(0);
175
+ if (item.t === '/exit') { exit(); return; }
176
+ if (item.t === '/clear') { setMsgs([]); setTodos([]); }
177
+ if (item.t === '/model') cycle();
178
+ if (item.t === '/thoughts') setShowT(p => !p);
179
+ if (item.t === '/resume') { const q = lastRef.current; if (q && !busy) submit(q); return; }
180
+ if (item.t === '/help') setMsgs(p => [...p, { role: 'asst', c: 'Commands: /help /clear /model /thoughts /resume /exit' }]);
181
+ setInp(''); setOverlay(false); setOvIdx(0);
160
182
  }
161
183
 
162
- useInput((inp, key) => {
163
- if (showOverlay) {
164
- if (key.escape) { setShowOverlay(false); setInput(''); setOverlayIdx(0); return; }
165
- if (key.upArrow) { setOverlayIdx(p => p > 0 ? p - 1 : COMMANDS.length - 1); return; }
166
- if (key.downArrow) { setOverlayIdx(p => p < COMMANDS.length - 1 ? p + 1 : 0); return; }
167
- if (key.return) { execCmd(COMMANDS[overlayIdx]); return; }
168
- return;
169
- }
170
- if (!startupDone && !streaming) {
171
- if (key.return) { setStartupDone(true); setInput(''); return; }
184
+ useInput((i, k) => {
185
+ if (k.ctrl && (i === 'c' || i === 'C')) { exit(); return; }
186
+ if (k.escape && pendingQ) {
187
+ if (acRef.current) acRef.current.abort();
188
+ setPendingQ(false); setQText(''); setBusy(false); setStream(''); setThink(false);
172
189
  return;
173
190
  }
174
- if (key.escape && streaming) {
175
- if (abortRef.current) abortRef.current.abort();
176
- setStreaming(false); setStreamText(''); setThinkingActive(false);
191
+ if (pendingQ) return;
192
+ if (overlay) {
193
+ if (k.escape) { setOverlay(false); setInp(''); setOvIdx(0); return; }
194
+ if (k.upArrow) setOvIdx(p => p > 0 ? p - 1 : CMDS.length - 1);
195
+ if (k.downArrow) setOvIdx(p => p < CMDS.length - 1 ? p + 1 : 0);
196
+ if (k.return) cmd(CMDS[ovIdx]);
177
197
  return;
178
198
  }
179
- if (key.ctrl) {
180
- if (inp === 'c' || inp === 'C') { exit(); return; }
181
- if (inp === 't' || inp === 'T') { setShowThought(p => !p); return; }
182
- if (inp === 'p' || inp === 'P') { cycleModel(); return; }
183
- if (inp === 'r' || inp === 'R') { const q = lastQRef.current; if (q && !streaming) handleSubmit(q); return; }
184
- if (inp === 'l' || inp === 'L') { setMessages([]); return; }
185
- if (inp === 'k' || inp === 'K') { setInput('/'); setShowOverlay(true); setOverlayIdx(0); return; }
199
+ if (!ready && !busy) { if (k.return) { setReady(true); setInp(''); } return; }
200
+ if (k.escape && busy) { if (acRef.current) acRef.current.abort(); setBusy(false); setStream(''); setThink(false); setPendingQ(false); return; }
201
+ if (k.ctrl) {
202
+ if (i === 'c' || i === 'C') { exit(); return; }
203
+ if (i === 't' || i === 'T') { setShowT(p => !p); return; }
204
+ if (i === 'p' || i === 'P') { cycle(); return; }
205
+ if (i === 'r' || i === 'R') { const q = lastRef.current; if (q && !busy) submit(q); return; }
206
+ if (i === 'l' || i === 'L') { setMsgs([]); setTodos([]); return; }
207
+ if (i === 'k' || i === 'K') { setInp('/'); setOverlay(true); setOvIdx(0); return; }
186
208
  }
187
209
  });
188
210
 
189
- // Build entries
190
- const entries = [];
191
- for (const msg of messages) {
192
- if (msg.role === 'user' || msg.role === 'assistant') entries.push(msg);
193
- }
194
-
195
- if (toolEv && streaming) entries.push({ role: 'tool', tool: toolEv.tool, query: toolEv.query, sites: toolEv.sites });
196
- if (thinkingActive && thinkingText) entries.push({ role: 'thought', content: thinkingText, time: thinkTime.toFixed(1) + 's' });
197
- if (streaming && streamText && !thinkingActive) entries.push({ role: 'ongoing', content: streamText });
198
-
199
- const MAX_CHAT = Math.max(3, rows - 10);
200
- const vis = entries.slice(-MAX_CHAT);
201
-
202
- // Startup screen
203
- if (!startupDone && messages.length === 0) {
211
+ // Startup
212
+ if (!ready && msgs.length === 0) {
204
213
  return h(Box, { flexDirection: 'column', width: '100%', height: rows, overflow: 'hidden' },
205
- h(Box, { height: 2 }),
206
- ...LOGO.map((l, i) => h(Box, { key: 'l' + i, height: 1, paddingLeft: 2 },
207
- h(Text, { color: '#00BBFF', bold: true }, l)
208
- )),
214
+ h(Box, { height: 3 }),
215
+ ...STARTUP.map((l, i) => h(Box, { key: 'l' + i, height: 1, paddingLeft: 2 }, h(Text, { color: '#00BBFF', bold: true }, l))),
209
216
  h(Box, { height: 1 }),
210
217
  h(Box, { height: 1, paddingLeft: 2 }, h(Text, { color: '#FFFFFF', bold: true }, 'OpenAxies')),
211
- h(Box, { height: 1, paddingLeft: 2 }, h(Text, { color: '#777788' }, 'local agent')),
218
+ h(Box, { height: 1, paddingLeft: 2 }, h(Text, { color: '#777788' }, 'autonomous agent')),
212
219
  h(Box, { height: 2 }),
213
220
  h(Box, { height: 1, paddingLeft: 2 }, h(Text, { color: '#555577' }, 'Press Enter to begin')),
214
221
  h(Box, { flexGrow: 1 }),
215
- h(Box, { height: 1, paddingLeft: 2 }, h(Text, { color: '#333344' }, 'esc interrupt ctrl+p models /help')),
222
+ h(Box, { height: 1, paddingLeft: 2 }, h(Text, { color: '#333344' }, 'esc quit ctrl+p models /help')),
216
223
  );
217
224
  }
218
225
 
219
- // Bordered header
220
- const borderT = '\u250C' + '\u2500'.repeat(W + 2) + '\u2510';
221
- const borderB = '\u2514' + '\u2500'.repeat(W + 2) + '\u2518';
222
- const pad = '\u2502' + ' '.repeat(W + 2) + '\u2502';
223
-
224
- function brdr(line) {
225
- return '\u2502 ' + line + ' '.repeat(Math.max(0, W + 1 - line.length)) + '\u2502';
226
+ // Build entries
227
+ const entries = [];
228
+ for (const m of msgs) {
229
+ if (m.role === 'user') entries.push({ t: 'user', c: m.c });
230
+ else if (m.role === 'asst') entries.push({ t: 'asst', c: m.c });
226
231
  }
227
-
228
- // Logo combined with info text
229
- const line1 = brdr(' ' + LOGO[0] + ' OpenAxies');
230
- const line2 = brdr(' ' + LOGO[1] + ' model: ' + label);
231
- const line3 = brdr(' ' + LOGO[2] + ' dir: ~');
232
- const line4 = brdr(' ' + LOGO[3] + ' cmds: /model /help /exit');
233
- const line5 = brdr(' ' + LOGO[4]);
234
-
235
- const header = h(Box, { flexShrink: 0, flexDirection: 'column', paddingLeft: 1, paddingRight: 1 },
236
- h(Text, { color: '#555577' }, borderT),
237
- h(Text, { color: '#555577' }, pad),
238
- h(Text, { color: '#555577' }, line1),
239
- h(Text, { color: '#555577' }, line2),
240
- h(Text, { color: '#555577' }, line3),
241
- h(Text, { color: '#555577' }, line4),
242
- h(Text, { color: '#555577' }, line5),
243
- h(Text, { color: '#555577' }, pad),
244
- h(Text, { color: '#555577' }, borderB),
245
- );
246
-
247
- // Overlay
248
- if (showOverlay) {
249
- vis.unshift({ role: '_ocmd', idx: overlayIdx });
232
+ for (const tc of toolCalls) {
233
+ if (tc.done) entries.push({ t: 'tool', tool: tc.tool, result: tc.result });
250
234
  }
251
-
252
- // Empty state
253
- if (vis.length === 0) {
254
- vis.push({ role: '_sep' });
255
- vis.push({ role: '_idle' });
235
+ if (tool && busy) entries.push({ t: 'tool', tool: tool.tool, q: tool.query, sites: tool.sites });
236
+ for (const td of todos) {
237
+ entries.push({ t: 'todo', text: td.text, status: td.status });
256
238
  }
239
+ if (think && thinkTxt) entries.push({ t: 'thought', c: thinkTxt, sec: thinkSec.toFixed(1) + 's' });
240
+ if (busy && stream && !think) entries.push({ t: 'asst', c: stream });
241
+ if (pendingQ) entries.push({ t: 'question', c: qText });
242
+ if (overlay) entries.push({ t: '_ov', idx: ovIdx });
243
+
244
+ const maxChat = Math.max(3, rows - 7);
245
+ const vis = entries.slice(-maxChat);
257
246
 
258
- // Render entries
259
- const els = [];
247
+ // Render chat
248
+ const chEls = [];
260
249
  for (let i = 0; i < vis.length; i++) {
261
250
  const e = vis[i];
262
251
  const k = 'e' + i;
263
-
264
- if (e.role === '_idle') {
265
- els.push(h(Box, { key: k, height: 1, paddingLeft: 2 }, h(Text, { color: '#00FF88', bold: true }, '\u25CB Ready')));
266
- } else if (e.role === '_ocmd') {
267
- els.push(h(Box, { key: k + 'h', height: 1, paddingLeft: 2 }, h(Text, { color: '#888899', bold: true }, 'Commands:')));
268
- for (let ci = 0; ci < COMMANDS.length; ci++) {
269
- const c = COMMANDS[ci];
270
- const sel = ci === e.idx;
271
- els.push(h(Box, { key: k + 'c' + ci, height: 1, paddingLeft: 3 },
272
- h(Text, { color: sel ? hex.neonBlue : '#666688' }, (sel ? '\u276F ' : ' ') + c.trigger),
273
- h(Text, { color: '#444466' }, ' ' + (c.desc || ''))
252
+ if (e.t === '_ov') {
253
+ chEls.push(h(Box, { key: k + 'h', height: 1, paddingLeft: 2 }, h(Text, { color: '#888899', bold: true }, 'Commands:')));
254
+ for (let ci = 0; ci < CMDS.length; ci++) {
255
+ const c = CMDS[ci]; const sel = ci === e.idx;
256
+ chEls.push(h(Box, { key: k + 'c' + ci, height: 1, paddingLeft: 3 },
257
+ h(Text, { color: sel ? hex.neonBlue : '#666688' }, (sel ? '\u276F ' : ' ') + c.t),
258
+ h(Text, { color: '#444466' }, ' ' + (c.d || ''))
274
259
  ));
275
260
  }
276
- } else if (e.role === 'user') {
277
- els.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
261
+ } else if (e.t === 'user') {
262
+ chEls.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
278
263
  h(Text, { color: '#888899' }, '\u203A '),
279
- h(Text, { color: hex.neonBlue, wrap: 'wrap' }, e.content),
264
+ h(Text, { color: hex.neonBlue, wrap: 'wrap' }, e.c),
280
265
  ));
281
- } else if (e.role === 'assistant' || e.role === 'ongoing') {
282
- els.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
266
+ } else if (e.t === 'asst') {
267
+ chEls.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
283
268
  h(Text, { color: '#88FF88' }, '\u2022 '),
284
- h(Text, { color: '#FFFFFF', wrap: 'wrap' }, e.content),
269
+ h(Text, { color: '#FFFFFF', wrap: 'wrap' }, e.c),
285
270
  ));
286
- } else if (e.role === 'thought') {
287
- els.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
288
- h(Text, { color: '#FF9F43', bold: true }, '\u2022 Thinking \u2022 ' + e.time),
271
+ } else if (e.t === 'todo') {
272
+ const mark = e.status === 'done' ? '\u2713' : '\u25A1';
273
+ const color = e.status === 'done' ? '#44AA44' : '#888899';
274
+ chEls.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
275
+ h(Text, { color: color }, '\u2022 ' + mark + ' ' + e.text),
289
276
  ));
290
- if (e.content) {
291
- els.push(h(Box, { key: k + 'c', height: 1, paddingLeft: 4 },
292
- h(Text, { color: '#FF9F43', wrap: 'wrap' }, e.content),
293
- ));
294
- }
295
- } else if (e.role === 'tool') {
296
- els.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
297
- h(Text, { color: '#5B5B8A' }, '\u2022 ' + (e.tool || 'tool') + (e.query ? ' "' + e.query + '"' : '') + (e.sites ? ' (' + e.sites + ' sites)' : '')),
277
+ } else if (e.t === 'thought') {
278
+ chEls.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
279
+ h(Text, { color: '#FF9F43', bold: true }, '\u2022 Thinking \u2022 ' + e.sec),
280
+ ));
281
+ if (e.c) chEls.push(h(Box, { key: k + 'c', height: 1, paddingLeft: 4 }, h(Text, { color: '#FF9F43', wrap: 'wrap' }, e.c)));
282
+ } else if (e.t === 'tool') {
283
+ chEls.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
284
+ h(Text, { color: '#5B5B8A' }, '\u2022 ' + (e.tool || 'tool') + (e.q ? ' ' + e.q : '') + (e.sites ? ' (' + e.sites + ' sites)' : '') + (e.result ? ' \u2713' : ' \u2026')),
285
+ ));
286
+ } else if (e.t === 'question') {
287
+ chEls.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
288
+ h(Text, { color: '#FFD700' }, '\u2753 '),
289
+ h(Text, { color: '#FFD700', wrap: 'wrap' }, e.c),
298
290
  ));
299
291
  }
300
292
  }
301
293
 
302
- const chat = h(Box, { flexGrow: 1, flexDirection: 'column', overflow: 'hidden' }, ...els);
294
+ if (chEls.length === 0) {
295
+ chEls.push(h(Box, { key: 'idle', height: 1, paddingLeft: 2 }, h(Text, { color: '#666688' }, '\u203A Ask me anything')));
296
+ }
303
297
 
304
- // Input
305
- const inputLine = h(Box, { height: 1, flexShrink: 0, paddingLeft: 2, paddingRight: 2 },
306
- h(Text, { color: '#888899' }, '\u203A '),
307
- h(Text, { color: hex.greenOnline }, '\u258C'),
298
+ const chatArea = h(Box, { flexGrow: 1, flexDirection: 'column', overflow: 'hidden' }, ...chEls);
299
+
300
+ // Input line
301
+ const inputBar = h(Box, { height: 1, flexShrink: 0, paddingLeft: 2, paddingRight: 2 },
302
+ h(Text, { color: pendingQ ? '#FFD700' : '#888899' }, pendingQ ? '\u2753 ' : '\u203A '),
303
+ pendingQ ? h(Text, { color: '#FFFFFF' }, '\u258C') : h(Text, { color: hex.greenOnline }, '\u258C'),
308
304
  h(Box, { width: 1 }),
309
305
  h(TextInput, {
310
- value: input,
311
- onChange: setInput,
312
- onSubmit: v => { if (typeof v === 'string' && v.trim() && !streaming) handleSubmit(v); },
313
- placeholder: '',
314
- focus: !showOverlay,
315
- showCursor: true,
316
- })
306
+ value: inp,
307
+ onChange: setInp,
308
+ onSubmit: v => {
309
+ if (pendingQ && qResolveRef.current) {
310
+ qResolveRef.current(v);
311
+ setInp('');
312
+ return;
313
+ }
314
+ if (typeof v === 'string' && v.trim() && !busy) submit(v);
315
+ },
316
+ placeholder: pendingQ ? '' : '',
317
+ focus: !overlay,
318
+ }),
317
319
  );
318
320
 
319
- // Footer bar
321
+ // Footer
320
322
  const footer = h(Box, { height: 1, flexShrink: 0, paddingLeft: 2, paddingRight: 2 },
321
323
  h(Text, { color: '#444466' }, 'esc \u2248 ctrl+t ctrl+p ctrl+k'),
322
324
  h(Box, { flexGrow: 1 }),
323
- h(Text, { color: '#555577' }, label.toLowerCase() + ' \u2022 local'),
325
+ h(Text, { color: '#555577' }, label.toLowerCase() + ' \u2022 ~'),
324
326
  );
325
327
 
326
328
  return h(Box, { flexDirection: 'column', width: '100%', height: rows, overflow: 'hidden' },
327
- header,
328
- chat,
329
- inputLine,
329
+ headBox(),
330
+ inputBar,
331
+ chatArea,
330
332
  footer,
331
333
  );
332
334
  }
@@ -0,0 +1,46 @@
1
+ import { TOOL_DEFS } from './tools.js';
2
+
3
+ export function buildSystemPrompt(cwd) {
4
+ const toolLines = TOOL_DEFS.map(t => {
5
+ const args = Object.entries(t.args).map(([k, v]) => `${k}: ${v}`).join(', ');
6
+ return `- ${t.name}(${args}): ${t.desc}`;
7
+ }).join('\n');
8
+
9
+ return `You are OpenAxies, an autonomous AI coding agent running in the terminal.
10
+
11
+ You have access to tools. When you need to gather information, modify files, or interact with the system, call a tool.
12
+
13
+ TOOLS:
14
+ ${toolLines}
15
+
16
+ IMPORTANT RULES:
17
+ 1. When you need to use a tool, respond with EXACTLY this format (no extra text before/after):
18
+ Action: tool_name
19
+ Action Input: {"arg1": "value1", "arg2": "value2"}
20
+
21
+ 2. After each tool result (Observation), decide what to do next.
22
+
23
+ 3. When you have enough information to respond to the user, use:
24
+ Final Answer: your complete response here
25
+
26
+ 4. You can call multiple tools sequentially. Each tool call is followed by an Observation.
27
+
28
+ 5. Current working directory: ${cwd}
29
+
30
+ 6. For coding tasks:
31
+ - Read files before editing them
32
+ - Use glob/grep to find relevant files
33
+ - Write clear, correct code
34
+ - Run bash commands to verify your work (tests, lint, etc.)
35
+ - Edit files with edit_file (search/replace) when possible
36
+
37
+ 7. For web searches, use web_search to get current information.
38
+
39
+ 8. If you need user input, use ask_question.
40
+
41
+ 9. Track your progress with todo_add and todo_done.
42
+
43
+ 10. Keep your Final Answer concise and helpful. Show the user what you did.
44
+
45
+ Remember: only call ONE tool at a time. Wait for the Observation before deciding next step.`;
46
+ }
@@ -0,0 +1,129 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { searchWeb } from '../providers/websearch.js';
5
+
6
+ function r(s) { return typeof s === 'string' ? s : ''; }
7
+
8
+ function simpleGlob(pattern) {
9
+ const parts = pattern.split('/');
10
+ const hasStar = parts.some(p => p.includes('*'));
11
+ if (!hasStar) return fs.existsSync(pattern) ? [pattern] : [];
12
+ const root = pattern.startsWith('/') ? '/' : '.';
13
+ const results = [];
14
+ function walk(dir, idx) {
15
+ if (idx >= parts.length) { results.push(dir); return; }
16
+ const part = parts[idx];
17
+ if (part.includes('**')) {
18
+ walk(dir, idx + 1);
19
+ try { for (const e of fs.readdirSync(dir)) { const p = dir + '/' + e; if (fs.statSync(p).isDirectory()) walk(p, idx); } } catch {}
20
+ } else if (part.includes('*')) {
21
+ const re = new RegExp('^' + part.replace(/\*/g, '.*') + '$');
22
+ try { for (const e of fs.readdirSync(dir)) { if (re.test(e)) walk(dir + '/' + e, idx + 1); } } catch {}
23
+ } else {
24
+ const p = dir + '/' + part;
25
+ if (fs.existsSync(p)) walk(p, idx + 1);
26
+ }
27
+ }
28
+ walk(root, 0);
29
+ return results;
30
+ }
31
+
32
+ export const TOOL_DEFS = [
33
+ { name: 'read_file', args: { path: 'string' }, desc: 'Read a file from the filesystem' },
34
+ { name: 'write_file', args: { path: 'string', content: 'string' }, desc: 'Write content to a file (overwrites)' },
35
+ { name: 'edit_file', args: { path: 'string', old_string: 'string', new_string: 'string' }, desc: 'Edit a file by replacing old_string with new_string' },
36
+ { name: 'glob', args: { pattern: 'string' }, desc: 'Find files matching a glob pattern (e.g. "src/**/*.js")' },
37
+ { name: 'grep', args: { pattern: 'string', path: 'string' }, desc: 'Search file contents with a regex pattern' },
38
+ { name: 'bash', args: { command: 'string' }, desc: 'Execute a shell command and get output' },
39
+ { name: 'web_search', args: { query: 'string' }, desc: 'Search the web for current information' },
40
+ { name: 'ask_question', args: { question: 'string' }, desc: 'Ask the user a question when you need clarification' },
41
+ { name: 'todo_add', args: { text: 'string' }, desc: 'Add a task to the todo list' },
42
+ { name: 'todo_done', args: { id: 'number' }, desc: 'Mark a todo item as complete by its index (0-based)' },
43
+ ];
44
+
45
+ export function parseToolCall(text) {
46
+ const s = r(text);
47
+ const m = s.match(/Action:\s*(\w+)\s*\nAction Input:\s*(\{[\s\S]*?\}|"[^"]*"|`[^`]*`|\S+)/);
48
+ if (!m) return null;
49
+ const name = m[1];
50
+ let raw = m[2].trim();
51
+ let args = {};
52
+ try {
53
+ if (raw.startsWith('{')) args = JSON.parse(raw);
54
+ else if (raw.startsWith('"')) args = { query: JSON.parse(raw) };
55
+ else if (raw.startsWith('`')) args = { command: raw.slice(1, -1) };
56
+ else args = { path: raw };
57
+ } catch { return null; }
58
+ return { name, args };
59
+ }
60
+
61
+ export async function execTool(toolCall, signal, questionCb) {
62
+ const { name, args } = toolCall;
63
+ try {
64
+ switch (name) {
65
+ case 'read_file': {
66
+ const p = r(args.path);
67
+ if (!fs.existsSync(p)) return 'Error: file not found: ' + p;
68
+ const c = fs.readFileSync(p, 'utf8');
69
+ return 'File ' + p + ':\n' + c.slice(0, 8000) + (c.length > 8000 ? '\n... [truncated]' : '');
70
+ }
71
+ case 'write_file': {
72
+ const p = r(args.path);
73
+ const d = path.dirname(p);
74
+ if (d && !fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
75
+ fs.writeFileSync(p, r(args.content));
76
+ const lines = r(args.content).split('\n').length;
77
+ return 'Wrote ' + p + ' (' + lines + ' lines)';
78
+ }
79
+ case 'edit_file': {
80
+ const p = r(args.path);
81
+ if (!fs.existsSync(p)) return 'Error: file not found: ' + p;
82
+ let c = fs.readFileSync(p, 'utf8');
83
+ const old = r(args.old_string);
84
+ const nw = r(args.new_string);
85
+ if (!c.includes(old)) return 'Error: old_string not found in ' + p;
86
+ c = c.replace(old, nw);
87
+ fs.writeFileSync(p, c);
88
+ return 'Edited ' + p;
89
+ }
90
+ case 'glob': {
91
+ const results = simpleGlob(r(args.pattern));
92
+ return results.length > 0 ? results.join('\n') : 'No files matched: ' + r(args.pattern);
93
+ }
94
+ case 'grep': {
95
+ const pat = r(args.pattern).replace(/"/g, '\\"');
96
+ const pth = r(args.path) || '.';
97
+ try {
98
+ const out = execSync('rg -l "' + pat + '" ' + pth + ' 2>/dev/null || true', { encoding: 'utf8', maxBuffer: 1024 * 1024 });
99
+ return out.trim() || 'No matches for: ' + r(args.pattern);
100
+ } catch { return 'No matches for: ' + r(args.pattern); }
101
+ }
102
+ case 'bash': {
103
+ const out = execSync(r(args.command), { encoding: 'utf8', maxBuffer: 1024 * 1024, timeout: 30000 });
104
+ return out.slice(0, 4000) || '(no output)';
105
+ }
106
+ case 'web_search': {
107
+ const res = await searchWeb(r(args.query), signal);
108
+ return res.sites > 0 ? 'Search results for "' + r(args.query) + '":\n' + res.summary : 'No search results';
109
+ }
110
+ case 'ask_question': {
111
+ if (typeof questionCb === 'function') {
112
+ const answer = await questionCb(r(args.question));
113
+ return 'User answered: ' + answer;
114
+ }
115
+ return 'Error: no question handler';
116
+ }
117
+ case 'todo_add': {
118
+ return 'Todo added: ' + r(args.text);
119
+ }
120
+ case 'todo_done': {
121
+ return 'Todo ' + args.id + ' marked done';
122
+ }
123
+ default:
124
+ return 'Error: unknown tool "' + name + '"';
125
+ }
126
+ } catch (e) {
127
+ return 'Error: ' + (e.message || String(e));
128
+ }
129
+ }
@@ -1,104 +1,142 @@
1
+ import { StateGraph, START, END } from '@langchain/langgraph';
1
2
  import { streamResponse } from './streaming.js';
2
- import { runWebSearchGraph, shouldUseWebSearch } from './websearch.js';
3
+ import { buildSystemPrompt } from '../agent/prompt.js';
4
+ import { parseToolCall, execTool } from '../agent/tools.js';
3
5
 
4
6
  const MODEL_ROUTES = Object.freeze({
5
7
  'openaxis/openaxis-llama': Object.freeze([
6
8
  'https://universal-618-clarity-main.hf.space/v1/chat/completions',
9
+ 'https://universal-618-clarity-4.hf.space/v1/chat/completions',
7
10
  ]),
8
11
  'openaxis/openaxis-gpt': Object.freeze([
9
12
  'https://universal-618-clarity-2.hf.space/v1/chat/completions',
13
+ 'https://universal-618-clarity-5.hf.space/v1/chat/completions',
10
14
  ]),
11
15
  'openaxis/openaxis-deepseek': Object.freeze([
12
16
  'https://universal-618-clarity-3.hf.space/v1/chat/completions',
17
+ 'https://universal-618-clarity-6.hf.space/v1/chat/completions',
13
18
  ]),
14
19
  });
15
20
 
16
21
  function checkMessages(messages) {
17
- if (Array.isArray(messages) === false) {
18
- throw new Error('messages must be an array');
19
- }
22
+ if (!Array.isArray(messages)) throw new Error('messages must be an array');
20
23
  for (let i = 0; i < messages.length; i++) {
21
- const msg = messages[i];
22
- if (msg === null || msg === undefined || typeof msg !== 'object') {
23
- throw new Error('message at index ' + i + ' must be an object');
24
- }
25
- if (typeof msg.role !== 'string' || msg.role.length === 0) {
26
- throw new Error('message at index ' + i + ' must have a non-empty role');
27
- }
28
- if (typeof msg.content !== 'string') {
29
- throw new Error('message at index ' + i + ' content must be a string');
30
- }
24
+ const m = messages[i];
25
+ if (!m || typeof m !== 'object') throw new Error('message at ' + i + ' must be an object');
26
+ if (typeof m.role !== 'string' || !m.role) throw new Error('message at ' + i + ' must have non-empty role');
27
+ if (typeof m.content !== 'string') throw new Error('message at ' + i + ' content must be string');
31
28
  }
32
29
  return messages;
33
30
  }
34
31
 
35
- export async function* callModel(modelConfig, messages, signal) {
36
- if (modelConfig === null || modelConfig === undefined || typeof modelConfig !== 'object') {
37
- throw new Error('modelConfig must be an object');
38
- }
39
- if (typeof modelConfig.id !== 'string') {
40
- throw new Error('modelConfig.id must be a string');
41
- }
42
-
43
- checkMessages(messages);
44
-
45
- const endpoints = MODEL_ROUTES[modelConfig.id];
46
- if (endpoints === null || endpoints === undefined) {
47
- throw new Error('No endpoints configured for model: ' + modelConfig.id);
48
- }
49
-
50
- const modelShort = modelConfig.id.replace(/^[^/]+\//, '');
51
- let modelMessages = messages;
52
- let webUsed = false;
53
- let webSites = 0;
54
- let webQuery = '';
32
+ function buildRouterGraph() {
33
+ const graph = new StateGraph({
34
+ channels: {
35
+ lastResponse: { reducer: (a, b) => b ?? a, default: () => '' },
36
+ iteration: { reducer: (a, b) => b ?? a, default: () => 0 },
37
+ decision: { reducer: (a, b) => b ?? a, default: () => 'final' },
38
+ toolName: { reducer: (a, b) => b ?? a, default: () => null },
39
+ toolArgs: { reducer: (a, b) => b ?? a, default: () => null },
40
+ }
41
+ });
55
42
 
56
- if (shouldUseWebSearch(messages) === true) {
57
- try {
58
- const webResult = await runWebSearchGraph(messages, signal);
59
- if (webResult.used === true && webResult.sites > 0) {
60
- webUsed = true;
61
- webSites = webResult.sites;
62
- webQuery = webResult.query || '';
63
- modelMessages = [{
64
- role: 'system',
65
- content:
66
- 'Current web search results (use when relevant, cite URLs inline):\n\n' +
67
- webResult.summary +
68
- '\n\nNow respond to the user using these results as needed.',
69
- }].concat(messages);
70
- }
71
- } catch (_) {
43
+ graph.addNode('router', async (state) => {
44
+ const tc = parseToolCall(state.lastResponse);
45
+ if (tc) {
46
+ return { decision: 'tool', toolName: tc.name, toolArgs: tc.args };
72
47
  }
73
- }
48
+ return { decision: 'final' };
49
+ });
50
+
51
+ graph.addEdge(START, 'router');
52
+ graph.addEdge('router', END);
74
53
 
75
- yield { type: 'tool', tool: 'websearch', used: webUsed, sites: webSites, query: webQuery };
54
+ return graph.compile();
55
+ }
76
56
 
57
+ async function* callLLMStream(endpoints, modelShort, messages, signal) {
77
58
  const body = {
78
59
  model: modelShort,
79
- messages: modelMessages,
60
+ messages,
80
61
  max_tokens: 4096,
81
62
  temperature: 0.7,
82
63
  stream: true,
83
64
  };
84
65
 
85
66
  let lastError = null;
86
-
87
- for (let i = 0; i < endpoints.length; i++) {
88
- const endpoint = endpoints[i];
67
+ for (const ep of endpoints) {
89
68
  try {
90
- const stream = streamResponse(endpoint, body, signal);
91
- for await (const event of stream) {
92
- yield event;
69
+ const stream = streamResponse(ep, body, signal);
70
+ for await (const ev of stream) {
71
+ yield ev;
93
72
  }
94
73
  return;
95
74
  } catch (err) {
96
75
  lastError = err;
97
- if (signal !== null && signal !== undefined && signal.aborted === true) {
76
+ if (signal?.aborted) return;
77
+ }
78
+ }
79
+ throw lastError || new Error('All endpoints failed');
80
+ }
81
+
82
+ const routerGraph = buildRouterGraph();
83
+
84
+ export async function* callModel(modelConfig, messages, signal) {
85
+ if (!modelConfig || typeof modelConfig !== 'object') throw new Error('modelConfig must be object');
86
+ if (typeof modelConfig.id !== 'string') throw new Error('modelConfig.id must be string');
87
+ checkMessages(messages);
88
+
89
+ const endpoints = MODEL_ROUTES[modelConfig.id];
90
+ if (!endpoints) throw new Error('No endpoints for ' + modelConfig.id);
91
+ const modelShort = modelConfig.id.replace(/^[^/]+\//, '');
92
+ const systemPrompt = buildSystemPrompt(process.cwd());
93
+
94
+ let msgs = [{ role: 'system', content: systemPrompt }].concat(messages);
95
+ let fullResponse = '';
96
+
97
+ for (let iter = 0; iter < 15 && !signal?.aborted; iter++) {
98
+ fullResponse = '';
99
+
100
+ const llmStream = callLLMStream(endpoints, modelShort, msgs, signal);
101
+ for await (const ev of llmStream) {
102
+ if (ev.type === 'token') {
103
+ fullResponse += ev.content || '';
104
+ yield ev;
105
+ } else if (ev.type === 'done') {
106
+ break;
107
+ } else if (ev.type === 'timeout') {
108
+ yield ev;
98
109
  return;
99
110
  }
100
111
  }
101
- }
102
112
 
103
- throw lastError !== null ? lastError : new Error('All endpoints failed for ' + modelConfig.id);
113
+ if (signal?.aborted) return;
114
+
115
+ const state = await routerGraph.invoke({ lastResponse: fullResponse, iteration: iter });
116
+
117
+ if (state.decision === 'final') {
118
+ yield { type: 'done' };
119
+ return;
120
+ }
121
+
122
+ yield { type: 'tool_call', tool: state.toolName, args: state.toolArgs };
123
+
124
+ let result;
125
+ if (state.toolName === 'ask_question') {
126
+ const answer = yield { type: 'question', text: state.toolArgs.question };
127
+ result = 'User answered: ' + (answer || '');
128
+ } else if (state.toolName === 'todo_add') {
129
+ result = await execTool({ name: state.toolName, args: state.toolArgs }, signal);
130
+ yield { type: 'todo_add', text: state.toolArgs.text, result };
131
+ } else if (state.toolName === 'todo_done') {
132
+ result = await execTool({ name: state.toolName, args: state.toolArgs }, signal);
133
+ yield { type: 'todo_done', id: state.toolArgs.id, result };
134
+ } else {
135
+ result = await execTool({ name: state.toolName, args: state.toolArgs }, signal);
136
+ yield { type: 'tool_result', tool: state.toolName, result: result.substring(0, 300) };
137
+ }
138
+
139
+ msgs.push({ role: 'assistant', content: fullResponse });
140
+ msgs.push({ role: 'user', content: 'Observation: ' + result });
141
+ }
104
142
  }
@@ -1,5 +1,5 @@
1
- const CONNECT_TIMEOUT = 10000;
2
- const READ_TIMEOUT = 30000;
1
+ const CONNECT_TIMEOUT = 120000;
2
+ const READ_TIMEOUT = 60000;
3
3
 
4
4
  function checkEndpoint(endpoint) {
5
5
  if (typeof endpoint !== 'string') {
@@ -46,7 +46,7 @@ export function shouldUseWebSearch(messages) {
46
46
  return asksExternalFact === true && hasNamedThing === true;
47
47
  }
48
48
 
49
- async function searchWeb(query, signal) {
49
+ export async function searchWeb(query, signal) {
50
50
  const key = getWebSearchKey();
51
51
  if (key.length === 0) {
52
52
  return {