opalacoder 0.1.0__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.
opalacoder/terminal.py ADDED
@@ -0,0 +1,186 @@
1
+ """Elegant terminal output utilities for OpalaCoder using Rich."""
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.text import Text
6
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
7
+ from rich.table import Table
8
+ from rich.rule import Rule
9
+ from rich import print as rprint
10
+ from contextlib import contextmanager
11
+ import sys
12
+
13
+ from .i18n import _
14
+
15
+ try:
16
+ import readline
17
+ except ImportError:
18
+ pass
19
+
20
+ console = Console(highlight=False)
21
+
22
+ class UserCancelled(Exception):
23
+ """Raised when the user wants to cancel the current operation."""
24
+ pass
25
+
26
+ class AppExit(Exception):
27
+ """Raised when the user wants to exit the application completely."""
28
+ pass
29
+
30
+ _CANCEL_WORDS = {"cancelar", "abortar", "/cancel", "/abort", "cancel", "abort"}
31
+ _EXIT_WORDS = {"/exit", "/quit", "/sair", "sair", "exit", "quit"}
32
+
33
+ def _check_cancel(text: str) -> None:
34
+ text_lower = text.strip().lower()
35
+ if text_lower in _CANCEL_WORDS:
36
+ raise UserCancelled()
37
+ if text_lower in _EXIT_WORDS:
38
+ raise AppExit()
39
+
40
+
41
+ # ─── Branding ─────────────────────────────────────────────────────────────────
42
+
43
+ BANNER = r"""
44
+ ___ _ ____ _
45
+ / _ \ _ __ __ _ | | __ _/ ___|___ __| | ___ _ __
46
+ | | | | '_ \ / _` || |/ _` | | / _ \ / _` |/ _ \ '__|
47
+ | |_| | |_) | (_| || | (_| | |__| (_) | (_| | __/ |
48
+ \___/| .__/ \__,_||_|\__,_|\____\___/ \__,_|\___|_|
49
+ |_|
50
+ """
51
+
52
+
53
+ def print_banner(version: str = "0.1.0", mode: str = "plan") -> None:
54
+ text = Text(BANNER, style="bold cyan")
55
+ console.print(text)
56
+ console.print(
57
+ f" [dim]version {version}[/dim] "
58
+ f"[bold]mode:[/bold] [yellow]{mode}[/yellow]"
59
+ )
60
+ console.print()
61
+
62
+
63
+ # ─── Section headers ──────────────────────────────────────────────────────────
64
+
65
+ def section(title: str, style: str = "bold blue") -> None:
66
+ console.print(Rule(f"[{style}]{title}[/{style}]", style="dim"))
67
+
68
+
69
+ def subsection(title: str) -> None:
70
+ console.print(f"\n[bold cyan]▶ {title}[/bold cyan]")
71
+
72
+
73
+ # ─── Info / status ────────────────────────────────────────────────────────────
74
+
75
+ def info(msg: str) -> None:
76
+ console.print(f"[dim] {msg}[/dim]")
77
+
78
+
79
+ def success(msg: str) -> None:
80
+ console.print(f"[bold green] ✓ {msg}[/bold green]")
81
+
82
+
83
+ def warning(msg: str) -> None:
84
+ console.print(f"[bold yellow] ⚠ {msg}[/bold yellow]")
85
+
86
+
87
+ def error(msg: str) -> None:
88
+ console.print(f"[bold red] ✗ {msg}[/bold red]")
89
+
90
+
91
+ def thinking(msg: str) -> None:
92
+ console.print(f"[italic dim cyan] 💭 {msg}[/italic dim cyan]")
93
+
94
+
95
+ # ─── Panels ───────────────────────────────────────────────────────────────────
96
+
97
+ def show_plan(plan_text: str, title: str = None) -> None:
98
+ title = title or _("generated_plan")
99
+ console.print(
100
+ Panel(plan_text, title=f"[bold]{title}[/bold]", border_style="cyan", expand=False)
101
+ )
102
+
103
+
104
+ def show_result(text: str, title: str = None) -> None:
105
+ title = title or _("final_result")
106
+ console.print(
107
+ Panel(text, title=f"[bold green]{title}[/bold green]", border_style="green")
108
+ )
109
+
110
+
111
+ def show_error_report(errors: list[tuple[str, str]]) -> None:
112
+ table = Table(title=_("exec_errors"), border_style="red", show_lines=True)
113
+ table.add_column(_("subplan"), style="bold yellow")
114
+ table.add_column(_("error"), style="red")
115
+ for sp_id, err in errors:
116
+ table.add_row(sp_id, err[:300])
117
+ console.print(table)
118
+
119
+
120
+ # ─── Spinner context ──────────────────────────────────────────────────────────
121
+
122
+ @contextmanager
123
+ def spinner(label: str):
124
+ with Progress(
125
+ SpinnerColumn(),
126
+ TextColumn(f"[cyan]{label}[/cyan]"),
127
+ transient=True,
128
+ console=console,
129
+ ) as progress:
130
+ progress.add_task("", total=None)
131
+ yield progress
132
+
133
+
134
+ # ─── User prompts ─────────────────────────────────────────────────────────────
135
+
136
+ def ask(prompt: str) -> str:
137
+ from rich.markup import escape
138
+ console.print(f"\n[bold yellow]?[/bold yellow] {escape(prompt)}")
139
+ ans = input(" → ").strip()
140
+ _check_cancel(ans)
141
+ return ans
142
+
143
+
144
+ def confirm(prompt: str, default: bool = True) -> bool:
145
+ from rich.markup import escape
146
+ hint = "[Y/n]" if default else "[y/N]"
147
+ console.print(f"\n[bold yellow]?[/bold yellow] {escape(prompt)} {hint}")
148
+ raw = input(" → ").strip().lower()
149
+ _check_cancel(raw)
150
+ if not raw:
151
+ return default
152
+ return raw in _("yes_hints")
153
+
154
+
155
+ def choose(prompt: str, options: list[str]) -> str:
156
+ """Let user pick from a numbered list; returns chosen option string."""
157
+ from rich.markup import escape
158
+ console.print(f"\n[bold yellow]?[/bold yellow] {escape(prompt)}")
159
+ for i, opt in enumerate(options, 1):
160
+ console.print(f" [cyan]{i}[/cyan]) {escape(opt)}")
161
+ while True:
162
+ raw = input(" → ").strip()
163
+ _check_cancel(raw)
164
+ if raw.isdigit() and 1 <= int(raw) <= len(options):
165
+ return options[int(raw) - 1]
166
+ # accept text match too
167
+ matches = [o for o in options if o.lower().startswith(raw.lower())]
168
+ if len(matches) == 1:
169
+ return matches[0]
170
+ console.print(f" [red]{_('invalid_option')}[/red]")
171
+
172
+
173
+ # ─── Subplan progress table ───────────────────────────────────────────────────
174
+
175
+ def subplan_status_table(statuses: list[tuple[str, str, str]]) -> None:
176
+ """statuses: [(sp_id, objective, status_label), ...]"""
177
+ table = Table(show_header=True, header_style="bold magenta", show_lines=False)
178
+ table.add_column(_("table_id"), style="cyan", width=6)
179
+ table.add_column(_("table_objective"))
180
+ table.add_column(_("table_status"), width=12)
181
+ for sp_id, obj, status in statuses:
182
+ color = {"OK": "green", "FALHOU": "red", "FAILED": "red", "ERRO": "red", "ERROR": "red", "→": "yellow"}.get(
183
+ status.upper().split()[0], "white"
184
+ )
185
+ table.add_row(sp_id, obj[:70], f"[{color}]{status}[/{color}]")
186
+ console.print(table)
opalacoder/tools.py ADDED
@@ -0,0 +1,351 @@
1
+ """Tools for the OpalaCoder Autonomous Agent (MemGPT pattern + HITL)."""
2
+
3
+ import os
4
+ import subprocess
5
+ import time
6
+ import ast
7
+ from pathlib import Path
8
+ from agenticblocks.core.function_block import as_tool
9
+ from . import terminal as T
10
+
11
+ # ─── Shared progress state ───────────────────────────────────────────────────
12
+ # The orchestrator reads these to render a live progress panel.
13
+
14
+ class _AgentProgress:
15
+ def __init__(self):
16
+ self.heartbeat: int = 0
17
+ self.max_heartbeats: int = 15
18
+ self.last_tool: str = "—"
19
+ self.last_args: str = ""
20
+ self.start_time: float = time.monotonic()
21
+ self.live_context = None
22
+
23
+ def update(self, tool_name: str, args_preview: str = "") -> None:
24
+ self.heartbeat += 1
25
+ self.last_tool = tool_name
26
+ self.last_args = args_preview[:80] if args_preview else ""
27
+
28
+ def elapsed(self) -> str:
29
+ secs = int(time.monotonic() - self.start_time)
30
+ m, s = divmod(secs, 60)
31
+ return f"{m}m{s:02d}s"
32
+
33
+
34
+ AGENT_PROGRESS = _AgentProgress()
35
+
36
+ # Set once at session start via set_project_path(); all tools use this as their workspace.
37
+ _PROJECT_PATH: str = ""
38
+
39
+
40
+ def set_project_path(path: str) -> None:
41
+ global _PROJECT_PATH
42
+ _PROJECT_PATH = os.path.abspath(path)
43
+
44
+
45
+ def get_project_path() -> str:
46
+ return _PROJECT_PATH or os.getcwd()
47
+
48
+
49
+ def _resolve_path(path: str) -> str:
50
+ """Make path absolute, rooted at the project directory if relative."""
51
+ if os.path.isabs(path):
52
+ return path
53
+ return os.path.join(get_project_path(), path)
54
+
55
+
56
+ def _preview(value: object, max_len: int = 60) -> str:
57
+ """Return a short, single-line preview of any argument value."""
58
+ s = str(value).replace("\n", " ")
59
+ return s[:max_len] + "…" if len(s) > max_len else s
60
+
61
+
62
+ # ─── Tools ───────────────────────────────────────────────────────────────────
63
+
64
+ @as_tool(name="read_file", description="Read the contents of a file in the project workspace. Relative paths are resolved from the project directory.")
65
+ def read_file(path: str) -> str:
66
+ resolved = _resolve_path(path)
67
+ AGENT_PROGRESS.update("read_file", f"path={_preview(resolved)}")
68
+ try:
69
+ with open(resolved, "r", encoding="utf-8") as f:
70
+ return f.read()
71
+ except Exception as e:
72
+ return f"Error reading {resolved}: {e}"
73
+
74
+
75
+ @as_tool(name="write_file", description="Write or overwrite a file inside the project directory. Relative paths are resolved from the project directory. Creates parent directories if needed.")
76
+ def write_file(path: str, content: str) -> str:
77
+ resolved = _resolve_path(path)
78
+ AGENT_PROGRESS.update("write_file", f"path={_preview(resolved)}")
79
+ try:
80
+ Path(resolved).parent.mkdir(parents=True, exist_ok=True)
81
+ with open(resolved, "w", encoding="utf-8") as f:
82
+ f.write(content)
83
+ return f"Successfully wrote to {resolved}."
84
+ except Exception as e:
85
+ return f"Error writing {resolved}: {e}"
86
+
87
+
88
+ @as_tool(name="run_command", description="Execute a non-interactive shell command (e.g. ls, mkdir, grep, npm install). Runs inside the project directory. Returns stdout/stderr. NEVER run servers or infinite processes.")
89
+ def run_command(command: str) -> str:
90
+ AGENT_PROGRESS.update("run_command", f"$ {_preview(command)}")
91
+ cwd = get_project_path()
92
+ try:
93
+ res = subprocess.run(
94
+ command,
95
+ shell=True,
96
+ capture_output=True,
97
+ text=True,
98
+ stdin=subprocess.DEVNULL,
99
+ timeout=120,
100
+ cwd=cwd,
101
+ )
102
+ out = res.stdout.strip()
103
+ err = res.stderr.strip()
104
+ # Truncate large outputs
105
+ if len(out) > 2000:
106
+ out = out[:1000] + "\n... [TRUNCATED] ...\n" + out[-500:]
107
+ if len(err) > 2000:
108
+ err = err[:1000] + "\n... [TRUNCATED] ...\n" + err[-500:]
109
+ output = ""
110
+ if out:
111
+ output += f"STDOUT:\n{out}\n"
112
+ if err:
113
+ output += f"STDERR:\n{err}\n"
114
+ return output if output else "Command executed successfully (no output)."
115
+ except subprocess.TimeoutExpired:
116
+ return "Error: Command timed out after 120 seconds."
117
+ except Exception as e:
118
+ return f"Error running command: {e}"
119
+
120
+
121
+ @as_tool(name="search_code", description="Search for a specific string across all files using grep. Searches inside the project directory by default.")
122
+ def search_code(query: str, path: str = ".") -> str:
123
+ resolved = _resolve_path(path)
124
+ AGENT_PROGRESS.update("search_code", f"query={_preview(query)} path={_preview(resolved)}")
125
+ try:
126
+ res = subprocess.run(
127
+ f"grep -rn '{query}' {resolved}",
128
+ shell=True,
129
+ capture_output=True,
130
+ text=True,
131
+ timeout=30,
132
+ cwd=get_project_path(),
133
+ )
134
+ return res.stdout if res.stdout else "No matches."
135
+ except Exception as e:
136
+ return f"Error searching code: {e}"
137
+
138
+
139
+ @as_tool(
140
+ name="ask_human",
141
+ description=(
142
+ "Pause execution and ask the human a critical question. "
143
+ "Use ONLY for genuinely dangerous or irreversible operations such as: "
144
+ "running 'rm -rf', 'sudo' commands, accessing credentials, or modifying files outside the workspace. "
145
+ "NEVER use for: creating directories, creating files, writing code, installing common packages (npm, pip), "
146
+ "or any standard development task. Just do those things directly."
147
+ )
148
+ )
149
+ def ask_human(question: str) -> str:
150
+ AGENT_PROGRESS.update("ask_human", _preview(question))
151
+
152
+ if getattr(AGENT_PROGRESS, "live_context", None):
153
+ AGENT_PROGRESS.live_context.stop()
154
+
155
+ try:
156
+ T.warning(f"\n[Agent requires input]: {question}")
157
+ return T.ask("Your response")
158
+ finally:
159
+ if getattr(AGENT_PROGRESS, "live_context", None):
160
+ AGENT_PROGRESS.live_context.start()
161
+
162
+
163
+ @as_tool(
164
+ name="get_project_overview",
165
+ description=(
166
+ "Return a compact overview of the current project: directory tree (max depth 3), "
167
+ "file count by type, and a summary of key files (README, package.json, requirements.txt, etc.). "
168
+ "Call this at the start of any task to understand the project before acting."
169
+ ),
170
+ )
171
+ def get_project_overview() -> str:
172
+ AGENT_PROGRESS.update("get_project_overview")
173
+ root = Path(get_project_path())
174
+
175
+ if not root.exists():
176
+ return f"Project directory does not exist yet: {root}"
177
+
178
+ # ── Directory tree (depth ≤ 3, skip hidden & node_modules/__pycache__) ──
179
+ SKIP = {".git", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache", "dist", "build"}
180
+
181
+ def _tree(path: Path, prefix: str = "", depth: int = 0) -> list[str]:
182
+ if depth > 3:
183
+ return [" " * depth + "…"]
184
+ lines = []
185
+ try:
186
+ entries = sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name))
187
+ except PermissionError:
188
+ return []
189
+ for entry in entries:
190
+ if entry.name.startswith(".") or entry.name in SKIP:
191
+ continue
192
+ connector = "├── " if entry != entries[-1] else "└── "
193
+ lines.append(prefix + connector + entry.name + ("/" if entry.is_dir() else ""))
194
+ if entry.is_dir():
195
+ extension = "│ " if entry != entries[-1] else " "
196
+ lines.extend(_tree(entry, prefix + extension, depth + 1))
197
+ return lines
198
+
199
+ tree_lines = _tree(root)
200
+ tree_str = f"{root.name}/\n" + "\n".join(tree_lines) if tree_lines else f"{root.name}/ (empty)"
201
+
202
+ # ── File count by extension ──
203
+ ext_count: dict[str, int] = {}
204
+ total = 0
205
+ for p in root.rglob("*"):
206
+ if p.is_file() and not any(part in SKIP or part.startswith(".") for part in p.parts):
207
+ ext = p.suffix.lower() or "(no ext)"
208
+ ext_count[ext] = ext_count.get(ext, 0) + 1
209
+ total += 1
210
+ ext_summary = ", ".join(
211
+ f"{ext}: {n}" for ext, n in sorted(ext_count.items(), key=lambda x: -x[1])[:8]
212
+ )
213
+
214
+ # ── Key file snapshots ──
215
+ KEY_FILES = ["README.md", "package.json", "requirements.txt", "pyproject.toml",
216
+ "Makefile", "Dockerfile", "docker-compose.yml", ".env.example"]
217
+ snippets = []
218
+ for name in KEY_FILES:
219
+ kf = root / name
220
+ if kf.exists():
221
+ try:
222
+ content = kf.read_text(encoding="utf-8", errors="replace")[:400]
223
+ snippets.append(f"### {name}\n{content.strip()}")
224
+ except Exception:
225
+ pass
226
+
227
+ parts = [
228
+ f"## Project: {root.name}",
229
+ f"Path: {root}",
230
+ f"Total files: {total} | By type: {ext_summary or 'n/a'}",
231
+ "",
232
+ "## Directory structure",
233
+ tree_str,
234
+ ]
235
+ if snippets:
236
+ parts += ["", "## Key files"] + snippets
237
+
238
+ return "\n".join(parts)
239
+
240
+
241
+ @as_tool(
242
+ name="get_file_overview",
243
+ description=(
244
+ "Return an overview of a file's structure. For Python files, it lists classes, functions, "
245
+ "and methods with their start and end line numbers. For other files, it returns the first 100 lines."
246
+ )
247
+ )
248
+ def get_file_overview(path: str) -> str:
249
+ resolved = _resolve_path(path)
250
+ AGENT_PROGRESS.update("get_file_overview", f"path={_preview(resolved)}")
251
+ try:
252
+ with open(resolved, "r", encoding="utf-8") as f:
253
+ content = f.read()
254
+
255
+ if not path.endswith(".py"):
256
+ lines = content.splitlines()
257
+ preview = "\n".join(lines[:100])
258
+ return f"Overview for {path} (non-Python):\n{preview}" + ("\n... [TRUNCATED]" if len(lines) > 100 else "")
259
+
260
+ tree = ast.parse(content)
261
+ overview = [f"File: {path}"]
262
+
263
+ for node in ast.iter_child_nodes(tree):
264
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
265
+ start = node.lineno
266
+ end = node.end_lineno
267
+ overview.append(f"{node.__class__.__name__} '{node.name}' (lines {start}-{end})")
268
+
269
+ if isinstance(node, ast.ClassDef):
270
+ for subnode in node.body:
271
+ if isinstance(subnode, (ast.FunctionDef, ast.AsyncFunctionDef)):
272
+ sub_start = subnode.lineno
273
+ sub_end = subnode.end_lineno
274
+ overview.append(f" Method '{subnode.name}' (lines {sub_start}-{sub_end})")
275
+
276
+ if len(overview) == 1:
277
+ overview.append("No classes or functions found.")
278
+
279
+ return "\n".join(overview)
280
+ except Exception as e:
281
+ return f"Error generating overview for {resolved}: {e}"
282
+
283
+ @as_tool(
284
+ name="write_content_pos",
285
+ description=(
286
+ "Insert content into a file starting at a specific line number (1-indexed). "
287
+ "The new content will be inserted just before the specified line."
288
+ )
289
+ )
290
+ def write_content_pos(path: str, content: str, pos: int) -> str:
291
+ resolved = _resolve_path(path)
292
+ AGENT_PROGRESS.update("write_content_pos", f"path={_preview(resolved)} pos={pos}")
293
+ try:
294
+ with open(resolved, "r", encoding="utf-8") as f:
295
+ lines = f.readlines()
296
+
297
+ idx = max(0, min(pos - 1, len(lines)))
298
+
299
+ if content and not content.endswith('\n'):
300
+ content += '\n'
301
+
302
+ lines.insert(idx, content)
303
+
304
+ with open(resolved, "w", encoding="utf-8") as f:
305
+ f.writelines(lines)
306
+
307
+ return f"Successfully inserted content at line {pos} in {resolved}."
308
+ except FileNotFoundError:
309
+ return f"Error: File {resolved} not found."
310
+ except Exception as e:
311
+ return f"Error writing to {resolved}: {e}"
312
+
313
+ @as_tool(
314
+ name="read_content_pos",
315
+ description=(
316
+ "Read a specific range of lines from a file. "
317
+ "start_pos and end_pos are 1-indexed line numbers (inclusive)."
318
+ )
319
+ )
320
+ def read_content_pos(path: str, start_pos: int, end_pos: int) -> str:
321
+ resolved = _resolve_path(path)
322
+ AGENT_PROGRESS.update("read_content_pos", f"path={_preview(resolved)} lines={start_pos}-{end_pos}")
323
+ try:
324
+ with open(resolved, "r", encoding="utf-8") as f:
325
+ lines = f.readlines()
326
+
327
+ start_idx = max(0, start_pos - 1)
328
+ end_idx = min(len(lines), end_pos)
329
+
330
+ if start_idx >= len(lines):
331
+ return f"Error: start_pos {start_pos} is beyond the end of the file (total lines: {len(lines)})."
332
+
333
+ selected_lines = lines[start_idx:end_idx]
334
+ return "".join(selected_lines)
335
+ except FileNotFoundError:
336
+ return f"Error: File {resolved} not found."
337
+ except Exception as e:
338
+ return f"Error reading {resolved}: {e}"
339
+
340
+ def get_available_tools():
341
+ return [
342
+ get_project_overview,
343
+ get_file_overview,
344
+ read_file,
345
+ read_content_pos,
346
+ write_file,
347
+ write_content_pos,
348
+ run_command,
349
+ search_code,
350
+ ask_human,
351
+ ]