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,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)
@@ -0,0 +1,11 @@
1
+ from .tools import (
2
+ git_status,
3
+ git_diff,
4
+ git_log,
5
+ git_commit,
6
+ git_add,
7
+ git_branch,
8
+ git_create_pr,
9
+ git_push,
10
+ )
11
+ from .auto_commit import collect_modified_paths