devorch 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.
tools/grep.py ADDED
@@ -0,0 +1,280 @@
1
+ """
2
+ Grep Tool - Search for patterns within file contents.
3
+ Similar to ripgrep/grep, optimized for code search.
4
+ """
5
+
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from tools.base import Tool
14
+
15
+
16
+ @dataclass
17
+ class GrepMatch:
18
+ """A single grep match result."""
19
+
20
+ file: str
21
+ line_number: int
22
+ line_content: str
23
+ match_start: int
24
+ match_end: int
25
+
26
+
27
+ class GrepToolSchema(BaseModel):
28
+ pattern: str = Field(..., description="Regex pattern to search for in file contents.")
29
+ path: str = Field(default=".", description="File or directory to search in.")
30
+ include: str = Field(default="*", description="File pattern to include (e.g., '*.py', '*.js').")
31
+ context: int = Field(
32
+ default=0, description="Number of context lines before and after match (0-5)."
33
+ )
34
+ max_results: int = Field(default=50, description="Maximum number of results to return.")
35
+ case_sensitive: bool = Field(default=True, description="Whether search is case-sensitive.")
36
+ whole_word: bool = Field(default=False, description="Match whole words only.")
37
+
38
+
39
+ class GrepTool(Tool):
40
+ """
41
+ Searches for patterns within file contents.
42
+ Returns matching lines with file paths and line numbers.
43
+ """
44
+
45
+ name = "grep"
46
+ description = """Search for a pattern within file contents. Returns matching lines with file paths and line numbers.
47
+ Use this to find specific code, functions, variables, or text patterns across files.
48
+ Examples:
49
+ - Search for a function: pattern="def process_data"
50
+ - Search for imports: pattern="from typing import", include="*.py"
51
+ - Search for TODO comments: pattern="TODO|FIXME", include="*.py"
52
+ - Case-insensitive search: pattern="error", case_sensitive=false"""
53
+ args_schema = GrepToolSchema
54
+
55
+ # File extensions to search by default (text files)
56
+ TEXT_EXTENSIONS = {
57
+ ".py",
58
+ ".js",
59
+ ".ts",
60
+ ".jsx",
61
+ ".tsx",
62
+ ".java",
63
+ ".c",
64
+ ".cpp",
65
+ ".h",
66
+ ".hpp",
67
+ ".go",
68
+ ".rs",
69
+ ".rb",
70
+ ".php",
71
+ ".swift",
72
+ ".kt",
73
+ ".scala",
74
+ ".cs",
75
+ ".html",
76
+ ".css",
77
+ ".scss",
78
+ ".less",
79
+ ".vue",
80
+ ".svelte",
81
+ ".json",
82
+ ".yaml",
83
+ ".yml",
84
+ ".toml",
85
+ ".xml",
86
+ ".md",
87
+ ".txt",
88
+ ".rst",
89
+ ".sh",
90
+ ".bash",
91
+ ".zsh",
92
+ ".fish",
93
+ ".ps1",
94
+ ".bat",
95
+ ".cmd",
96
+ ".sql",
97
+ ".graphql",
98
+ ".proto",
99
+ ".env",
100
+ ".gitignore",
101
+ ".dockerignore",
102
+ "Dockerfile",
103
+ "Makefile",
104
+ ".cfg",
105
+ ".ini",
106
+ ".conf",
107
+ ".config",
108
+ }
109
+
110
+ # Directories to skip
111
+ SKIP_DIRS = {
112
+ ".git",
113
+ ".svn",
114
+ ".hg",
115
+ "node_modules",
116
+ "__pycache__",
117
+ ".venv",
118
+ "venv",
119
+ "env",
120
+ ".env",
121
+ "dist",
122
+ "build",
123
+ ".next",
124
+ ".nuxt",
125
+ "target",
126
+ "out",
127
+ ".idea",
128
+ ".vscode",
129
+ ".pytest_cache",
130
+ ".mypy_cache",
131
+ "coverage",
132
+ "htmlcov",
133
+ ".tox",
134
+ "eggs",
135
+ "*.egg-info",
136
+ }
137
+
138
+ def _should_search_file(self, filepath: str, include_pattern: str) -> bool:
139
+ """Check if file should be searched based on extension and pattern."""
140
+ filename = os.path.basename(filepath)
141
+ ext = os.path.splitext(filename)[1].lower()
142
+
143
+ # Check include pattern
144
+ if include_pattern != "*":
145
+ import fnmatch
146
+
147
+ if not fnmatch.fnmatch(filename, include_pattern):
148
+ return False
149
+
150
+ # Check if it's a text file
151
+ if ext and ext not in self.TEXT_EXTENSIONS:
152
+ # Allow files without extension if they match pattern
153
+ if ext:
154
+ return False
155
+
156
+ return True
157
+
158
+ def _should_skip_dir(self, dirname: str) -> bool:
159
+ """Check if directory should be skipped."""
160
+ return dirname in self.SKIP_DIRS or dirname.startswith(".")
161
+
162
+ def _search_file(
163
+ self, filepath: str, regex: re.Pattern, context: int, max_results: int, current_results: int
164
+ ) -> list[dict]:
165
+ """Search a single file for matches."""
166
+ results = []
167
+
168
+ try:
169
+ with open(filepath, encoding="utf-8", errors="ignore") as f:
170
+ lines = f.readlines()
171
+
172
+ for i, line in enumerate(lines):
173
+ if current_results + len(results) >= max_results:
174
+ break
175
+
176
+ match = regex.search(line)
177
+ if match:
178
+ result = {
179
+ "file": filepath,
180
+ "line": i + 1,
181
+ "content": line.rstrip("\n\r"),
182
+ "match": match.group(),
183
+ }
184
+
185
+ # Add context lines if requested
186
+ if context > 0:
187
+ context_before = []
188
+ context_after = []
189
+
190
+ for j in range(max(0, i - context), i):
191
+ context_before.append(f"{j + 1}: {lines[j].rstrip()}")
192
+
193
+ for j in range(i + 1, min(len(lines), i + context + 1)):
194
+ context_after.append(f"{j + 1}: {lines[j].rstrip()}")
195
+
196
+ if context_before:
197
+ result["context_before"] = context_before
198
+ if context_after:
199
+ result["context_after"] = context_after
200
+
201
+ results.append(result)
202
+
203
+ except Exception:
204
+ pass # Skip files that can't be read
205
+
206
+ return results
207
+
208
+ def run(self, arguments: dict[str, Any]) -> Any:
209
+ pattern = arguments.get("pattern")
210
+ path = arguments.get("path", ".")
211
+ include = arguments.get("include", "*")
212
+ context = min(arguments.get("context", 0), 5) # Max 5 context lines
213
+ max_results = min(arguments.get("max_results", 50), 100) # Max 100 results
214
+ case_sensitive = arguments.get("case_sensitive", True)
215
+ whole_word = arguments.get("whole_word", False)
216
+
217
+ if not pattern:
218
+ return "Error: Pattern not provided."
219
+
220
+ # Build regex
221
+ try:
222
+ if whole_word:
223
+ pattern = rf"\b{pattern}\b"
224
+
225
+ flags = 0 if case_sensitive else re.IGNORECASE
226
+ regex = re.compile(pattern, flags)
227
+ except re.error as e:
228
+ return f"Error: Invalid regex pattern - {e}"
229
+
230
+ results = []
231
+
232
+ # Handle single file
233
+ if os.path.isfile(path):
234
+ results = self._search_file(path, regex, context, max_results, 0)
235
+ # Handle directory
236
+ elif os.path.isdir(path):
237
+ for root, dirs, files in os.walk(path):
238
+ # Skip certain directories
239
+ dirs[:] = [d for d in dirs if not self._should_skip_dir(d)]
240
+
241
+ for filename in files:
242
+ if len(results) >= max_results:
243
+ break
244
+
245
+ filepath = os.path.join(root, filename)
246
+
247
+ if self._should_search_file(filepath, include):
248
+ file_results = self._search_file(
249
+ filepath, regex, context, max_results, len(results)
250
+ )
251
+ results.extend(file_results)
252
+
253
+ if len(results) >= max_results:
254
+ break
255
+ else:
256
+ return f"Error: Path '{path}' not found."
257
+
258
+ if not results:
259
+ return f"No matches found for pattern: {pattern}"
260
+
261
+ # Format output
262
+ output_lines = [f"Found {len(results)} match(es):\n"]
263
+
264
+ for r in results:
265
+ # Format: file:line: content
266
+ output_lines.append(f"{r['file']}:{r['line']}: {r['content']}")
267
+
268
+ if "context_before" in r:
269
+ for ctx in r["context_before"]:
270
+ output_lines.append(f" {ctx}")
271
+ output_lines.append(f" {r['line']}: {r['content']} <-- match")
272
+ if "context_after" in r:
273
+ for ctx in r["context_after"]:
274
+ output_lines.append(f" {ctx}")
275
+ output_lines.append("")
276
+
277
+ if len(results) >= max_results:
278
+ output_lines.append(f"\n(Results truncated at {max_results})")
279
+
280
+ return "\n".join(output_lines)
tools/search.py ADDED
@@ -0,0 +1,150 @@
1
+ import glob
2
+ import os
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from tools.base import Tool
8
+
9
+
10
+ class SearchToolSchema(BaseModel):
11
+ pattern: str = Field(..., description="Glob pattern (e.g., '*.py', 'test_*.js', '**/*.md').")
12
+ directory: str = Field(default=".", description="Directory to search in.")
13
+ type: str = Field(default="all", description="Filter: 'file', 'dir', or 'all'.")
14
+ max_results: int = Field(default=100, description="Maximum number of results.")
15
+ include_hidden: bool = Field(default=False, description="Include hidden files/directories.")
16
+
17
+
18
+ class SearchTool(Tool):
19
+ name = "search"
20
+ description = """Search for files and directories matching a glob pattern.
21
+
22
+ Examples:
23
+ - Find Python files: pattern="*.py"
24
+ - Find test files: pattern="test_*.py"
25
+ - Find all in subdirs: pattern="**/*.js"
26
+ - Find specific file: pattern="**/config.yaml"
27
+ - Find directories: pattern="**/tests", type="dir"
28
+ - Find markdown docs: pattern="**/*.md", directory="docs"
29
+
30
+ Common patterns:
31
+ - *.ext - Files with extension in current dir
32
+ - **/*.ext - Files with extension in all subdirs
33
+ - **/name - File/dir with exact name in all subdirs
34
+ - prefix* - Files starting with prefix
35
+ - *suffix - Files ending with suffix"""
36
+ args_schema = SearchToolSchema
37
+
38
+ # Directories to skip by default
39
+ SKIP_DIRS = {
40
+ ".git",
41
+ ".svn",
42
+ ".hg",
43
+ "node_modules",
44
+ "__pycache__",
45
+ ".venv",
46
+ "venv",
47
+ "env",
48
+ ".env",
49
+ "dist",
50
+ "build",
51
+ ".next",
52
+ ".nuxt",
53
+ "target",
54
+ "out",
55
+ ".idea",
56
+ ".vscode",
57
+ ".pytest_cache",
58
+ ".mypy_cache",
59
+ }
60
+
61
+ def _filter_results(
62
+ self, matches: list[str], type_filter: str, include_hidden: bool, max_results: int
63
+ ) -> list[str]:
64
+ """Filter and limit results."""
65
+ filtered = []
66
+
67
+ for match in matches:
68
+ # Skip hidden unless requested
69
+ if not include_hidden:
70
+ parts = match.replace("\\", "/").split("/")
71
+ if any(p.startswith(".") and p not in (".", "..") for p in parts):
72
+ continue
73
+
74
+ # Skip common ignored directories
75
+ if any(skip in parts for skip in self.SKIP_DIRS):
76
+ continue
77
+
78
+ # Type filter
79
+ if type_filter == "file" and not os.path.isfile(match):
80
+ continue
81
+ if type_filter == "dir" and not os.path.isdir(match):
82
+ continue
83
+
84
+ filtered.append(match)
85
+
86
+ if len(filtered) >= max_results:
87
+ break
88
+
89
+ return filtered
90
+
91
+ def run(self, arguments: dict[str, Any]) -> Any:
92
+ pattern = arguments.get("pattern")
93
+ directory = arguments.get("directory", ".")
94
+ type_filter = arguments.get("type", "all").lower()
95
+ max_results = min(arguments.get("max_results", 100), 500)
96
+ include_hidden = arguments.get("include_hidden", False)
97
+
98
+ if not pattern:
99
+ return "Error: Pattern not provided."
100
+
101
+ if not os.path.isdir(directory):
102
+ return f"Error: Directory '{directory}' not found."
103
+
104
+ try:
105
+ # Handle different pattern formats
106
+ if pattern.startswith("**/"):
107
+ search_path = os.path.join(directory, pattern)
108
+ elif "**" in pattern:
109
+ search_path = os.path.join(directory, pattern)
110
+ else:
111
+ # Search recursively by default
112
+ search_path = os.path.join(directory, "**", pattern)
113
+
114
+ matches = glob.glob(search_path, recursive=True)
115
+
116
+ # Filter results
117
+ filtered = self._filter_results(matches, type_filter, include_hidden, max_results)
118
+
119
+ if not filtered:
120
+ return f"No files found matching: {pattern}"
121
+
122
+ # Format output with file info
123
+ output_lines = [f"Found {len(filtered)} result(s):\n"]
124
+
125
+ for match in filtered:
126
+ # Normalize path
127
+ rel_path = os.path.relpath(match, directory) if directory != "." else match
128
+
129
+ if os.path.isdir(match):
130
+ output_lines.append(f" [DIR] {rel_path}/")
131
+ else:
132
+ try:
133
+ size = os.path.getsize(match)
134
+ if size < 1024:
135
+ size_str = f"{size}B"
136
+ elif size < 1024 * 1024:
137
+ size_str = f"{size // 1024}KB"
138
+ else:
139
+ size_str = f"{size // (1024 * 1024)}MB"
140
+ output_lines.append(f" {size_str:>6} {rel_path}")
141
+ except Exception:
142
+ output_lines.append(f" {'?':>6} {rel_path}")
143
+
144
+ if len(matches) > max_results:
145
+ output_lines.append(f"\n(Showing {max_results} of {len(matches)} results)")
146
+
147
+ return "\n".join(output_lines)
148
+
149
+ except Exception as e:
150
+ return f"Search error: {str(e)}"
tools/shell.py ADDED
@@ -0,0 +1,55 @@
1
+ import subprocess
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from tools.base import Tool
7
+
8
+
9
+ class ShellToolSchema(BaseModel):
10
+ command: str = Field(..., description="The shell command to execute.")
11
+
12
+
13
+ class ShellTool(Tool):
14
+ name = "shell"
15
+ description = """\
16
+ Executes a shell command and captures its output.
17
+
18
+ Use this for short-lived commands that return output, such as:
19
+ - Package management: `npm install`, `pip install`, `cargo build`
20
+ - Version control: `git status`, `git add`, `git commit`, `git clone`
21
+ - File operations: `mkdir`, `cp`, `mv`, `rm`
22
+ - Inspecting output: `cat`, `echo`, `pwd`, `ls`
23
+
24
+ For long-running servers or interactive scaffold tools, use `open_terminal` instead."""
25
+ args_schema = ShellToolSchema
26
+
27
+ def run(self, arguments: dict[str, Any]) -> Any:
28
+ try:
29
+ command = arguments.get("command")
30
+ if not command:
31
+ return "Error: No command provided."
32
+
33
+ result = subprocess.run(
34
+ command,
35
+ shell=True,
36
+ capture_output=True,
37
+ text=True,
38
+ check=False,
39
+ timeout=120,
40
+ encoding="utf-8",
41
+ errors="replace",
42
+ )
43
+
44
+ output = ""
45
+ if result.stdout:
46
+ output += f"STDOUT:\n{result.stdout}\n"
47
+ if result.stderr:
48
+ output += f"STDERR:\n{result.stderr}\n"
49
+
50
+ return output if output else f"Command completed with exit code {result.returncode}."
51
+
52
+ except subprocess.TimeoutExpired:
53
+ return "Error: Command timed out after 120 seconds."
54
+ except Exception as e:
55
+ return f"Failed to execute command: {str(e)}"
tools/task.py ADDED
@@ -0,0 +1,91 @@
1
+ """Task tool for AI to track work progress."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from core.tasks import get_task_manager
8
+ from tools.base import Tool
9
+
10
+
11
+ class TaskItem(BaseModel):
12
+ """A single task item."""
13
+
14
+ content: str = Field(
15
+ ..., description="What needs to be done (imperative form, e.g., 'Run tests')"
16
+ )
17
+ status: str = Field("pending", description="Task status: pending, in_progress, or completed")
18
+ activeForm: str = Field(
19
+ ..., description="Present continuous form shown during execution (e.g., 'Running tests')"
20
+ )
21
+
22
+
23
+ class TaskToolSchema(BaseModel):
24
+ """Schema for the task tool."""
25
+
26
+ todos: list[TaskItem] = Field(..., description="The updated todo list with all tasks")
27
+
28
+
29
+ class TaskTool(Tool):
30
+ """
31
+ Tool for AI to manage a task list and track progress.
32
+
33
+ Use this tool to:
34
+ - Create a task list when starting multi-step work
35
+ - Update task status as you complete items
36
+ - Show the user what you're working on
37
+
38
+ Guidelines:
39
+ - Only ONE task should be 'in_progress' at a time
40
+ - Mark tasks 'completed' immediately after finishing
41
+ - Content should be imperative (e.g., "Fix bug")
42
+ - ActiveForm should be present continuous (e.g., "Fixing bug")
43
+ """
44
+
45
+ name = "task"
46
+ description = """Create and manage a task list to track progress on multi-step work.
47
+ Use this when:
48
+ - Working on tasks with 3+ steps
49
+ - User provides multiple items to do
50
+ - You want to show progress to the user
51
+
52
+ Task status options: pending, in_progress, completed
53
+ Only ONE task should be in_progress at a time."""
54
+
55
+ args_schema = TaskToolSchema
56
+
57
+ def run(self, arguments: dict[str, Any]) -> str:
58
+ """Execute the task tool."""
59
+ todos = arguments.get("todos", [])
60
+
61
+ if not todos:
62
+ return "No tasks provided."
63
+
64
+ # Convert to list of dicts if needed
65
+ task_list = []
66
+ for item in todos:
67
+ if isinstance(item, dict):
68
+ task_list.append(item)
69
+ else:
70
+ # Pydantic model
71
+ task_list.append(
72
+ {
73
+ "content": item.content,
74
+ "status": item.status,
75
+ "activeForm": item.activeForm,
76
+ }
77
+ )
78
+
79
+ # Update task manager
80
+ task_manager = get_task_manager()
81
+ task_manager.set_tasks(task_list)
82
+
83
+ # Return summary
84
+ completed = sum(1 for t in task_list if t.get("status") == "completed")
85
+ in_progress = sum(1 for t in task_list if t.get("status") == "in_progress")
86
+ pending = sum(1 for t in task_list if t.get("status") == "pending")
87
+
88
+ current = next((t for t in task_list if t.get("status") == "in_progress"), None)
89
+ current_text = f" Current: {current['activeForm']}" if current else ""
90
+
91
+ return f"Tasks updated: {completed} completed, {in_progress} in progress, {pending} pending.{current_text}"
tools/terminal.py ADDED
@@ -0,0 +1,123 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from tools.base import Tool
9
+
10
+
11
+ class OpenTerminalSchema(BaseModel):
12
+ command: str = Field(
13
+ ...,
14
+ description=(
15
+ "The command to run in a new terminal window. "
16
+ "Use this for long-running servers (npm run dev, vite, uvicorn, flask run, etc.), "
17
+ "interactive scaffold tools (npm create, npx create-*, ng new, etc.), "
18
+ "or anything else that would block the main session if run normally."
19
+ ),
20
+ )
21
+
22
+
23
+ class OpenTerminalTool(Tool):
24
+ name = "open_terminal"
25
+ description = """\
26
+ Opens a new terminal window and runs the given command inside it.
27
+
28
+ Use this tool whenever you need to:
29
+ - Start a development server or daemon (e.g. `npm run dev`, `vite`, `uvicorn app:main`, `flask run`, `next dev`)
30
+ - Run an interactive scaffold that prompts the user (e.g. `npm create vite@latest`, `npx create-next-app`, `ng new myapp`)
31
+ - Run any long-running process that should NOT block the current session
32
+
33
+ The main DevOrch session remains fully interactive while the command runs in its own window.
34
+ After calling this tool, continue the conversation normally — do NOT wait for the command to finish."""
35
+ args_schema = OpenTerminalSchema
36
+
37
+ def run(self, arguments: dict[str, Any]) -> Any:
38
+ command = arguments.get("command", "").strip()
39
+ if not command:
40
+ return "Error: No command provided."
41
+
42
+ working_dir = os.getcwd()
43
+ system = sys.platform
44
+
45
+ try:
46
+ if system == "win32":
47
+ subprocess.Popen(
48
+ ["cmd", "/c", "start", "cmd", "/k", command],
49
+ cwd=working_dir,
50
+ )
51
+ return (
52
+ f"✓ Opened new terminal window\n\n"
53
+ f"Command: {command}\n\n"
54
+ f"The command is now running in a separate window. "
55
+ f"You can continue the conversation here."
56
+ )
57
+
58
+ elif system == "darwin":
59
+ escaped_command = command.replace('"', '\\"')
60
+ escaped_dir = working_dir.replace('"', '\\"')
61
+ subprocess.Popen(
62
+ [
63
+ "osascript",
64
+ "-e",
65
+ f'tell app "Terminal" to do script "cd \\"{escaped_dir}\\" && {escaped_command}"',
66
+ ]
67
+ )
68
+ return (
69
+ f"✓ Opened new Terminal window\n\n"
70
+ f"Command: {command}\n\n"
71
+ f"The command is now running in a separate window. "
72
+ f"You can continue the conversation here."
73
+ )
74
+
75
+ else:
76
+ # Linux: try common terminal emulators in order
77
+ terminals = [
78
+ [
79
+ "gnome-terminal",
80
+ "--working-directory",
81
+ working_dir,
82
+ "--",
83
+ "bash",
84
+ "-c",
85
+ f"{command}; exec bash",
86
+ ],
87
+ ["konsole", "--workdir", working_dir, "-e", f"bash -c '{command}; exec bash'"],
88
+ ["xterm", "-e", f"bash -c 'cd \"{working_dir}\" && {command}; exec bash'"],
89
+ [
90
+ "x-terminal-emulator",
91
+ "-e",
92
+ f"bash -c 'cd \"{working_dir}\" && {command}; exec bash'",
93
+ ],
94
+ ]
95
+ for term_cmd in terminals:
96
+ try:
97
+ subprocess.Popen(term_cmd)
98
+ return (
99
+ f"✓ Opened new terminal window\n\n"
100
+ f"Command: {command}\n\n"
101
+ f"The command is now running in a separate window. "
102
+ f"You can continue the conversation here."
103
+ )
104
+ except FileNotFoundError:
105
+ continue
106
+
107
+ # Fallback: background process
108
+ process = subprocess.Popen(
109
+ command,
110
+ shell=True,
111
+ stdout=subprocess.DEVNULL,
112
+ stderr=subprocess.DEVNULL,
113
+ start_new_session=True,
114
+ cwd=working_dir,
115
+ )
116
+ return (
117
+ f"✓ Started in background (PID: {process.pid})\n\n"
118
+ f"Command: {command}\n\n"
119
+ f"Note: No GUI terminal emulator found — process is running in the background."
120
+ )
121
+
122
+ except Exception as e:
123
+ return f"Error opening terminal: {str(e)}\n\nRun manually: {command}"