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 ADDED
File without changes
replio/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ main()
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
@@ -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
File without changes
@@ -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}'
@@ -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())
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,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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ replio = replio.main:main
@@ -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