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.
- examples/cli_entrypoint.py +60 -0
- examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
- examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
- examples/components/messages_component.py +199 -0
- examples/file_freshness_example.py +22 -22
- examples/file_watching_example.py +32 -26
- examples/interruptible_tui.py +921 -3
- examples/repl_tui.py +129 -0
- examples/skills/example_usage.py +57 -0
- examples/start.py +173 -0
- minion_code/__init__.py +1 -1
- minion_code/acp_server/__init__.py +34 -0
- minion_code/acp_server/agent.py +539 -0
- minion_code/acp_server/hooks.py +354 -0
- minion_code/acp_server/main.py +194 -0
- minion_code/acp_server/permissions.py +142 -0
- minion_code/acp_server/test_client.py +104 -0
- minion_code/adapters/__init__.py +22 -0
- minion_code/adapters/output_adapter.py +207 -0
- minion_code/adapters/rich_adapter.py +169 -0
- minion_code/adapters/textual_adapter.py +254 -0
- minion_code/agents/__init__.py +2 -2
- minion_code/agents/code_agent.py +517 -104
- minion_code/agents/hooks.py +378 -0
- minion_code/cli.py +538 -429
- minion_code/cli_simple.py +665 -0
- minion_code/commands/__init__.py +136 -29
- minion_code/commands/clear_command.py +19 -46
- minion_code/commands/help_command.py +33 -49
- minion_code/commands/history_command.py +37 -55
- minion_code/commands/model_command.py +194 -0
- minion_code/commands/quit_command.py +9 -12
- minion_code/commands/resume_command.py +181 -0
- minion_code/commands/skill_command.py +89 -0
- minion_code/commands/status_command.py +48 -73
- minion_code/commands/tools_command.py +54 -52
- minion_code/commands/version_command.py +34 -69
- minion_code/components/ConfirmDialog.py +430 -0
- minion_code/components/Message.py +318 -97
- minion_code/components/MessageResponse.py +30 -29
- minion_code/components/Messages.py +351 -0
- minion_code/components/PromptInput.py +499 -245
- minion_code/components/__init__.py +24 -17
- minion_code/const.py +7 -0
- minion_code/screens/REPL.py +1453 -469
- minion_code/screens/__init__.py +1 -1
- minion_code/services/__init__.py +20 -20
- minion_code/services/event_system.py +19 -14
- minion_code/services/file_freshness_service.py +223 -170
- minion_code/skills/__init__.py +25 -0
- minion_code/skills/skill.py +128 -0
- minion_code/skills/skill_loader.py +198 -0
- minion_code/skills/skill_registry.py +177 -0
- minion_code/subagents/__init__.py +31 -0
- minion_code/subagents/builtin/__init__.py +30 -0
- minion_code/subagents/builtin/claude_code_guide.py +32 -0
- minion_code/subagents/builtin/explore.py +36 -0
- minion_code/subagents/builtin/general_purpose.py +19 -0
- minion_code/subagents/builtin/plan.py +61 -0
- minion_code/subagents/subagent.py +116 -0
- minion_code/subagents/subagent_loader.py +147 -0
- minion_code/subagents/subagent_registry.py +151 -0
- minion_code/tools/__init__.py +8 -2
- minion_code/tools/bash_tool.py +16 -3
- minion_code/tools/file_edit_tool.py +201 -104
- minion_code/tools/file_read_tool.py +183 -26
- minion_code/tools/file_write_tool.py +17 -3
- minion_code/tools/glob_tool.py +23 -2
- minion_code/tools/grep_tool.py +229 -21
- minion_code/tools/ls_tool.py +28 -3
- minion_code/tools/multi_edit_tool.py +89 -84
- minion_code/tools/python_interpreter_tool.py +9 -1
- minion_code/tools/skill_tool.py +210 -0
- minion_code/tools/task_tool.py +287 -0
- minion_code/tools/todo_read_tool.py +28 -24
- minion_code/tools/todo_write_tool.py +82 -65
- minion_code/{types.py → type_defs.py} +15 -2
- minion_code/utils/__init__.py +45 -17
- minion_code/utils/config.py +610 -0
- minion_code/utils/history.py +114 -0
- minion_code/utils/logs.py +53 -0
- minion_code/utils/mcp_loader.py +153 -55
- minion_code/utils/output_truncator.py +233 -0
- minion_code/utils/session_storage.py +369 -0
- minion_code/utils/todo_file_utils.py +26 -22
- minion_code/utils/todo_storage.py +43 -33
- minion_code/web/__init__.py +9 -0
- minion_code/web/adapters/__init__.py +5 -0
- minion_code/web/adapters/web_adapter.py +524 -0
- minion_code/web/api/__init__.py +7 -0
- minion_code/web/api/chat.py +277 -0
- minion_code/web/api/interactions.py +136 -0
- minion_code/web/api/sessions.py +135 -0
- minion_code/web/server.py +149 -0
- minion_code/web/services/__init__.py +5 -0
- minion_code/web/services/session_manager.py +420 -0
- minion_code-0.1.2.dist-info/METADATA +476 -0
- minion_code-0.1.2.dist-info/RECORD +111 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/WHEEL +1 -1
- minion_code-0.1.2.dist-info/entry_points.txt +6 -0
- tests/test_adapter.py +67 -0
- tests/test_adapter_simple.py +79 -0
- tests/test_file_read_tool.py +144 -0
- tests/test_readonly_tools.py +0 -2
- tests/test_skills.py +441 -0
- examples/advance_tui.py +0 -508
- examples/rich_example.py +0 -4
- examples/simple_file_watching.py +0 -57
- examples/simple_tui.py +0 -267
- examples/simple_usage.py +0 -69
- minion_code-0.1.0.dist-info/METADATA +0 -350
- minion_code-0.1.0.dist-info/RECORD +0 -59
- minion_code-0.1.0.dist-info/entry_points.txt +0 -4
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/top_level.txt +0 -0
minion_code/tools/ls_tool.py
CHANGED
|
@@ -5,7 +5,9 @@ Directory listing tool
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
8
9
|
from minion.tools import BaseTool
|
|
10
|
+
from ..utils.output_truncator import truncate_output
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
class LsTool(BaseTool):
|
|
@@ -15,7 +17,11 @@ class LsTool(BaseTool):
|
|
|
15
17
|
description = "List directory contents"
|
|
16
18
|
readonly = True # Read-only tool, does not modify system state
|
|
17
19
|
inputs = {
|
|
18
|
-
"path": {
|
|
20
|
+
"path": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Directory path to list",
|
|
23
|
+
"nullable": True,
|
|
24
|
+
},
|
|
19
25
|
"recursive": {
|
|
20
26
|
"type": "boolean",
|
|
21
27
|
"description": "Whether to list recursively",
|
|
@@ -24,10 +30,23 @@ class LsTool(BaseTool):
|
|
|
24
30
|
}
|
|
25
31
|
output_type = "string"
|
|
26
32
|
|
|
33
|
+
def __init__(self, workdir: Optional[str] = None, *args, **kwargs):
|
|
34
|
+
super().__init__(*args, **kwargs)
|
|
35
|
+
self.workdir = Path(workdir) if workdir else None
|
|
36
|
+
|
|
37
|
+
def _resolve_path(self, path: str) -> Path:
|
|
38
|
+
"""Resolve path using workdir if path is relative."""
|
|
39
|
+
p = Path(path)
|
|
40
|
+
if p.is_absolute():
|
|
41
|
+
return p
|
|
42
|
+
if self.workdir:
|
|
43
|
+
return self.workdir / p
|
|
44
|
+
return p # Relative to cwd (backward compatible)
|
|
45
|
+
|
|
27
46
|
def forward(self, path: str = ".", recursive: bool = False) -> str:
|
|
28
47
|
"""List directory contents"""
|
|
29
48
|
try:
|
|
30
|
-
dir_path =
|
|
49
|
+
dir_path = self._resolve_path(path)
|
|
31
50
|
if not dir_path.exists():
|
|
32
51
|
return f"Error: Path does not exist - {path}"
|
|
33
52
|
|
|
@@ -59,7 +78,13 @@ class LsTool(BaseTool):
|
|
|
59
78
|
else:
|
|
60
79
|
result += f" Other: {item.name}\n"
|
|
61
80
|
|
|
62
|
-
return result
|
|
81
|
+
return self.format_for_observation(result)
|
|
63
82
|
|
|
64
83
|
except Exception as e:
|
|
65
84
|
return f"Error listing directory: {str(e)}"
|
|
85
|
+
|
|
86
|
+
def format_for_observation(self, output: Any) -> str:
|
|
87
|
+
"""格式化输出,自动截断过大内容"""
|
|
88
|
+
if isinstance(output, str):
|
|
89
|
+
return truncate_output(output, tool_name=self.name)
|
|
90
|
+
return str(output)
|
|
@@ -8,7 +8,11 @@ import time
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Dict, Any, Optional, List
|
|
10
10
|
from minion.tools import BaseTool
|
|
11
|
-
from minion_code.services import
|
|
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 MultiEditTool(BaseTool):
|
|
@@ -16,15 +20,15 @@ class MultiEditTool(BaseTool):
|
|
|
16
20
|
A tool for making multiple edits to a single file atomically.
|
|
17
21
|
Based on the TypeScript MultiEditTool implementation.
|
|
18
22
|
"""
|
|
19
|
-
|
|
23
|
+
|
|
20
24
|
name = "multi_edit"
|
|
21
25
|
description = "A tool for making multiple edits to a single file in one operation"
|
|
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
|
"edits": {
|
|
30
34
|
"type": "array",
|
|
@@ -34,23 +38,23 @@ class MultiEditTool(BaseTool):
|
|
|
34
38
|
"properties": {
|
|
35
39
|
"old_string": {
|
|
36
40
|
"type": "string",
|
|
37
|
-
"description": "The text to replace"
|
|
41
|
+
"description": "The text to replace",
|
|
38
42
|
},
|
|
39
43
|
"new_string": {
|
|
40
|
-
"type": "string",
|
|
41
|
-
"description": "The text to replace it with"
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "The text to replace it with",
|
|
42
46
|
},
|
|
43
47
|
"replace_all": {
|
|
44
48
|
"type": "boolean",
|
|
45
|
-
"description": "Replace all occurrences of old_string (default: false)"
|
|
46
|
-
}
|
|
49
|
+
"description": "Replace all occurrences of old_string (default: false)",
|
|
50
|
+
},
|
|
47
51
|
},
|
|
48
|
-
"required": ["old_string", "new_string"]
|
|
49
|
-
}
|
|
50
|
-
}
|
|
52
|
+
"required": ["old_string", "new_string"],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
51
55
|
}
|
|
52
56
|
output_type = "string"
|
|
53
|
-
|
|
57
|
+
|
|
54
58
|
def forward(self, file_path: str, edits: List[Dict[str, Any]]) -> str:
|
|
55
59
|
"""Execute multi-edit operation."""
|
|
56
60
|
try:
|
|
@@ -58,35 +62,37 @@ class MultiEditTool(BaseTool):
|
|
|
58
62
|
validation_result = self._validate_input(file_path, edits)
|
|
59
63
|
if not validation_result["valid"]:
|
|
60
64
|
return f"Error: {validation_result['message']}"
|
|
61
|
-
|
|
65
|
+
|
|
62
66
|
# Apply all edits atomically
|
|
63
67
|
result = self._apply_multi_edit(file_path, edits)
|
|
64
68
|
return result
|
|
65
|
-
|
|
69
|
+
|
|
66
70
|
except Exception as e:
|
|
67
71
|
return f"Error during multi-edit: {str(e)}"
|
|
68
|
-
|
|
69
|
-
def _validate_input(
|
|
72
|
+
|
|
73
|
+
def _validate_input(
|
|
74
|
+
self, file_path: str, edits: List[Dict[str, Any]]
|
|
75
|
+
) -> Dict[str, Any]:
|
|
70
76
|
"""Validate input parameters."""
|
|
71
|
-
|
|
77
|
+
|
|
72
78
|
# Check if we have edits
|
|
73
79
|
if not edits or len(edits) == 0:
|
|
74
80
|
return {
|
|
75
81
|
"valid": False,
|
|
76
|
-
"message": "At least one edit operation is required."
|
|
82
|
+
"message": "At least one edit operation is required.",
|
|
77
83
|
}
|
|
78
|
-
|
|
84
|
+
|
|
79
85
|
# Resolve absolute path
|
|
80
86
|
if not os.path.isabs(file_path):
|
|
81
87
|
file_path = os.path.abspath(file_path)
|
|
82
|
-
|
|
88
|
+
|
|
83
89
|
# Check if it's a Jupyter notebook
|
|
84
|
-
if file_path.endswith(
|
|
90
|
+
if file_path.endswith(".ipynb"):
|
|
85
91
|
return {
|
|
86
92
|
"valid": False,
|
|
87
|
-
"message": "File is a Jupyter Notebook. Use NotebookEdit tool instead."
|
|
93
|
+
"message": "File is a Jupyter Notebook. Use NotebookEdit tool instead.",
|
|
88
94
|
}
|
|
89
|
-
|
|
95
|
+
|
|
90
96
|
# Handle new file creation
|
|
91
97
|
if not os.path.exists(file_path):
|
|
92
98
|
# For new files, ensure parent directory can be created
|
|
@@ -97,14 +103,14 @@ class MultiEditTool(BaseTool):
|
|
|
97
103
|
except Exception as e:
|
|
98
104
|
return {
|
|
99
105
|
"valid": False,
|
|
100
|
-
"message": f"Cannot create parent directory: {str(e)}"
|
|
106
|
+
"message": f"Cannot create parent directory: {str(e)}",
|
|
101
107
|
}
|
|
102
|
-
|
|
108
|
+
|
|
103
109
|
# For new files, first edit must create the file (empty old_string)
|
|
104
110
|
if len(edits) == 0 or edits[0].get("old_string", "") != "":
|
|
105
111
|
return {
|
|
106
112
|
"valid": False,
|
|
107
|
-
"message": "For new files, the first edit must have an empty old_string to create the file content."
|
|
113
|
+
"message": "For new files, the first edit must have an empty old_string to create the file content.",
|
|
108
114
|
}
|
|
109
115
|
else:
|
|
110
116
|
# For existing files, check freshness
|
|
@@ -113,64 +119,61 @@ class MultiEditTool(BaseTool):
|
|
|
113
119
|
if freshness_result.conflict:
|
|
114
120
|
return {
|
|
115
121
|
"valid": False,
|
|
116
|
-
"message": "File has been modified since last read. Read it again before editing."
|
|
122
|
+
"message": "File has been modified since last read. Read it again before editing.",
|
|
117
123
|
}
|
|
118
124
|
except Exception:
|
|
119
125
|
# If freshness checking fails, continue with basic validation
|
|
120
126
|
pass
|
|
121
|
-
|
|
127
|
+
|
|
122
128
|
# Check if file is binary
|
|
123
129
|
if self._is_binary_file(file_path):
|
|
124
|
-
return {
|
|
125
|
-
|
|
126
|
-
"message": "Cannot edit binary files."
|
|
127
|
-
}
|
|
128
|
-
|
|
130
|
+
return {"valid": False, "message": "Cannot edit binary files."}
|
|
131
|
+
|
|
129
132
|
# Pre-validate that all old_strings exist in the file
|
|
130
133
|
try:
|
|
131
|
-
with open(file_path,
|
|
134
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
132
135
|
current_content = f.read()
|
|
133
|
-
|
|
136
|
+
|
|
134
137
|
for i, edit in enumerate(edits):
|
|
135
138
|
old_string = edit.get("old_string", "")
|
|
136
139
|
if old_string != "" and old_string not in current_content:
|
|
137
140
|
return {
|
|
138
141
|
"valid": False,
|
|
139
|
-
"message": f"Edit {i + 1}: String to replace not found in file: \"{old_string[:100]}{'...' if len(old_string) > 100 else ''}\""
|
|
142
|
+
"message": f"Edit {i + 1}: String to replace not found in file: \"{old_string[:100]}{'...' if len(old_string) > 100 else ''}\"",
|
|
140
143
|
}
|
|
141
|
-
|
|
144
|
+
|
|
142
145
|
except UnicodeDecodeError:
|
|
143
146
|
return {
|
|
144
147
|
"valid": False,
|
|
145
|
-
"message": "Cannot read file - appears to be binary or has encoding issues."
|
|
148
|
+
"message": "Cannot read file - appears to be binary or has encoding issues.",
|
|
146
149
|
}
|
|
147
|
-
|
|
150
|
+
|
|
148
151
|
# Validate each edit
|
|
149
152
|
for i, edit in enumerate(edits):
|
|
150
153
|
old_string = edit.get("old_string", "")
|
|
151
154
|
new_string = edit.get("new_string", "")
|
|
152
|
-
|
|
155
|
+
|
|
153
156
|
if old_string == new_string:
|
|
154
157
|
return {
|
|
155
158
|
"valid": False,
|
|
156
|
-
"message": f"Edit {i + 1}: old_string and new_string cannot be the same"
|
|
159
|
+
"message": f"Edit {i + 1}: old_string and new_string cannot be the same",
|
|
157
160
|
}
|
|
158
|
-
|
|
161
|
+
|
|
159
162
|
return {"valid": True}
|
|
160
|
-
|
|
163
|
+
|
|
161
164
|
def _apply_multi_edit(self, file_path: str, edits: List[Dict[str, Any]]) -> str:
|
|
162
165
|
"""Apply all edits to the file atomically."""
|
|
163
|
-
|
|
166
|
+
|
|
164
167
|
# Resolve absolute path
|
|
165
168
|
if not os.path.isabs(file_path):
|
|
166
169
|
file_path = os.path.abspath(file_path)
|
|
167
|
-
|
|
170
|
+
|
|
168
171
|
# Read current file content (or empty for new files)
|
|
169
172
|
file_exists = os.path.exists(file_path)
|
|
170
|
-
|
|
173
|
+
|
|
171
174
|
if file_exists:
|
|
172
175
|
try:
|
|
173
|
-
with open(file_path,
|
|
176
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
174
177
|
current_content = f.read()
|
|
175
178
|
except UnicodeDecodeError:
|
|
176
179
|
return "Error: Cannot read file - appears to be binary or has encoding issues."
|
|
@@ -178,94 +181,96 @@ class MultiEditTool(BaseTool):
|
|
|
178
181
|
current_content = ""
|
|
179
182
|
# Ensure parent directory exists
|
|
180
183
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
181
|
-
|
|
184
|
+
|
|
182
185
|
# Apply all edits sequentially
|
|
183
186
|
modified_content = current_content
|
|
184
187
|
applied_edits = []
|
|
185
|
-
|
|
188
|
+
|
|
186
189
|
for i, edit in enumerate(edits):
|
|
187
190
|
old_string = edit.get("old_string", "")
|
|
188
191
|
new_string = edit.get("new_string", "")
|
|
189
192
|
replace_all = edit.get("replace_all", False)
|
|
190
|
-
|
|
193
|
+
|
|
191
194
|
try:
|
|
192
195
|
result = self._apply_content_edit(
|
|
193
196
|
modified_content, old_string, new_string, replace_all
|
|
194
197
|
)
|
|
195
198
|
modified_content = result["new_content"]
|
|
196
|
-
applied_edits.append(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
applied_edits.append(
|
|
200
|
+
{
|
|
201
|
+
"edit_index": i + 1,
|
|
202
|
+
"success": True,
|
|
203
|
+
"old_string": old_string[:100]
|
|
204
|
+
+ ("..." if len(old_string) > 100 else ""),
|
|
205
|
+
"new_string": new_string[:100]
|
|
206
|
+
+ ("..." if len(new_string) > 100 else ""),
|
|
207
|
+
"occurrences": result["occurrences"],
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
|
|
204
211
|
except Exception as e:
|
|
205
212
|
# If any edit fails, abort the entire operation
|
|
206
213
|
error_message = str(e)
|
|
207
214
|
return f"Error in edit {i + 1}: {error_message}"
|
|
208
|
-
|
|
215
|
+
|
|
209
216
|
# Write the modified content
|
|
210
217
|
try:
|
|
211
|
-
with open(file_path,
|
|
218
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
212
219
|
f.write(modified_content)
|
|
213
220
|
except Exception as e:
|
|
214
221
|
return f"Error writing file: {str(e)}"
|
|
215
|
-
|
|
222
|
+
|
|
216
223
|
# Record the file edit
|
|
217
224
|
record_file_edit(file_path, modified_content)
|
|
218
|
-
|
|
225
|
+
|
|
219
226
|
# Generate result summary
|
|
220
227
|
operation = "create" if not file_exists else "update"
|
|
221
228
|
summary = f"Successfully applied {len(edits)} edits to {file_path}"
|
|
222
|
-
|
|
229
|
+
|
|
223
230
|
# Add details about each edit
|
|
224
231
|
details = []
|
|
225
232
|
for edit_info in applied_edits:
|
|
226
233
|
details.append(
|
|
227
234
|
f"Edit {edit_info['edit_index']}: Replaced {edit_info['occurrences']} occurrence(s)"
|
|
228
235
|
)
|
|
229
|
-
|
|
236
|
+
|
|
230
237
|
if details:
|
|
231
238
|
summary += "\n" + "\n".join(details)
|
|
232
|
-
|
|
239
|
+
|
|
233
240
|
return summary
|
|
234
|
-
|
|
235
|
-
def _apply_content_edit(
|
|
236
|
-
|
|
241
|
+
|
|
242
|
+
def _apply_content_edit(
|
|
243
|
+
self, content: str, old_string: str, new_string: str, replace_all: bool = False
|
|
244
|
+
) -> Dict[str, Any]:
|
|
237
245
|
"""Apply a single content edit."""
|
|
238
|
-
|
|
246
|
+
|
|
239
247
|
if replace_all:
|
|
240
248
|
# Replace all occurrences
|
|
241
249
|
import re
|
|
250
|
+
|
|
242
251
|
# Escape special regex characters in old_string
|
|
243
252
|
escaped_old = re.escape(old_string)
|
|
244
253
|
pattern = re.compile(escaped_old)
|
|
245
254
|
matches = pattern.findall(content)
|
|
246
255
|
occurrences = len(matches)
|
|
247
256
|
new_content = pattern.sub(new_string, content)
|
|
248
|
-
|
|
249
|
-
return {
|
|
250
|
-
"new_content": new_content,
|
|
251
|
-
"occurrences": occurrences
|
|
252
|
-
}
|
|
257
|
+
|
|
258
|
+
return {"new_content": new_content, "occurrences": occurrences}
|
|
253
259
|
else:
|
|
254
260
|
# Replace single occurrence
|
|
255
261
|
if old_string in content:
|
|
256
|
-
new_content = content.replace(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
262
|
+
new_content = content.replace(
|
|
263
|
+
old_string, new_string, 1
|
|
264
|
+
) # Replace only first occurrence
|
|
265
|
+
return {"new_content": new_content, "occurrences": 1}
|
|
261
266
|
else:
|
|
262
267
|
raise Exception(f"String not found: {old_string[:50]}...")
|
|
263
|
-
|
|
268
|
+
|
|
264
269
|
def _is_binary_file(self, file_path: str) -> bool:
|
|
265
270
|
"""Check if file is binary."""
|
|
266
271
|
try:
|
|
267
|
-
with open(file_path,
|
|
272
|
+
with open(file_path, "rb") as f:
|
|
268
273
|
chunk = f.read(1024)
|
|
269
|
-
return b
|
|
274
|
+
return b"\0" in chunk
|
|
270
275
|
except Exception:
|
|
271
|
-
return False
|
|
276
|
+
return False
|
|
@@ -7,7 +7,9 @@ Python code execution tool
|
|
|
7
7
|
import io
|
|
8
8
|
import sys
|
|
9
9
|
from contextlib import redirect_stdout, redirect_stderr
|
|
10
|
+
from typing import Any
|
|
10
11
|
from minion.tools import BaseTool
|
|
12
|
+
from ..utils.output_truncator import truncate_output
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
class PythonInterpreterTool(BaseTool):
|
|
@@ -99,7 +101,13 @@ class PythonInterpreterTool(BaseTool):
|
|
|
99
101
|
if not output_parts:
|
|
100
102
|
output_parts.append("Code executed successfully, no output.")
|
|
101
103
|
|
|
102
|
-
return "\n".join(output_parts)
|
|
104
|
+
return self.format_for_observation("\n".join(output_parts))
|
|
103
105
|
|
|
104
106
|
except Exception as e:
|
|
105
107
|
return f"Error executing code: {str(e)}"
|
|
108
|
+
|
|
109
|
+
def format_for_observation(self, output: Any) -> str:
|
|
110
|
+
"""格式化输出,自动截断过大内容"""
|
|
111
|
+
if isinstance(output, str):
|
|
112
|
+
return truncate_output(output, tool_name=self.name)
|
|
113
|
+
return str(output)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Skill Tool - executes skills within the conversation.
|
|
5
|
+
|
|
6
|
+
This tool allows Claude to invoke skills that provide specialized knowledge
|
|
7
|
+
and workflows for specific tasks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
|
|
13
|
+
from minion.tools import BaseTool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SkillTool(BaseTool):
|
|
17
|
+
"""
|
|
18
|
+
Tool for executing skills within the main conversation.
|
|
19
|
+
|
|
20
|
+
Skills are modular packages that extend Claude's capabilities by providing
|
|
21
|
+
specialized knowledge, workflows, and tools. When a skill is invoked,
|
|
22
|
+
its instructions are loaded into the conversation context.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name: str = "Skill"
|
|
26
|
+
description: str = """Execute a skill within the main conversation.
|
|
27
|
+
|
|
28
|
+
Skills are folders of instructions, scripts, and resources that Claude loads
|
|
29
|
+
dynamically to improve performance on specialized tasks.
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
- Invoke skills using this tool with the skill name only (no arguments)
|
|
33
|
+
- When you invoke a skill, its prompt will expand and provide detailed instructions
|
|
34
|
+
- Only use skills listed in <available_skills> in the system prompt
|
|
35
|
+
|
|
36
|
+
Important:
|
|
37
|
+
- Only use skills that are listed as available
|
|
38
|
+
- Do not invoke a skill that is already running
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
parameters: dict = {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"skill": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "The skill name to execute (e.g., 'pdf', 'xlsx', 'docx')",
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"required": ["skill"],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def __init__(self, **kwargs):
|
|
53
|
+
super().__init__(**kwargs)
|
|
54
|
+
self._registry = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def registry(self):
|
|
58
|
+
"""Get the skill registry, loading skills if needed."""
|
|
59
|
+
if self._registry is None:
|
|
60
|
+
from minion_code.skills import SkillRegistry
|
|
61
|
+
from minion_code.skills.skill_loader import load_skills
|
|
62
|
+
|
|
63
|
+
self._registry = load_skills()
|
|
64
|
+
return self._registry
|
|
65
|
+
|
|
66
|
+
def forward(self, skill: str, **kwargs) -> Dict[str, Any]:
|
|
67
|
+
"""
|
|
68
|
+
Execute a skill by loading its instructions into the conversation.
|
|
69
|
+
This is the synchronous entry point required by BaseTool.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
skill: Name of the skill to execute
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Dict containing the skill prompt and metadata
|
|
76
|
+
"""
|
|
77
|
+
return self.execute_skill(skill)
|
|
78
|
+
|
|
79
|
+
def execute_skill(self, skill: str) -> Dict[str, Any]:
|
|
80
|
+
"""
|
|
81
|
+
Execute a skill by loading its instructions into the conversation.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
skill: Name of the skill to execute
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Dict containing the skill prompt and metadata
|
|
88
|
+
"""
|
|
89
|
+
# Check if skill exists
|
|
90
|
+
skill_obj = self.registry.get(skill)
|
|
91
|
+
|
|
92
|
+
if skill_obj is None:
|
|
93
|
+
available = [s.name for s in self.registry.list_all()]
|
|
94
|
+
return {
|
|
95
|
+
"success": False,
|
|
96
|
+
"error": f"Unknown skill: {skill}",
|
|
97
|
+
"available_skills": available[:10], # Show first 10
|
|
98
|
+
"hint": "Use one of the available skills listed above",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Get the skill prompt
|
|
102
|
+
prompt = skill_obj.get_prompt()
|
|
103
|
+
|
|
104
|
+
# Build response with skill content
|
|
105
|
+
return {
|
|
106
|
+
"success": True,
|
|
107
|
+
"skill_name": skill_obj.name,
|
|
108
|
+
"skill_description": skill_obj.description,
|
|
109
|
+
"skill_location": skill_obj.location,
|
|
110
|
+
"skill_path": str(
|
|
111
|
+
skill_obj.path
|
|
112
|
+
), # Absolute path for resolving relative resources
|
|
113
|
+
"prompt": prompt,
|
|
114
|
+
"message": f'The "{skill_obj.name}" skill is loading',
|
|
115
|
+
"allowed_tools": skill_obj.allowed_tools,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async def execute(self, skill: str, **kwargs) -> Dict[str, Any]:
|
|
119
|
+
"""
|
|
120
|
+
Async wrapper for execute_skill.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
skill: Name of the skill to execute
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Dict containing the skill prompt and metadata
|
|
127
|
+
"""
|
|
128
|
+
return self.execute_skill(skill)
|
|
129
|
+
|
|
130
|
+
def validate_skill(self, skill: str) -> tuple[bool, Optional[str]]:
|
|
131
|
+
"""
|
|
132
|
+
Validate that a skill exists and can be executed.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
skill: Name of the skill to validate
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Tuple of (is_valid, error_message)
|
|
139
|
+
"""
|
|
140
|
+
if not skill:
|
|
141
|
+
return False, "Skill name is required"
|
|
142
|
+
|
|
143
|
+
if not self.registry.exists(skill):
|
|
144
|
+
available = [s.name for s in self.registry.list_all()]
|
|
145
|
+
return (
|
|
146
|
+
False,
|
|
147
|
+
f"Unknown skill: {skill}. Available: {', '.join(available[:5])}",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return True, None
|
|
151
|
+
|
|
152
|
+
def get_available_skills_prompt(self, char_budget: int = 10000) -> str:
|
|
153
|
+
"""
|
|
154
|
+
Generate a prompt listing available skills for the system message.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
char_budget: Maximum characters for skills list
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Formatted skills prompt
|
|
161
|
+
"""
|
|
162
|
+
return self.registry.generate_skills_prompt(char_budget)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def generate_skill_tool_prompt() -> str:
|
|
166
|
+
"""
|
|
167
|
+
Generate the complete skill tool prompt including available skills.
|
|
168
|
+
|
|
169
|
+
This is used to generate the skill tool description in the system prompt.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Complete skill tool prompt
|
|
173
|
+
"""
|
|
174
|
+
from minion_code.skills.skill_loader import load_skills
|
|
175
|
+
|
|
176
|
+
registry = load_skills()
|
|
177
|
+
skills = registry.list_all()
|
|
178
|
+
|
|
179
|
+
if not skills:
|
|
180
|
+
return """Execute a skill within the main conversation.
|
|
181
|
+
|
|
182
|
+
No skills are currently available. Skills can be added to:
|
|
183
|
+
- .claude/skills/ (project-level)
|
|
184
|
+
- ~/.claude/skills/ (user-level)
|
|
185
|
+
- .minion/skills/ (project-level)
|
|
186
|
+
- ~/.minion/skills/ (user-level)
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
skills_xml = "\n".join(skill.to_xml() for skill in skills)
|
|
190
|
+
|
|
191
|
+
return f"""Execute a skill within the main conversation.
|
|
192
|
+
|
|
193
|
+
<skills_instructions>
|
|
194
|
+
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
|
|
195
|
+
|
|
196
|
+
How to use skills:
|
|
197
|
+
- Invoke skills using this tool with the skill name only (no arguments)
|
|
198
|
+
- When you invoke a skill, you will see <command-message>The "{{name}}" skill is loading</command-message>
|
|
199
|
+
- The skill's prompt will expand and provide detailed instructions on how to complete the task
|
|
200
|
+
- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
|
|
201
|
+
|
|
202
|
+
Important:
|
|
203
|
+
- Only use skills listed in <available_skills> below
|
|
204
|
+
- Do not invoke a skill that is already running
|
|
205
|
+
</skills_instructions>
|
|
206
|
+
|
|
207
|
+
<available_skills>
|
|
208
|
+
{skills_xml}
|
|
209
|
+
</available_skills>
|
|
210
|
+
"""
|