replio 0.5.0__tar.gz

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-0.5.0/LICENSE ADDED
@@ -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.
replio-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: replio
3
+ Version: 0.5.0
4
+ Summary: A terminal-based REPL AI chat application allowing web search alongside other tool calling
5
+ Author: Contributors
6
+ Requires-Python: >=3.10
7
+ License-File: LICENSE
8
+ Dynamic: license-file
replio-0.5.0/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # REPL.io
2
+
3
+ <p>
4
+ <img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python >=3.10">
5
+ <img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License">
6
+ <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero dependencies">
7
+ </p>
8
+
9
+ A zero-dependency AI chat REPL for your terminal.
10
+
11
+ ## Why?
12
+
13
+ REPL.io uses **nothing but the Python standard library**.
14
+ No dependencies and gigabytes of `node_modules`.
15
+ Clone and run.
16
+
17
+ ## Features
18
+
19
+ - **Zero external dependencies** — Python stdlib only
20
+ - **Multi-provider** — Ollama, OpenAI, Anthropic, Groq, any OpenAI-compatible API
21
+ - **Streaming responses** — live token-by-token output via SSE
22
+ - **Web search** — DuckDuckGo integration, page fetching, auto-query refinement
23
+ - **Tool calling** — models search the web and fetch pages on the fly
24
+ - **Sessions** — save, load, switch, and auto-name conversations
25
+ - **Slash commands** — `/help`, `/model`, `/provider`, `/connect`, `/session`, `/config`, `/exit`
26
+ - **Dual config** — global `~/.config/replio/` + per-project `.replio/` JSON merge
27
+ - **Input history** — readline-based up/down recall + tab completion
28
+ - **Thinking/reasoning display** — see model reasoning tokens (DeepSeek R1, o1, etc.)
29
+
30
+ ## Quick Start
31
+
32
+ ```bash
33
+ pip install replio
34
+ replio
35
+ ```
36
+
37
+ Or from source:
38
+
39
+ ```bash
40
+ git clone https://github.com/emyasnikov/replio && cd replio
41
+ python3 -m venv .venv && .venv/bin/pip install -e .
42
+ .venv/bin/replio
43
+ ```
44
+
45
+ ### First-time setup
46
+
47
+ ```
48
+ >>> /connect
49
+ Provider [ollama]:
50
+ Base URL [https://ollama.com]:
51
+ API key: sk-...
52
+ Model [gpt-oss:20b-cloud]:
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ```
58
+ >>> /help list all commands
59
+ >>> /model <model> switch model
60
+ >>> /search <query> search the web
61
+ >>> /session list saved conversations
62
+ >>> /exit goodbye
63
+ ```
64
+
65
+ Type any message to chat. Tab-complete `/` commands. Arrow keys for history.
66
+
67
+ ## Project Structure
68
+
69
+ ```
70
+ src/replio/
71
+ ├── chat.py REPL loop + streaming display
72
+ ├── config.py Config load/merge/save
73
+ ├── commands/ Slash command system
74
+ ├── providers/ LLM provider abstraction
75
+ ├── sessions/ Session CRUD
76
+ ├── tools/ Web search, page fetch, tool registry
77
+ ├── web/ DuckDuckGo search + formatting
78
+ └── utils/ HTTP SSE streaming
79
+ ```
80
+
81
+ ## Adding a Provider
82
+
83
+ Create a subclass of `BaseProvider` implementing `chat()` and `list_models()` using the OpenAI-compatible `/v1/chat/completions` format, then register it in `ChatLoop._reinit_provider()`.
84
+
85
+ ## Contributing
86
+
87
+ See [TODO.md](TODO.md) for open tasks and [CHANGELOG.md](CHANGELOG.md) for release history.
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "replio"
7
+ version = "0.5.0"
8
+ description = "A terminal-based REPL AI chat application allowing web search alongside other tool calling"
9
+ requires-python = ">=3.10"
10
+ authors = [{name = "Contributors"}]
11
+
12
+ [project.scripts]
13
+ replio = "replio.main:main"
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["src"]
replio-0.5.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ main()
@@ -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