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,290 @@
|
|
|
1
|
+
"""Feedback Analyzer — LLM-powered error diagnosis for autonomous self-improvement.
|
|
2
|
+
|
|
3
|
+
Takes a sanitized error, gathers code context from the project, and uses the
|
|
4
|
+
user's own DeepSeek API key to diagnose the root cause and suggest a fix.
|
|
5
|
+
|
|
6
|
+
All analysis runs locally on the user's machine. Nothing is sent to any
|
|
7
|
+
central server. The user's API key is used directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from .error_reporter import sanitize_traceback, _clean_path
|
|
21
|
+
|
|
22
|
+
# ------------------------------------------------------------------ #
|
|
23
|
+
# Data model
|
|
24
|
+
# ------------------------------------------------------------------ #
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Diagnosis:
|
|
29
|
+
"""Structured result of LLM-powered error analysis."""
|
|
30
|
+
|
|
31
|
+
error_type: str
|
|
32
|
+
error_message: str
|
|
33
|
+
root_cause: str # Natural-language explanation of the root cause
|
|
34
|
+
affected_files: list[str] # Relative paths of files that need changing
|
|
35
|
+
fix_suggestion: str # Concrete fix description
|
|
36
|
+
confidence: str # "high" | "medium" | "low"
|
|
37
|
+
raw_analysis: str = "" # Full LLM response for debugging
|
|
38
|
+
|
|
39
|
+
def to_markdown(self) -> str:
|
|
40
|
+
"""Format as a GitHub-issue-ready Markdown section."""
|
|
41
|
+
files_list = "\n".join(f" - `{f}`" for f in self.affected_files) or " - (none)"
|
|
42
|
+
return f"""\
|
|
43
|
+
### 🤖 Autonomous Diagnosis
|
|
44
|
+
|
|
45
|
+
**Root Cause:** {self.root_cause}
|
|
46
|
+
|
|
47
|
+
**Affected files:**
|
|
48
|
+
{files_list}
|
|
49
|
+
|
|
50
|
+
**Suggested Fix:** {self.fix_suggestion}
|
|
51
|
+
|
|
52
|
+
**Confidence:** `{self.confidence}`
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------ #
|
|
57
|
+
# LLM call helpers
|
|
58
|
+
# ------------------------------------------------------------------ #
|
|
59
|
+
|
|
60
|
+
ANALYSIS_SYSTEM_PROMPT = """You are a senior software engineer analyzing a bug in the **DeepSeek Code** project — an open-source AI coding assistant that runs in the terminal.
|
|
61
|
+
|
|
62
|
+
You will receive:
|
|
63
|
+
1. An error report (type, message, traceback)
|
|
64
|
+
2. Relevant source code snippets from the project
|
|
65
|
+
|
|
66
|
+
Your task: diagnose the ROOT CAUSE and propose a SPECIFIC, CONCRETE fix.
|
|
67
|
+
|
|
68
|
+
CRITICAL:
|
|
69
|
+
- Only suggest changes to DeepSeek Code's OWN source code (luckyd_code/ and tests/)
|
|
70
|
+
- Do NOT suggest changes to the user's project or third-party libraries
|
|
71
|
+
- Be precise about which file(s) and what lines need to change
|
|
72
|
+
- Consider error handling gaps, edge cases, type issues, and import problems
|
|
73
|
+
- If you cannot determine the root cause from the provided context, say so honestly
|
|
74
|
+
|
|
75
|
+
Respond in this EXACT JSON format (no other text):
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"root_cause": "...",
|
|
79
|
+
"affected_files": ["path/relative/to/project/root.py"],
|
|
80
|
+
"fix_suggestion": "...",
|
|
81
|
+
"confidence": "high|medium|low"
|
|
82
|
+
}
|
|
83
|
+
```"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_relevant_files(error_data: dict[str, str], project_root: str) -> dict[str, str]:
|
|
87
|
+
"""Collect source files mentioned in the traceback or likely relevant.
|
|
88
|
+
|
|
89
|
+
Returns a dict of {relative_path: file_contents} — at most 5 files,
|
|
90
|
+
truncated to 200 lines each.
|
|
91
|
+
"""
|
|
92
|
+
tb = error_data.get("traceback", "")
|
|
93
|
+
relevant: dict[str, str] = {}
|
|
94
|
+
root = Path(project_root).resolve()
|
|
95
|
+
|
|
96
|
+
# Extract file references from traceback
|
|
97
|
+
for line in tb.split("\n"):
|
|
98
|
+
if "luckyd_code" in line and ".py" in line:
|
|
99
|
+
# Try to extract a relative path
|
|
100
|
+
import re
|
|
101
|
+
m = re.search(r'([\w/.-]*luckyd_code/[\w/.-]+\.py)', line)
|
|
102
|
+
if m:
|
|
103
|
+
rel = m.group(1)
|
|
104
|
+
abs_path = root / rel
|
|
105
|
+
if abs_path.exists() and rel not in relevant:
|
|
106
|
+
try:
|
|
107
|
+
content = abs_path.read_text(encoding="utf-8")
|
|
108
|
+
lines = content.split("\n")
|
|
109
|
+
if len(lines) > 200:
|
|
110
|
+
content = "\n".join(lines[:200]) + "\n... (file truncated)"
|
|
111
|
+
relevant[rel] = content
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
# If we found fewer than 3 files, try to read files referenced by error message
|
|
116
|
+
if len(relevant) < 3:
|
|
117
|
+
msg = error_data.get("error_message", "")
|
|
118
|
+
for part in msg.replace("'", " ").replace('"', " ").split():
|
|
119
|
+
if ".py" in part:
|
|
120
|
+
candidate = root / part
|
|
121
|
+
if candidate.exists() and str(candidate.relative_to(root)) not in relevant:
|
|
122
|
+
try:
|
|
123
|
+
content = candidate.read_text(encoding="utf-8")
|
|
124
|
+
lines = content.split("\n")
|
|
125
|
+
if len(lines) > 200:
|
|
126
|
+
content = "\n".join(lines[:200]) + "\n... (file truncated)"
|
|
127
|
+
relevant[str(candidate.relative_to(root))] = content
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
return dict(list(relevant.items())[:5])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _call_llm(
|
|
135
|
+
system_prompt: str,
|
|
136
|
+
user_message: str,
|
|
137
|
+
api_key: str,
|
|
138
|
+
base_url: str = "https://api.deepseek.com/v1",
|
|
139
|
+
model: str = "deepseek-v4-flash",
|
|
140
|
+
timeout: float = 30.0,
|
|
141
|
+
) -> str:
|
|
142
|
+
"""Make a single synchronous (non-streaming) LLM call.
|
|
143
|
+
|
|
144
|
+
Returns the response text, or an error string starting with "ERROR:".
|
|
145
|
+
"""
|
|
146
|
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
|
147
|
+
headers = {
|
|
148
|
+
"Authorization": f"Bearer {api_key}",
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
}
|
|
151
|
+
payload = {
|
|
152
|
+
"model": model,
|
|
153
|
+
"messages": [
|
|
154
|
+
{"role": "system", "content": system_prompt},
|
|
155
|
+
{"role": "user", "content": user_message},
|
|
156
|
+
],
|
|
157
|
+
"max_tokens": 2048,
|
|
158
|
+
"temperature": 0.1, # Low temperature for deterministic analysis
|
|
159
|
+
"stream": False,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
with httpx.Client(timeout=httpx.Timeout(timeout, connect=10.0)) as client:
|
|
164
|
+
resp = client.post(url, headers=headers, json=payload)
|
|
165
|
+
resp.raise_for_status()
|
|
166
|
+
data = resp.json()
|
|
167
|
+
content = data["choices"][0]["message"]["content"]
|
|
168
|
+
return content.strip()
|
|
169
|
+
except httpx.HTTPStatusError as e:
|
|
170
|
+
body = e.response.text[:500] if e.response else ""
|
|
171
|
+
return f"ERROR: HTTP {e.response.status_code if e.response else '?'}: {body}"
|
|
172
|
+
except httpx.TimeoutException:
|
|
173
|
+
return "ERROR: LLM request timed out"
|
|
174
|
+
except Exception as e:
|
|
175
|
+
return f"ERROR: {e}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_diagnosis_json(raw: str) -> Optional[dict[str, Any]]:
|
|
179
|
+
"""Extract JSON from an LLM response that may have markdown fences."""
|
|
180
|
+
if not raw or raw.startswith("ERROR:"):
|
|
181
|
+
return None
|
|
182
|
+
# Try to extract JSON from ```json ... ``` fences
|
|
183
|
+
import re
|
|
184
|
+
m = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', raw, re.DOTALL)
|
|
185
|
+
if m:
|
|
186
|
+
try:
|
|
187
|
+
return json.loads(m.group(1))
|
|
188
|
+
except json.JSONDecodeError:
|
|
189
|
+
pass
|
|
190
|
+
# Try parsing the whole response as JSON
|
|
191
|
+
try:
|
|
192
|
+
return json.loads(raw)
|
|
193
|
+
except json.JSONDecodeError:
|
|
194
|
+
pass
|
|
195
|
+
# Try finding a bare JSON object
|
|
196
|
+
m = re.search(r'\{[^{}]*"root_cause"[^{}]*\}', raw, re.DOTALL)
|
|
197
|
+
if m:
|
|
198
|
+
try:
|
|
199
|
+
return json.loads(m.group(0))
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
pass
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------ #
|
|
206
|
+
# Main API
|
|
207
|
+
# ------------------------------------------------------------------ #
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def analyze_error(
|
|
211
|
+
exc_or_data: BaseException | dict[str, str],
|
|
212
|
+
api_key: str,
|
|
213
|
+
base_url: str = "https://api.deepseek.com/v1",
|
|
214
|
+
model: str = "deepseek-v4-flash",
|
|
215
|
+
project_root: str = "",
|
|
216
|
+
) -> Optional[Diagnosis]:
|
|
217
|
+
"""Analyze an error with LLM-powered root cause diagnosis.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
exc_or_data: Either a live exception or a sanitized traceback dict.
|
|
221
|
+
api_key: DeepSeek API key (the user's own).
|
|
222
|
+
base_url: API base URL.
|
|
223
|
+
model: Model name to use.
|
|
224
|
+
project_root: Root of the DeepSeek Code project (auto-detected if empty).
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
A Diagnosis on success, None if analysis failed or no root cause found.
|
|
228
|
+
"""
|
|
229
|
+
# Resolve project root
|
|
230
|
+
if not project_root:
|
|
231
|
+
project_root = str(Path(__file__).resolve().parent.parent)
|
|
232
|
+
|
|
233
|
+
# Sanitize if given a live exception
|
|
234
|
+
if isinstance(exc_or_data, BaseException):
|
|
235
|
+
error_data = sanitize_traceback(exc_or_data)
|
|
236
|
+
else:
|
|
237
|
+
error_data = exc_or_data
|
|
238
|
+
|
|
239
|
+
# Gather code context
|
|
240
|
+
file_context = _get_relevant_files(error_data, project_root)
|
|
241
|
+
|
|
242
|
+
# Build user message
|
|
243
|
+
context_section = ""
|
|
244
|
+
if file_context:
|
|
245
|
+
context_section = "## Relevant Source Code\n\n"
|
|
246
|
+
for fpath, content in file_context.items():
|
|
247
|
+
context_section += f"### {fpath}\n```python\n{content}\n```\n\n"
|
|
248
|
+
else:
|
|
249
|
+
context_section = "## Relevant Source Code\n(No relevant files could be extracted from the traceback.)\n"
|
|
250
|
+
|
|
251
|
+
user_message = f"""## Error Report
|
|
252
|
+
|
|
253
|
+
**Error Type:** `{error_data['error_type']}`
|
|
254
|
+
**Message:** `{error_data['error_message']}`
|
|
255
|
+
**Python:** {error_data.get('python_version', 'unknown')}
|
|
256
|
+
**OS:** {error_data.get('os', 'unknown')}
|
|
257
|
+
|
|
258
|
+
## Traceback
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
{error_data['traceback']}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
{context_section}"""
|
|
265
|
+
|
|
266
|
+
# Call LLM
|
|
267
|
+
raw = _call_llm(
|
|
268
|
+
system_prompt=ANALYSIS_SYSTEM_PROMPT,
|
|
269
|
+
user_message=user_message,
|
|
270
|
+
api_key=api_key,
|
|
271
|
+
base_url=base_url,
|
|
272
|
+
model=model,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if raw.startswith("ERROR:"):
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
parsed = _parse_diagnosis_json(raw)
|
|
279
|
+
if not parsed:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
return Diagnosis(
|
|
283
|
+
error_type=error_data["error_type"],
|
|
284
|
+
error_message=error_data["error_message"],
|
|
285
|
+
root_cause=parsed.get("root_cause", "Unknown"),
|
|
286
|
+
affected_files=parsed.get("affected_files", []),
|
|
287
|
+
fix_suggestion=parsed.get("fix_suggestion", ""),
|
|
288
|
+
confidence=parsed.get("confidence", "low"),
|
|
289
|
+
raw_analysis=raw,
|
|
290
|
+
)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Auto-reindex on file changes — watchdog-based background file watcher.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from luckyd_code.file_watcher import FileWatcher
|
|
6
|
+
|
|
7
|
+
watcher = FileWatcher("/path/to/project")
|
|
8
|
+
watcher.start()
|
|
9
|
+
...
|
|
10
|
+
watcher.stop()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
from .log import get_logger
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FileWatcher:
|
|
23
|
+
"""Watch a project directory for source file changes and auto-reindex.
|
|
24
|
+
|
|
25
|
+
Uses watchdog if available; falls back to a polling timer otherwise.
|
|
26
|
+
Debounces rapid changes so reindex doesn't fire on every keystroke.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, root: str = "", debounce_seconds: float = 3.0,
|
|
30
|
+
on_change: Optional[Callable[[list[str]], None]] = None):
|
|
31
|
+
self.root = Path(root or os.getcwd()).resolve()
|
|
32
|
+
self.debounce_seconds = debounce_seconds
|
|
33
|
+
self.on_change = on_change
|
|
34
|
+
self._watchdog = None
|
|
35
|
+
self._thread: Optional[threading.Thread] = None
|
|
36
|
+
self._stop_event = threading.Event()
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._pending: set[str] = set()
|
|
39
|
+
self._running = False
|
|
40
|
+
self._poll_interval = 1.0 # seconds between polls in fallback mode
|
|
41
|
+
self._use_watchdog = False
|
|
42
|
+
self._paused = False
|
|
43
|
+
self._debounce_timer: Optional[threading.Timer] = None
|
|
44
|
+
|
|
45
|
+
# Build the set of watched file extensions
|
|
46
|
+
self._watched_exts = {
|
|
47
|
+
".py", ".pyi", ".js", ".jsx", ".ts", ".tsx",
|
|
48
|
+
".rs", ".go", ".java", ".rb", ".php",
|
|
49
|
+
".c", ".h", ".cpp", ".hpp", ".cs",
|
|
50
|
+
".swift", ".kt", ".scala",
|
|
51
|
+
".json", ".yaml", ".yml", ".toml",
|
|
52
|
+
".md", ".sql", ".sh", ".bat",
|
|
53
|
+
".html", ".css",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------ #
|
|
57
|
+
# Public API
|
|
58
|
+
# ------------------------------------------------------------------ #
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_running(self) -> bool:
|
|
62
|
+
return self._running
|
|
63
|
+
|
|
64
|
+
def start(self):
|
|
65
|
+
"""Start watching for file changes in a background thread."""
|
|
66
|
+
if self._running:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
self._stop_event.clear()
|
|
70
|
+
self._pending.clear()
|
|
71
|
+
|
|
72
|
+
# Try watchdog first
|
|
73
|
+
watchdog_ok = self._try_watchdog()
|
|
74
|
+
|
|
75
|
+
if watchdog_ok:
|
|
76
|
+
self._running = True
|
|
77
|
+
get_logger().info("File watcher started (watchdog) on %s", self.root)
|
|
78
|
+
else:
|
|
79
|
+
# Fallback: poll for mtime changes
|
|
80
|
+
self._thread = threading.Thread(
|
|
81
|
+
target=self._poll_loop,
|
|
82
|
+
daemon=True,
|
|
83
|
+
name="file-watcher-poll",
|
|
84
|
+
)
|
|
85
|
+
self._thread.start()
|
|
86
|
+
self._running = True
|
|
87
|
+
get_logger().info("File watcher started (polling) on %s", self.root)
|
|
88
|
+
|
|
89
|
+
def stop(self):
|
|
90
|
+
"""Stop watching."""
|
|
91
|
+
self._running = False
|
|
92
|
+
self._stop_event.set()
|
|
93
|
+
|
|
94
|
+
if self._watchdog is not None:
|
|
95
|
+
try:
|
|
96
|
+
self._watchdog.stop()
|
|
97
|
+
self._watchdog.join(timeout=3)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
self._watchdog = None
|
|
101
|
+
|
|
102
|
+
if self._thread and self._thread.is_alive():
|
|
103
|
+
self._thread.join(timeout=3)
|
|
104
|
+
self._thread = None
|
|
105
|
+
|
|
106
|
+
get_logger().info("File watcher stopped")
|
|
107
|
+
|
|
108
|
+
def pause(self):
|
|
109
|
+
"""Temporarily pause reindex on changes."""
|
|
110
|
+
self._paused = True
|
|
111
|
+
|
|
112
|
+
def resume(self):
|
|
113
|
+
"""Resume reindex after pause."""
|
|
114
|
+
self._paused = False
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def status(self) -> str:
|
|
118
|
+
if not self._running:
|
|
119
|
+
return "stopped"
|
|
120
|
+
mode = "watchdog" if self._use_watchdog else "polling"
|
|
121
|
+
paused = " (paused)" if self._paused else ""
|
|
122
|
+
pending = f" ({len(self._pending)} pending)" if self._pending else ""
|
|
123
|
+
return f"running [{mode}]{paused}{pending}"
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------ #
|
|
126
|
+
# Watchdog-based watching
|
|
127
|
+
# ------------------------------------------------------------------ #
|
|
128
|
+
|
|
129
|
+
def _try_watchdog(self) -> bool:
|
|
130
|
+
"""Try to use watchdog for file watching. Returns True on success."""
|
|
131
|
+
try:
|
|
132
|
+
from watchdog.observers import Observer
|
|
133
|
+
from watchdog.events import FileSystemEventHandler
|
|
134
|
+
|
|
135
|
+
class _Handler(FileSystemEventHandler):
|
|
136
|
+
def __init__(self, watcher):
|
|
137
|
+
self.watcher = watcher
|
|
138
|
+
|
|
139
|
+
def on_modified(self, event):
|
|
140
|
+
if not event.is_directory:
|
|
141
|
+
self.watcher._on_file_changed(event.src_path)
|
|
142
|
+
|
|
143
|
+
def on_created(self, event):
|
|
144
|
+
if not event.is_directory:
|
|
145
|
+
self.watcher._on_file_changed(event.src_path)
|
|
146
|
+
|
|
147
|
+
handler = _Handler(self)
|
|
148
|
+
observer = Observer()
|
|
149
|
+
observer.schedule(handler, str(self.root), recursive=True)
|
|
150
|
+
observer.daemon = True
|
|
151
|
+
observer.start()
|
|
152
|
+
self._watchdog = observer
|
|
153
|
+
self._use_watchdog = True
|
|
154
|
+
return True
|
|
155
|
+
except Exception as e:
|
|
156
|
+
get_logger().warning("watchdog init failed, falling back to polling: %s", e)
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------ #
|
|
160
|
+
# Fallback polling
|
|
161
|
+
# ------------------------------------------------------------------ #
|
|
162
|
+
|
|
163
|
+
def _poll_loop(self):
|
|
164
|
+
"""Polling fallback when watchdog is unavailable.
|
|
165
|
+
|
|
166
|
+
Tracks mtime/size of watched files and detects changes.
|
|
167
|
+
"""
|
|
168
|
+
snapshot: dict[str, tuple[float, int]] = {}
|
|
169
|
+
last_trigger = 0.0
|
|
170
|
+
|
|
171
|
+
while not self._stop_event.is_set():
|
|
172
|
+
time.sleep(self._poll_interval)
|
|
173
|
+
|
|
174
|
+
if self._paused:
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
now = time.time()
|
|
178
|
+
changed: list[str] = []
|
|
179
|
+
|
|
180
|
+
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
181
|
+
dirnames[:] = [d for d in dirnames
|
|
182
|
+
if not d.startswith(".") and d != "__pycache__"]
|
|
183
|
+
for fname in filenames:
|
|
184
|
+
ext = Path(fname).suffix.lower()
|
|
185
|
+
if ext not in self._watched_exts:
|
|
186
|
+
continue
|
|
187
|
+
fpath = Path(dirpath) / fname
|
|
188
|
+
try:
|
|
189
|
+
st = fpath.stat()
|
|
190
|
+
key = str(fpath)
|
|
191
|
+
prev = snapshot.get(key)
|
|
192
|
+
cur = (st.st_mtime, st.st_size)
|
|
193
|
+
if prev is not None and prev != cur:
|
|
194
|
+
changed.append(key)
|
|
195
|
+
snapshot[key] = cur
|
|
196
|
+
except OSError:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
if changed:
|
|
200
|
+
with self._lock:
|
|
201
|
+
self._pending.update(changed)
|
|
202
|
+
last_trigger = now
|
|
203
|
+
|
|
204
|
+
# Debounce: only trigger if no new changes for debounce_seconds
|
|
205
|
+
if self._pending and (now - last_trigger) >= self.debounce_seconds:
|
|
206
|
+
with self._lock:
|
|
207
|
+
batch = list(self._pending)
|
|
208
|
+
self._pending.clear()
|
|
209
|
+
self._fire(batch)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------ #
|
|
212
|
+
# Shared change handling
|
|
213
|
+
# ------------------------------------------------------------------ #
|
|
214
|
+
|
|
215
|
+
def _on_file_changed(self, path: str):
|
|
216
|
+
"""Called by watchdog on each file change event."""
|
|
217
|
+
if self._paused:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
ext = Path(path).suffix.lower()
|
|
221
|
+
if ext not in self._watched_exts:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
with self._lock:
|
|
225
|
+
self._pending.add(path)
|
|
226
|
+
|
|
227
|
+
# Start a debounce timer if not already running
|
|
228
|
+
if self._debounce_timer is None or not self._debounce_timer.is_alive():
|
|
229
|
+
self._debounce_timer = threading.Timer(
|
|
230
|
+
self.debounce_seconds,
|
|
231
|
+
self._debounce_fire,
|
|
232
|
+
)
|
|
233
|
+
self._debounce_timer.daemon = True
|
|
234
|
+
self._debounce_timer.start()
|
|
235
|
+
|
|
236
|
+
def _debounce_fire(self):
|
|
237
|
+
"""Called after debounce window elapses (watchdog mode)."""
|
|
238
|
+
with self._lock:
|
|
239
|
+
batch = list(self._pending)
|
|
240
|
+
self._pending.clear()
|
|
241
|
+
if batch:
|
|
242
|
+
self._fire(batch)
|
|
243
|
+
|
|
244
|
+
def _fire(self, changed_files: list[str]):
|
|
245
|
+
"""Trigger reindex with the list of changed files."""
|
|
246
|
+
if not changed_files:
|
|
247
|
+
return
|
|
248
|
+
try:
|
|
249
|
+
# Rel import to avoid circular dependency
|
|
250
|
+
from .brain import rebuild_project
|
|
251
|
+
result = rebuild_project(str(self.root))
|
|
252
|
+
stats = f"{result.get('chunks', 0)} chunks, {result.get('files', 0)} files"
|
|
253
|
+
get_logger().info("Auto-reindexed (%d files changed): %s",
|
|
254
|
+
len(changed_files), stats)
|
|
255
|
+
if self.on_change:
|
|
256
|
+
self.on_change(changed_files)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
get_logger().warning("Auto-reindex failed: %s", e)
|