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.
@@ -0,0 +1,298 @@
1
+ """
2
+ Managed terminal session tool — lets the LLM start long-running processes,
3
+ read their output, send input, and stop them, all within the conversation.
4
+
5
+ Session data is stored as a simple in-process dict (sessions survive for the
6
+ lifetime of one DevOrch run). Each session gets a temp log file so the LLM
7
+ can poll output without blocking.
8
+ """
9
+
10
+ import os
11
+ import subprocess
12
+ import tempfile
13
+ import threading
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from tools.base import Tool
19
+
20
+ # ── In-memory session registry ──────────────────────────────────────────────
21
+ # { session_id: { "process": Popen, "log_file": str, "command": str } }
22
+ _SESSIONS: dict[str, dict] = {}
23
+ _LOCK = threading.Lock()
24
+
25
+
26
+ def _stream_to_file(process: subprocess.Popen, log_path: str) -> None:
27
+ """Background thread: read stdout+stderr and append to log file."""
28
+ try:
29
+ with open(log_path, "ab") as f:
30
+ for line in process.stdout: # type: ignore[union-attr]
31
+ f.write(line)
32
+ f.flush()
33
+ except Exception:
34
+ pass
35
+
36
+
37
+ # ── Schema ───────────────────────────────────────────────────────────────────
38
+
39
+
40
+ class TerminalSessionSchema(BaseModel):
41
+ action: str = Field(
42
+ ...,
43
+ description=(
44
+ "Action to perform. One of: "
45
+ "'start' — launch a command in a managed background session; "
46
+ "'read' — get recent output from a running session; "
47
+ "'send' — send a line of text/input to a running session's stdin; "
48
+ "'stop' — terminate a session; "
49
+ "'list' — list all active sessions."
50
+ ),
51
+ )
52
+ session_id: str | None = Field(
53
+ None,
54
+ description=(
55
+ "Identifier for the session. Required for 'start', 'read', 'send', 'stop'. "
56
+ "Choose a short, descriptive name, e.g. 'frontend', 'api-server', 'worker'."
57
+ ),
58
+ )
59
+ command: str | None = Field(
60
+ None,
61
+ description="Shell command to run. Required for 'start'.",
62
+ )
63
+ input: str | None = Field(
64
+ None,
65
+ description=(
66
+ "Text to send to the process stdin. Required for 'send'. "
67
+ "Include a newline (\\n) if the program expects Enter to be pressed."
68
+ ),
69
+ )
70
+ lines: int = Field(
71
+ 50,
72
+ description="Number of recent output lines to return for 'read'. Default 50.",
73
+ )
74
+
75
+
76
+ # ── Tool ──────────────────────────────────────────────────────────────────────
77
+
78
+
79
+ class TerminalSessionTool(Tool):
80
+ name = "terminal_session"
81
+ description = """\
82
+ Manages long-running background terminal sessions so you can interact with them
83
+ across multiple conversation turns.
84
+
85
+ Actions:
86
+ - **start** — Start a command in a named background session (e.g. `npm run dev`).
87
+ The process runs in the background; use `read` to see its output.
88
+ - **read** — Read recent stdout/stderr output from a running session.
89
+ - **send** — Send a line of input to the process stdin (e.g. to restart nodemon with `rs\\n`).
90
+ - **stop** — Kill a running session.
91
+ - **list** — Show all currently active sessions and their status.
92
+
93
+ Example workflow:
94
+ 1. `start` session_id="server" command="npm run dev"
95
+ 2. `read` session_id="server" # check startup logs
96
+ 3. `send` session_id="server" input="rs\\n" # restart nodemon
97
+ 4. `stop` session_id="server"
98
+
99
+ Use `open_terminal` instead if you want an interactive visible terminal window.
100
+ Use this tool when you need programmatic access to a process (check output, send input)."""
101
+ args_schema = TerminalSessionSchema
102
+
103
+ # ── helpers ──────────────────────────────────────────────────────────────
104
+
105
+ def _get_session(self, session_id: str) -> dict | None:
106
+ with _LOCK:
107
+ return _SESSIONS.get(session_id)
108
+
109
+ def _session_alive(self, session: dict) -> bool:
110
+ return session["process"].poll() is None
111
+
112
+ # ── actions ──────────────────────────────────────────────────────────────
113
+
114
+ def _start(self, session_id: str, command: str) -> str:
115
+ with _LOCK:
116
+ if session_id in _SESSIONS:
117
+ existing = _SESSIONS[session_id]
118
+ if existing["process"].poll() is None:
119
+ return (
120
+ f"Error: Session '{session_id}' is already running. "
121
+ f"Use stop first or choose a different session_id."
122
+ )
123
+ # Dead session — clean up and restart
124
+ del _SESSIONS[session_id]
125
+
126
+ # Create a temp log file for stdout+stderr
127
+ log_fd, log_path = tempfile.mkstemp(prefix=f"devorch_{session_id}_", suffix=".log")
128
+ os.close(log_fd)
129
+
130
+ try:
131
+ process = subprocess.Popen(
132
+ command,
133
+ shell=True,
134
+ stdin=subprocess.PIPE,
135
+ stdout=subprocess.PIPE,
136
+ stderr=subprocess.STDOUT,
137
+ cwd=os.getcwd(),
138
+ bufsize=0, # unbuffered for real-time output
139
+ )
140
+ except Exception as e:
141
+ os.unlink(log_path)
142
+ return f"Error starting session '{session_id}': {e}"
143
+
144
+ # Background thread streams process output to the log file
145
+ t = threading.Thread(
146
+ target=_stream_to_file,
147
+ args=(process, log_path),
148
+ daemon=True,
149
+ name=f"devorch-session-{session_id}",
150
+ )
151
+ t.start()
152
+
153
+ with _LOCK:
154
+ _SESSIONS[session_id] = {
155
+ "process": process,
156
+ "log_path": log_path,
157
+ "command": command,
158
+ }
159
+
160
+ return (
161
+ f"✓ Session '{session_id}' started (PID {process.pid})\n\n"
162
+ f"Command: {command}\n\n"
163
+ f"Use read action to see output. Use send to send input. Use stop to terminate."
164
+ )
165
+
166
+ def _read(self, session_id: str, lines: int) -> str:
167
+ session = self._get_session(session_id)
168
+ if not session:
169
+ return f"Error: No session named '{session_id}'. Use list to see active sessions."
170
+
171
+ log_path = session["log_path"]
172
+ alive = self._session_alive(session)
173
+ status = "running" if alive else f"exited (code {session['process'].returncode})"
174
+
175
+ try:
176
+ with open(log_path, "rb") as f:
177
+ content = f.read()
178
+
179
+ text = content.decode("utf-8", errors="replace")
180
+ output_lines = text.splitlines()
181
+
182
+ if not output_lines:
183
+ return f"[Session '{session_id}' — {status}]\nNo output yet."
184
+
185
+ tail = output_lines[-lines:]
186
+ skipped = max(0, len(output_lines) - lines)
187
+ prefix = f"[... {skipped} earlier lines omitted ...]\n" if skipped else ""
188
+
189
+ return f"[Session '{session_id}' — {status}]\n\n{prefix}" + "\n".join(tail)
190
+
191
+ except Exception as e:
192
+ return f"Error reading session '{session_id}' output: {e}"
193
+
194
+ def _send(self, session_id: str, input_text: str) -> str:
195
+ session = self._get_session(session_id)
196
+ if not session:
197
+ return f"Error: No session named '{session_id}'."
198
+
199
+ if not self._session_alive(session):
200
+ return f"Error: Session '{session_id}' is no longer running."
201
+
202
+ process = session["process"]
203
+ if not process.stdin:
204
+ return f"Error: Session '{session_id}' does not have an open stdin pipe."
205
+
206
+ try:
207
+ encoded = input_text.encode("utf-8")
208
+ process.stdin.write(encoded)
209
+ process.stdin.flush()
210
+ return f"✓ Sent to '{session_id}': {repr(input_text)}"
211
+ except Exception as e:
212
+ return f"Error sending input to '{session_id}': {e}"
213
+
214
+ def _stop(self, session_id: str) -> str:
215
+ session = self._get_session(session_id)
216
+ if not session:
217
+ return f"Error: No session named '{session_id}'."
218
+
219
+ process = session["process"]
220
+ if not self._session_alive(session):
221
+ with _LOCK:
222
+ _SESSIONS.pop(session_id, None)
223
+ return f"Session '{session_id}' was already stopped."
224
+
225
+ try:
226
+ process.terminate()
227
+ try:
228
+ process.wait(timeout=5)
229
+ except subprocess.TimeoutExpired:
230
+ process.kill()
231
+ process.wait()
232
+
233
+ # Clean up log file
234
+ log_path = session.get("log_path", "")
235
+ try:
236
+ if log_path and os.path.exists(log_path):
237
+ os.unlink(log_path)
238
+ except Exception:
239
+ pass
240
+
241
+ with _LOCK:
242
+ _SESSIONS.pop(session_id, None)
243
+
244
+ return f"✓ Session '{session_id}' stopped."
245
+
246
+ except Exception as e:
247
+ return f"Error stopping session '{session_id}': {e}"
248
+
249
+ def _list(self) -> str:
250
+ with _LOCK:
251
+ sessions = dict(_SESSIONS)
252
+
253
+ if not sessions:
254
+ return "No active sessions."
255
+
256
+ lines = ["Active sessions:\n"]
257
+ for sid, s in sessions.items():
258
+ alive = s["process"].poll() is None
259
+ status = "● running" if alive else f"✗ exited ({s['process'].returncode})"
260
+ lines.append(f" {sid:20s} {status:20s} {s['command']}")
261
+
262
+ return "\n".join(lines)
263
+
264
+ # ── dispatch ─────────────────────────────────────────────────────────────
265
+
266
+ def run(self, arguments: dict[str, Any]) -> Any:
267
+ action = (arguments.get("action") or "").lower().strip()
268
+ session_id = (arguments.get("session_id") or "").strip()
269
+ command = arguments.get("command", "")
270
+ input_text = arguments.get("input", "")
271
+ lines = int(arguments.get("lines") or 50)
272
+
273
+ if action == "list":
274
+ return self._list()
275
+
276
+ if not session_id:
277
+ return "Error: session_id is required for this action."
278
+
279
+ if action == "start":
280
+ if not command:
281
+ return "Error: command is required for 'start'."
282
+ return self._start(session_id, command)
283
+
284
+ elif action == "read":
285
+ return self._read(session_id, lines)
286
+
287
+ elif action == "send":
288
+ if input_text is None:
289
+ return "Error: input is required for 'send'."
290
+ return self._send(session_id, input_text)
291
+
292
+ elif action == "stop":
293
+ return self._stop(session_id)
294
+
295
+ else:
296
+ return (
297
+ f"Error: Unknown action '{action}'. Valid actions: start, read, send, stop, list."
298
+ )
tools/tests.py ADDED
File without changes
tools/websearch.py ADDED
@@ -0,0 +1,166 @@
1
+ """Web search tool using DuckDuckGo (no API key required)."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from tools.base import Tool
8
+
9
+ try:
10
+ from duckduckgo_search import DDGS
11
+
12
+ DDGS_AVAILABLE = True
13
+ except ImportError:
14
+ DDGS_AVAILABLE = False
15
+
16
+
17
+ class WebSearchSchema(BaseModel):
18
+ query: str = Field(..., description="The search query to look up on the web")
19
+ max_results: int = Field(5, description="Maximum number of results to return (1-10)")
20
+
21
+
22
+ class WebSearchTool(Tool):
23
+ """
24
+ Search the web using DuckDuckGo.
25
+
26
+ Use this tool when you need:
27
+ - Current information (news, recent events)
28
+ - Documentation or API references
29
+ - Solutions to programming problems
30
+ - Package/library information
31
+ - Any information you're unsure about
32
+ """
33
+
34
+ name = "websearch"
35
+ description = """Search the web for current information. Use when you need:
36
+ - Up-to-date information (news, releases, docs)
37
+ - Programming solutions or best practices
38
+ - Package/library documentation
39
+ - Any factual information you're uncertain about"""
40
+
41
+ args_schema = WebSearchSchema
42
+
43
+ def run(self, arguments: dict[str, Any]) -> str:
44
+ if not DDGS_AVAILABLE:
45
+ return "Error: Web search not available. Install with: pip install duckduckgo-search"
46
+
47
+ query = arguments.get("query", "")
48
+ max_results = min(max(arguments.get("max_results", 5), 1), 10)
49
+
50
+ if not query:
51
+ return "Error: No search query provided."
52
+
53
+ try:
54
+ results = self._search(query, max_results)
55
+
56
+ if not results:
57
+ return f"No results found for: {query}"
58
+
59
+ # Format results
60
+ output = f"Search results for: {query}\n\n"
61
+
62
+ for i, result in enumerate(results, 1):
63
+ title = result.get("title", "No title")
64
+ url = result.get("href", result.get("link", ""))
65
+ snippet = result.get("body", result.get("snippet", ""))
66
+
67
+ output += f"{i}. **{title}**\n"
68
+ if url:
69
+ output += f" URL: {url}\n"
70
+ if snippet:
71
+ output += f" {snippet}\n"
72
+ output += "\n"
73
+
74
+ return output.strip()
75
+
76
+ except Exception as e:
77
+ return f"Error searching: {str(e)}"
78
+
79
+ def _search(self, query: str, max_results: int) -> list[dict]:
80
+ """Perform the actual search."""
81
+ with DDGS() as ddgs:
82
+ results = list(ddgs.text(query, max_results=max_results))
83
+ return results
84
+
85
+
86
+ class WebFetchSchema(BaseModel):
87
+ url: str = Field(..., description="The URL to fetch content from")
88
+
89
+
90
+ class WebFetchTool(Tool):
91
+ """
92
+ Fetch and read content from a URL.
93
+
94
+ Use this tool to read documentation pages, articles, or any web content.
95
+ """
96
+
97
+ name = "webfetch"
98
+ description = """Fetch and read content from a specific URL. Use when:
99
+ - You need to read a documentation page
100
+ - User provides a specific URL to check
101
+ - You found a relevant URL from search results"""
102
+
103
+ args_schema = WebFetchSchema
104
+
105
+ def run(self, arguments: dict[str, Any]) -> str:
106
+ url = arguments.get("url", "")
107
+
108
+ if not url:
109
+ return "Error: No URL provided."
110
+
111
+ try:
112
+ import httpx
113
+
114
+ # Fetch the page
115
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
116
+
117
+ with httpx.Client(timeout=15.0, follow_redirects=True) as client:
118
+ response = client.get(url, headers=headers)
119
+ response.raise_for_status()
120
+ html = response.text
121
+
122
+ # Simple HTML to text conversion
123
+ text = self._html_to_text(html)
124
+
125
+ # Truncate if too long
126
+ max_length = 4000
127
+ if len(text) > max_length:
128
+ text = text[:max_length] + "\n\n[Content truncated...]"
129
+
130
+ return f"Content from {url}:\n\n{text}"
131
+
132
+ except Exception as e:
133
+ return f"Error fetching URL: {str(e)}"
134
+
135
+ def _html_to_text(self, html: str) -> str:
136
+ """Convert HTML to readable text."""
137
+ import re
138
+ from html import unescape
139
+
140
+ # Remove script and style elements
141
+ html = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
142
+ html = re.sub(r"<style[^>]*>.*?</style>", "", html, flags=re.DOTALL | re.IGNORECASE)
143
+
144
+ # Remove HTML comments
145
+ html = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
146
+
147
+ # Replace common elements with newlines
148
+ html = re.sub(r"<br[^>]*>", "\n", html, flags=re.IGNORECASE)
149
+ html = re.sub(r"<p[^>]*>", "\n\n", html, flags=re.IGNORECASE)
150
+ html = re.sub(r"<div[^>]*>", "\n", html, flags=re.IGNORECASE)
151
+ html = re.sub(r"<h[1-6][^>]*>", "\n\n", html, flags=re.IGNORECASE)
152
+ html = re.sub(r"</h[1-6]>", "\n", html, flags=re.IGNORECASE)
153
+ html = re.sub(r"<li[^>]*>", "\n- ", html, flags=re.IGNORECASE)
154
+
155
+ # Remove all other HTML tags
156
+ html = re.sub(r"<[^>]+>", "", html)
157
+
158
+ # Unescape HTML entities
159
+ text = unescape(html)
160
+
161
+ # Clean up whitespace
162
+ text = re.sub(r"\n\s*\n", "\n\n", text)
163
+ text = re.sub(r" +", " ", text)
164
+ text = text.strip()
165
+
166
+ return text
utils/logger.py ADDED
@@ -0,0 +1,52 @@
1
+ import logging
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+
6
+ # Global rich console instance
7
+ console = Console()
8
+
9
+
10
+ def setup_logger(name: str) -> logging.Logger:
11
+ """Sets up a standard python logger if needed for file logging, etc."""
12
+ logger = logging.getLogger(name)
13
+ if not logger.handlers:
14
+ handler = logging.StreamHandler()
15
+ formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s")
16
+ handler.setFormatter(formatter)
17
+ logger.addHandler(handler)
18
+ logger.setLevel(logging.INFO)
19
+ return logger
20
+
21
+
22
+ def print_error(msg: str):
23
+ """Prints an error message in bold red."""
24
+ console.print(f"[bold red]Error: {msg}[/bold red]")
25
+
26
+
27
+ def print_warning(msg: str):
28
+ """Prints a warning message in bold yellow."""
29
+ console.print(f"[bold yellow]Warning: {msg}[/bold yellow]")
30
+
31
+
32
+ def print_success(msg: str):
33
+ """Prints a success message in bold green."""
34
+ console.print(f"[bold green]{msg}[/bold green]")
35
+
36
+
37
+ def print_info(msg: str):
38
+ """Prints an info message in blue."""
39
+ console.print(f"[blue]{msg}[/blue]")
40
+
41
+
42
+ def print_panel(content, title: str = "", border_style: str = "blue", fit: bool = False):
43
+ """Prints a rich Panel."""
44
+ if fit:
45
+ console.print(Panel.fit(content, title=title, border_style=border_style))
46
+ else:
47
+ console.print(Panel(content, title=title, border_style=border_style))
48
+
49
+
50
+ def get_console() -> Console:
51
+ """Returns the global rich Console instance to be used for status spinners, etc."""
52
+ return console