minion-code 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.
Files changed (59) hide show
  1. examples/advance_tui.py +508 -0
  2. examples/agent_with_todos.py +165 -0
  3. examples/file_freshness_example.py +97 -0
  4. examples/file_watching_example.py +110 -0
  5. examples/interruptible_tui.py +5 -0
  6. examples/message_response_children_demo.py +226 -0
  7. examples/rich_example.py +4 -0
  8. examples/simple_file_watching.py +57 -0
  9. examples/simple_tui.py +267 -0
  10. examples/simple_usage.py +69 -0
  11. minion_code/__init__.py +16 -0
  12. minion_code/agents/__init__.py +11 -0
  13. minion_code/agents/code_agent.py +320 -0
  14. minion_code/cli.py +502 -0
  15. minion_code/commands/__init__.py +90 -0
  16. minion_code/commands/clear_command.py +70 -0
  17. minion_code/commands/help_command.py +90 -0
  18. minion_code/commands/history_command.py +104 -0
  19. minion_code/commands/quit_command.py +32 -0
  20. minion_code/commands/status_command.py +115 -0
  21. minion_code/commands/tools_command.py +86 -0
  22. minion_code/commands/version_command.py +104 -0
  23. minion_code/components/Message.py +304 -0
  24. minion_code/components/MessageResponse.py +188 -0
  25. minion_code/components/PromptInput.py +534 -0
  26. minion_code/components/__init__.py +29 -0
  27. minion_code/screens/REPL.py +925 -0
  28. minion_code/screens/__init__.py +4 -0
  29. minion_code/services/__init__.py +50 -0
  30. minion_code/services/event_system.py +108 -0
  31. minion_code/services/file_freshness_service.py +582 -0
  32. minion_code/tools/__init__.py +69 -0
  33. minion_code/tools/bash_tool.py +58 -0
  34. minion_code/tools/file_edit_tool.py +238 -0
  35. minion_code/tools/file_read_tool.py +73 -0
  36. minion_code/tools/file_write_tool.py +36 -0
  37. minion_code/tools/glob_tool.py +58 -0
  38. minion_code/tools/grep_tool.py +105 -0
  39. minion_code/tools/ls_tool.py +65 -0
  40. minion_code/tools/multi_edit_tool.py +271 -0
  41. minion_code/tools/python_interpreter_tool.py +105 -0
  42. minion_code/tools/todo_read_tool.py +100 -0
  43. minion_code/tools/todo_write_tool.py +234 -0
  44. minion_code/tools/user_input_tool.py +53 -0
  45. minion_code/types.py +88 -0
  46. minion_code/utils/__init__.py +44 -0
  47. minion_code/utils/mcp_loader.py +211 -0
  48. minion_code/utils/todo_file_utils.py +110 -0
  49. minion_code/utils/todo_storage.py +149 -0
  50. minion_code-0.1.0.dist-info/METADATA +350 -0
  51. minion_code-0.1.0.dist-info/RECORD +59 -0
  52. minion_code-0.1.0.dist-info/WHEEL +5 -0
  53. minion_code-0.1.0.dist-info/entry_points.txt +4 -0
  54. minion_code-0.1.0.dist-info/licenses/LICENSE +661 -0
  55. minion_code-0.1.0.dist-info/top_level.txt +3 -0
  56. tests/__init__.py +1 -0
  57. tests/test_basic.py +20 -0
  58. tests/test_readonly_tools.py +102 -0
  59. tests/test_tools.py +83 -0
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Multi-edit tool based on TypeScript MultiEditTool implementation.
4
+ """
5
+
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Dict, Any, Optional, List
10
+ from minion.tools import BaseTool
11
+ from minion_code.services import record_file_read, record_file_edit, check_file_freshness
12
+
13
+
14
+ class MultiEditTool(BaseTool):
15
+ """
16
+ A tool for making multiple edits to a single file atomically.
17
+ Based on the TypeScript MultiEditTool implementation.
18
+ """
19
+
20
+ name = "multi_edit"
21
+ description = "A tool for making multiple edits to a single file in one operation"
22
+ readonly = False
23
+
24
+ inputs = {
25
+ "file_path": {
26
+ "type": "string",
27
+ "description": "The absolute path to the file to modify"
28
+ },
29
+ "edits": {
30
+ "type": "array",
31
+ "description": "Array of edit operations to perform sequentially",
32
+ "items": {
33
+ "type": "object",
34
+ "properties": {
35
+ "old_string": {
36
+ "type": "string",
37
+ "description": "The text to replace"
38
+ },
39
+ "new_string": {
40
+ "type": "string",
41
+ "description": "The text to replace it with"
42
+ },
43
+ "replace_all": {
44
+ "type": "boolean",
45
+ "description": "Replace all occurrences of old_string (default: false)"
46
+ }
47
+ },
48
+ "required": ["old_string", "new_string"]
49
+ }
50
+ }
51
+ }
52
+ output_type = "string"
53
+
54
+ def forward(self, file_path: str, edits: List[Dict[str, Any]]) -> str:
55
+ """Execute multi-edit operation."""
56
+ try:
57
+ # Validate inputs
58
+ validation_result = self._validate_input(file_path, edits)
59
+ if not validation_result["valid"]:
60
+ return f"Error: {validation_result['message']}"
61
+
62
+ # Apply all edits atomically
63
+ result = self._apply_multi_edit(file_path, edits)
64
+ return result
65
+
66
+ except Exception as e:
67
+ return f"Error during multi-edit: {str(e)}"
68
+
69
+ def _validate_input(self, file_path: str, edits: List[Dict[str, Any]]) -> Dict[str, Any]:
70
+ """Validate input parameters."""
71
+
72
+ # Check if we have edits
73
+ if not edits or len(edits) == 0:
74
+ return {
75
+ "valid": False,
76
+ "message": "At least one edit operation is required."
77
+ }
78
+
79
+ # Resolve absolute path
80
+ if not os.path.isabs(file_path):
81
+ file_path = os.path.abspath(file_path)
82
+
83
+ # Check if it's a Jupyter notebook
84
+ if file_path.endswith('.ipynb'):
85
+ return {
86
+ "valid": False,
87
+ "message": "File is a Jupyter Notebook. Use NotebookEdit tool instead."
88
+ }
89
+
90
+ # Handle new file creation
91
+ if not os.path.exists(file_path):
92
+ # For new files, ensure parent directory can be created
93
+ parent_dir = os.path.dirname(file_path)
94
+ if parent_dir and not os.path.exists(parent_dir):
95
+ try:
96
+ os.makedirs(parent_dir, exist_ok=True)
97
+ except Exception as e:
98
+ return {
99
+ "valid": False,
100
+ "message": f"Cannot create parent directory: {str(e)}"
101
+ }
102
+
103
+ # For new files, first edit must create the file (empty old_string)
104
+ if len(edits) == 0 or edits[0].get("old_string", "") != "":
105
+ return {
106
+ "valid": False,
107
+ "message": "For new files, the first edit must have an empty old_string to create the file content."
108
+ }
109
+ else:
110
+ # For existing files, check freshness
111
+ try:
112
+ freshness_result = check_file_freshness(file_path)
113
+ if freshness_result.conflict:
114
+ return {
115
+ "valid": False,
116
+ "message": "File has been modified since last read. Read it again before editing."
117
+ }
118
+ except Exception:
119
+ # If freshness checking fails, continue with basic validation
120
+ pass
121
+
122
+ # Check if file is binary
123
+ if self._is_binary_file(file_path):
124
+ return {
125
+ "valid": False,
126
+ "message": "Cannot edit binary files."
127
+ }
128
+
129
+ # Pre-validate that all old_strings exist in the file
130
+ try:
131
+ with open(file_path, 'r', encoding='utf-8') as f:
132
+ current_content = f.read()
133
+
134
+ for i, edit in enumerate(edits):
135
+ old_string = edit.get("old_string", "")
136
+ if old_string != "" and old_string not in current_content:
137
+ return {
138
+ "valid": False,
139
+ "message": f"Edit {i + 1}: String to replace not found in file: \"{old_string[:100]}{'...' if len(old_string) > 100 else ''}\""
140
+ }
141
+
142
+ except UnicodeDecodeError:
143
+ return {
144
+ "valid": False,
145
+ "message": "Cannot read file - appears to be binary or has encoding issues."
146
+ }
147
+
148
+ # Validate each edit
149
+ for i, edit in enumerate(edits):
150
+ old_string = edit.get("old_string", "")
151
+ new_string = edit.get("new_string", "")
152
+
153
+ if old_string == new_string:
154
+ return {
155
+ "valid": False,
156
+ "message": f"Edit {i + 1}: old_string and new_string cannot be the same"
157
+ }
158
+
159
+ return {"valid": True}
160
+
161
+ def _apply_multi_edit(self, file_path: str, edits: List[Dict[str, Any]]) -> str:
162
+ """Apply all edits to the file atomically."""
163
+
164
+ # Resolve absolute path
165
+ if not os.path.isabs(file_path):
166
+ file_path = os.path.abspath(file_path)
167
+
168
+ # Read current file content (or empty for new files)
169
+ file_exists = os.path.exists(file_path)
170
+
171
+ if file_exists:
172
+ try:
173
+ with open(file_path, 'r', encoding='utf-8') as f:
174
+ current_content = f.read()
175
+ except UnicodeDecodeError:
176
+ return "Error: Cannot read file - appears to be binary or has encoding issues."
177
+ else:
178
+ current_content = ""
179
+ # Ensure parent directory exists
180
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
181
+
182
+ # Apply all edits sequentially
183
+ modified_content = current_content
184
+ applied_edits = []
185
+
186
+ for i, edit in enumerate(edits):
187
+ old_string = edit.get("old_string", "")
188
+ new_string = edit.get("new_string", "")
189
+ replace_all = edit.get("replace_all", False)
190
+
191
+ try:
192
+ result = self._apply_content_edit(
193
+ modified_content, old_string, new_string, replace_all
194
+ )
195
+ modified_content = result["new_content"]
196
+ applied_edits.append({
197
+ "edit_index": i + 1,
198
+ "success": True,
199
+ "old_string": old_string[:100] + ("..." if len(old_string) > 100 else ""),
200
+ "new_string": new_string[:100] + ("..." if len(new_string) > 100 else ""),
201
+ "occurrences": result["occurrences"]
202
+ })
203
+
204
+ except Exception as e:
205
+ # If any edit fails, abort the entire operation
206
+ error_message = str(e)
207
+ return f"Error in edit {i + 1}: {error_message}"
208
+
209
+ # Write the modified content
210
+ try:
211
+ with open(file_path, 'w', encoding='utf-8') as f:
212
+ f.write(modified_content)
213
+ except Exception as e:
214
+ return f"Error writing file: {str(e)}"
215
+
216
+ # Record the file edit
217
+ record_file_edit(file_path, modified_content)
218
+
219
+ # Generate result summary
220
+ operation = "create" if not file_exists else "update"
221
+ summary = f"Successfully applied {len(edits)} edits to {file_path}"
222
+
223
+ # Add details about each edit
224
+ details = []
225
+ for edit_info in applied_edits:
226
+ details.append(
227
+ f"Edit {edit_info['edit_index']}: Replaced {edit_info['occurrences']} occurrence(s)"
228
+ )
229
+
230
+ if details:
231
+ summary += "\n" + "\n".join(details)
232
+
233
+ return summary
234
+
235
+ def _apply_content_edit(self, content: str, old_string: str, new_string: str,
236
+ replace_all: bool = False) -> Dict[str, Any]:
237
+ """Apply a single content edit."""
238
+
239
+ if replace_all:
240
+ # Replace all occurrences
241
+ import re
242
+ # Escape special regex characters in old_string
243
+ escaped_old = re.escape(old_string)
244
+ pattern = re.compile(escaped_old)
245
+ matches = pattern.findall(content)
246
+ occurrences = len(matches)
247
+ new_content = pattern.sub(new_string, content)
248
+
249
+ return {
250
+ "new_content": new_content,
251
+ "occurrences": occurrences
252
+ }
253
+ else:
254
+ # Replace single occurrence
255
+ if old_string in content:
256
+ new_content = content.replace(old_string, new_string, 1) # Replace only first occurrence
257
+ return {
258
+ "new_content": new_content,
259
+ "occurrences": 1
260
+ }
261
+ else:
262
+ raise Exception(f"String not found: {old_string[:50]}...")
263
+
264
+ def _is_binary_file(self, file_path: str) -> bool:
265
+ """Check if file is binary."""
266
+ try:
267
+ with open(file_path, 'rb') as f:
268
+ chunk = f.read(1024)
269
+ return b'\0' in chunk
270
+ except Exception:
271
+ return False
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Python code execution tool
5
+ """
6
+
7
+ import io
8
+ import sys
9
+ from contextlib import redirect_stdout, redirect_stderr
10
+ from minion.tools import BaseTool
11
+
12
+
13
+ class PythonInterpreterTool(BaseTool):
14
+ """Python code execution tool"""
15
+
16
+ name = "python_interpreter"
17
+ description = "Execute Python code"
18
+ readonly = False # Code execution may modify system state
19
+ inputs = {"code": {"type": "string", "description": "Python code to execute"}}
20
+ output_type = "string"
21
+
22
+ def __init__(self, authorized_imports=None):
23
+ super().__init__()
24
+ if authorized_imports is None:
25
+ self.authorized_imports = [
26
+ "math",
27
+ "random",
28
+ "datetime",
29
+ "json",
30
+ "re",
31
+ "os",
32
+ "sys",
33
+ "collections",
34
+ "itertools",
35
+ "functools",
36
+ "operator",
37
+ ]
38
+ else:
39
+ self.authorized_imports = list(
40
+ set(["math", "random", "datetime", "json", "re", "os", "sys"])
41
+ | set(authorized_imports)
42
+ )
43
+
44
+ def forward(self, code: str) -> str:
45
+ """Execute Python code"""
46
+ # Create restricted global environment
47
+ restricted_globals = {
48
+ "__builtins__": {
49
+ "print": print,
50
+ "len": len,
51
+ "str": str,
52
+ "int": int,
53
+ "float": float,
54
+ "bool": bool,
55
+ "list": list,
56
+ "dict": dict,
57
+ "tuple": tuple,
58
+ "set": set,
59
+ "range": range,
60
+ "enumerate": enumerate,
61
+ "zip": zip,
62
+ "sum": sum,
63
+ "max": max,
64
+ "min": min,
65
+ "abs": abs,
66
+ "round": round,
67
+ "sorted": sorted,
68
+ "reversed": reversed,
69
+ "any": any,
70
+ "all": all,
71
+ "__import__": __import__, # Add __import__ function
72
+ }
73
+ }
74
+
75
+ # Add authorized imports
76
+ for module_name in self.authorized_imports:
77
+ try:
78
+ restricted_globals[module_name] = __import__(module_name)
79
+ except ImportError:
80
+ pass
81
+
82
+ # Capture output
83
+ stdout_capture = io.StringIO()
84
+ stderr_capture = io.StringIO()
85
+
86
+ try:
87
+ with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
88
+ exec(code, restricted_globals)
89
+
90
+ stdout_content = stdout_capture.getvalue()
91
+ stderr_content = stderr_capture.getvalue()
92
+
93
+ output_parts = []
94
+ if stdout_content:
95
+ output_parts.append(f"Standard output:\n{stdout_content}")
96
+ if stderr_content:
97
+ output_parts.append(f"Standard error:\n{stderr_content}")
98
+
99
+ if not output_parts:
100
+ output_parts.append("Code executed successfully, no output.")
101
+
102
+ return "\n".join(output_parts)
103
+
104
+ except Exception as e:
105
+ return f"Error executing code: {str(e)}"
@@ -0,0 +1,100 @@
1
+ """Todo Read Tool for viewing current todo items."""
2
+
3
+ from typing import Optional
4
+ from minion.tools import BaseTool
5
+ from minion.types import AgentState
6
+
7
+ from ..utils.todo_storage import get_todos, TodoStatus
8
+
9
+
10
+ def format_todos_display(todos):
11
+ """Format todos for display."""
12
+ if not todos:
13
+ return "No todos currently"
14
+
15
+ # Sort: [completed, in_progress, pending]
16
+ order = [TodoStatus.COMPLETED, TodoStatus.IN_PROGRESS, TodoStatus.PENDING]
17
+ sorted_todos = sorted(todos, key=lambda t: (order.index(t.status), t.content))
18
+
19
+ # Find the next pending task
20
+ next_pending_index = next(
21
+ (i for i, todo in enumerate(sorted_todos) if todo.status == TodoStatus.PENDING),
22
+ -1
23
+ )
24
+
25
+ lines = []
26
+ for i, todo in enumerate(sorted_todos):
27
+ # Determine checkbox and formatting
28
+ if todo.status == TodoStatus.COMPLETED:
29
+ checkbox = "☒"
30
+ prefix = " ⎿ "
31
+ content = f"~~{todo.content}~~" # Strikethrough for completed
32
+ elif todo.status == TodoStatus.IN_PROGRESS:
33
+ checkbox = "☐"
34
+ prefix = " ⎿ "
35
+ content = f"**{todo.content}**" # Bold for in progress
36
+ else: # pending
37
+ checkbox = "☐"
38
+ prefix = " ⎿ "
39
+ if i == next_pending_index:
40
+ content = f"**{todo.content}**" # Bold for next pending
41
+ else:
42
+ content = todo.content
43
+
44
+ lines.append(f"{prefix}{checkbox} {content}")
45
+
46
+ return "\n".join(lines)
47
+
48
+
49
+ class TodoReadTool(BaseTool):
50
+ """Tool for reading and displaying current todo items."""
51
+
52
+ name = "todo_read"
53
+ description = "View current todo items and their status."
54
+ readonly = True # Read-only tool, does not modify system state
55
+ needs_state = True # Tool needs agent state
56
+ inputs = {}
57
+ output_type = "string"
58
+
59
+ def _get_agent_id(self, state: AgentState) -> str:
60
+ """Get agent ID from agent state."""
61
+ # Try to get from metadata
62
+ return state.agent.agent_id
63
+
64
+ def forward(self, state: AgentState) -> str:
65
+ """Execute the todo read operation."""
66
+ try:
67
+ # Get agent ID from agent state
68
+ agent_id = self._get_agent_id(state)
69
+
70
+ # Get current todos
71
+ todos = get_todos(agent_id)
72
+
73
+ if not todos:
74
+ return "No todos currently"
75
+
76
+ # Generate statistics
77
+ stats = {
78
+ 'total': len(todos),
79
+ 'pending': len([t for t in todos if t.status == TodoStatus.PENDING]),
80
+ 'in_progress': len([t for t in todos if t.status == TodoStatus.IN_PROGRESS]),
81
+ 'completed': len([t for t in todos if t.status == TodoStatus.COMPLETED])
82
+ }
83
+
84
+ # Update agent metadata with current todo stats
85
+ state.metadata['current_todo_stats'] = stats
86
+
87
+ # Reset iteration counter since todo tool was used
88
+ state.metadata["iteration_without_todos"] = 0
89
+
90
+ # Format display
91
+ display = format_todos_display(todos)
92
+
93
+ # Generate summary
94
+ summary = f"Found {stats['total']} todo(s): {stats['pending']} pending, {stats['in_progress']} in progress, {stats['completed']} completed"
95
+
96
+ result = f"{summary}\n\n{display}"
97
+ return result
98
+
99
+ except Exception as e:
100
+ return f"Error reading todos: {str(e)}"