gemi-cli 0.1.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.
gemi/agent/tools.py ADDED
@@ -0,0 +1,571 @@
1
+ import os
2
+ import signal
3
+ import subprocess
4
+ import threading
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+
9
+ from gemi.ui import print_approval_prompt
10
+
11
+ console = Console()
12
+
13
+ TOOL_DEFINITIONS = [
14
+ {
15
+ "name": "read_file",
16
+ "description": "Read the contents of a file. Returns the file content with line numbers.",
17
+ "parameters": {
18
+ "type": "object",
19
+ "properties": {
20
+ "path": {
21
+ "type": "string",
22
+ "description": "Path to the file to read (relative to current directory)",
23
+ },
24
+ },
25
+ "required": ["path"],
26
+ },
27
+ },
28
+ {
29
+ "name": "write_file",
30
+ "description": "Write content to a file. Creates the file if it doesn't exist, overwrites if it does.",
31
+ "parameters": {
32
+ "type": "object",
33
+ "properties": {
34
+ "path": {
35
+ "type": "string",
36
+ "description": "Path to the file to write",
37
+ },
38
+ "content": {
39
+ "type": "string",
40
+ "description": "Content to write to the file",
41
+ },
42
+ },
43
+ "required": ["path", "content"],
44
+ },
45
+ },
46
+ {
47
+ "name": "edit_file",
48
+ "description": "Edit a file by replacing old_text with new_text. The old_text must match exactly.",
49
+ "parameters": {
50
+ "type": "object",
51
+ "properties": {
52
+ "path": {
53
+ "type": "string",
54
+ "description": "Path to the file to edit",
55
+ },
56
+ "old_text": {
57
+ "type": "string",
58
+ "description": "The exact text to find and replace",
59
+ },
60
+ "new_text": {
61
+ "type": "string",
62
+ "description": "The text to replace it with",
63
+ },
64
+ },
65
+ "required": ["path", "old_text", "new_text"],
66
+ },
67
+ },
68
+ {
69
+ "name": "run_command",
70
+ "description": "Execute a shell command and return its output. Commands run non-blocking — fast commands return immediately, long-running ones return after 30s with partial output while continuing in background.",
71
+ "parameters": {
72
+ "type": "object",
73
+ "properties": {
74
+ "command": {
75
+ "type": "string",
76
+ "description": "The shell command to execute",
77
+ },
78
+ },
79
+ "required": ["command"],
80
+ },
81
+ },
82
+ {
83
+ "name": "list_directory",
84
+ "description": "List files and directories in the given path.",
85
+ "parameters": {
86
+ "type": "object",
87
+ "properties": {
88
+ "path": {
89
+ "type": "string",
90
+ "description": "Path to the directory to list (defaults to current directory)",
91
+ },
92
+ },
93
+ "required": [],
94
+ },
95
+ },
96
+ {
97
+ "name": "search_files",
98
+ "description": "Search for a text pattern in files. Like grep -rn.",
99
+ "parameters": {
100
+ "type": "object",
101
+ "properties": {
102
+ "pattern": {
103
+ "type": "string",
104
+ "description": "Text pattern to search for",
105
+ },
106
+ "path": {
107
+ "type": "string",
108
+ "description": "Directory to search in (defaults to current directory)",
109
+ },
110
+ },
111
+ "required": ["pattern"],
112
+ },
113
+ },
114
+ {
115
+ "name": "find_files",
116
+ "description": "Find files matching a glob pattern.",
117
+ "parameters": {
118
+ "type": "object",
119
+ "properties": {
120
+ "pattern": {
121
+ "type": "string",
122
+ "description": "Glob pattern to match (e.g., '**/*.py', '*.json')",
123
+ },
124
+ },
125
+ "required": ["pattern"],
126
+ },
127
+ },
128
+ {
129
+ "name": "git_status",
130
+ "description": "Show the current git status — modified, staged, and untracked files.",
131
+ "parameters": {
132
+ "type": "object",
133
+ "properties": {},
134
+ "required": [],
135
+ },
136
+ },
137
+ {
138
+ "name": "git_diff",
139
+ "description": "Show git diff of unstaged changes, or diff between two refs.",
140
+ "parameters": {
141
+ "type": "object",
142
+ "properties": {
143
+ "ref": {
144
+ "type": "string",
145
+ "description": "Optional ref to diff against (e.g., 'HEAD', 'main', 'HEAD~3'). Defaults to unstaged changes.",
146
+ },
147
+ "staged": {
148
+ "type": "string",
149
+ "description": "Set to 'true' to show staged changes instead.",
150
+ },
151
+ },
152
+ "required": [],
153
+ },
154
+ },
155
+ {
156
+ "name": "git_log",
157
+ "description": "Show recent git commit history.",
158
+ "parameters": {
159
+ "type": "object",
160
+ "properties": {
161
+ "count": {
162
+ "type": "string",
163
+ "description": "Number of commits to show (default: 10)",
164
+ },
165
+ },
166
+ "required": [],
167
+ },
168
+ },
169
+ {
170
+ "name": "git_commit",
171
+ "description": "Stage files and create a git commit.",
172
+ "parameters": {
173
+ "type": "object",
174
+ "properties": {
175
+ "message": {
176
+ "type": "string",
177
+ "description": "Commit message",
178
+ },
179
+ "files": {
180
+ "type": "string",
181
+ "description": "Space-separated file paths to stage, or '.' for all changes",
182
+ },
183
+ },
184
+ "required": ["message"],
185
+ },
186
+ },
187
+ {
188
+ "name": "git_branch",
189
+ "description": "List branches, create a new branch, or switch branches.",
190
+ "parameters": {
191
+ "type": "object",
192
+ "properties": {
193
+ "action": {
194
+ "type": "string",
195
+ "description": "'list', 'create <name>', or 'switch <name>'",
196
+ },
197
+ },
198
+ "required": ["action"],
199
+ },
200
+ },
201
+ {
202
+ "name": "create_plan",
203
+ "description": "Create a step-by-step plan for complex tasks. Use this BEFORE executing when the task involves multiple files, multiple steps, or building something new. Each step should be a concrete action you will take.",
204
+ "parameters": {
205
+ "type": "object",
206
+ "properties": {
207
+ "title": {
208
+ "type": "string",
209
+ "description": "Short title for the plan",
210
+ },
211
+ "steps": {
212
+ "type": "array",
213
+ "items": {
214
+ "type": "object",
215
+ "properties": {
216
+ "title": {
217
+ "type": "string",
218
+ "description": "Short title for this step",
219
+ },
220
+ "description": {
221
+ "type": "string",
222
+ "description": "What you will do in this step",
223
+ },
224
+ },
225
+ "required": ["title", "description"],
226
+ },
227
+ "description": "List of steps to execute",
228
+ },
229
+ },
230
+ "required": ["title", "steps"],
231
+ },
232
+ },
233
+ ]
234
+
235
+ _edit_history: list[dict] = []
236
+ _background_processes: list[subprocess.Popen] = []
237
+
238
+
239
+ def execute_tool(name: str, args: dict, auto_approve_reads: bool = True, auto_approve_writes: bool = False) -> str:
240
+ read_tools = {"read_file", "list_directory", "search_files", "find_files", "git_status", "git_diff", "git_log"}
241
+ write_tools = {"write_file", "edit_file", "run_command", "git_commit", "git_branch"}
242
+
243
+ if name in write_tools and not auto_approve_writes:
244
+ if not print_approval_prompt(name, args):
245
+ return "User denied this action."
246
+
247
+ try:
248
+ if name == "read_file":
249
+ return _read_file(args["path"])
250
+ elif name == "write_file":
251
+ return _write_file(args["path"], args["content"])
252
+ elif name == "edit_file":
253
+ return _edit_file(args["path"], args["old_text"], args["new_text"])
254
+ elif name == "run_command":
255
+ return _run_command(args["command"])
256
+ elif name == "list_directory":
257
+ return _list_directory(args.get("path", "."))
258
+ elif name == "search_files":
259
+ return _search_files(args["pattern"], args.get("path", "."))
260
+ elif name == "find_files":
261
+ return _find_files(args["pattern"])
262
+ elif name == "git_status":
263
+ return _git_status()
264
+ elif name == "git_diff":
265
+ return _git_diff(args.get("ref"), args.get("staged"))
266
+ elif name == "git_log":
267
+ return _git_log(args.get("count", "10"))
268
+ elif name == "git_commit":
269
+ return _git_commit(args["message"], args.get("files"))
270
+ elif name == "git_branch":
271
+ return _git_branch(args["action"])
272
+ else:
273
+ return f"Unknown tool: {name}"
274
+ except Exception as e:
275
+ return f"Error: {e}"
276
+
277
+
278
+ def get_last_edit() -> dict | None:
279
+ return _edit_history[-1] if _edit_history else None
280
+
281
+
282
+ def _read_file(path: str) -> str:
283
+ p = Path(path)
284
+ if not p.exists():
285
+ return f"File not found: {path}"
286
+ if p.stat().st_size > 1_000_000:
287
+ return f"File too large: {p.stat().st_size} bytes. Read a specific section instead."
288
+ content = p.read_text(errors="replace")
289
+ lines = content.split("\n")
290
+ numbered = [f"{i + 1:4d} | {line}" for i, line in enumerate(lines)]
291
+ return "\n".join(numbered)
292
+
293
+
294
+ def _write_file(path: str, content: str) -> str:
295
+ p = Path(path)
296
+ p.parent.mkdir(parents=True, exist_ok=True)
297
+ p.write_text(content)
298
+ return f"Written {len(content)} bytes to {path}"
299
+
300
+
301
+ def _edit_file(path: str, old_text: str, new_text: str) -> str:
302
+ p = Path(path)
303
+ if not p.exists():
304
+ return f"File not found: {path}"
305
+ content = p.read_text()
306
+ count = content.count(old_text)
307
+ if count == 0:
308
+ return "old_text not found in file. Make sure it matches exactly."
309
+ if count > 1:
310
+ return f"old_text found {count} times. Provide a more specific match."
311
+
312
+ start_pos = content.index(old_text)
313
+ start_line = content[:start_pos].count("\n") + 1
314
+
315
+ _edit_history.append({"path": path, "old_content": content})
316
+ new_content = content.replace(old_text, new_text, 1)
317
+ p.write_text(new_content)
318
+ diff_lines = _make_diff(old_text, new_text, path, start_line)
319
+ return f"Edited {path} successfully.\n\nDiff:\n{diff_lines}"
320
+
321
+
322
+ def _make_diff(old_text: str, new_text: str, path: str, start_line: int = 1) -> str:
323
+ old_lines = old_text.splitlines(keepends=True)
324
+ new_lines = new_text.splitlines(keepends=True)
325
+ lines = []
326
+ ln = start_line
327
+ for line in old_lines:
328
+ lines.append(f"{ln:4d} - {line.rstrip()}")
329
+ ln += 1
330
+ ln = start_line
331
+ for line in new_lines:
332
+ lines.append(f"{ln:4d} + {line.rstrip()}")
333
+ ln += 1
334
+ return "\n".join(lines)
335
+
336
+
337
+ def _run_command(command: str) -> str:
338
+ try:
339
+ proc = subprocess.Popen(
340
+ command,
341
+ shell=True,
342
+ stdout=subprocess.PIPE,
343
+ stderr=subprocess.PIPE,
344
+ text=True,
345
+ cwd=os.getcwd(),
346
+ start_new_session=True,
347
+ )
348
+
349
+ captured_stdout = []
350
+ captured_stderr = []
351
+
352
+ def _read_stream(stream, buf):
353
+ try:
354
+ for line in stream:
355
+ buf.append(line)
356
+ except Exception:
357
+ pass
358
+
359
+ t_out = threading.Thread(target=_read_stream, args=(proc.stdout, captured_stdout), daemon=True)
360
+ t_err = threading.Thread(target=_read_stream, args=(proc.stderr, captured_stderr), daemon=True)
361
+ t_out.start()
362
+ t_err.start()
363
+
364
+ t_out.join(timeout=30)
365
+ t_err.join(timeout=0.5)
366
+
367
+ poll = proc.poll()
368
+ if poll is not None:
369
+ t_out.join(timeout=2)
370
+ t_err.join(timeout=1)
371
+ output = ""
372
+ if captured_stdout:
373
+ output += "".join(captured_stdout)
374
+ if captured_stderr:
375
+ output += f"\nSTDERR:\n{''.join(captured_stderr)}"
376
+ if poll != 0:
377
+ output += f"\nExit code: {poll}"
378
+ return output.strip() or "(no output)"
379
+
380
+ _background_processes.append(proc)
381
+ output = f"Process still running after 30s — continuing in background (PID {proc.pid}).\n"
382
+ initial_out = "".join(captured_stdout).strip()
383
+ initial_err = "".join(captured_stderr).strip()
384
+ if initial_out:
385
+ lines = initial_out.splitlines()
386
+ preview = "\n".join(lines[:15])
387
+ if len(lines) > 15:
388
+ preview += f"\n... ({len(lines)} lines total)"
389
+ output += f"\nOutput so far:\n{preview}"
390
+ if initial_err:
391
+ lines = initial_err.splitlines()
392
+ preview = "\n".join(lines[:10])
393
+ output += f"\nStderr so far:\n{preview}"
394
+
395
+ try:
396
+ proc.stdout.close()
397
+ except Exception:
398
+ pass
399
+ try:
400
+ proc.stderr.close()
401
+ except Exception:
402
+ pass
403
+
404
+ return output
405
+
406
+ except Exception as e:
407
+ return f"Error running command: {e}"
408
+
409
+
410
+ def cleanup_background_processes():
411
+ for proc in _background_processes:
412
+ try:
413
+ if proc.poll() is None:
414
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
415
+ except Exception:
416
+ pass
417
+ _background_processes.clear()
418
+
419
+
420
+ def _list_directory(path: str) -> str:
421
+ p = Path(path)
422
+ if not p.exists():
423
+ return f"Directory not found: {path}"
424
+ if not p.is_dir():
425
+ return f"Not a directory: {path}"
426
+ entries = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
427
+ lines = []
428
+ for entry in entries[:100]:
429
+ prefix = "📁 " if entry.is_dir() else " "
430
+ lines.append(f"{prefix}{entry.name}")
431
+ if len(list(p.iterdir())) > 100:
432
+ lines.append(f" ... and {len(list(p.iterdir())) - 100} more")
433
+ return "\n".join(lines) or "(empty directory)"
434
+
435
+
436
+ def _search_files(pattern: str, path: str) -> str:
437
+ try:
438
+ result = subprocess.run(
439
+ ["grep", "-rn", "--include=*", "-l", pattern, path],
440
+ capture_output=True,
441
+ text=True,
442
+ timeout=15,
443
+ )
444
+ if not result.stdout.strip():
445
+ return f"No matches found for '{pattern}'"
446
+
447
+ files = result.stdout.strip().split("\n")[:20]
448
+ output_parts = []
449
+ for f in files:
450
+ grep_result = subprocess.run(
451
+ ["grep", "-n", pattern, f],
452
+ capture_output=True,
453
+ text=True,
454
+ timeout=5,
455
+ )
456
+ matches = grep_result.stdout.strip().split("\n")[:5]
457
+ output_parts.append(f"\n{f}:")
458
+ output_parts.extend(f" {m}" for m in matches)
459
+
460
+ return "\n".join(output_parts)
461
+ except subprocess.TimeoutExpired:
462
+ return "Search timed out."
463
+
464
+
465
+ def _find_files(pattern: str) -> str:
466
+ matches = sorted(Path(".").glob(pattern))[:50]
467
+ if not matches:
468
+ return f"No files matching '{pattern}'"
469
+ return "\n".join(str(m) for m in matches)
470
+
471
+
472
+ def _git_status() -> str:
473
+ result = subprocess.run(
474
+ ["git", "status", "--short"],
475
+ capture_output=True, text=True, timeout=10, cwd=os.getcwd(),
476
+ )
477
+ if result.returncode != 0:
478
+ return f"Not a git repository or git error: {result.stderr.strip()}"
479
+ branch = subprocess.run(
480
+ ["git", "branch", "--show-current"],
481
+ capture_output=True, text=True, timeout=5, cwd=os.getcwd(),
482
+ )
483
+ output = f"Branch: {branch.stdout.strip()}\n\n"
484
+ output += result.stdout.strip() or "(working tree clean)"
485
+ return output
486
+
487
+
488
+ def _git_diff(ref: str | None = None, staged: str | None = None) -> str:
489
+ cmd = ["git", "diff"]
490
+ if staged == "true":
491
+ cmd.append("--staged")
492
+ elif ref:
493
+ cmd.append(ref)
494
+ result = subprocess.run(
495
+ cmd, capture_output=True, text=True, timeout=15, cwd=os.getcwd(),
496
+ )
497
+ output = result.stdout.strip()
498
+ if not output:
499
+ return "No changes to show."
500
+ if len(output) > 5000:
501
+ output = output[:5000] + "\n\n... (diff truncated, too large)"
502
+ return output
503
+
504
+
505
+ def _git_log(count: str = "10") -> str:
506
+ try:
507
+ n = min(int(count), 50)
508
+ except ValueError:
509
+ n = 10
510
+ result = subprocess.run(
511
+ ["git", "log", f"-{n}", "--oneline", "--decorate"],
512
+ capture_output=True, text=True, timeout=10, cwd=os.getcwd(),
513
+ )
514
+ return result.stdout.strip() or "No commits yet."
515
+
516
+
517
+ def _git_commit(message: str, files: str | None = None) -> str:
518
+ if files:
519
+ file_list = files.split()
520
+ add_result = subprocess.run(
521
+ ["git", "add"] + file_list,
522
+ capture_output=True, text=True, timeout=10, cwd=os.getcwd(),
523
+ )
524
+ if add_result.returncode != 0:
525
+ return f"Failed to stage files: {add_result.stderr.strip()}"
526
+ else:
527
+ add_result = subprocess.run(
528
+ ["git", "add", "-A"],
529
+ capture_output=True, text=True, timeout=10, cwd=os.getcwd(),
530
+ )
531
+
532
+ result = subprocess.run(
533
+ ["git", "commit", "-m", message],
534
+ capture_output=True, text=True, timeout=15, cwd=os.getcwd(),
535
+ )
536
+ if result.returncode != 0:
537
+ return f"Commit failed: {result.stderr.strip()}"
538
+ return result.stdout.strip()
539
+
540
+
541
+ def _git_branch(action: str) -> str:
542
+ parts = action.strip().split(maxsplit=1)
543
+ cmd = parts[0].lower()
544
+
545
+ if cmd == "list":
546
+ result = subprocess.run(
547
+ ["git", "branch", "-a"],
548
+ capture_output=True, text=True, timeout=10, cwd=os.getcwd(),
549
+ )
550
+ return result.stdout.strip() or "No branches."
551
+
552
+ if len(parts) < 2:
553
+ return "Usage: 'list', 'create <name>', or 'switch <name>'"
554
+
555
+ branch_name = parts[1]
556
+ if cmd == "create":
557
+ result = subprocess.run(
558
+ ["git", "checkout", "-b", branch_name],
559
+ capture_output=True, text=True, timeout=10, cwd=os.getcwd(),
560
+ )
561
+ elif cmd == "switch":
562
+ result = subprocess.run(
563
+ ["git", "checkout", branch_name],
564
+ capture_output=True, text=True, timeout=10, cwd=os.getcwd(),
565
+ )
566
+ else:
567
+ return f"Unknown git branch action: {cmd}. Use 'list', 'create', or 'switch'."
568
+
569
+ if result.returncode != 0:
570
+ return f"Error: {result.stderr.strip()}"
571
+ return result.stdout.strip() or result.stderr.strip()
gemi/compaction.py ADDED
@@ -0,0 +1,67 @@
1
+ from gemi.providers.base import Message
2
+
3
+ CHARS_PER_TOKEN = 4
4
+
5
+
6
+ def estimate_tokens(messages: list[Message]) -> int:
7
+ total_chars = sum(len(m.content or "") for m in messages)
8
+ return total_chars // CHARS_PER_TOKEN
9
+
10
+
11
+ def needs_compaction(messages: list[Message], max_tokens: int) -> bool:
12
+ used = estimate_tokens(messages)
13
+ return used > int(max_tokens * 0.75)
14
+
15
+
16
+ def compact_messages(messages: list[Message], max_tokens: int) -> list[Message]:
17
+ system_msg = messages[0] if messages and messages[0].role == "system" else None
18
+ conversation = messages[1:] if system_msg else messages[:]
19
+
20
+ if len(conversation) < 6:
21
+ return messages
22
+
23
+ keep_recent = max(4, len(conversation) // 3)
24
+ old_messages = conversation[:-keep_recent]
25
+ recent_messages = conversation[-keep_recent:]
26
+
27
+ summary_parts = []
28
+ for msg in old_messages:
29
+ if msg.role == "user":
30
+ summary_parts.append(f"- User asked: {_truncate(msg.content, 100)}")
31
+ elif msg.role == "assistant" and msg.content:
32
+ summary_parts.append(f"- Assistant: {_truncate(msg.content, 100)}")
33
+ elif msg.role == "tool":
34
+ summary_parts.append(f"- Tool ({msg.name}): {_truncate(msg.content, 60)}")
35
+
36
+ summary_text = (
37
+ "[Earlier conversation summary]\n"
38
+ + "\n".join(summary_parts[-20:])
39
+ + "\n[End of summary — conversation continues below]"
40
+ )
41
+
42
+ summary_msg = Message(role="user", content=summary_text)
43
+ result = ([system_msg] if system_msg else []) + [summary_msg] + recent_messages
44
+
45
+ if estimate_tokens(result) > max_tokens * 0.8:
46
+ return _aggressive_compact(result, max_tokens, system_msg)
47
+
48
+ return result
49
+
50
+
51
+ def _aggressive_compact(messages: list[Message], max_tokens: int, system_msg: Message | None) -> list[Message]:
52
+ conversation = messages[1:] if system_msg else messages[:]
53
+
54
+ for msg in conversation:
55
+ if msg.role == "tool" and msg.content and len(msg.content) > 500:
56
+ msg.content = msg.content[:500] + "\n... (truncated)"
57
+
58
+ return ([system_msg] if system_msg else []) + conversation
59
+
60
+
61
+ def _truncate(text: str | None, length: int) -> str:
62
+ if not text:
63
+ return ""
64
+ text = text.strip().replace("\n", " ")
65
+ if len(text) <= length:
66
+ return text
67
+ return text[:length] + "..."
gemi/config.py ADDED
@@ -0,0 +1,53 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+ GEMI_DIR = Path.home() / ".gemi"
6
+ CONFIG_PATH = GEMI_DIR / "config.yaml"
7
+
8
+ DEFAULT_CONFIG = {
9
+ "default_provider": "gemini",
10
+ "default_model": "gemini-2.5-flash",
11
+ "rotation": {
12
+ "strategy": "failover",
13
+ "auto_switch_provider": True,
14
+ "provider_priority": [
15
+ "gemini", "groq", "deepseek", "openrouter",
16
+ "cerebras", "mistral", "together", "openai", "ollama",
17
+ ],
18
+ },
19
+ "agent": {
20
+ "max_iterations": 50,
21
+ "auto_approve_reads": True,
22
+ "auto_approve_writes": False,
23
+ },
24
+ }
25
+
26
+
27
+ def ensure_gemi_dir():
28
+ GEMI_DIR.mkdir(parents=True, exist_ok=True)
29
+
30
+
31
+ def load_config() -> dict:
32
+ ensure_gemi_dir()
33
+ if CONFIG_PATH.exists():
34
+ with open(CONFIG_PATH) as f:
35
+ user_config = yaml.safe_load(f) or {}
36
+ return _deep_merge(DEFAULT_CONFIG, user_config)
37
+ return DEFAULT_CONFIG.copy()
38
+
39
+
40
+ def save_config(config: dict):
41
+ ensure_gemi_dir()
42
+ with open(CONFIG_PATH, "w") as f:
43
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
44
+
45
+
46
+ def _deep_merge(base: dict, override: dict) -> dict:
47
+ result = base.copy()
48
+ for key, value in override.items():
49
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
50
+ result[key] = _deep_merge(result[key], value)
51
+ else:
52
+ result[key] = value
53
+ return result
gemi/keys/__init__.py ADDED
File without changes