chatty-agent 0.2.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.
chatty/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
chatty/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from chatty.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
chatty/backup.py ADDED
@@ -0,0 +1,141 @@
1
+ import os
2
+ import time
3
+ import logging
4
+ from typing import List, Tuple
5
+
6
+ logger = logging.getLogger("chatty")
7
+
8
+
9
+ def get_backups_dir(sandbox_dir: str) -> str:
10
+ return os.path.join(sandbox_dir, ".chatty", "backups")
11
+
12
+
13
+ def ensure_gitignore_ignores_chatty(sandbox_dir: str) -> None:
14
+ """Ensures .chatty/ is present in .gitignore so backups are not committed to git."""
15
+ gitignore_path = os.path.join(sandbox_dir, ".gitignore")
16
+ try:
17
+ if os.path.exists(gitignore_path):
18
+ with open(gitignore_path, 'r', encoding='utf-8', errors='ignore') as f:
19
+ content = f.read()
20
+ if ".chatty/" in content or ".chatty" in content:
21
+ return
22
+ with open(gitignore_path, 'a', encoding='utf-8') as f:
23
+ if content and not content.endswith('\n'):
24
+ f.write('\n')
25
+ f.write('# Chatty backups\n.chatty/\n')
26
+ else:
27
+ with open(gitignore_path, 'w', encoding='utf-8') as f:
28
+ f.write('# Chatty backups\n.chatty/\n')
29
+ except Exception:
30
+ pass
31
+
32
+
33
+ def backup_file(sandbox_dir: str, rel_path: str) -> None:
34
+ """Creates a timestamped backup of the file under .chatty/backups/path/to/file/timestamp.bak."""
35
+ from chatty.safety import get_safe_path, load_ignore_patterns, is_path_ignored
36
+
37
+ try:
38
+ ignore_patterns = load_ignore_patterns(sandbox_dir)
39
+ if is_path_ignored(rel_path, ignore_patterns):
40
+ return
41
+
42
+ abs_path = get_safe_path(sandbox_dir, rel_path)
43
+ if not os.path.exists(abs_path) or not os.path.isfile(abs_path):
44
+ return
45
+
46
+ ensure_gitignore_ignores_chatty(sandbox_dir)
47
+ file_backups_dir = os.path.join(get_backups_dir(sandbox_dir), rel_path)
48
+ os.makedirs(file_backups_dir, exist_ok=True)
49
+
50
+ timestamp = int(time.time() * 1000)
51
+ backup_path = os.path.join(file_backups_dir, f"{timestamp}.bak")
52
+ while os.path.exists(backup_path):
53
+ timestamp += 1
54
+ backup_path = os.path.join(file_backups_dir, f"{timestamp}.bak")
55
+
56
+ import shutil
57
+ shutil.copy2(abs_path, backup_path)
58
+
59
+ logger.info(f"Created backup of '{rel_path}' at '{os.path.relpath(backup_path, sandbox_dir)}'")
60
+ prune_backups(file_backups_dir, max_backups=10)
61
+ except Exception as e:
62
+ logger.warning(f"Failed to backup file '{rel_path}': {e}")
63
+
64
+
65
+ def prune_backups(file_backups_dir: str, max_backups: int = 10) -> None:
66
+ """Keep only the latest max_backups in the directory."""
67
+ try:
68
+ if not os.path.isdir(file_backups_dir):
69
+ return
70
+ files = []
71
+ for f in os.listdir(file_backups_dir):
72
+ path = os.path.join(file_backups_dir, f)
73
+ if os.path.isfile(path) and f.endswith(".bak"):
74
+ files.append(path)
75
+
76
+ def get_timestamp(filepath):
77
+ name = os.path.basename(filepath)
78
+ try:
79
+ return int(name.split(".")[0])
80
+ except ValueError:
81
+ return os.path.getmtime(filepath)
82
+
83
+ files.sort(key=get_timestamp)
84
+ if len(files) > max_backups:
85
+ to_delete = files[:-max_backups]
86
+ for path in to_delete:
87
+ os.remove(path)
88
+ except Exception:
89
+ pass
90
+
91
+
92
+ def list_backups(sandbox_dir: str, rel_path: str) -> List[Tuple[int, str]]:
93
+ """Returns a list of tuples (timestamp, format_time_str) of available backups for the given file."""
94
+ file_backups_dir = os.path.join(get_backups_dir(sandbox_dir), rel_path)
95
+ if not os.path.isdir(file_backups_dir):
96
+ return []
97
+
98
+ backups = []
99
+ for f in os.listdir(file_backups_dir):
100
+ path = os.path.join(file_backups_dir, f)
101
+ if os.path.isfile(path) and f.endswith(".bak"):
102
+ name = f[:-4]
103
+ try:
104
+ ts = int(name)
105
+ time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts / 1000.0))
106
+ backups.append((ts, time_str))
107
+ except ValueError:
108
+ pass
109
+
110
+ backups.sort(key=lambda x: x[0], reverse=True)
111
+ return backups
112
+
113
+
114
+ def restore_backup(sandbox_dir: str, rel_path: str, timestamp: int = None) -> str:
115
+ """Restores a backup for the given file. If timestamp is None, restores the latest backup."""
116
+ from chatty.safety import get_safe_path
117
+
118
+ file_backups_dir = os.path.join(get_backups_dir(sandbox_dir), rel_path)
119
+ if not os.path.isdir(file_backups_dir):
120
+ return f"Error: No backups found for file '{rel_path}'."
121
+
122
+ backups = list_backups(sandbox_dir, rel_path)
123
+ if not backups:
124
+ return f"Error: No backups found for file '{rel_path}'."
125
+
126
+ target_ts = timestamp
127
+ if target_ts is None:
128
+ target_ts = backups[0][0]
129
+
130
+ backup_file_path = os.path.join(file_backups_dir, f"{target_ts}.bak")
131
+ if not os.path.exists(backup_file_path):
132
+ return f"Error: Backup file with timestamp '{target_ts}' not found for '{rel_path}'."
133
+
134
+ abs_path = get_safe_path(sandbox_dir, rel_path, write=True)
135
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
136
+
137
+ import shutil
138
+ shutil.copy2(backup_file_path, abs_path)
139
+
140
+ time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(target_ts / 1000.0))
141
+ return f"Successfully restored '{rel_path}' to backup version from {time_str}."
chatty/cli.py ADDED
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import logging
4
+ import sys
5
+ from rich.console import Console
6
+
7
+ from chatty.logging_setup import setup_logging
8
+ from chatty.utils import get_ollama_models, load_system_prompt_from_file
9
+ from chatty.session import ChatbotSession
10
+ from chatty.llm import get_default_openrouter_model
11
+
12
+ logger = logging.getLogger("chatty")
13
+ console = Console()
14
+
15
+
16
+ def main():
17
+ parser = argparse.ArgumentParser(
18
+ description="AI Chatbot CLI with advanced sandboxed text and file system interaction."
19
+ )
20
+ parser.add_argument(
21
+ "--provider", "-p",
22
+ help="The LLM backend provider to use. Defaults to the domain of the --url if --url is provided, or 'ollama'."
23
+ )
24
+ parser.add_argument(
25
+ "--model", "-m",
26
+ action="append",
27
+ help="Model identifier(s) to use. Can be specified multiple times or as comma-separated values. The first model becomes the active model."
28
+ )
29
+ parser.add_argument(
30
+ "--oracle-model",
31
+ help="Model identifier to use as the oracle. Default determines based on provider."
32
+ )
33
+ parser.add_argument(
34
+ "--context-size", "-c",
35
+ type=int,
36
+ default=8192,
37
+ help="Target context window length constraint in tokens (default: 8192)."
38
+ )
39
+ parser.add_argument(
40
+ "--sandbox", "-s",
41
+ default="./sandbox",
42
+ help="Path to the sandboxed file system directory. Writes are strictly restricted here (default: ./sandbox)."
43
+ )
44
+ parser.add_argument(
45
+ "--skills-path", "-k",
46
+ action="append",
47
+ default=[],
48
+ help="Custom directories to search for skills. Can be specified multiple times."
49
+ )
50
+ parser.add_argument(
51
+ "--ondemand-skills-path", "-o",
52
+ action="append",
53
+ default=[],
54
+ help="Custom directories to search for on-demand skills. Can be specified multiple times."
55
+ )
56
+ parser.add_argument(
57
+ "--whitelist", "-w",
58
+ action="append",
59
+ default=[],
60
+ help="Add an out-of-sandbox path to the initial whitelist. Can end with :ro or :rw to set mode (defaults to ro). Can be specified multiple times."
61
+ )
62
+ parser.add_argument(
63
+ "--static-skills",
64
+ action="store_true",
65
+ default=None,
66
+ help="Load all available skills statically into the system prompt to maximize prompt caching (defaults to True for OpenRouter, False for Ollama)."
67
+ )
68
+ parser.add_argument(
69
+ "--prompt-caching",
70
+ action="store_true",
71
+ default=False,
72
+ help="Explicitly enable prompt caching for compatible models (adds cache_control tagging, default: False)."
73
+ )
74
+ parser.add_argument(
75
+ "--max-loops", "-l",
76
+ type=int,
77
+ default=20,
78
+ help="Maximum sequential tool execution loops allowed in a single turn (default: 20)."
79
+ )
80
+ parser.add_argument(
81
+ "--config-prompt", "-f",
82
+ help="Path to a YAML or text configuration file containing the custom system prompt."
83
+ )
84
+ parser.add_argument(
85
+ "--prompt-mode", "-d",
86
+ choices=["replace", "integrate"],
87
+ default="replace",
88
+ help="How to apply the custom system prompt file (replace default prompt, or integrate/append to it)."
89
+ )
90
+ parser.add_argument(
91
+ "--api-key", "-a",
92
+ help="OpenRouter API key. Overrides the OPENROUTER_API_KEY environment variable."
93
+ )
94
+ parser.add_argument(
95
+ "--url", "-u",
96
+ help="API Base URL override (defaults to Ollama local endpoint or OpenRouter base URL)."
97
+ )
98
+ parser.add_argument(
99
+ "--max-read-chars",
100
+ type=int,
101
+ default=40000,
102
+ help="Max characters to read from a file during full read tool execution (default: 40000)."
103
+ )
104
+ parser.add_argument(
105
+ "--max-grep-results",
106
+ type=int,
107
+ default=100,
108
+ help="Max results returned by regex search tool (default: 100)."
109
+ )
110
+ parser.add_argument(
111
+ "--max-command-chars",
112
+ type=int,
113
+ default=16000,
114
+ help="Max characters returned from standard output/error of a shell command (default: 16000)."
115
+ )
116
+ parser.add_argument(
117
+ "--max-history-tool-chars",
118
+ type=int,
119
+ default=1000,
120
+ help="Max characters to keep in historical tool outputs before compression (default: 1000)."
121
+ )
122
+ parser.add_argument(
123
+ "--history-keep-messages",
124
+ type=int,
125
+ default=4,
126
+ help="Number of recent messages to keep fully uncompressed (default: 4)."
127
+ )
128
+ parser.add_argument(
129
+ "--max-url-chars",
130
+ type=int,
131
+ default=24000,
132
+ help="Max characters returned from fetched URLs (default: 24000)."
133
+ )
134
+ parser.add_argument(
135
+ "--max-dir-items",
136
+ type=int,
137
+ default=200,
138
+ help="Max items listed by the directory list tool (default: 200)."
139
+ )
140
+ parser.add_argument(
141
+ "--log-file",
142
+ default="chatty.log",
143
+ help="Path to the file where operations will be logged (default: chatty.log). Set to empty string to disable logging."
144
+ )
145
+ parser.add_argument(
146
+ "--log-level",
147
+ default="info",
148
+ choices=["debug", "info", "warning", "error"],
149
+ help="Logging level (default: info)."
150
+ )
151
+ parser.add_argument(
152
+ "--headless",
153
+ action="store_true",
154
+ default=False,
155
+ help="Run the chatbot in headless mode (no console printing or terminal interactive loop)."
156
+ )
157
+ parser.add_argument(
158
+ "--max-thinking-chars",
159
+ type=int,
160
+ default=12000,
161
+ help="Maximum internal thinking characters before prompting the user (default: 12000)."
162
+ )
163
+ parser.add_argument(
164
+ "--max-thinking-leeway-chars",
165
+ type=int,
166
+ default=2000,
167
+ help="Leeway in characters beyond the maximum before hard-aborting or prompting (default: 2000)."
168
+ )
169
+ parser.add_argument(
170
+ "--api-delay",
171
+ type=float,
172
+ default=2.5,
173
+ help="Minimum delay in seconds between consecutive API requests (default: 2.5)."
174
+ )
175
+ parser.add_argument(
176
+ "--api-timeout",
177
+ type=float,
178
+ default=60.0,
179
+ help="Timeout in seconds for API requests and streams (default: 60.0)."
180
+ )
181
+
182
+ args = parser.parse_args()
183
+
184
+ # Initialize logging
185
+ if args.log_file:
186
+ setup_logging(args.log_file, args.log_level)
187
+ logger.info("==========================================")
188
+ logger.info(f"Logging initialized to '{args.log_file}' (level: {args.log_level}).")
189
+
190
+ # Load system prompt from file if specified
191
+ custom_system_prompt = None
192
+ if args.config_prompt:
193
+ try:
194
+ custom_system_prompt = load_system_prompt_from_file(args.config_prompt)
195
+ if not args.headless:
196
+ console.print(f"[bold blue]Info:[/bold blue] Loaded custom system prompt from '{args.config_prompt}' (mode: {args.prompt_mode}).")
197
+ except Exception as e:
198
+ if not args.headless:
199
+ console.print(f"[bold red]Error loading prompt configuration:[/bold red] {e}")
200
+ sys.exit(1)
201
+
202
+ from urllib.parse import urlparse
203
+
204
+ # Determine provider
205
+ provider = args.provider
206
+ if not provider:
207
+ if args.url:
208
+ parsed = urlparse(args.url)
209
+ domain = parsed.netloc or parsed.path
210
+ if ":" in domain:
211
+ domain = domain.split(":")[0]
212
+ if not domain:
213
+ parser.error("Could not parse domain name from --url.")
214
+ provider = domain
215
+ else:
216
+ provider = "ollama"
217
+
218
+ # Validate custom provider constraints
219
+ from chatty.providers import PROVIDER_KEYS, get_provider
220
+ is_standard_provider = provider in PROVIDER_KEYS
221
+ if not is_standard_provider:
222
+ if not args.url:
223
+ parser.error(f"Provider '{provider}' requires an API URL. Pass it via --url / -u.")
224
+ if not args.model:
225
+ parser.error(f"Provider '{provider}' requires a model. Pass it via --model / -m.")
226
+
227
+ # Resolve default models
228
+ models = []
229
+ if args.model:
230
+ for m in args.model:
231
+ for part in m.split(','):
232
+ part = part.strip()
233
+ if part:
234
+ models.append(part)
235
+
236
+ if not models:
237
+ prov_inst = get_provider(provider)
238
+ if provider == "ollama":
239
+ # Attempt to auto-detect model from local Ollama tags
240
+ ollama_url = args.url or prov_inst.get_default_url()
241
+ local_models = get_ollama_models(ollama_url)
242
+ if local_models:
243
+ models = [local_models[0]]
244
+ if not args.headless:
245
+ console.print(f"[bold blue]Info:[/bold blue] Auto-detected local Ollama model: [bold green]{models[0]}[/bold green]")
246
+ else:
247
+ models = [prov_inst.get_default_model()]
248
+ if not args.headless:
249
+ console.print(f"[bold blue]Info:[/bold blue] No local Ollama models detected. Fallback default: [bold green]{models[0]}[/bold green]")
250
+ else:
251
+ default_m = prov_inst.get_default_model(args.api_key)
252
+ if default_m:
253
+ models = [default_m]
254
+ if not args.headless:
255
+ console.print(f"[bold blue]Info:[/bold blue] {provider.capitalize()} provider selected. Default model: [bold green]{models[0]}[/bold green]")
256
+ else:
257
+ parser.error(f"Provider '{provider}' requires a model. Pass it via --model / -m.")
258
+
259
+ model = models[0]
260
+
261
+ # Initialize and execute chat session
262
+ with ChatbotSession(
263
+ provider=provider,
264
+ model=model,
265
+ models=models,
266
+ oracle_model=args.oracle_model,
267
+ context_size=args.context_size,
268
+ sandbox=args.sandbox,
269
+ api_key=args.api_key,
270
+ url=args.url,
271
+ max_loops=args.max_loops,
272
+ system_prompt_override=custom_system_prompt,
273
+ prompt_mode=args.prompt_mode,
274
+ skills_paths=args.skills_path,
275
+ ondemand_skills_paths=args.ondemand_skills_path,
276
+ max_read_chars=args.max_read_chars,
277
+ max_grep_results=args.max_grep_results,
278
+ max_command_chars=args.max_command_chars,
279
+ max_history_tool_chars=args.max_history_tool_chars,
280
+ history_keep_messages=args.history_keep_messages,
281
+ max_url_chars=args.max_url_chars,
282
+ max_dir_items=args.max_dir_items,
283
+ static_skills=args.static_skills,
284
+ prompt_caching=args.prompt_caching,
285
+ headless=args.headless,
286
+ whitelist=args.whitelist,
287
+ max_thinking_chars=args.max_thinking_chars,
288
+ max_thinking_leeway_chars=args.max_thinking_leeway_chars,
289
+ api_delay=args.api_delay,
290
+ api_timeout=args.api_timeout
291
+ ) as chat_session:
292
+ if not args.headless:
293
+ chat_session.start_loop()
294
+
295
+
296
+ if __name__ == "__main__":
297
+ main()