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.
Files changed (127) hide show
  1. luckyd_code/__init__.py +54 -0
  2. luckyd_code/__main__.py +5 -0
  3. luckyd_code/_agent_loop.py +551 -0
  4. luckyd_code/_data_dir.py +73 -0
  5. luckyd_code/agent.py +38 -0
  6. luckyd_code/analytics/__init__.py +18 -0
  7. luckyd_code/analytics/reporter.py +195 -0
  8. luckyd_code/analytics/scanner.py +443 -0
  9. luckyd_code/analytics/smells.py +316 -0
  10. luckyd_code/analytics/trends.py +303 -0
  11. luckyd_code/api.py +473 -0
  12. luckyd_code/audit_daemon.py +845 -0
  13. luckyd_code/autonomous_fixer.py +473 -0
  14. luckyd_code/background.py +159 -0
  15. luckyd_code/backup.py +237 -0
  16. luckyd_code/brain/__init__.py +84 -0
  17. luckyd_code/brain/assembler.py +100 -0
  18. luckyd_code/brain/chunker.py +345 -0
  19. luckyd_code/brain/constants.py +73 -0
  20. luckyd_code/brain/embedder.py +163 -0
  21. luckyd_code/brain/graph.py +311 -0
  22. luckyd_code/brain/indexer.py +316 -0
  23. luckyd_code/brain/parser.py +140 -0
  24. luckyd_code/brain/retriever.py +234 -0
  25. luckyd_code/cli.py +894 -0
  26. luckyd_code/cli_commands/__init__.py +1 -0
  27. luckyd_code/cli_commands/audit.py +120 -0
  28. luckyd_code/cli_commands/background.py +83 -0
  29. luckyd_code/cli_commands/brain.py +87 -0
  30. luckyd_code/cli_commands/config.py +75 -0
  31. luckyd_code/cli_commands/dispatcher.py +695 -0
  32. luckyd_code/cli_commands/sessions.py +41 -0
  33. luckyd_code/cli_entry.py +147 -0
  34. luckyd_code/cli_utils.py +112 -0
  35. luckyd_code/config.py +205 -0
  36. luckyd_code/context.py +214 -0
  37. luckyd_code/cost_tracker.py +209 -0
  38. luckyd_code/error_reporter.py +508 -0
  39. luckyd_code/exceptions.py +39 -0
  40. luckyd_code/export.py +126 -0
  41. luckyd_code/feedback_analyzer.py +290 -0
  42. luckyd_code/file_watcher.py +258 -0
  43. luckyd_code/git/__init__.py +11 -0
  44. luckyd_code/git/auto_commit.py +157 -0
  45. luckyd_code/git/tools.py +85 -0
  46. luckyd_code/hooks.py +236 -0
  47. luckyd_code/indexer.py +280 -0
  48. luckyd_code/init.py +39 -0
  49. luckyd_code/keybindings.py +77 -0
  50. luckyd_code/log.py +55 -0
  51. luckyd_code/mcp/__init__.py +6 -0
  52. luckyd_code/mcp/client.py +184 -0
  53. luckyd_code/memory/__init__.py +19 -0
  54. luckyd_code/memory/manager.py +339 -0
  55. luckyd_code/metrics/__init__.py +5 -0
  56. luckyd_code/model_registry.py +131 -0
  57. luckyd_code/orchestrator.py +204 -0
  58. luckyd_code/permissions/__init__.py +1 -0
  59. luckyd_code/permissions/manager.py +103 -0
  60. luckyd_code/planner.py +361 -0
  61. luckyd_code/plugins.py +91 -0
  62. luckyd_code/py.typed +0 -0
  63. luckyd_code/retry.py +57 -0
  64. luckyd_code/router.py +417 -0
  65. luckyd_code/sandbox.py +156 -0
  66. luckyd_code/self_critique.py +2 -0
  67. luckyd_code/self_improve.py +274 -0
  68. luckyd_code/sessions.py +114 -0
  69. luckyd_code/settings.py +72 -0
  70. luckyd_code/skills/__init__.py +8 -0
  71. luckyd_code/skills/review.py +22 -0
  72. luckyd_code/skills/security.py +17 -0
  73. luckyd_code/tasks/__init__.py +1 -0
  74. luckyd_code/tasks/manager.py +102 -0
  75. luckyd_code/templates/icon-192.png +0 -0
  76. luckyd_code/templates/icon-512.png +0 -0
  77. luckyd_code/templates/index.html +1965 -0
  78. luckyd_code/templates/manifest.json +14 -0
  79. luckyd_code/templates/src/app.js +694 -0
  80. luckyd_code/templates/src/body.html +767 -0
  81. luckyd_code/templates/src/cdn.txt +2 -0
  82. luckyd_code/templates/src/style.css +474 -0
  83. luckyd_code/templates/sw.js +31 -0
  84. luckyd_code/templates/test.html +6 -0
  85. luckyd_code/themes.py +48 -0
  86. luckyd_code/tools/__init__.py +97 -0
  87. luckyd_code/tools/agent_tools.py +65 -0
  88. luckyd_code/tools/bash.py +360 -0
  89. luckyd_code/tools/brain_tools.py +137 -0
  90. luckyd_code/tools/browser.py +369 -0
  91. luckyd_code/tools/datetime_tool.py +34 -0
  92. luckyd_code/tools/dockerfile_gen.py +212 -0
  93. luckyd_code/tools/file_ops.py +381 -0
  94. luckyd_code/tools/game_gen.py +360 -0
  95. luckyd_code/tools/git_tools.py +130 -0
  96. luckyd_code/tools/git_worktree.py +63 -0
  97. luckyd_code/tools/path_validate.py +64 -0
  98. luckyd_code/tools/project_gen.py +187 -0
  99. luckyd_code/tools/readme_gen.py +227 -0
  100. luckyd_code/tools/registry.py +157 -0
  101. luckyd_code/tools/shell_detect.py +109 -0
  102. luckyd_code/tools/web.py +89 -0
  103. luckyd_code/tools/youtube.py +187 -0
  104. luckyd_code/tools_bridge.py +144 -0
  105. luckyd_code/undo.py +126 -0
  106. luckyd_code/update.py +60 -0
  107. luckyd_code/verify.py +360 -0
  108. luckyd_code/web_app.py +176 -0
  109. luckyd_code/web_routes/__init__.py +23 -0
  110. luckyd_code/web_routes/background.py +73 -0
  111. luckyd_code/web_routes/brain.py +109 -0
  112. luckyd_code/web_routes/cost.py +12 -0
  113. luckyd_code/web_routes/files.py +133 -0
  114. luckyd_code/web_routes/memories.py +94 -0
  115. luckyd_code/web_routes/misc.py +67 -0
  116. luckyd_code/web_routes/project.py +48 -0
  117. luckyd_code/web_routes/review.py +20 -0
  118. luckyd_code/web_routes/sessions.py +44 -0
  119. luckyd_code/web_routes/settings.py +43 -0
  120. luckyd_code/web_routes/static.py +70 -0
  121. luckyd_code/web_routes/update.py +19 -0
  122. luckyd_code/web_routes/ws.py +237 -0
  123. luckyd_code-1.2.2.dist-info/METADATA +297 -0
  124. luckyd_code-1.2.2.dist-info/RECORD +127 -0
  125. luckyd_code-1.2.2.dist-info/WHEEL +4 -0
  126. luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
  127. 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)