replio 0.5.0__py3-none-any.whl
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.
- replio/__init__.py +0 -0
- replio/__main__.py +3 -0
- replio/chat.py +452 -0
- replio/commands/__init__.py +0 -0
- replio/commands/builtins.py +149 -0
- replio/commands/registry.py +33 -0
- replio/config.py +51 -0
- replio/main.py +33 -0
- replio/providers/__init__.py +0 -0
- replio/providers/base.py +19 -0
- replio/providers/ollama.py +109 -0
- replio/sessions/__init__.py +0 -0
- replio/sessions/manager.py +64 -0
- replio/tools/__init__.py +0 -0
- replio/tools/builtins.py +97 -0
- replio/tools/registry.py +38 -0
- replio/utils/__init__.py +0 -0
- replio/utils/http.py +36 -0
- replio/web/__init__.py +0 -0
- replio/web/display.py +25 -0
- replio/web/search.py +68 -0
- replio-0.5.0.dist-info/METADATA +8 -0
- replio-0.5.0.dist-info/RECORD +27 -0
- replio-0.5.0.dist-info/WHEEL +5 -0
- replio-0.5.0.dist-info/entry_points.txt +2 -0
- replio-0.5.0.dist-info/licenses/LICENSE +21 -0
- replio-0.5.0.dist-info/top_level.txt +1 -0
replio/__init__.py
ADDED
|
File without changes
|
replio/__main__.py
ADDED
replio/chat.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import json
|
|
3
|
+
import readline
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .providers.ollama import OllamaProvider
|
|
9
|
+
from .sessions.manager import SessionManager
|
|
10
|
+
from .commands.registry import CommandRegistry
|
|
11
|
+
from .commands.builtins import register_builtins
|
|
12
|
+
|
|
13
|
+
HISTFILE = '.replio_history'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChatLoop:
|
|
17
|
+
def __init__(self, config: Config):
|
|
18
|
+
self.config = config
|
|
19
|
+
self.provider = None
|
|
20
|
+
self._reinit_provider()
|
|
21
|
+
|
|
22
|
+
sessions_dir = config.local_path.parent / 'sessions'
|
|
23
|
+
self.sessions = SessionManager(sessions_dir)
|
|
24
|
+
self.current_session = self.sessions.create()
|
|
25
|
+
self._load_history(config)
|
|
26
|
+
self.registry = CommandRegistry(self)
|
|
27
|
+
register_builtins(self.registry)
|
|
28
|
+
self._setup_readline()
|
|
29
|
+
|
|
30
|
+
def _reinit_provider(self):
|
|
31
|
+
provider_name = self.config.get('provider', 'ollama')
|
|
32
|
+
if provider_name == 'ollama':
|
|
33
|
+
self.provider = OllamaProvider(
|
|
34
|
+
base_url=self.config.get('base_url'),
|
|
35
|
+
api_key=self.config.get('api_key'),
|
|
36
|
+
model=self.config.get('model'),
|
|
37
|
+
temperature=self.config.get('temperature'),
|
|
38
|
+
max_tokens=self.config.get('max_tokens'),
|
|
39
|
+
)
|
|
40
|
+
else:
|
|
41
|
+
print(f'Unknown provider "{provider_name}", falling back to ollama')
|
|
42
|
+
self.config.set('provider', 'ollama')
|
|
43
|
+
self._reinit_provider()
|
|
44
|
+
|
|
45
|
+
def _load_history(self, config):
|
|
46
|
+
hist = config.local_path.parent / HISTFILE
|
|
47
|
+
if hist.exists():
|
|
48
|
+
try:
|
|
49
|
+
readline.read_history_file(str(hist))
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
readline.set_history_length(1000)
|
|
53
|
+
|
|
54
|
+
def _save_history(self):
|
|
55
|
+
hist = self.config.local_path.parent / HISTFILE
|
|
56
|
+
try:
|
|
57
|
+
hist.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
readline.write_history_file(str(hist))
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def _setup_readline(self):
|
|
63
|
+
commands = sorted(set(self.registry.commands.keys()))
|
|
64
|
+
|
|
65
|
+
def completer(text, state):
|
|
66
|
+
if text.startswith('/'):
|
|
67
|
+
options = [c for c in commands if c.startswith(text)]
|
|
68
|
+
if state < len(options):
|
|
69
|
+
return options[state] + ' '
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
readline.set_completer(completer)
|
|
73
|
+
readline.parse_and_bind('tab: complete')
|
|
74
|
+
|
|
75
|
+
def session_auto_save(self):
|
|
76
|
+
if self.current_session and self.current_session.messages:
|
|
77
|
+
self.sessions.save(self.current_session)
|
|
78
|
+
|
|
79
|
+
def run(self):
|
|
80
|
+
system_prompt = self.config.get('system_prompt', '')
|
|
81
|
+
if system_prompt:
|
|
82
|
+
self.current_session.add_message('system', system_prompt)
|
|
83
|
+
|
|
84
|
+
model_str = self.config.get('model', '?')
|
|
85
|
+
provider_str = self.config.get('provider', '?')
|
|
86
|
+
print(f'REPL.io ({provider_str}: {model_str}) /help for commands')
|
|
87
|
+
|
|
88
|
+
while True:
|
|
89
|
+
try:
|
|
90
|
+
line = input('\001\033[36m\002>>>\001\033[0m\002 ').strip()
|
|
91
|
+
except (EOFError, KeyboardInterrupt):
|
|
92
|
+
print()
|
|
93
|
+
self.session_auto_save()
|
|
94
|
+
self._save_history()
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
if not line:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if line.startswith('/'):
|
|
101
|
+
self.current_session.add_message('command', line)
|
|
102
|
+
self.registry.dispatch(line)
|
|
103
|
+
self.session_auto_save()
|
|
104
|
+
else:
|
|
105
|
+
self._handle_message(line)
|
|
106
|
+
|
|
107
|
+
self._save_history()
|
|
108
|
+
|
|
109
|
+
def _stream_response(self) -> str | None:
|
|
110
|
+
messages = self.current_session.messages
|
|
111
|
+
full_response = ''
|
|
112
|
+
start = datetime.now(timezone.utc)
|
|
113
|
+
first_content = True
|
|
114
|
+
show_thinking = self.config.get('show_thinking', True)
|
|
115
|
+
thinking = False
|
|
116
|
+
md_state = {'code_block': False, 'inline_code': False, 'bold': False}
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
for event in self.provider.chat(messages):
|
|
120
|
+
t = event.get('type', '')
|
|
121
|
+
if t == 'thinking':
|
|
122
|
+
if show_thinking:
|
|
123
|
+
if first_content:
|
|
124
|
+
sys.stdout.write('\001\033[33m\002<<< \001\033[0m\002')
|
|
125
|
+
sys.stdout.flush()
|
|
126
|
+
first_content = False
|
|
127
|
+
sys.stdout.write('\001\033[90m\002' + event['content'] + '\001\033[0m\002')
|
|
128
|
+
sys.stdout.flush()
|
|
129
|
+
full_response += event['content']
|
|
130
|
+
elif t == 'token':
|
|
131
|
+
token = event['content']
|
|
132
|
+
while token:
|
|
133
|
+
if not thinking:
|
|
134
|
+
idx = -1
|
|
135
|
+
marker = ''
|
|
136
|
+
for m in ('<thinking>',):
|
|
137
|
+
pos = token.find(m)
|
|
138
|
+
if pos != -1 and (idx == -1 or pos < idx):
|
|
139
|
+
idx = pos
|
|
140
|
+
marker = m
|
|
141
|
+
if idx != -1:
|
|
142
|
+
before = token[:idx]
|
|
143
|
+
if before:
|
|
144
|
+
if first_content:
|
|
145
|
+
sys.stdout.write('\001\033[33m\002<<< \001\033[0m\002')
|
|
146
|
+
sys.stdout.flush()
|
|
147
|
+
first_content = False
|
|
148
|
+
sys.stdout.write(before)
|
|
149
|
+
sys.stdout.flush()
|
|
150
|
+
full_response += before
|
|
151
|
+
if first_content:
|
|
152
|
+
sys.stdout.write('\001\033[33m\002<<< \001\033[0m\002')
|
|
153
|
+
sys.stdout.flush()
|
|
154
|
+
first_content = False
|
|
155
|
+
if show_thinking:
|
|
156
|
+
sys.stdout.write('\001\033[90m\002' + marker + '\001\033[0m\002')
|
|
157
|
+
sys.stdout.flush()
|
|
158
|
+
full_response += marker
|
|
159
|
+
token = token[idx + len(marker):]
|
|
160
|
+
thinking = True
|
|
161
|
+
else:
|
|
162
|
+
if self.config.get('markdown_streaming'):
|
|
163
|
+
segments = self._render_token(token, md_state)
|
|
164
|
+
for text, ansi in segments:
|
|
165
|
+
if first_content:
|
|
166
|
+
sys.stdout.write('\001\033[33m\002<<< \001\033[0m\002')
|
|
167
|
+
sys.stdout.flush()
|
|
168
|
+
first_content = False
|
|
169
|
+
sys.stdout.write(f'\001{ansi}\002{text}\001\033[0m\002')
|
|
170
|
+
sys.stdout.flush()
|
|
171
|
+
else:
|
|
172
|
+
if first_content:
|
|
173
|
+
sys.stdout.write('\001\033[33m\002<<< \001\033[0m\002')
|
|
174
|
+
sys.stdout.flush()
|
|
175
|
+
first_content = False
|
|
176
|
+
sys.stdout.write(token)
|
|
177
|
+
sys.stdout.flush()
|
|
178
|
+
full_response += token
|
|
179
|
+
token = ''
|
|
180
|
+
else:
|
|
181
|
+
closer = ''
|
|
182
|
+
closer_pos = -1
|
|
183
|
+
for c in ('</thinking>',):
|
|
184
|
+
pos = token.find(c)
|
|
185
|
+
if pos != -1 and (closer_pos == -1 or pos < closer_pos):
|
|
186
|
+
closer_pos = pos
|
|
187
|
+
closer = c
|
|
188
|
+
if closer_pos != -1:
|
|
189
|
+
before = token[:closer_pos]
|
|
190
|
+
if before and show_thinking:
|
|
191
|
+
sys.stdout.write('\001\033[90m\002' + before + '\001\033[0m\002')
|
|
192
|
+
sys.stdout.flush()
|
|
193
|
+
full_response += before
|
|
194
|
+
sys.stdout.write(closer)
|
|
195
|
+
sys.stdout.flush()
|
|
196
|
+
full_response += closer
|
|
197
|
+
token = token[closer_pos + len(closer):]
|
|
198
|
+
thinking = False
|
|
199
|
+
else:
|
|
200
|
+
if show_thinking:
|
|
201
|
+
sys.stdout.write('\001\033[90m\002' + token + '\001\033[0m\002')
|
|
202
|
+
sys.stdout.flush()
|
|
203
|
+
full_response += token
|
|
204
|
+
token = ''
|
|
205
|
+
elif t == 'error':
|
|
206
|
+
code = event.get('code', '')
|
|
207
|
+
msg = event.get('message', 'Unknown error')
|
|
208
|
+
print(f'\001\033[91m\002[Error {code}]\001\033[0m\002 {msg}')
|
|
209
|
+
return None
|
|
210
|
+
elif t == 'done':
|
|
211
|
+
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
|
|
212
|
+
print()
|
|
213
|
+
print(f'\001\033[90m\002({elapsed:.1f}s)\001\033[0m\002')
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
finally:
|
|
217
|
+
if full_response:
|
|
218
|
+
end = datetime.now(timezone.utc)
|
|
219
|
+
duration = (end - start).total_seconds()
|
|
220
|
+
self.current_session.add_message(
|
|
221
|
+
'assistant', full_response,
|
|
222
|
+
timestamp=end.isoformat(timespec='seconds'),
|
|
223
|
+
duration=round(duration, 1),
|
|
224
|
+
model=self.config.get('model'),
|
|
225
|
+
provider=self.config.get('provider'),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
self.session_auto_save()
|
|
229
|
+
|
|
230
|
+
return full_response
|
|
231
|
+
|
|
232
|
+
def _perform_search(self, query: str, silent: bool = False) -> str | None:
|
|
233
|
+
from .web.search import search as web_search
|
|
234
|
+
from .web.display import format_results, format_context
|
|
235
|
+
|
|
236
|
+
num = self.config.get('search_results', 5)
|
|
237
|
+
results = web_search(query, num)
|
|
238
|
+
|
|
239
|
+
if not results:
|
|
240
|
+
if not silent:
|
|
241
|
+
print('\001\033[90m\002(no search results)\001\033[0m\002')
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
if not silent:
|
|
245
|
+
print()
|
|
246
|
+
print(format_results(query, results))
|
|
247
|
+
|
|
248
|
+
return format_context(query, results)
|
|
249
|
+
|
|
250
|
+
def _init_tooling(self):
|
|
251
|
+
if not self.config.get('tool_calling'):
|
|
252
|
+
self._tool_registry = None
|
|
253
|
+
return None
|
|
254
|
+
from .tools.registry import ToolRegistry
|
|
255
|
+
from .tools.builtins import register_tools
|
|
256
|
+
self._tool_registry = ToolRegistry()
|
|
257
|
+
register_tools(self._tool_registry)
|
|
258
|
+
return self._tool_registry.schema()
|
|
259
|
+
|
|
260
|
+
def _show_tool_status(self, name, arguments):
|
|
261
|
+
args_str = ', '.join(f'{k}={v!r}' for k, v in arguments.items())
|
|
262
|
+
print(f'\001\033[90m\002[{name}: {args_str}]\001\033[0m\002')
|
|
263
|
+
|
|
264
|
+
def _output_content(self, content):
|
|
265
|
+
end = datetime.now(timezone.utc)
|
|
266
|
+
elapsed = round((end - self._response_start).total_seconds(), 1)
|
|
267
|
+
|
|
268
|
+
print(f'\001\033[33m\002<<<\001\033[0m\002 {content}')
|
|
269
|
+
print(f'\001\033[90m\002({elapsed:.1f}s)\001\033[0m\002')
|
|
270
|
+
|
|
271
|
+
self.current_session.add_message(
|
|
272
|
+
'assistant', content,
|
|
273
|
+
timestamp=end.isoformat(timespec='seconds'),
|
|
274
|
+
duration=elapsed,
|
|
275
|
+
model=self.config.get('model'),
|
|
276
|
+
provider=self.config.get('provider'),
|
|
277
|
+
)
|
|
278
|
+
self.session_auto_save()
|
|
279
|
+
|
|
280
|
+
def _refine_query(self, query: str) -> str:
|
|
281
|
+
context_count = self.config.get('query_refine_context', 4)
|
|
282
|
+
context_msgs = self.current_session.messages[-context_count:] if context_count > 0 else []
|
|
283
|
+
refine_sys = "You are a search query optimizer. Rewrite the user's query to be more specific and standalone based on the conversation context. Return ONLY the rewritten query, nothing else."
|
|
284
|
+
refined = self.provider.chat_nonstreaming(
|
|
285
|
+
[{'role': 'system', 'content': refine_sys}] + context_msgs + [{'role': 'user', 'content': query}],
|
|
286
|
+
tools=None,
|
|
287
|
+
)
|
|
288
|
+
refined_query = (refined.get('content') or query).strip().strip('"\'')
|
|
289
|
+
return refined_query if refined_query else query
|
|
290
|
+
|
|
291
|
+
def _render_token(self, token: str, state: dict) -> list[tuple[str, str]]:
|
|
292
|
+
segments = []
|
|
293
|
+
while token:
|
|
294
|
+
if state['code_block']:
|
|
295
|
+
idx = token.find('```')
|
|
296
|
+
if idx != -1:
|
|
297
|
+
before = token[:idx]
|
|
298
|
+
if before:
|
|
299
|
+
segments.append((before, '\033[36m'))
|
|
300
|
+
state['code_block'] = False
|
|
301
|
+
token = token[idx + 3:]
|
|
302
|
+
else:
|
|
303
|
+
segments.append((token, '\033[36m'))
|
|
304
|
+
token = ''
|
|
305
|
+
elif state['inline_code']:
|
|
306
|
+
idx = token.find('`')
|
|
307
|
+
if idx != -1:
|
|
308
|
+
before = token[:idx]
|
|
309
|
+
if before:
|
|
310
|
+
segments.append((before, '\033[32m'))
|
|
311
|
+
state['inline_code'] = False
|
|
312
|
+
token = token[idx + 1:]
|
|
313
|
+
else:
|
|
314
|
+
segments.append((token, '\033[32m'))
|
|
315
|
+
token = ''
|
|
316
|
+
elif state['bold']:
|
|
317
|
+
idx = token.find('**')
|
|
318
|
+
if idx != -1:
|
|
319
|
+
before = token[:idx]
|
|
320
|
+
if before:
|
|
321
|
+
segments.append((before, '\033[1m'))
|
|
322
|
+
state['bold'] = False
|
|
323
|
+
token = token[idx + 2:]
|
|
324
|
+
else:
|
|
325
|
+
segments.append((token, '\033[1m'))
|
|
326
|
+
token = ''
|
|
327
|
+
else:
|
|
328
|
+
idx = -1
|
|
329
|
+
marker = ''
|
|
330
|
+
for m in ('```', '**', '`'):
|
|
331
|
+
pos = token.find(m)
|
|
332
|
+
if pos != -1 and (idx == -1 or pos < idx):
|
|
333
|
+
idx = pos
|
|
334
|
+
marker = m
|
|
335
|
+
if idx != -1:
|
|
336
|
+
before = token[:idx]
|
|
337
|
+
if before:
|
|
338
|
+
segments.append((before, ''))
|
|
339
|
+
if marker == '```':
|
|
340
|
+
state['code_block'] = True
|
|
341
|
+
elif marker == '**':
|
|
342
|
+
state['bold'] = True
|
|
343
|
+
elif marker == '`':
|
|
344
|
+
state['inline_code'] = True
|
|
345
|
+
token = token[idx + len(marker):]
|
|
346
|
+
else:
|
|
347
|
+
segments.append((token, ''))
|
|
348
|
+
token = ''
|
|
349
|
+
return segments
|
|
350
|
+
|
|
351
|
+
def _chat_with_tools(self, force_search: str | None = None):
|
|
352
|
+
messages = self.current_session.messages
|
|
353
|
+
tools_schema = self._init_tooling()
|
|
354
|
+
self._response_start = datetime.now(timezone.utc)
|
|
355
|
+
|
|
356
|
+
if force_search:
|
|
357
|
+
context = self._perform_search(force_search, silent=False)
|
|
358
|
+
if context:
|
|
359
|
+
messages.append({
|
|
360
|
+
'role': 'tool',
|
|
361
|
+
'tool_call_id': 'forced',
|
|
362
|
+
'content': context,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
while True:
|
|
367
|
+
result = self.provider.chat_nonstreaming(messages, tools=tools_schema)
|
|
368
|
+
|
|
369
|
+
if 'error' in result:
|
|
370
|
+
err = result['error']
|
|
371
|
+
print(f'\001\033[91m\002[Error {err["code"]}]\001\033[0m\002 {err["message"]}')
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
tcs = result.get('tool_calls')
|
|
375
|
+
if tcs:
|
|
376
|
+
messages.append({
|
|
377
|
+
'role': 'assistant',
|
|
378
|
+
'content': result.get('content'),
|
|
379
|
+
'tool_calls': tcs,
|
|
380
|
+
'timestamp': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
|
381
|
+
})
|
|
382
|
+
for tc in tcs:
|
|
383
|
+
name = tc['function']['name']
|
|
384
|
+
args = json.loads(tc['function']['arguments'])
|
|
385
|
+
if (self.config.get('query_refine')
|
|
386
|
+
and name == 'web_search'
|
|
387
|
+
and len(args.get('query', '').split()) <= self.config.get('query_refine_min_words', 3)):
|
|
388
|
+
original = args['query']
|
|
389
|
+
args['query'] = self._refine_query(args['query'])
|
|
390
|
+
if args['query'] != original:
|
|
391
|
+
print(f'\001\033[90m\002[refine: "{original}" → "{args["query"]}"]\001\033[0m\002')
|
|
392
|
+
if self.config.get('tool_status_visible', True):
|
|
393
|
+
self._show_tool_status(name, args)
|
|
394
|
+
output = self._tool_registry.execute(name, args)
|
|
395
|
+
messages.append({
|
|
396
|
+
'role': 'tool',
|
|
397
|
+
'tool_call_id': tc['id'],
|
|
398
|
+
'content': output,
|
|
399
|
+
'timestamp': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
|
400
|
+
})
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
response = self._stream_response()
|
|
404
|
+
if not response and result.get('content'):
|
|
405
|
+
end = datetime.now(timezone.utc)
|
|
406
|
+
duration = round((end - self._response_start).total_seconds(), 1)
|
|
407
|
+
self.current_session.add_message(
|
|
408
|
+
'assistant', result['content'],
|
|
409
|
+
timestamp=end.isoformat(timespec='seconds'),
|
|
410
|
+
duration=duration,
|
|
411
|
+
model=self.config.get('model'),
|
|
412
|
+
provider=self.config.get('provider'),
|
|
413
|
+
)
|
|
414
|
+
break
|
|
415
|
+
finally:
|
|
416
|
+
self.session_auto_save()
|
|
417
|
+
|
|
418
|
+
def _handle_message(self, content):
|
|
419
|
+
now = datetime.now(timezone.utc)
|
|
420
|
+
self.current_session.add_message(
|
|
421
|
+
'user', content, timestamp=now.isoformat(timespec='seconds')
|
|
422
|
+
)
|
|
423
|
+
self.session_auto_save()
|
|
424
|
+
|
|
425
|
+
user_msgs = [m for m in self.current_session.messages if m['role'] == 'user']
|
|
426
|
+
if len(user_msgs) == 1:
|
|
427
|
+
ts = self.current_session.name
|
|
428
|
+
truncated = content[:40]
|
|
429
|
+
space = truncated.rfind(' ')
|
|
430
|
+
if space > 0:
|
|
431
|
+
truncated = truncated[:space]
|
|
432
|
+
msg_part = ''.join(c if c.isalnum() or c in '-_ ' else '' for c in truncated).strip().replace(' ', '_')
|
|
433
|
+
if msg_part:
|
|
434
|
+
old = self.sessions.sessions_dir / f'{self.current_session.name}.json'
|
|
435
|
+
self.current_session.name = f'{ts}_{msg_part.lower()}'
|
|
436
|
+
new = self.sessions.sessions_dir / f'{self.current_session.name}.json'
|
|
437
|
+
if old.exists() and old != new:
|
|
438
|
+
old.rename(new)
|
|
439
|
+
self.session_auto_save()
|
|
440
|
+
|
|
441
|
+
if self.config.get('tool_calling'):
|
|
442
|
+
self._chat_with_tools()
|
|
443
|
+
elif self.config.get('web_search'):
|
|
444
|
+
context = self._perform_search(content, silent=True)
|
|
445
|
+
if context:
|
|
446
|
+
self.current_session.add_message('system', context)
|
|
447
|
+
else:
|
|
448
|
+
print('\001\033[90m\002(Skipping AI — no search results)\001\033[0m\002')
|
|
449
|
+
return
|
|
450
|
+
self._stream_response()
|
|
451
|
+
else:
|
|
452
|
+
self._stream_response()
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def register_builtins(registry):
|
|
5
|
+
chat = registry.chat_loop
|
|
6
|
+
|
|
7
|
+
@registry.register('help', aliases=['h'])
|
|
8
|
+
def help_cmd(_=None):
|
|
9
|
+
print('Available commands:')
|
|
10
|
+
seen = set()
|
|
11
|
+
for name, fn in sorted(registry.commands.items()):
|
|
12
|
+
if id(fn) not in seen:
|
|
13
|
+
seen.add(id(fn))
|
|
14
|
+
print(f' /{name}')
|
|
15
|
+
aliases = [k for k, v in registry.commands.items()
|
|
16
|
+
if v is fn and k != name]
|
|
17
|
+
if aliases:
|
|
18
|
+
print(f' aliases: {", ".join(aliases)}')
|
|
19
|
+
|
|
20
|
+
@registry.register('exit', aliases=['quit', 'q'])
|
|
21
|
+
def exit_cmd(_=None):
|
|
22
|
+
chat.session_auto_save()
|
|
23
|
+
sys.exit(0)
|
|
24
|
+
|
|
25
|
+
@registry.register('model')
|
|
26
|
+
def model_cmd(arg=''):
|
|
27
|
+
if arg:
|
|
28
|
+
chat.config.set('model', arg.strip())
|
|
29
|
+
chat.provider.model = chat.config.get('model')
|
|
30
|
+
print(f'Model set to: {chat.config.get("model")}')
|
|
31
|
+
else:
|
|
32
|
+
print(f'Current model: {chat.config.get("model")}')
|
|
33
|
+
|
|
34
|
+
@registry.register('provider')
|
|
35
|
+
def provider_cmd(arg=''):
|
|
36
|
+
if arg:
|
|
37
|
+
chat.config.set('provider', arg.strip())
|
|
38
|
+
chat._reinit_provider()
|
|
39
|
+
print(f'Provider set to: {chat.config.get("provider")}')
|
|
40
|
+
else:
|
|
41
|
+
print(f'Current provider: {chat.config.get("provider")}')
|
|
42
|
+
|
|
43
|
+
@registry.register('connect')
|
|
44
|
+
def connect_cmd(_=None):
|
|
45
|
+
print('Setting up provider connection:')
|
|
46
|
+
provider = input(
|
|
47
|
+
f' Provider [{chat.config.get("provider")}]: '
|
|
48
|
+
).strip() or chat.config.get('provider')
|
|
49
|
+
base_url = input(
|
|
50
|
+
f' Base URL [{chat.config.get("base_url")}]: '
|
|
51
|
+
).strip() or chat.config.get('base_url')
|
|
52
|
+
api_key = input(' API key (leave empty to skip): ').strip()
|
|
53
|
+
model = input(
|
|
54
|
+
f' Model [{chat.config.get("model")}]: '
|
|
55
|
+
).strip() or chat.config.get('model')
|
|
56
|
+
|
|
57
|
+
chat.config.set('provider', provider)
|
|
58
|
+
chat.config.set('base_url', base_url)
|
|
59
|
+
chat.config.set('model', model)
|
|
60
|
+
if api_key:
|
|
61
|
+
chat.config.set('api_key', api_key)
|
|
62
|
+
|
|
63
|
+
chat._reinit_provider()
|
|
64
|
+
print(f'Connected to {provider} ({base_url})')
|
|
65
|
+
|
|
66
|
+
@registry.register('config')
|
|
67
|
+
def config_cmd(arg=''):
|
|
68
|
+
parts = arg.strip().split(maxsplit=1)
|
|
69
|
+
if not arg:
|
|
70
|
+
for k, v in chat.config.data.items():
|
|
71
|
+
val = '***' if k == 'api_key' and v else v
|
|
72
|
+
print(f' {k}: {val}')
|
|
73
|
+
elif len(parts) == 1:
|
|
74
|
+
key = parts[0]
|
|
75
|
+
val = chat.config.get(key)
|
|
76
|
+
if key == 'api_key':
|
|
77
|
+
val = '***' if val else val
|
|
78
|
+
print(f' {key}: {val}')
|
|
79
|
+
else:
|
|
80
|
+
key, value = parts
|
|
81
|
+
chat.config.set(key, value)
|
|
82
|
+
print(f'Config {key} = {value}')
|
|
83
|
+
|
|
84
|
+
@registry.register('session')
|
|
85
|
+
def session_cmd(arg=''):
|
|
86
|
+
parts = arg.strip().split(maxsplit=1)
|
|
87
|
+
action = parts[0] if parts else ''
|
|
88
|
+
|
|
89
|
+
if not action:
|
|
90
|
+
print('Session commands:')
|
|
91
|
+
print(' /session new start a new session')
|
|
92
|
+
print(' /session list list saved sessions')
|
|
93
|
+
print(' /session load <name> load a session')
|
|
94
|
+
print(' /session delete <name> delete a session')
|
|
95
|
+
print(' /session save save current session')
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if action == 'new':
|
|
99
|
+
s = chat.sessions.create()
|
|
100
|
+
print(f'New session: {s.name}')
|
|
101
|
+
elif action == 'list':
|
|
102
|
+
sessions = chat.sessions.list()
|
|
103
|
+
if sessions:
|
|
104
|
+
current = chat.sessions.current.name if chat.sessions.current else ''
|
|
105
|
+
for s in sessions:
|
|
106
|
+
marker = ' <-- current' if s == current else ''
|
|
107
|
+
print(f' {s}{marker}')
|
|
108
|
+
else:
|
|
109
|
+
print(' No sessions found')
|
|
110
|
+
elif action == 'load':
|
|
111
|
+
name = parts[1] if len(parts) > 1 else ''
|
|
112
|
+
if not name:
|
|
113
|
+
print('Usage: /session load <name>')
|
|
114
|
+
return
|
|
115
|
+
s = chat.sessions.load(name)
|
|
116
|
+
if s:
|
|
117
|
+
print(f'Loaded session: {name} ({len(s.messages)} messages)')
|
|
118
|
+
else:
|
|
119
|
+
print(f'Session not found: {name}')
|
|
120
|
+
elif action == 'delete':
|
|
121
|
+
name = parts[1] if len(parts) > 1 else ''
|
|
122
|
+
if not name:
|
|
123
|
+
print('Usage: /session delete <name>')
|
|
124
|
+
return
|
|
125
|
+
if chat.sessions.delete(name):
|
|
126
|
+
print(f'Deleted session: {name}')
|
|
127
|
+
else:
|
|
128
|
+
print(f'Session not found: {name}')
|
|
129
|
+
elif action == 'save':
|
|
130
|
+
chat.session_auto_save()
|
|
131
|
+
print('Session saved')
|
|
132
|
+
|
|
133
|
+
@registry.register('search', aliases=['web'])
|
|
134
|
+
def search_cmd(arg=''):
|
|
135
|
+
if not arg:
|
|
136
|
+
print('Usage: /search <query>')
|
|
137
|
+
return
|
|
138
|
+
from datetime import datetime, timezone
|
|
139
|
+
now = datetime.now(timezone.utc)
|
|
140
|
+
chat.current_session.add_message(
|
|
141
|
+
'user', arg, timestamp=now.isoformat(timespec='seconds')
|
|
142
|
+
)
|
|
143
|
+
if chat.config.get('tool_calling'):
|
|
144
|
+
chat._chat_with_tools(force_search=arg)
|
|
145
|
+
else:
|
|
146
|
+
if not chat._perform_search(arg):
|
|
147
|
+
print('No results found — try a different query.')
|
|
148
|
+
return
|
|
149
|
+
chat._stream_response()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CommandRegistry:
|
|
5
|
+
def __init__(self, chat_loop):
|
|
6
|
+
self.chat_loop = chat_loop
|
|
7
|
+
self.commands: dict[str, Callable] = {}
|
|
8
|
+
|
|
9
|
+
def register(self, name: str, aliases: list[str] | None = None,
|
|
10
|
+
handler: Callable | None = None):
|
|
11
|
+
if handler is None:
|
|
12
|
+
def wrapper(fn):
|
|
13
|
+
self.commands[name] = fn
|
|
14
|
+
for alias in (aliases or []):
|
|
15
|
+
self.commands[alias] = fn
|
|
16
|
+
return fn
|
|
17
|
+
return wrapper
|
|
18
|
+
self.commands[name] = handler
|
|
19
|
+
for alias in (aliases or []):
|
|
20
|
+
self.commands[alias] = handler
|
|
21
|
+
|
|
22
|
+
def dispatch(self, line: str):
|
|
23
|
+
parts = line.strip().split(maxsplit=1)
|
|
24
|
+
cmd = parts[0].lstrip('/')
|
|
25
|
+
arg = parts[1] if len(parts) > 1 else ''
|
|
26
|
+
handler = self.commands.get(cmd)
|
|
27
|
+
if handler:
|
|
28
|
+
try:
|
|
29
|
+
handler(arg)
|
|
30
|
+
except TypeError:
|
|
31
|
+
handler()
|
|
32
|
+
else:
|
|
33
|
+
print(f'Unknown command: /{cmd}. Type /help for available commands.')
|
replio/config.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
DEFAULT_CONFIG = {
|
|
6
|
+
'provider': 'ollama',
|
|
7
|
+
'model': 'llama3.2',
|
|
8
|
+
'base_url': 'https://api.ollama.com',
|
|
9
|
+
'api_key': '',
|
|
10
|
+
'temperature': 0.7,
|
|
11
|
+
'max_tokens': 2048,
|
|
12
|
+
'system_prompt': '',
|
|
13
|
+
'tool_calling': True,
|
|
14
|
+
'tool_status_visible': True,
|
|
15
|
+
'query_refine': False,
|
|
16
|
+
'query_refine_min_words': 3,
|
|
17
|
+
'query_refine_context': 4,
|
|
18
|
+
'show_thinking': True,
|
|
19
|
+
'markdown_streaming': False,
|
|
20
|
+
'web_search': False,
|
|
21
|
+
'search_results': 5,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Config:
|
|
26
|
+
def __init__(self, path: str | None = None):
|
|
27
|
+
self.global_path = Path.home() / '.config' / 'replio' / 'config.json'
|
|
28
|
+
if path:
|
|
29
|
+
self.local_path = Path(path).resolve() / '.replio' / 'config.json'
|
|
30
|
+
else:
|
|
31
|
+
self.local_path = Path.cwd() / '.replio' / 'config.json'
|
|
32
|
+
self.data = dict(DEFAULT_CONFIG)
|
|
33
|
+
self._load()
|
|
34
|
+
|
|
35
|
+
def _load(self):
|
|
36
|
+
for p in [self.global_path, self.local_path]:
|
|
37
|
+
if p.exists():
|
|
38
|
+
with open(p) as f:
|
|
39
|
+
self.data.update(json.load(f))
|
|
40
|
+
|
|
41
|
+
def save(self):
|
|
42
|
+
self.local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
with open(self.local_path, 'w') as f:
|
|
44
|
+
json.dump(self.data, f, indent=2)
|
|
45
|
+
|
|
46
|
+
def get(self, key, default=None):
|
|
47
|
+
return self.data.get(key, default)
|
|
48
|
+
|
|
49
|
+
def set(self, key, value):
|
|
50
|
+
self.data[key] = value
|
|
51
|
+
self.save()
|
replio/main.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import argparse
|
|
3
|
+
|
|
4
|
+
from .config import Config
|
|
5
|
+
from .chat import ChatLoop
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
description='REPL.io — A terminal-based REPL AI chat application'
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
'--path', '-p',
|
|
14
|
+
help='Project path (default: current directory)'
|
|
15
|
+
)
|
|
16
|
+
args = parser.parse_args()
|
|
17
|
+
|
|
18
|
+
config = Config(path=args.path)
|
|
19
|
+
chat = ChatLoop(config)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
chat.run()
|
|
23
|
+
except SystemExit:
|
|
24
|
+
pass
|
|
25
|
+
except KeyboardInterrupt:
|
|
26
|
+
print()
|
|
27
|
+
except Exception as e:
|
|
28
|
+
print(f'\nUnexpected error: {e}', file=sys.stderr)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == '__main__':
|
|
33
|
+
main()
|
|
File without changes
|
replio/providers/base.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class BaseProvider:
|
|
2
|
+
def __init__(self, base_url: str = '', api_key: str = '',
|
|
3
|
+
model: str = '', temperature: float = 0.7,
|
|
4
|
+
max_tokens: int = 2048):
|
|
5
|
+
self.base_url = base_url.rstrip('/')
|
|
6
|
+
self.api_key = api_key
|
|
7
|
+
self.model = model
|
|
8
|
+
self.temperature = temperature
|
|
9
|
+
self.max_tokens = max_tokens
|
|
10
|
+
|
|
11
|
+
def chat(self, messages: list[dict], stream: bool = True):
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
def chat_nonstreaming(self, messages: list[dict],
|
|
15
|
+
tools: list[dict] | None = None) -> dict:
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
def list_models(self) -> list[str]:
|
|
19
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib.request
|
|
3
|
+
import urllib.error
|
|
4
|
+
|
|
5
|
+
from .base import BaseProvider
|
|
6
|
+
from ..utils.http import stream_sse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OllamaProvider(BaseProvider):
|
|
10
|
+
DEFAULT_BASE_URL = 'https://api.ollama.com'
|
|
11
|
+
|
|
12
|
+
def __init__(self, **kwargs):
|
|
13
|
+
super().__init__(**kwargs)
|
|
14
|
+
if not self.base_url:
|
|
15
|
+
self.base_url = self.DEFAULT_BASE_URL
|
|
16
|
+
|
|
17
|
+
def _headers(self):
|
|
18
|
+
headers = {'Content-Type': 'application/json'}
|
|
19
|
+
if self.api_key:
|
|
20
|
+
headers['Authorization'] = f'Bearer {self.api_key}'
|
|
21
|
+
return headers
|
|
22
|
+
|
|
23
|
+
def _payload(self, messages, stream=False, tools=None):
|
|
24
|
+
payload = {
|
|
25
|
+
'model': self.model,
|
|
26
|
+
'messages': messages,
|
|
27
|
+
'temperature': self.temperature,
|
|
28
|
+
'max_tokens': self.max_tokens,
|
|
29
|
+
'stream': stream,
|
|
30
|
+
}
|
|
31
|
+
if tools:
|
|
32
|
+
payload['tools'] = tools
|
|
33
|
+
return payload
|
|
34
|
+
|
|
35
|
+
def _post(self, payload):
|
|
36
|
+
url = f'{self.base_url}/v1/chat/completions'
|
|
37
|
+
data = json.dumps(payload).encode('utf-8')
|
|
38
|
+
req = urllib.request.Request(url, data=data, headers=self._headers())
|
|
39
|
+
try:
|
|
40
|
+
with urllib.request.urlopen(req) as resp:
|
|
41
|
+
return json.loads(resp.read())
|
|
42
|
+
except urllib.error.HTTPError as e:
|
|
43
|
+
body = e.read().decode('utf-8', errors='replace')
|
|
44
|
+
return {'error': {'code': e.code, 'message': body}}
|
|
45
|
+
except urllib.error.URLError as e:
|
|
46
|
+
return {'error': {'code': 0, 'message': f'Network error: {e.reason}'}}
|
|
47
|
+
except Exception as e:
|
|
48
|
+
return {'error': {'code': 0, 'message': str(e)}}
|
|
49
|
+
|
|
50
|
+
def chat_nonstreaming(self, messages: list[dict],
|
|
51
|
+
tools: list[dict] | None = None) -> dict:
|
|
52
|
+
payload = self._payload(messages, stream=False, tools=tools)
|
|
53
|
+
result = self._post(payload)
|
|
54
|
+
if 'error' in result:
|
|
55
|
+
return result
|
|
56
|
+
choice = result['choices'][0]
|
|
57
|
+
message = choice['message']
|
|
58
|
+
return {
|
|
59
|
+
'role': message.get('role', 'assistant'),
|
|
60
|
+
'content': message.get('content'),
|
|
61
|
+
'tool_calls': message.get('tool_calls'),
|
|
62
|
+
'finish_reason': choice.get('finish_reason'),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def chat(self, messages: list[dict], stream: bool = True):
|
|
66
|
+
payload = self._payload(messages, stream=stream)
|
|
67
|
+
|
|
68
|
+
if not stream:
|
|
69
|
+
result = self._post(payload)
|
|
70
|
+
if 'error' in result:
|
|
71
|
+
yield {'type': 'error', 'code': result['error']['code'], 'message': result['error']['message']}
|
|
72
|
+
return
|
|
73
|
+
content = result['choices'][0]['message']['content']
|
|
74
|
+
yield {'type': 'token', 'content': content}
|
|
75
|
+
yield {'type': 'done'}
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
for event in stream_sse(self._endpoint(), self._headers(), payload):
|
|
79
|
+
if 'type' in event:
|
|
80
|
+
yield event
|
|
81
|
+
return
|
|
82
|
+
choices = event.get('choices', [])
|
|
83
|
+
if not choices:
|
|
84
|
+
continue
|
|
85
|
+
delta = choices[0].get('delta', {})
|
|
86
|
+
reasoning = delta.get('reasoning_content', '')
|
|
87
|
+
if reasoning:
|
|
88
|
+
yield {'type': 'thinking', 'content': reasoning}
|
|
89
|
+
continue
|
|
90
|
+
content = delta.get('content', '')
|
|
91
|
+
if content:
|
|
92
|
+
yield {'type': 'token', 'content': content}
|
|
93
|
+
finish = choices[0].get('finish_reason')
|
|
94
|
+
if finish:
|
|
95
|
+
yield {'type': 'done', 'reason': finish}
|
|
96
|
+
|
|
97
|
+
def _endpoint(self):
|
|
98
|
+
return f'{self.base_url}/v1/chat/completions'
|
|
99
|
+
|
|
100
|
+
def list_models(self) -> list[str]:
|
|
101
|
+
url = f'{self.base_url}/v1/models'
|
|
102
|
+
req = urllib.request.Request(url, headers=self._headers())
|
|
103
|
+
try:
|
|
104
|
+
with urllib.request.urlopen(req) as resp:
|
|
105
|
+
data = json.loads(resp.read())
|
|
106
|
+
return [m['id'] for m in data.get('data', [])]
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print(f'\001\033[91m\002[Error]\001\033[0m\002 Failed to list models: {e}')
|
|
109
|
+
return []
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Session:
|
|
7
|
+
def __init__(self, name: str, messages: list | None = None):
|
|
8
|
+
self.name = name
|
|
9
|
+
self.messages = messages or []
|
|
10
|
+
|
|
11
|
+
def add_message(self, role: str, content: str, **kwargs):
|
|
12
|
+
msg = {'role': role, 'content': content}
|
|
13
|
+
msg['timestamp'] = kwargs.pop(
|
|
14
|
+
'timestamp', datetime.now(timezone.utc).isoformat(timespec='seconds')
|
|
15
|
+
)
|
|
16
|
+
msg.update(kwargs)
|
|
17
|
+
self.messages.append(msg)
|
|
18
|
+
|
|
19
|
+
def to_dict(self):
|
|
20
|
+
visible = [m for m in self.messages if m.get('role') != 'tool']
|
|
21
|
+
return {'name': self.name, 'messages': visible}
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_dict(cls, data):
|
|
25
|
+
return cls(data['name'], data.get('messages', []))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SessionManager:
|
|
29
|
+
def __init__(self, sessions_dir: Path):
|
|
30
|
+
self.sessions_dir = sessions_dir
|
|
31
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self.current: Session | None = None
|
|
33
|
+
|
|
34
|
+
def create(self, name: str | None = None) -> Session:
|
|
35
|
+
if not name:
|
|
36
|
+
name = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
37
|
+
self.current = Session(name)
|
|
38
|
+
return self.current
|
|
39
|
+
|
|
40
|
+
def load(self, name: str) -> Session | None:
|
|
41
|
+
path = self.sessions_dir / f'{name}.json'
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return None
|
|
44
|
+
with open(path) as f:
|
|
45
|
+
data = json.load(f)
|
|
46
|
+
self.current = Session.from_dict(data)
|
|
47
|
+
return self.current
|
|
48
|
+
|
|
49
|
+
def save(self, session: Session | None = None):
|
|
50
|
+
s = session or self.current
|
|
51
|
+
if s is None:
|
|
52
|
+
return
|
|
53
|
+
with open(self.sessions_dir / f'{s.name}.json', 'w') as f:
|
|
54
|
+
json.dump(s.to_dict(), f, indent=2)
|
|
55
|
+
|
|
56
|
+
def list(self) -> list[str]:
|
|
57
|
+
return sorted(p.stem for p in self.sessions_dir.glob('*.json'))
|
|
58
|
+
|
|
59
|
+
def delete(self, name: str) -> bool:
|
|
60
|
+
path = self.sessions_dir / f'{name}.json'
|
|
61
|
+
if path.exists():
|
|
62
|
+
path.unlink()
|
|
63
|
+
return True
|
|
64
|
+
return False
|
replio/tools/__init__.py
ADDED
|
File without changes
|
replio/tools/builtins.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from html.parser import HTMLParser
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _TextExtractor(HTMLParser):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
super().__init__()
|
|
9
|
+
self._parts = []
|
|
10
|
+
self._skip_depth = 0
|
|
11
|
+
self._skip_tags = frozenset({'script', 'style', 'svg', 'noscript'})
|
|
12
|
+
self._block_tags = frozenset({
|
|
13
|
+
'p', 'br', 'li', 'div', 'tr', 'td', 'th',
|
|
14
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
15
|
+
'blockquote', 'pre', 'hr',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
def handle_starttag(self, tag, attrs):
|
|
19
|
+
if tag in self._skip_tags:
|
|
20
|
+
self._skip_depth += 1
|
|
21
|
+
if self._skip_depth == 0 and tag in self._block_tags:
|
|
22
|
+
self._parts.append('\n')
|
|
23
|
+
|
|
24
|
+
def handle_endtag(self, tag):
|
|
25
|
+
if tag in self._skip_tags:
|
|
26
|
+
self._skip_depth = max(0, self._skip_depth - 1)
|
|
27
|
+
if self._skip_depth == 0 and tag in self._block_tags:
|
|
28
|
+
self._parts.append('\n')
|
|
29
|
+
|
|
30
|
+
def handle_data(self, data):
|
|
31
|
+
if self._skip_depth == 0:
|
|
32
|
+
self._parts.append(data)
|
|
33
|
+
|
|
34
|
+
def text(self):
|
|
35
|
+
text = ''.join(self._parts)
|
|
36
|
+
text = re.sub(r' +', ' ', text)
|
|
37
|
+
text = re.sub(r'\n ', '\n', text)
|
|
38
|
+
text = re.sub(r' \n', '\n', text)
|
|
39
|
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
40
|
+
return text.strip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def register_tools(registry):
|
|
44
|
+
from ..web.search import search as web_search_fn
|
|
45
|
+
from ..web.display import format_context
|
|
46
|
+
|
|
47
|
+
@registry.register(
|
|
48
|
+
name='web_search',
|
|
49
|
+
description='Search the web for current information. Use this to find recent news, facts, documentation, and any information that may be time-sensitive or outside the model\'s training data.',
|
|
50
|
+
parameters={
|
|
51
|
+
'type': 'object',
|
|
52
|
+
'properties': {
|
|
53
|
+
'query': {
|
|
54
|
+
'type': 'string',
|
|
55
|
+
'description': 'The search query — be specific and concise',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
'required': ['query'],
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
def web_search(query: str) -> str:
|
|
62
|
+
results = web_search_fn(query)
|
|
63
|
+
if not results:
|
|
64
|
+
return 'No search results found.'
|
|
65
|
+
return format_context(query, results)
|
|
66
|
+
|
|
67
|
+
@registry.register(
|
|
68
|
+
name='fetch_page',
|
|
69
|
+
description='Fetch and read the full content of a web page. Use this when search result snippets are insufficient and you need detailed information from a specific URL.',
|
|
70
|
+
parameters={
|
|
71
|
+
'type': 'object',
|
|
72
|
+
'properties': {
|
|
73
|
+
'url': {
|
|
74
|
+
'type': 'string',
|
|
75
|
+
'description': 'The full URL of the page to fetch',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
'required': ['url'],
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
def fetch_page(url: str) -> str:
|
|
82
|
+
import urllib.request
|
|
83
|
+
try:
|
|
84
|
+
req = urllib.request.Request(
|
|
85
|
+
url,
|
|
86
|
+
headers={'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64)'},
|
|
87
|
+
)
|
|
88
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
89
|
+
content = resp.read().decode('utf-8', errors='replace')
|
|
90
|
+
extractor = _TextExtractor()
|
|
91
|
+
extractor.feed(content)
|
|
92
|
+
text = extractor.text()
|
|
93
|
+
if len(text) > 8000:
|
|
94
|
+
text = text[:8000] + '\n... (truncated)'
|
|
95
|
+
return text
|
|
96
|
+
except Exception as e:
|
|
97
|
+
return f'Error fetching page: {e}'
|
replio/tools/registry.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class ToolRegistry:
|
|
2
|
+
def __init__(self):
|
|
3
|
+
self._tools: dict[str, dict] = {}
|
|
4
|
+
self._schema: list[dict] = []
|
|
5
|
+
|
|
6
|
+
def register(self, name: str, description: str, parameters: dict):
|
|
7
|
+
def wrapper(fn):
|
|
8
|
+
entry = {
|
|
9
|
+
'name': name,
|
|
10
|
+
'fn': fn,
|
|
11
|
+
'schema': {
|
|
12
|
+
'type': 'function',
|
|
13
|
+
'function': {
|
|
14
|
+
'name': name,
|
|
15
|
+
'description': description,
|
|
16
|
+
'parameters': parameters,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
self._tools[name] = entry
|
|
21
|
+
self._schema.append(entry['schema'])
|
|
22
|
+
return fn
|
|
23
|
+
return wrapper
|
|
24
|
+
|
|
25
|
+
def execute(self, name: str, arguments: dict) -> str:
|
|
26
|
+
tool = self._tools.get(name)
|
|
27
|
+
if not tool:
|
|
28
|
+
return f'Error: unknown tool "{name}"'
|
|
29
|
+
try:
|
|
30
|
+
return tool['fn'](**arguments)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
return f'Error executing {name}: {e}'
|
|
33
|
+
|
|
34
|
+
def schema(self) -> list[dict]:
|
|
35
|
+
return list(self._schema)
|
|
36
|
+
|
|
37
|
+
def names(self) -> list[str]:
|
|
38
|
+
return list(self._tools.keys())
|
replio/utils/__init__.py
ADDED
|
File without changes
|
replio/utils/http.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import urllib.request
|
|
2
|
+
import urllib.error
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def stream_sse(url, headers, payload, timeout=120):
|
|
7
|
+
data = json.dumps(payload).encode('utf-8')
|
|
8
|
+
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
12
|
+
buffer = ''
|
|
13
|
+
while True:
|
|
14
|
+
chunk = resp.read(4096)
|
|
15
|
+
if not chunk:
|
|
16
|
+
break
|
|
17
|
+
buffer += chunk.decode('utf-8')
|
|
18
|
+
while '\n' in buffer:
|
|
19
|
+
line, buffer = buffer.split('\n', 1)
|
|
20
|
+
line = line.strip()
|
|
21
|
+
if not line:
|
|
22
|
+
continue
|
|
23
|
+
if line.startswith('data: '):
|
|
24
|
+
data_str = line[6:]
|
|
25
|
+
if data_str == '[DONE]':
|
|
26
|
+
yield {'type': 'done'}
|
|
27
|
+
return
|
|
28
|
+
try:
|
|
29
|
+
yield json.loads(data_str)
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
pass
|
|
32
|
+
except urllib.error.HTTPError as e:
|
|
33
|
+
body = e.read().decode('utf-8', errors='replace')
|
|
34
|
+
yield {'type': 'error', 'code': e.code, 'message': body}
|
|
35
|
+
except Exception as e:
|
|
36
|
+
yield {'type': 'error', 'message': str(e)}
|
replio/web/__init__.py
ADDED
|
File without changes
|
replio/web/display.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
def format_results(query: str, results: list[dict]) -> str:
|
|
2
|
+
lines = [f'Search results for "{query}":\n']
|
|
3
|
+
for i, r in enumerate(results, 1):
|
|
4
|
+
title = r.get('title', '')
|
|
5
|
+
url = r.get('url', '')
|
|
6
|
+
snippet = r.get('snippet', '')
|
|
7
|
+
lines.append(f' {i}. {title}')
|
|
8
|
+
lines.append(f' {url}')
|
|
9
|
+
if snippet:
|
|
10
|
+
lines.append(f' ({snippet})')
|
|
11
|
+
lines.append('')
|
|
12
|
+
return '\n'.join(lines)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_context(query: str, results: list[dict]) -> str:
|
|
16
|
+
parts = [f'Web search results for "{query}":']
|
|
17
|
+
for i, r in enumerate(results, 1):
|
|
18
|
+
title = r.get('title', '')
|
|
19
|
+
url = r.get('url', '')
|
|
20
|
+
snippet = r.get('snippet', '')
|
|
21
|
+
parts.append(f'{i}. {title}')
|
|
22
|
+
parts.append(f' URL: {url}')
|
|
23
|
+
if snippet:
|
|
24
|
+
parts.append(f' Snippet: {snippet}')
|
|
25
|
+
return '\n'.join(parts)
|
replio/web/search.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import urllib.request
|
|
2
|
+
import urllib.parse
|
|
3
|
+
from html.parser import HTMLParser
|
|
4
|
+
from typing import Generator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SEARCH_URL = 'https://lite.duckduckgo.com/lite/'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DDGResultParser(HTMLParser):
|
|
11
|
+
def __init__(self):
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.results: list[dict] = []
|
|
14
|
+
self._cur: dict | None = None
|
|
15
|
+
self._state = 'idle'
|
|
16
|
+
self._text = ''
|
|
17
|
+
|
|
18
|
+
def handle_starttag(self, tag, attrs):
|
|
19
|
+
a = dict(attrs)
|
|
20
|
+
if tag == 'a' and a.get('rel') == 'nofollow':
|
|
21
|
+
self._cur = {'url': a.get('href', ''), 'title': '', 'snippet': ''}
|
|
22
|
+
self._state = 'link'
|
|
23
|
+
self._text = ''
|
|
24
|
+
return
|
|
25
|
+
if self._state == 'idle':
|
|
26
|
+
return
|
|
27
|
+
if tag == 'br':
|
|
28
|
+
if self._state in ('link', 'link_done'):
|
|
29
|
+
if self._state == 'link':
|
|
30
|
+
self._cur['title'] = self._text.strip()
|
|
31
|
+
self._state = 'snippet'
|
|
32
|
+
self._text = ''
|
|
33
|
+
elif self._state == 'snippet':
|
|
34
|
+
self._cur['snippet'] = self._text.strip()
|
|
35
|
+
self._state = 'done'
|
|
36
|
+
self._text = ''
|
|
37
|
+
|
|
38
|
+
def handle_data(self, data):
|
|
39
|
+
if self._state in ('link', 'snippet'):
|
|
40
|
+
self._text += data
|
|
41
|
+
|
|
42
|
+
def handle_endtag(self, tag):
|
|
43
|
+
if tag == 'a' and self._state == 'link':
|
|
44
|
+
self._cur['title'] = self._text.strip()
|
|
45
|
+
self._state = 'link_done'
|
|
46
|
+
self._text = ''
|
|
47
|
+
if tag == 'td' and self._cur:
|
|
48
|
+
if self._cur.get('title'):
|
|
49
|
+
self.results.append(self._cur)
|
|
50
|
+
self._cur = None
|
|
51
|
+
self._state = 'idle'
|
|
52
|
+
self._text = ''
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def search(query: str, num_results: int = 5) -> list[dict]:
|
|
56
|
+
data = urllib.parse.urlencode({'q': query}).encode()
|
|
57
|
+
headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64)'}
|
|
58
|
+
req = urllib.request.Request(SEARCH_URL, data=data, headers=headers)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
62
|
+
html = resp.read().decode('utf-8', errors='replace')
|
|
63
|
+
except Exception:
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
parser = DDGResultParser()
|
|
67
|
+
parser.feed(html)
|
|
68
|
+
return parser.results[:num_results]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
replio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
replio/__main__.py,sha256=vBQ82334kX06ImDbFlPFgiBRiLIinwNk3z8Khs6hd74,31
|
|
3
|
+
replio/chat.py,sha256=O_m1HV6OoyumFuYEw6Okhx7V3P_l3mQRJ3dBtDS3hvc,19510
|
|
4
|
+
replio/config.py,sha256=Nua0fok53JYCAQ6THt1-DdkZhdvKKJCMy5md9ZuMW6U,1422
|
|
5
|
+
replio/main.py,sha256=MRCjLGDJH5qF1E7rnStYd96WXWMsGRrsYiJ3hQFuflc,681
|
|
6
|
+
replio/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
replio/commands/builtins.py,sha256=pYohkvd6cpXsVaq262C0vAl6vlFFjrK9r2kpQYC2Ny4,5425
|
|
8
|
+
replio/commands/registry.py,sha256=rBNjYXW4PYeZo4arNbC6H5fFzCBY7hzHpVu7VIl6z5Y,1079
|
|
9
|
+
replio/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
replio/providers/base.py,sha256=36_nzwWtf44vg29JPLUtfVgo_Bkh6dkloWjteeKOPYc,693
|
|
11
|
+
replio/providers/ollama.py,sha256=LzlGumhFuMj5jwi_Jy7HPFXaWrJf6a2_vPLVKc_kGaA,4040
|
|
12
|
+
replio/sessions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
replio/sessions/manager.py,sha256=k3LeP2ofnFEWxargU2A1ITZEgyYgImT-TeUnsGSWa0A,2036
|
|
14
|
+
replio/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
replio/tools/builtins.py,sha256=0deLB3E94dFDIW_NtLIfxp-jkphmq0FBXyIkHpWSM-U,3361
|
|
16
|
+
replio/tools/registry.py,sha256=eGtpWJDDKnER4b-Na6eNm65w2HuOEIfkOhIdKXKa624,1180
|
|
17
|
+
replio/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
replio/utils/http.py,sha256=TIQna8OYXIuo45wzvWMKYCoZyVCA0ekdIlIX3cHotPU,1346
|
|
19
|
+
replio/web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
replio/web/display.py,sha256=IPmi2XlCDga4Ovv3kPBLooc_IvIc4GLTFwenlEqr1oM,881
|
|
21
|
+
replio/web/search.py,sha256=hWwIRjir1ZptlO-INRX7yYOm3v6znm3Qy53GWt15h3k,2184
|
|
22
|
+
replio-0.5.0.dist-info/licenses/LICENSE,sha256=rLsIfIWeBi6EkxfJCvVKgb3ZfAYB1N-XW_NasdJy21k,1074
|
|
23
|
+
replio-0.5.0.dist-info/METADATA,sha256=NmptH1GQnaJeL65Hhx0WdzX2q9OhvepVn08sfQNH29Y,239
|
|
24
|
+
replio-0.5.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
25
|
+
replio-0.5.0.dist-info/entry_points.txt,sha256=kSlxLze8TgNcH7xsU6g_NUl6tdZOlu2ASUZQCbJxrls,44
|
|
26
|
+
replio-0.5.0.dist-info/top_level.txt,sha256=_veHcbCUHNLeeFruPOxEE6-v9KGJz_g4x8IFa5Vwh2Q,7
|
|
27
|
+
replio-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Evgenij Myasnikov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
replio
|