luckyd-code 1.2.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.
- luckyd_code/__init__.py +54 -0
- luckyd_code/__main__.py +5 -0
- luckyd_code/_agent_loop.py +551 -0
- luckyd_code/_data_dir.py +73 -0
- luckyd_code/agent.py +38 -0
- luckyd_code/analytics/__init__.py +18 -0
- luckyd_code/analytics/reporter.py +195 -0
- luckyd_code/analytics/scanner.py +443 -0
- luckyd_code/analytics/smells.py +316 -0
- luckyd_code/analytics/trends.py +303 -0
- luckyd_code/api.py +473 -0
- luckyd_code/audit_daemon.py +845 -0
- luckyd_code/autonomous_fixer.py +473 -0
- luckyd_code/background.py +159 -0
- luckyd_code/backup.py +237 -0
- luckyd_code/brain/__init__.py +84 -0
- luckyd_code/brain/assembler.py +100 -0
- luckyd_code/brain/chunker.py +345 -0
- luckyd_code/brain/constants.py +73 -0
- luckyd_code/brain/embedder.py +163 -0
- luckyd_code/brain/graph.py +311 -0
- luckyd_code/brain/indexer.py +316 -0
- luckyd_code/brain/parser.py +140 -0
- luckyd_code/brain/retriever.py +234 -0
- luckyd_code/cli.py +894 -0
- luckyd_code/cli_commands/__init__.py +1 -0
- luckyd_code/cli_commands/audit.py +120 -0
- luckyd_code/cli_commands/background.py +83 -0
- luckyd_code/cli_commands/brain.py +87 -0
- luckyd_code/cli_commands/config.py +75 -0
- luckyd_code/cli_commands/dispatcher.py +695 -0
- luckyd_code/cli_commands/sessions.py +41 -0
- luckyd_code/cli_entry.py +147 -0
- luckyd_code/cli_utils.py +112 -0
- luckyd_code/config.py +205 -0
- luckyd_code/context.py +214 -0
- luckyd_code/cost_tracker.py +209 -0
- luckyd_code/error_reporter.py +508 -0
- luckyd_code/exceptions.py +39 -0
- luckyd_code/export.py +126 -0
- luckyd_code/feedback_analyzer.py +290 -0
- luckyd_code/file_watcher.py +258 -0
- luckyd_code/git/__init__.py +11 -0
- luckyd_code/git/auto_commit.py +157 -0
- luckyd_code/git/tools.py +85 -0
- luckyd_code/hooks.py +236 -0
- luckyd_code/indexer.py +280 -0
- luckyd_code/init.py +39 -0
- luckyd_code/keybindings.py +77 -0
- luckyd_code/log.py +55 -0
- luckyd_code/mcp/__init__.py +6 -0
- luckyd_code/mcp/client.py +184 -0
- luckyd_code/memory/__init__.py +19 -0
- luckyd_code/memory/manager.py +339 -0
- luckyd_code/metrics/__init__.py +5 -0
- luckyd_code/model_registry.py +131 -0
- luckyd_code/orchestrator.py +204 -0
- luckyd_code/permissions/__init__.py +1 -0
- luckyd_code/permissions/manager.py +103 -0
- luckyd_code/planner.py +361 -0
- luckyd_code/plugins.py +91 -0
- luckyd_code/py.typed +0 -0
- luckyd_code/retry.py +57 -0
- luckyd_code/router.py +417 -0
- luckyd_code/sandbox.py +156 -0
- luckyd_code/self_critique.py +2 -0
- luckyd_code/self_improve.py +274 -0
- luckyd_code/sessions.py +114 -0
- luckyd_code/settings.py +72 -0
- luckyd_code/skills/__init__.py +8 -0
- luckyd_code/skills/review.py +22 -0
- luckyd_code/skills/security.py +17 -0
- luckyd_code/tasks/__init__.py +1 -0
- luckyd_code/tasks/manager.py +102 -0
- luckyd_code/templates/icon-192.png +0 -0
- luckyd_code/templates/icon-512.png +0 -0
- luckyd_code/templates/index.html +1965 -0
- luckyd_code/templates/manifest.json +14 -0
- luckyd_code/templates/src/app.js +694 -0
- luckyd_code/templates/src/body.html +767 -0
- luckyd_code/templates/src/cdn.txt +2 -0
- luckyd_code/templates/src/style.css +474 -0
- luckyd_code/templates/sw.js +31 -0
- luckyd_code/templates/test.html +6 -0
- luckyd_code/themes.py +48 -0
- luckyd_code/tools/__init__.py +97 -0
- luckyd_code/tools/agent_tools.py +65 -0
- luckyd_code/tools/bash.py +360 -0
- luckyd_code/tools/brain_tools.py +137 -0
- luckyd_code/tools/browser.py +369 -0
- luckyd_code/tools/datetime_tool.py +34 -0
- luckyd_code/tools/dockerfile_gen.py +212 -0
- luckyd_code/tools/file_ops.py +381 -0
- luckyd_code/tools/game_gen.py +360 -0
- luckyd_code/tools/git_tools.py +130 -0
- luckyd_code/tools/git_worktree.py +63 -0
- luckyd_code/tools/path_validate.py +64 -0
- luckyd_code/tools/project_gen.py +187 -0
- luckyd_code/tools/readme_gen.py +227 -0
- luckyd_code/tools/registry.py +157 -0
- luckyd_code/tools/shell_detect.py +109 -0
- luckyd_code/tools/web.py +89 -0
- luckyd_code/tools/youtube.py +187 -0
- luckyd_code/tools_bridge.py +144 -0
- luckyd_code/undo.py +126 -0
- luckyd_code/update.py +60 -0
- luckyd_code/verify.py +360 -0
- luckyd_code/web_app.py +176 -0
- luckyd_code/web_routes/__init__.py +23 -0
- luckyd_code/web_routes/background.py +73 -0
- luckyd_code/web_routes/brain.py +109 -0
- luckyd_code/web_routes/cost.py +12 -0
- luckyd_code/web_routes/files.py +133 -0
- luckyd_code/web_routes/memories.py +94 -0
- luckyd_code/web_routes/misc.py +67 -0
- luckyd_code/web_routes/project.py +48 -0
- luckyd_code/web_routes/review.py +20 -0
- luckyd_code/web_routes/sessions.py +44 -0
- luckyd_code/web_routes/settings.py +43 -0
- luckyd_code/web_routes/static.py +70 -0
- luckyd_code/web_routes/update.py +19 -0
- luckyd_code/web_routes/ws.py +237 -0
- luckyd_code-1.2.2.dist-info/METADATA +297 -0
- luckyd_code-1.2.2.dist-info/RECORD +127 -0
- luckyd_code-1.2.2.dist-info/WHEEL +4 -0
- luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
- luckyd_code-1.2.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Sub-agent tool for spawning child agents."""
|
|
2
|
+
|
|
3
|
+
from .registry import Tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Reference to the Repl instance for config access
|
|
7
|
+
_repl = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def set_repl(repl):
|
|
11
|
+
global _repl
|
|
12
|
+
_repl = repl
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SubAgentTool(Tool):
|
|
16
|
+
name = "SubAgent"
|
|
17
|
+
description = "Spawn a child agent to work independently on a subtask. Use for research, exploration, or parallel work."
|
|
18
|
+
permission_risk = "medium"
|
|
19
|
+
parameters = {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"task": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "The task for the sub-agent to complete",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
"required": ["task"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def run(self, task: str) -> str: # type: ignore[override]
|
|
31
|
+
global _repl
|
|
32
|
+
if _repl is None:
|
|
33
|
+
return "Error: sub-agent not available (not initialized)"
|
|
34
|
+
from ..agent import SubAgent
|
|
35
|
+
agent = SubAgent(_repl.config, task, _repl.registry.list_tools())
|
|
36
|
+
return agent.run()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AgentHandoffTool(Tool):
|
|
40
|
+
name = "AgentHandoff"
|
|
41
|
+
description = "Hand off a subtask to a specialized agent role. Roles: researcher (gather info), coder (implement changes), reviewer (review code), tester (write/run tests). Use when a task needs a specialist."
|
|
42
|
+
permission_risk = "medium"
|
|
43
|
+
parameters = {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"role": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"enum": ["researcher", "coder", "reviewer", "tester"],
|
|
49
|
+
"description": "The specialist role to hand off to",
|
|
50
|
+
},
|
|
51
|
+
"task": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "The specific task for the specialist agent",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
"required": ["role", "task"],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def run(self, role: str, task: str) -> str: # type: ignore[override]
|
|
60
|
+
global _repl
|
|
61
|
+
if _repl is None:
|
|
62
|
+
return "Error: handoff not available (not initialized)"
|
|
63
|
+
from ..orchestrator import AgentHandoff
|
|
64
|
+
handoff = AgentHandoff(_repl.config)
|
|
65
|
+
return handoff.handoff(role, task, _repl.registry.list_tools())
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Bash tool with safety guards and optional Docker sandbox.
|
|
2
|
+
|
|
3
|
+
Auto-detects the best available shell on Windows (Git Bash → WSL → cmd.exe)
|
|
4
|
+
so the AI can use standard Unix commands like ls, grep, find, and curl.
|
|
5
|
+
|
|
6
|
+
Cross-platform subprocess handling:
|
|
7
|
+
- Uses Popen with process groups for reliable timeout enforcement
|
|
8
|
+
- Proper Windows process isolation via creationflags
|
|
9
|
+
- Rewrites interactive commands into non-interactive equivalents
|
|
10
|
+
- Auto-detects .venv/pip/pytest and routes them through cmd.exe on Windows
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from .registry import Tool
|
|
22
|
+
from .shell_detect import resolve_shell, ShellInfo
|
|
23
|
+
from ..settings import load_settings
|
|
24
|
+
from ..sandbox import get_sandbox
|
|
25
|
+
|
|
26
|
+
# Default working directory for commands — evaluated at runtime so --dir flag works
|
|
27
|
+
def _get_cwd() -> Path:
|
|
28
|
+
return Path.cwd()
|
|
29
|
+
|
|
30
|
+
# Cached shell detection result (reset on /config set shell)
|
|
31
|
+
_SHELL_CACHE: Optional[ShellInfo] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_shell() -> ShellInfo:
|
|
35
|
+
"""Get cached shell info, detecting on first call."""
|
|
36
|
+
global _SHELL_CACHE
|
|
37
|
+
if _SHELL_CACHE is None:
|
|
38
|
+
settings = load_settings()
|
|
39
|
+
shell_setting = settings.get("shell", "auto")
|
|
40
|
+
_SHELL_CACHE = resolve_shell(shell_setting)
|
|
41
|
+
return _SHELL_CACHE
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reset_shell_cache():
|
|
45
|
+
"""Force re-detection on next call. Used by /config set shell."""
|
|
46
|
+
global _SHELL_CACHE
|
|
47
|
+
_SHELL_CACHE = None
|
|
48
|
+
|
|
49
|
+
# Commands that are blocked for safety
|
|
50
|
+
BLOCKED_PATTERNS = [
|
|
51
|
+
"rm -rf /",
|
|
52
|
+
"rm -rf ~",
|
|
53
|
+
"rm -rf .",
|
|
54
|
+
"> /dev/sda",
|
|
55
|
+
"mkfs.",
|
|
56
|
+
"dd if=",
|
|
57
|
+
":(){ :|:& };:", # fork bomb
|
|
58
|
+
"chmod 777",
|
|
59
|
+
"sudo ",
|
|
60
|
+
"su ",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
# Interactive commands that will hang
|
|
64
|
+
INTERACTIVE_COMMANDS = [
|
|
65
|
+
"vim", "vi", "nano", "emacs", "less", "more", "top", "htop",
|
|
66
|
+
"ssh", "telnet", "ftp", "python -i", "irb", "node -i",
|
|
67
|
+
# Windows cmd.exe commands that prompt for input when run without flags
|
|
68
|
+
"date", "time", "pause", "choice",
|
|
69
|
+
# stdin readers that block forever without a pipe
|
|
70
|
+
"clip",
|
|
71
|
+
# Additional commands that typically require a TTY
|
|
72
|
+
"watch", "tail -f", "journalctl -f", "docker attach",
|
|
73
|
+
"mysql", "psql", "sqlite3", "redis-cli", "mongo",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _fix_windows_cmd(command: str) -> str:
|
|
78
|
+
"""Rewrite bare Windows commands that are interactive into their
|
|
79
|
+
non-interactive equivalents so they never hang.
|
|
80
|
+
|
|
81
|
+
Examples
|
|
82
|
+
--------
|
|
83
|
+
``date`` → ``date /T`` (print date, don't prompt to change it)
|
|
84
|
+
``time`` → ``time /T`` (print time, don't prompt to change it)
|
|
85
|
+
``ping host`` → ``ping -n 4 host`` (bounded ping instead of infinite)
|
|
86
|
+
``choice`` → ``choice /N /T 0 /D Y`` (non-interactive choice, select default)
|
|
87
|
+
``pause`` → ``echo.`` (skip pause)
|
|
88
|
+
"""
|
|
89
|
+
import re
|
|
90
|
+
stripped = command.strip()
|
|
91
|
+
|
|
92
|
+
# 'date' alone or 'date ' with no flags → date /T
|
|
93
|
+
if re.fullmatch(r'date', stripped, re.IGNORECASE):
|
|
94
|
+
return 'date /T'
|
|
95
|
+
if re.match(r'date\s+(?!/)(.+)', stripped, re.IGNORECASE):
|
|
96
|
+
return 'date /T'
|
|
97
|
+
|
|
98
|
+
# 'time' alone → time /T
|
|
99
|
+
if re.fullmatch(r'time', stripped, re.IGNORECASE):
|
|
100
|
+
return 'time /T'
|
|
101
|
+
if re.match(r'time\s+(?!/)(.+)', stripped, re.IGNORECASE):
|
|
102
|
+
return 'time /T'
|
|
103
|
+
|
|
104
|
+
# 'ping host' without -n → add '-n 4' so it terminates
|
|
105
|
+
ping_match = re.match(r'(ping)\s+(?!.*-n\s+\d)(.+)', stripped, re.IGNORECASE)
|
|
106
|
+
if ping_match:
|
|
107
|
+
return f'ping -n 4 {ping_match.group(2)}'
|
|
108
|
+
|
|
109
|
+
# 'choice' without /T → add non-interactive defaults
|
|
110
|
+
if re.fullmatch(r'choice', stripped, re.IGNORECASE):
|
|
111
|
+
return 'choice /N /T 0 /D Y'
|
|
112
|
+
|
|
113
|
+
# 'pause' → skip
|
|
114
|
+
if re.fullmatch(r'pause', stripped, re.IGNORECASE):
|
|
115
|
+
return 'echo.'
|
|
116
|
+
|
|
117
|
+
# 'clip' without input → pipe nothing
|
|
118
|
+
if re.fullmatch(r'clip', stripped, re.IGNORECASE):
|
|
119
|
+
return 'echo.| clip'
|
|
120
|
+
|
|
121
|
+
return command
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _fix_unix_ping(command: str) -> str:
|
|
125
|
+
"""On Unix shells, bare 'ping host' runs forever — add '-c 4'."""
|
|
126
|
+
import re
|
|
127
|
+
stripped = command.strip()
|
|
128
|
+
ping_match = re.match(r'(ping)\s+(?!.*-c\s+\d)(.+)', stripped, re.IGNORECASE)
|
|
129
|
+
if ping_match:
|
|
130
|
+
return f'ping -c 4 {ping_match.group(2)}'
|
|
131
|
+
return command
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_dangerous(command: str) -> str | None:
|
|
135
|
+
"""Check if a command is potentially dangerous. Returns warning or None."""
|
|
136
|
+
cmd_lower = command.lower().strip()
|
|
137
|
+
|
|
138
|
+
for pattern in BLOCKED_PATTERNS:
|
|
139
|
+
if pattern in cmd_lower:
|
|
140
|
+
return f"Command blocked for safety: matches '{pattern}'"
|
|
141
|
+
|
|
142
|
+
# Check for interactive commands
|
|
143
|
+
for ic in INTERACTIVE_COMMANDS:
|
|
144
|
+
if cmd_lower.startswith(ic) or f" {ic} " in f" {cmd_lower} ":
|
|
145
|
+
return f"Interactive command '{ic}' is not supported in non-interactive shell — use the DateTime tool for date/time queries"
|
|
146
|
+
|
|
147
|
+
# Warn about pip install / npm install (can be slow or modify system)
|
|
148
|
+
if "pip install" in cmd_lower or "npm install" in cmd_lower:
|
|
149
|
+
pass # These are generally useful, just warn via permission system
|
|
150
|
+
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _CommandTimeout(Exception):
|
|
155
|
+
"""Raised when a command exceeds its timeout."""
|
|
156
|
+
def __init__(self, elapsed: float):
|
|
157
|
+
self.elapsed = elapsed
|
|
158
|
+
super().__init__(f"Command timed out after {elapsed:.0f}s")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _run_with_timeout(
|
|
162
|
+
cmd,
|
|
163
|
+
*,
|
|
164
|
+
shell: bool = False,
|
|
165
|
+
timeout_sec: float = 120,
|
|
166
|
+
cwd: str | Path | None = None,
|
|
167
|
+
) -> tuple[str, str, int]:
|
|
168
|
+
"""Execute a subprocess with reliable timeout enforcement.
|
|
169
|
+
|
|
170
|
+
Uses Popen with process groups so that on timeout the entire process
|
|
171
|
+
tree is terminated — no orphaned child processes.
|
|
172
|
+
|
|
173
|
+
On Windows, uses CREATE_NEW_PROCESS_GROUP for proper isolation.
|
|
174
|
+
On Unix, uses os.setsid to create a new session.
|
|
175
|
+
|
|
176
|
+
Returns (stdout, stderr, returncode).
|
|
177
|
+
Raises _CommandTimeout if the process doesn't finish in time.
|
|
178
|
+
"""
|
|
179
|
+
cwd = str(cwd) if cwd else None
|
|
180
|
+
|
|
181
|
+
# On Windows, some commands (like 'where') can hang when searching
|
|
182
|
+
# network paths. Mitigate by ensuring system32 is prioritized.
|
|
183
|
+
env = os.environ.copy()
|
|
184
|
+
if sys.platform == "win32" and shell:
|
|
185
|
+
# Ensure system32 is first in PATH for cmd.exe reliability
|
|
186
|
+
system32 = r"C:\Windows\System32"
|
|
187
|
+
current_path = env.get("PATH", "")
|
|
188
|
+
if system32 not in current_path.split(os.pathsep)[:1]:
|
|
189
|
+
env["PATH"] = system32 + os.pathsep + current_path
|
|
190
|
+
|
|
191
|
+
# Build creation flags for proper process group handling
|
|
192
|
+
kwargs: dict = {
|
|
193
|
+
"stdout": subprocess.PIPE,
|
|
194
|
+
"stderr": subprocess.PIPE,
|
|
195
|
+
"text": True,
|
|
196
|
+
"cwd": cwd,
|
|
197
|
+
"env": env,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if sys.platform == "win32":
|
|
201
|
+
# CREATE_NEW_PROCESS_GROUP prevents Ctrl+C propagation
|
|
202
|
+
# CREATE_NO_WINDOW prevents a console window from popping up
|
|
203
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
204
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
205
|
+
kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
|
|
206
|
+
else:
|
|
207
|
+
# Create a new session so we can kill the full process tree.
|
|
208
|
+
# start_new_session=True makes the child a process group leader;
|
|
209
|
+
# preexec_fn=os.setsid would do the same thing but Python raises
|
|
210
|
+
# ValueError if both are set simultaneously — use only one.
|
|
211
|
+
kwargs["start_new_session"] = True
|
|
212
|
+
|
|
213
|
+
t0 = time.time()
|
|
214
|
+
proc = subprocess.Popen(cmd, shell=shell, **kwargs)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
stdout, stderr = proc.communicate(timeout=timeout_sec)
|
|
218
|
+
return stdout or "", stderr or "", proc.returncode or 0
|
|
219
|
+
except subprocess.TimeoutExpired:
|
|
220
|
+
elapsed = time.time() - t0
|
|
221
|
+
|
|
222
|
+
# Kill the full process tree
|
|
223
|
+
try:
|
|
224
|
+
if sys.platform == "win32":
|
|
225
|
+
# Terminate process tree on Windows
|
|
226
|
+
subprocess.run(
|
|
227
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
228
|
+
capture_output=True,
|
|
229
|
+
timeout=10,
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
# Kill the process group on Unix
|
|
233
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
# Also try direct kill
|
|
238
|
+
try:
|
|
239
|
+
proc.kill()
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
# Small wait for cleanup
|
|
244
|
+
try:
|
|
245
|
+
proc.wait(timeout=2)
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
raise _CommandTimeout(elapsed)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class BashTool(Tool):
|
|
253
|
+
name = "Bash"
|
|
254
|
+
description = "Execute a shell command and get its output."
|
|
255
|
+
permission_risk = "high"
|
|
256
|
+
parameters = {
|
|
257
|
+
"type": "object",
|
|
258
|
+
"properties": {
|
|
259
|
+
"command": {
|
|
260
|
+
"type": "string",
|
|
261
|
+
"description": "The shell command to execute",
|
|
262
|
+
},
|
|
263
|
+
"description": {
|
|
264
|
+
"type": "string",
|
|
265
|
+
"description": "Clear description of what this command does",
|
|
266
|
+
},
|
|
267
|
+
"timeout": {
|
|
268
|
+
"type": "integer",
|
|
269
|
+
"description": "Timeout in milliseconds (default 120000, max 600000)",
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
"required": ["command"],
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
def run(self, command: str, description: str = "", timeout: int = 120000) -> str: # type: ignore[override]
|
|
276
|
+
# Safety check
|
|
277
|
+
warning = _is_dangerous(command)
|
|
278
|
+
if warning:
|
|
279
|
+
return f"Error: {warning}"
|
|
280
|
+
|
|
281
|
+
timeout_sec = min(timeout / 1000, 600)
|
|
282
|
+
# Minimum 1 second timeout
|
|
283
|
+
timeout_sec = max(timeout_sec, 1)
|
|
284
|
+
|
|
285
|
+
# Check if sandbox mode is enabled
|
|
286
|
+
settings = load_settings()
|
|
287
|
+
use_sandbox = settings.get("sandbox", False)
|
|
288
|
+
|
|
289
|
+
if use_sandbox:
|
|
290
|
+
sandbox = get_sandbox()
|
|
291
|
+
if sandbox.available:
|
|
292
|
+
stdout, stderr, rc = sandbox.run(command, timeout=int(timeout_sec))
|
|
293
|
+
output = stdout
|
|
294
|
+
if stderr:
|
|
295
|
+
output += ("\n" + stderr) if output else stderr
|
|
296
|
+
if rc != 0 and not output:
|
|
297
|
+
output = f"Command exited with code {rc}"
|
|
298
|
+
return (output.strip()[:10000]
|
|
299
|
+
or f"(command completed with exit code {rc}, no output)")
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
shell_info = _get_shell()
|
|
303
|
+
|
|
304
|
+
# On Windows, prefer cmd.exe for .venv/pip/pytest/python commands
|
|
305
|
+
# because Git Bash struggles with Windows-style paths and venv scripts.
|
|
306
|
+
use_cmd = False
|
|
307
|
+
if shell_info.unix_like and sys.platform == "win32":
|
|
308
|
+
cmd_lower = command.lower().strip()
|
|
309
|
+
win_indicators = (
|
|
310
|
+
".venv", "venv\\", "venv/",
|
|
311
|
+
"pytest", "pip ", "pip3 ",
|
|
312
|
+
".bat", ".exe",
|
|
313
|
+
"python -m", "python3 -m",
|
|
314
|
+
)
|
|
315
|
+
if any(ind in cmd_lower for ind in win_indicators):
|
|
316
|
+
use_cmd = True
|
|
317
|
+
|
|
318
|
+
if use_cmd or not shell_info.unix_like:
|
|
319
|
+
# cmd.exe — rewrite any interactive-but-fixable commands first
|
|
320
|
+
command = _fix_windows_cmd(command)
|
|
321
|
+
stdout, stderr, rc = _run_with_timeout(
|
|
322
|
+
command,
|
|
323
|
+
shell=True,
|
|
324
|
+
timeout_sec=timeout_sec,
|
|
325
|
+
cwd=_get_cwd(),
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
# Unix shell (Git Bash / WSL) — fix bare ping before running
|
|
329
|
+
command = _fix_unix_ping(command)
|
|
330
|
+
full_args = [shell_info.path] + shell_info.args + ["-c", command]
|
|
331
|
+
stdout, stderr, rc = _run_with_timeout(
|
|
332
|
+
full_args,
|
|
333
|
+
shell=False,
|
|
334
|
+
timeout_sec=timeout_sec,
|
|
335
|
+
cwd=_get_cwd(),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
output = ""
|
|
339
|
+
if stdout:
|
|
340
|
+
output += stdout
|
|
341
|
+
if stderr:
|
|
342
|
+
if output:
|
|
343
|
+
output += "\n"
|
|
344
|
+
output += stderr
|
|
345
|
+
if rc != 0 and not output:
|
|
346
|
+
output = f"Command exited with code {rc}"
|
|
347
|
+
|
|
348
|
+
# Truncate very long output
|
|
349
|
+
max_output = 10000
|
|
350
|
+
if len(output) > max_output:
|
|
351
|
+
output = output[:max_output] + f"\n... (truncated, {len(output)} total chars)"
|
|
352
|
+
|
|
353
|
+
return output.strip() or f"(command completed with exit code {rc}, no output)"
|
|
354
|
+
|
|
355
|
+
except _CommandTimeout as e:
|
|
356
|
+
return f"Error: command timed out after {e.elapsed:.0f}s: {command[:200]}"
|
|
357
|
+
except OSError as e:
|
|
358
|
+
return f"Error: system error executing command: {e}"
|
|
359
|
+
except Exception as e:
|
|
360
|
+
return f"Error executing command: {e}"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Brain tools — query the codebase knowledge graph and RAG index."""
|
|
2
|
+
|
|
3
|
+
from ..brain import KnowledgeGraph, Retriever, ContextAssembler
|
|
4
|
+
from .registry import Tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Global instances that persist across tool calls
|
|
8
|
+
_graph: KnowledgeGraph | None = None
|
|
9
|
+
_retriever: Retriever | None = None
|
|
10
|
+
_assembler: ContextAssembler | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_graph() -> KnowledgeGraph:
|
|
14
|
+
"""Get the shared graph instance, loading from disk if needed."""
|
|
15
|
+
global _graph
|
|
16
|
+
if _graph is None:
|
|
17
|
+
_graph = KnowledgeGraph()
|
|
18
|
+
_graph.load()
|
|
19
|
+
return _graph
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_retriever() -> Retriever:
|
|
23
|
+
"""Get the shared retriever instance."""
|
|
24
|
+
global _retriever
|
|
25
|
+
if _retriever is None:
|
|
26
|
+
_retriever = Retriever()
|
|
27
|
+
return _retriever
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_assembler() -> ContextAssembler:
|
|
31
|
+
"""Get the shared context assembler instance."""
|
|
32
|
+
global _assembler
|
|
33
|
+
if _assembler is None:
|
|
34
|
+
_assembler = ContextAssembler()
|
|
35
|
+
return _assembler
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BrainSearchTool(Tool):
|
|
39
|
+
name = "BrainSearch"
|
|
40
|
+
description = "Search the codebase for functions, classes, and code patterns using semantic understanding. Use this INSTEAD of Grep when you need to find code by concept or behavior (e.g., 'authentication flow', 'database retry logic') rather than exact name matches."
|
|
41
|
+
parameters = {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"query": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "Search term — natural language description of what you're looking for (e.g., 'user authentication', 'database connection', 'error handling')",
|
|
47
|
+
},
|
|
48
|
+
"max_results": {
|
|
49
|
+
"type": "integer",
|
|
50
|
+
"description": "Maximum results to return (default 10)",
|
|
51
|
+
"default": 10,
|
|
52
|
+
},
|
|
53
|
+
"file_filter": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "Optional file path filter (e.g., 'auth.py', 'src/api/')",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
"required": ["query"],
|
|
59
|
+
}
|
|
60
|
+
permission_risk = "safe"
|
|
61
|
+
|
|
62
|
+
def run(self, query: str, max_results: int = 10, file_filter: str = "") -> str: # type: ignore[override]
|
|
63
|
+
retriever = _get_retriever()
|
|
64
|
+
|
|
65
|
+
results = retriever.search(
|
|
66
|
+
query,
|
|
67
|
+
k=max_results,
|
|
68
|
+
file_filter=file_filter or None,
|
|
69
|
+
)
|
|
70
|
+
if not results:
|
|
71
|
+
# Check if old graph has anything
|
|
72
|
+
graph = _get_graph()
|
|
73
|
+
if not graph.nodes:
|
|
74
|
+
return "Codebase index is empty. Run `/brain rebuild` first to index your codebase."
|
|
75
|
+
return f"No results found for '{query}'."
|
|
76
|
+
|
|
77
|
+
lines = [f"Brain search results for '{query}':", ""]
|
|
78
|
+
for r in results:
|
|
79
|
+
file_path = r.get("file_path", "?")
|
|
80
|
+
start = r.get("start_line", 0)
|
|
81
|
+
end = r.get("end_line", 0)
|
|
82
|
+
score = r.get("score", 0)
|
|
83
|
+
name = r.get("name", "")
|
|
84
|
+
chunk_type = r.get("type", "?")
|
|
85
|
+
lang = r.get("language", "")
|
|
86
|
+
|
|
87
|
+
loc = f"{file_path}:{start}-{end}" if start else file_path
|
|
88
|
+
label = f"[{chunk_type}] {name} ({lang})" if name else f"[{chunk_type}] ({lang})"
|
|
89
|
+
lines.append(f" {label} — {loc} (score: {score:.2f})")
|
|
90
|
+
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class BrainStatusTool(Tool):
|
|
95
|
+
name = "BrainStatus"
|
|
96
|
+
description = "Show the current state of the codebase index — vector index stats, knowledge graph stats, languages found, and last indexed time."
|
|
97
|
+
parameters = {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"properties": {},
|
|
100
|
+
"required": [],
|
|
101
|
+
}
|
|
102
|
+
permission_risk = "safe"
|
|
103
|
+
|
|
104
|
+
def run(self) -> str: # type: ignore[override]
|
|
105
|
+
retriever = _get_retriever()
|
|
106
|
+
info = retriever.stats()
|
|
107
|
+
|
|
108
|
+
lines = ["=== Vector Index ==="]
|
|
109
|
+
vec = info.get("vector", {})
|
|
110
|
+
if vec.get("available"):
|
|
111
|
+
lines.append(f" Chunks: {vec.get('chunks', 0)}")
|
|
112
|
+
lines.append(f" Files: {vec.get('files', 0)}")
|
|
113
|
+
languages = vec.get("languages", {})
|
|
114
|
+
if languages:
|
|
115
|
+
lang_str = ", ".join(f"{k}={v}" for k, v in sorted(languages.items()))
|
|
116
|
+
lines.append(f" Languages: {lang_str}")
|
|
117
|
+
last = vec.get("last_indexed", 0)
|
|
118
|
+
if last:
|
|
119
|
+
import time
|
|
120
|
+
last_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(last))
|
|
121
|
+
lines.append(f" Last indexed: {last_str}")
|
|
122
|
+
stale = info.get("stale_files", 0)
|
|
123
|
+
if stale:
|
|
124
|
+
lines.append(f" [yellow]Stale files: {stale} (run /brain rebuild)[/yellow]")
|
|
125
|
+
else:
|
|
126
|
+
lines.append(" Not available (install faiss-cpu + sentence-transformers for vector search)")
|
|
127
|
+
|
|
128
|
+
lines.append("\n=== Knowledge Graph (Fallback) ===")
|
|
129
|
+
graph_data = info.get("graph", {})
|
|
130
|
+
if graph_data.get("nodes"):
|
|
131
|
+
lines.append(f" Symbols: {graph_data.get('nodes', 0)}")
|
|
132
|
+
lines.append(f" Relations: {graph_data.get('edges', 0)}")
|
|
133
|
+
lines.append(f" Files: {graph_data.get('files_parsed', 0)}")
|
|
134
|
+
else:
|
|
135
|
+
lines.append(" Empty")
|
|
136
|
+
|
|
137
|
+
return "\n".join(lines)
|