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.
- cli/commands/ask.py +0 -0
- cli/commands/edit.py +0 -0
- cli/commands/run.py +0 -0
- cli/main.py +1527 -0
- config/permissions.py +285 -0
- config/profiles.py +0 -0
- config/settings.py +185 -0
- core/agent.py +433 -0
- core/context.py +0 -0
- core/executor.py +213 -0
- core/modes.py +141 -0
- core/planner.py +16 -0
- core/sessions.py +271 -0
- core/tasks.py +142 -0
- devorch-0.1.2.dist-info/METADATA +528 -0
- devorch-0.1.2.dist-info/RECORD +50 -0
- devorch-0.1.2.dist-info/WHEEL +4 -0
- devorch-0.1.2.dist-info/entry_points.txt +2 -0
- providers/__init__.py +130 -0
- providers/anthropic.py +127 -0
- providers/base.py +51 -0
- providers/custom.py +186 -0
- providers/deepseek.py +170 -0
- providers/gemini.py +192 -0
- providers/github_copilot.py +196 -0
- providers/groq.py +158 -0
- providers/kimi.py +181 -0
- providers/lmstudio.py +147 -0
- providers/local.py +214 -0
- providers/mistral.py +161 -0
- providers/openai.py +114 -0
- providers/openrouter.py +195 -0
- providers/together.py +159 -0
- schemas/message.py +32 -0
- schemas/task.py +115 -0
- schemas/tool.py +0 -0
- tools/__init__.py +30 -0
- tools/base.py +40 -0
- tools/edit.py +236 -0
- tools/filesystem.py +224 -0
- tools/git.py +0 -0
- tools/grep.py +280 -0
- tools/search.py +150 -0
- tools/shell.py +55 -0
- tools/task.py +91 -0
- tools/terminal.py +123 -0
- tools/terminal_session.py +298 -0
- tools/tests.py +0 -0
- tools/websearch.py +166 -0
- utils/logger.py +52 -0
|
@@ -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
|