minion-code 0.1.0__py3-none-any.whl → 0.1.2__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 (115) hide show
  1. examples/cli_entrypoint.py +60 -0
  2. examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
  3. examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
  4. examples/components/messages_component.py +199 -0
  5. examples/file_freshness_example.py +22 -22
  6. examples/file_watching_example.py +32 -26
  7. examples/interruptible_tui.py +921 -3
  8. examples/repl_tui.py +129 -0
  9. examples/skills/example_usage.py +57 -0
  10. examples/start.py +173 -0
  11. minion_code/__init__.py +1 -1
  12. minion_code/acp_server/__init__.py +34 -0
  13. minion_code/acp_server/agent.py +539 -0
  14. minion_code/acp_server/hooks.py +354 -0
  15. minion_code/acp_server/main.py +194 -0
  16. minion_code/acp_server/permissions.py +142 -0
  17. minion_code/acp_server/test_client.py +104 -0
  18. minion_code/adapters/__init__.py +22 -0
  19. minion_code/adapters/output_adapter.py +207 -0
  20. minion_code/adapters/rich_adapter.py +169 -0
  21. minion_code/adapters/textual_adapter.py +254 -0
  22. minion_code/agents/__init__.py +2 -2
  23. minion_code/agents/code_agent.py +517 -104
  24. minion_code/agents/hooks.py +378 -0
  25. minion_code/cli.py +538 -429
  26. minion_code/cli_simple.py +665 -0
  27. minion_code/commands/__init__.py +136 -29
  28. minion_code/commands/clear_command.py +19 -46
  29. minion_code/commands/help_command.py +33 -49
  30. minion_code/commands/history_command.py +37 -55
  31. minion_code/commands/model_command.py +194 -0
  32. minion_code/commands/quit_command.py +9 -12
  33. minion_code/commands/resume_command.py +181 -0
  34. minion_code/commands/skill_command.py +89 -0
  35. minion_code/commands/status_command.py +48 -73
  36. minion_code/commands/tools_command.py +54 -52
  37. minion_code/commands/version_command.py +34 -69
  38. minion_code/components/ConfirmDialog.py +430 -0
  39. minion_code/components/Message.py +318 -97
  40. minion_code/components/MessageResponse.py +30 -29
  41. minion_code/components/Messages.py +351 -0
  42. minion_code/components/PromptInput.py +499 -245
  43. minion_code/components/__init__.py +24 -17
  44. minion_code/const.py +7 -0
  45. minion_code/screens/REPL.py +1453 -469
  46. minion_code/screens/__init__.py +1 -1
  47. minion_code/services/__init__.py +20 -20
  48. minion_code/services/event_system.py +19 -14
  49. minion_code/services/file_freshness_service.py +223 -170
  50. minion_code/skills/__init__.py +25 -0
  51. minion_code/skills/skill.py +128 -0
  52. minion_code/skills/skill_loader.py +198 -0
  53. minion_code/skills/skill_registry.py +177 -0
  54. minion_code/subagents/__init__.py +31 -0
  55. minion_code/subagents/builtin/__init__.py +30 -0
  56. minion_code/subagents/builtin/claude_code_guide.py +32 -0
  57. minion_code/subagents/builtin/explore.py +36 -0
  58. minion_code/subagents/builtin/general_purpose.py +19 -0
  59. minion_code/subagents/builtin/plan.py +61 -0
  60. minion_code/subagents/subagent.py +116 -0
  61. minion_code/subagents/subagent_loader.py +147 -0
  62. minion_code/subagents/subagent_registry.py +151 -0
  63. minion_code/tools/__init__.py +8 -2
  64. minion_code/tools/bash_tool.py +16 -3
  65. minion_code/tools/file_edit_tool.py +201 -104
  66. minion_code/tools/file_read_tool.py +183 -26
  67. minion_code/tools/file_write_tool.py +17 -3
  68. minion_code/tools/glob_tool.py +23 -2
  69. minion_code/tools/grep_tool.py +229 -21
  70. minion_code/tools/ls_tool.py +28 -3
  71. minion_code/tools/multi_edit_tool.py +89 -84
  72. minion_code/tools/python_interpreter_tool.py +9 -1
  73. minion_code/tools/skill_tool.py +210 -0
  74. minion_code/tools/task_tool.py +287 -0
  75. minion_code/tools/todo_read_tool.py +28 -24
  76. minion_code/tools/todo_write_tool.py +82 -65
  77. minion_code/{types.py → type_defs.py} +15 -2
  78. minion_code/utils/__init__.py +45 -17
  79. minion_code/utils/config.py +610 -0
  80. minion_code/utils/history.py +114 -0
  81. minion_code/utils/logs.py +53 -0
  82. minion_code/utils/mcp_loader.py +153 -55
  83. minion_code/utils/output_truncator.py +233 -0
  84. minion_code/utils/session_storage.py +369 -0
  85. minion_code/utils/todo_file_utils.py +26 -22
  86. minion_code/utils/todo_storage.py +43 -33
  87. minion_code/web/__init__.py +9 -0
  88. minion_code/web/adapters/__init__.py +5 -0
  89. minion_code/web/adapters/web_adapter.py +524 -0
  90. minion_code/web/api/__init__.py +7 -0
  91. minion_code/web/api/chat.py +277 -0
  92. minion_code/web/api/interactions.py +136 -0
  93. minion_code/web/api/sessions.py +135 -0
  94. minion_code/web/server.py +149 -0
  95. minion_code/web/services/__init__.py +5 -0
  96. minion_code/web/services/session_manager.py +420 -0
  97. minion_code-0.1.2.dist-info/METADATA +476 -0
  98. minion_code-0.1.2.dist-info/RECORD +111 -0
  99. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/WHEEL +1 -1
  100. minion_code-0.1.2.dist-info/entry_points.txt +6 -0
  101. tests/test_adapter.py +67 -0
  102. tests/test_adapter_simple.py +79 -0
  103. tests/test_file_read_tool.py +144 -0
  104. tests/test_readonly_tools.py +0 -2
  105. tests/test_skills.py +441 -0
  106. examples/advance_tui.py +0 -508
  107. examples/rich_example.py +0 -4
  108. examples/simple_file_watching.py +0 -57
  109. examples/simple_tui.py +0 -267
  110. examples/simple_usage.py +0 -69
  111. minion_code-0.1.0.dist-info/METADATA +0 -350
  112. minion_code-0.1.0.dist-info/RECORD +0 -59
  113. minion_code-0.1.0.dist-info/entry_points.txt +0 -4
  114. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/licenses/LICENSE +0 -0
  115. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/top_level.txt +0 -0
@@ -8,7 +8,11 @@ import time
8
8
  from pathlib import Path
9
9
  from typing import Dict, Any, Optional
10
10
  from minion.tools import BaseTool
11
- from minion_code.services import record_file_read, record_file_edit, check_file_freshness
11
+ from minion_code.services import (
12
+ record_file_read,
13
+ record_file_edit,
14
+ check_file_freshness,
15
+ )
12
16
 
13
17
 
14
18
  class FileEditTool(BaseTool):
@@ -16,27 +20,36 @@ class FileEditTool(BaseTool):
16
20
  A tool for editing files with string replacement.
17
21
  Based on the TypeScript FileEditTool implementation.
18
22
  """
19
-
23
+
20
24
  name = "file_edit"
21
- description = "A tool for editing files by replacing old_string with new_string with freshness tracking"
25
+ description = "A tool for editing files by replacing old_string with new_string with freshness tracking. For large strings (>2000 chars), consider using MultiEditTool or breaking into smaller edits."
22
26
  readonly = False
23
-
27
+
24
28
  inputs = {
25
29
  "file_path": {
26
30
  "type": "string",
27
- "description": "The absolute path to the file to modify"
31
+ "description": "The absolute path to the file to modify",
28
32
  },
29
33
  "old_string": {
30
- "type": "string",
31
- "description": "The text to replace (must be unique within the file)"
32
- },
33
- "new_string": {
34
34
  "type": "string",
35
- "description": "The text to replace it with"
36
- }
35
+ "description": "The text to replace (must be unique within the file)",
36
+ },
37
+ "new_string": {"type": "string", "description": "The text to replace it with"},
37
38
  }
38
39
  output_type = "string"
39
-
40
+
41
+ def __init__(self, workdir: Optional[str] = None, *args, **kwargs):
42
+ super().__init__(*args, **kwargs)
43
+ self.workdir = Path(workdir) if workdir else None
44
+
45
+ def _resolve_path(self, file_path: str) -> str:
46
+ """Resolve path using workdir if path is relative."""
47
+ if os.path.isabs(file_path):
48
+ return file_path
49
+ if self.workdir:
50
+ return str(self.workdir / file_path)
51
+ return os.path.abspath(file_path) # Fallback to cwd (backward compatible)
52
+
40
53
  def forward(self, file_path: str, old_string: str, new_string: str) -> str:
41
54
  """Execute file edit operation."""
42
55
  try:
@@ -44,195 +57,279 @@ class FileEditTool(BaseTool):
44
57
  validation_result = self._validate_input(file_path, old_string, new_string)
45
58
  if not validation_result["valid"]:
46
59
  return f"Error: {validation_result['message']}"
47
-
60
+
61
+ # Check for warnings about large strings
62
+ warning_message = ""
63
+ if "warning" in validation_result:
64
+ warning_message = f"⚠️ Warning: {validation_result['warning']}\n\n"
65
+
48
66
  # Apply the edit
49
67
  result = self._apply_edit(file_path, old_string, new_string)
68
+
69
+ # Prepend warning if present
70
+ if warning_message:
71
+ result = warning_message + result
72
+
50
73
  return result
51
-
74
+
52
75
  except Exception as e:
53
76
  return f"Error during file edit: {str(e)}"
54
-
55
- def _validate_input(self, file_path: str, old_string: str, new_string: str) -> Dict[str, Any]:
77
+
78
+ def _validate_input(
79
+ self, file_path: str, old_string: str, new_string: str
80
+ ) -> Dict[str, Any]:
56
81
  """Validate input parameters."""
57
-
82
+
58
83
  # Check if old_string and new_string are the same
59
84
  if old_string == new_string:
60
85
  return {
61
86
  "valid": False,
62
- "message": "No changes to make: old_string and new_string are exactly the same."
87
+ "message": "No changes to make: old_string and new_string are exactly the same.",
63
88
  }
64
-
65
- # Resolve absolute path
66
- if not os.path.isabs(file_path):
67
- file_path = os.path.abspath(file_path)
68
-
69
- # Handle new file creation
70
- if not os.path.exists(file_path) and old_string == "":
71
- return {"valid": True}
72
-
73
- # Check if file exists for existing file edits
74
- if not os.path.exists(file_path):
89
+
90
+ # Check for large strings and suggest better alternatives
91
+ large_string_threshold = 2000 # characters
92
+ very_large_threshold = 5000 # characters
93
+
94
+ old_string_size = len(old_string)
95
+ new_string_size = len(new_string)
96
+ max_size = max(old_string_size, new_string_size)
97
+
98
+ if max_size > very_large_threshold:
99
+ suggestions = self._suggest_alternatives_for_large_edit(
100
+ old_string, new_string
101
+ )
75
102
  return {
76
103
  "valid": False,
77
- "message": "File does not exist."
104
+ "message": f"String is very large ({max_size} characters). For better performance and reliability, "
105
+ f"large single edits should be avoided as they can be error-prone and difficult to debug.\n\n"
106
+ f"{suggestions}",
78
107
  }
79
-
108
+ elif max_size > large_string_threshold:
109
+ # Allow but warn
110
+ lines_count = max(old_string.count("\n"), new_string.count("\n"))
111
+ suggestions = self._suggest_alternatives_for_large_edit(
112
+ old_string, new_string
113
+ )
114
+ warning_msg = (
115
+ f"Large string detected ({max_size} characters, ~{lines_count} lines). "
116
+ )
117
+ if suggestions:
118
+ warning_msg += f"Consider these alternatives:\n{suggestions}"
119
+ else:
120
+ warning_msg += "Consider using MultiEditTool for multiple smaller edits or breaking this into smaller chunks for better reliability and easier debugging."
121
+
122
+ return {"valid": True, "warning": warning_msg}
123
+
124
+ # Resolve path using workdir if relative
125
+ resolved_path = self._resolve_path(file_path)
126
+
127
+ # Handle new file creation
128
+ if not os.path.exists(resolved_path) and old_string == "":
129
+ return {"valid": True}
130
+
131
+ # Check if file exists for existing file edits
132
+ if not os.path.exists(resolved_path):
133
+ return {"valid": False, "message": "File does not exist."}
134
+
80
135
  # Check if it's a Jupyter notebook
81
- if file_path.endswith('.ipynb'):
136
+ if resolved_path.endswith(".ipynb"):
82
137
  return {
83
138
  "valid": False,
84
- "message": "File is a Jupyter Notebook. Use NotebookEdit tool instead."
139
+ "message": "File is a Jupyter Notebook. Use NotebookEdit tool instead.",
85
140
  }
86
-
141
+
87
142
  # Check file freshness (if we have tracking)
88
143
  try:
89
- freshness_result = check_file_freshness(file_path)
144
+ freshness_result = check_file_freshness(resolved_path)
90
145
  if freshness_result.conflict:
91
146
  return {
92
147
  "valid": False,
93
- "message": "File has been modified since last read. Read it again before editing."
148
+ "message": "File has been modified since last read. Read it again before editing.",
94
149
  }
95
150
  except Exception:
96
151
  # If freshness checking fails, continue with basic validation
97
152
  pass
98
-
153
+
99
154
  # Check if file is binary
100
- if self._is_binary_file(file_path):
101
- return {
102
- "valid": False,
103
- "message": "Cannot edit binary files."
104
- }
105
-
155
+ if self._is_binary_file(resolved_path):
156
+ return {"valid": False, "message": "Cannot edit binary files."}
157
+
106
158
  # For existing files, validate old_string exists and is unique
107
159
  if old_string != "":
108
160
  try:
109
- with open(file_path, 'r', encoding='utf-8') as f:
161
+ with open(resolved_path, "r", encoding="utf-8") as f:
110
162
  content = f.read()
111
-
163
+
112
164
  if old_string not in content:
113
165
  return {
114
166
  "valid": False,
115
- "message": "String to replace not found in file."
167
+ "message": "String to replace not found in file.",
116
168
  }
117
-
169
+
118
170
  # Check for multiple matches
119
171
  matches = content.count(old_string)
120
172
  if matches > 1:
121
173
  return {
122
174
  "valid": False,
123
175
  "message": f"Found {matches} matches of the string to replace. "
124
- "For safety, this tool only supports replacing exactly one occurrence at a time. "
125
- "Add more lines of context to your edit and try again."
176
+ "For safety, this tool only supports replacing exactly one occurrence at a time. "
177
+ "Add more lines of context to your edit and try again.",
126
178
  }
127
-
179
+
128
180
  except UnicodeDecodeError:
129
181
  return {
130
182
  "valid": False,
131
- "message": "Cannot read file - appears to be binary or has encoding issues."
183
+ "message": "Cannot read file - appears to be binary or has encoding issues.",
132
184
  }
133
-
185
+
134
186
  return {"valid": True}
135
-
187
+
136
188
  def _apply_edit(self, file_path: str, old_string: str, new_string: str) -> str:
137
189
  """Apply the edit to the file."""
138
-
139
- # Resolve absolute path
140
- if not os.path.isabs(file_path):
141
- file_path = os.path.abspath(file_path)
142
-
190
+
191
+ # Resolve path using workdir if relative
192
+ resolved_path = self._resolve_path(file_path)
193
+
143
194
  # Handle new file creation
144
195
  if old_string == "":
145
196
  # Create new file
146
- os.makedirs(os.path.dirname(file_path), exist_ok=True)
147
-
148
- with open(file_path, 'w', encoding='utf-8') as f:
197
+ os.makedirs(os.path.dirname(resolved_path), exist_ok=True)
198
+
199
+ with open(resolved_path, "w", encoding="utf-8") as f:
149
200
  f.write(new_string)
150
-
201
+
151
202
  # Record the file edit
152
- record_file_edit(file_path, new_string)
153
-
154
- return f"Successfully created new file: {file_path}"
155
-
203
+ record_file_edit(resolved_path, new_string)
204
+
205
+ return f"Successfully created new file: {resolved_path}"
206
+
156
207
  # Edit existing file
157
208
  try:
158
209
  # Read current content
159
- with open(file_path, 'r', encoding='utf-8') as f:
210
+ with open(resolved_path, "r", encoding="utf-8") as f:
160
211
  original_content = f.read()
161
-
212
+
162
213
  # Apply replacement
163
214
  if new_string == "":
164
215
  # Handle deletion - check if we need to remove trailing newline
165
- if (not old_string.endswith('\n') and
166
- original_content.find(old_string + '\n') != -1):
167
- updated_content = original_content.replace(old_string + '\n', new_string)
216
+ if (
217
+ not old_string.endswith("\n")
218
+ and original_content.find(old_string + "\n") != -1
219
+ ):
220
+ updated_content = original_content.replace(
221
+ old_string + "\n", new_string
222
+ )
168
223
  else:
169
224
  updated_content = original_content.replace(old_string, new_string)
170
225
  else:
171
226
  updated_content = original_content.replace(old_string, new_string)
172
-
227
+
173
228
  # Verify the replacement worked
174
229
  if updated_content == original_content:
175
230
  return "Error: Original and edited file match exactly. Failed to apply edit."
176
-
231
+
177
232
  # Write updated content
178
- with open(file_path, 'w', encoding='utf-8') as f:
233
+ with open(resolved_path, "w", encoding="utf-8") as f:
179
234
  f.write(updated_content)
180
-
235
+
181
236
  # Record the file edit
182
- record_file_edit(file_path, updated_content)
183
-
237
+ record_file_edit(resolved_path, updated_content)
238
+
184
239
  # Generate result message with snippet
185
240
  snippet_info = self._get_snippet(original_content, old_string, new_string)
186
-
187
- result = f"The file {file_path} has been updated. Here's the result of the edit:\n"
188
- result += self._add_line_numbers(snippet_info['snippet'], snippet_info['start_line'])
189
-
241
+
242
+ result = f"The file {resolved_path} has been updated. Here's the result of the edit:\n"
243
+ result += self._add_line_numbers(
244
+ snippet_info["snippet"], snippet_info["start_line"]
245
+ )
246
+
190
247
  return result
191
-
248
+
192
249
  except Exception as e:
193
250
  return f"Error applying edit: {str(e)}"
194
-
251
+
195
252
  def _is_binary_file(self, file_path: str) -> bool:
196
253
  """Check if file is binary."""
197
254
  try:
198
- with open(file_path, 'rb') as f:
255
+ with open(file_path, "rb") as f:
199
256
  chunk = f.read(1024)
200
- return b'\0' in chunk
257
+ return b"\0" in chunk
201
258
  except Exception:
202
259
  return False
203
-
204
- def _get_snippet(self, original_content: str, old_string: str, new_string: str,
205
- context_lines: int = 4) -> Dict[str, Any]:
260
+
261
+ def _get_snippet(
262
+ self,
263
+ original_content: str,
264
+ old_string: str,
265
+ new_string: str,
266
+ context_lines: int = 4,
267
+ ) -> Dict[str, Any]:
206
268
  """Get a snippet of the file showing the change with context."""
207
-
269
+
208
270
  # Find the replacement position
209
271
  before_replacement = original_content.split(old_string)[0]
210
- replacement_line = before_replacement.count('\n')
211
-
272
+ replacement_line = before_replacement.count("\n")
273
+
212
274
  # Create the new content
213
275
  new_content = original_content.replace(old_string, new_string)
214
- new_lines = new_content.split('\n')
215
-
276
+ new_lines = new_content.split("\n")
277
+
216
278
  # Calculate snippet boundaries
217
279
  start_line = max(0, replacement_line - context_lines)
218
- end_line = min(len(new_lines), replacement_line + context_lines + new_string.count('\n') + 1)
219
-
280
+ end_line = min(
281
+ len(new_lines),
282
+ replacement_line + context_lines + new_string.count("\n") + 1,
283
+ )
284
+
220
285
  # Extract snippet
221
286
  snippet_lines = new_lines[start_line:end_line]
222
- snippet = '\n'.join(snippet_lines)
223
-
287
+ snippet = "\n".join(snippet_lines)
288
+
224
289
  return {
225
- 'snippet': snippet,
226
- 'start_line': start_line + 1 # Convert to 1-based line numbers
290
+ "snippet": snippet,
291
+ "start_line": start_line + 1, # Convert to 1-based line numbers
227
292
  }
228
-
293
+
294
+ def _suggest_alternatives_for_large_edit(
295
+ self, old_string: str, new_string: str
296
+ ) -> str:
297
+ """Suggest alternative approaches for large string edits."""
298
+ old_lines = old_string.count("\n") + 1
299
+ new_lines = new_string.count("\n") + 1
300
+
301
+ suggestions = []
302
+
303
+ if old_lines > 20 or new_lines > 20:
304
+ suggestions.append(
305
+ "• Use MultiEditTool to break this into multiple smaller string replacements"
306
+ )
307
+
308
+ if len(old_string) > 3000 or len(new_string) > 3000:
309
+ suggestions.append(
310
+ "• Consider using FileWriteTool to rewrite the entire file if making extensive changes"
311
+ )
312
+
313
+ if old_lines > 10:
314
+ suggestions.append(
315
+ "• Break the edit into smaller, more focused string replacements"
316
+ )
317
+ suggestions.append(
318
+ "• Use more specific context to make smaller, safer edits"
319
+ )
320
+
321
+ if suggestions:
322
+ return "Alternative approaches for large edits:\n" + "\n".join(suggestions)
323
+
324
+ return ""
325
+
229
326
  def _add_line_numbers(self, content: str, start_line: int = 1) -> str:
230
327
  """Add line numbers to content."""
231
- lines = content.split('\n')
328
+ lines = content.split("\n")
232
329
  numbered_lines = []
233
-
330
+
234
331
  for i, line in enumerate(lines):
235
332
  line_num = start_line + i
236
333
  numbered_lines.append(f"{line_num:6d} {line}")
237
-
238
- return '\n'.join(numbered_lines)
334
+
335
+ return "\n".join(numbered_lines)