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,508 @@
|
|
|
1
|
+
"""Error reporter — safe, opt-in error telemetry via GitHub Issues.
|
|
2
|
+
|
|
3
|
+
Captures unhandled exceptions, sanitizes them thoroughly, and opens a
|
|
4
|
+
pre-filled GitHub Issue URL in the user's browser so they can review and
|
|
5
|
+
submit. Nothing is sent without explicit user consent.
|
|
6
|
+
|
|
7
|
+
Settings key: ``error_reporting``
|
|
8
|
+
- ``"ask"`` (default) — prompt the user before opening the issue
|
|
9
|
+
- ``"off"`` — never prompt; silently log locally only
|
|
10
|
+
- ``"log"`` — write sanitized details to a local log file (no browser)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import platform
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import webbrowser
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# --- Globals ----------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
GITHUB_ISSUES_URL = (
|
|
29
|
+
"https://github.com/Dylanchess0320/LuckyD-Code/issues/new"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
SANITIZE_PATTERNS: list[str] = [
|
|
33
|
+
"DEEPSEEK_API_KEY",
|
|
34
|
+
"OPENAI_API_KEY",
|
|
35
|
+
"ANTHROPIC_API_KEY",
|
|
36
|
+
"GITHUB_TOKEN",
|
|
37
|
+
"GH_TOKEN",
|
|
38
|
+
"HUGGINGFACE_TOKEN",
|
|
39
|
+
"TOGETHER_API_KEY",
|
|
40
|
+
"COHERE_API_KEY",
|
|
41
|
+
"MISTRAL_API_KEY",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_seen_hashes: set[str] = set()
|
|
45
|
+
|
|
46
|
+
# Regex for common API key *value* formats (not just the key name).
|
|
47
|
+
# These catch key values that leak into error messages, logs, etc.
|
|
48
|
+
_API_KEY_VALUE_RE = re.compile(
|
|
49
|
+
r"(?:sk-(?:ant-)?|gh[poru]_|hf_)[a-zA-Z0-9_-]{16,}",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sanitize_line(line: str) -> str:
|
|
54
|
+
"""Redact common secret patterns from a single string."""
|
|
55
|
+
# 1. Redact known API key value patterns (regardless of context)
|
|
56
|
+
line = _API_KEY_VALUE_RE.sub("[REDACTED]", line)
|
|
57
|
+
|
|
58
|
+
# 2. Redact key names (env var names that appear in messages)
|
|
59
|
+
for pattern in SANITIZE_PATTERNS:
|
|
60
|
+
if pattern not in line:
|
|
61
|
+
continue
|
|
62
|
+
# key=value style
|
|
63
|
+
needle = f"{pattern}="
|
|
64
|
+
if needle in line:
|
|
65
|
+
idx = line.index(needle)
|
|
66
|
+
rest = line[idx + len(needle) :]
|
|
67
|
+
end = len(rest)
|
|
68
|
+
for c in " \t\n\r\"';":
|
|
69
|
+
pos = rest.find(c)
|
|
70
|
+
if pos != -1 and pos < end:
|
|
71
|
+
end = pos
|
|
72
|
+
line = line[: idx + len(needle)] + "[REDACTED]" + rest[end:]
|
|
73
|
+
# bare key present (env var name leaked in message)
|
|
74
|
+
elif pattern in line:
|
|
75
|
+
line = line.replace(pattern, "[REDACTED]")
|
|
76
|
+
return line
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _clean_path(filepath: str) -> str:
|
|
80
|
+
"""Replace absolute paths with safe, generic labels."""
|
|
81
|
+
cwd = os.getcwd()
|
|
82
|
+
home = str(os.path.expanduser("~"))
|
|
83
|
+
|
|
84
|
+
if filepath.startswith(cwd):
|
|
85
|
+
rel = filepath[len(cwd) :].lstrip(os.sep)
|
|
86
|
+
return f"<cwd>/{rel}"
|
|
87
|
+
if filepath.startswith(home):
|
|
88
|
+
return f"~/.../{os.path.basename(filepath)}"
|
|
89
|
+
if "site-packages" in filepath:
|
|
90
|
+
idx = filepath.find("site-packages")
|
|
91
|
+
return f"<venv>/{filepath[idx:]}"
|
|
92
|
+
# Just keep the filename
|
|
93
|
+
return os.path.basename(filepath)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sanitize_traceback(exc: BaseException) -> dict[str, str]:
|
|
97
|
+
"""Build a fully-sanitised dict from a live exception.
|
|
98
|
+
|
|
99
|
+
Strips: API keys, absolute file paths, environment-variable values,
|
|
100
|
+
and anything else that could leak user data.
|
|
101
|
+
"""
|
|
102
|
+
tb_text = "".join(
|
|
103
|
+
_sanitize_line(line)
|
|
104
|
+
for line in __import__("traceback").format_exception(
|
|
105
|
+
type(exc), exc, exc.__traceback__
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Second pass: clean paths in the traceback
|
|
110
|
+
cleaned_lines: list[str] = []
|
|
111
|
+
for line in tb_text.split("\n"):
|
|
112
|
+
# File "C:\Users\...\foo.py", line 42, in bar
|
|
113
|
+
cleaned = line
|
|
114
|
+
if 'File "' in line:
|
|
115
|
+
start = line.index('File "') + 6
|
|
116
|
+
end = line.index('"', start)
|
|
117
|
+
path = line[start:end]
|
|
118
|
+
cleaned_path = _clean_path(path)
|
|
119
|
+
cleaned = line[:start] + cleaned_path + line[end:]
|
|
120
|
+
cleaned_lines.append(cleaned)
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"error_type": type(exc).__name__,
|
|
124
|
+
"error_message": _sanitize_line(str(exc)),
|
|
125
|
+
"traceback": "\n".join(cleaned_lines),
|
|
126
|
+
"python_version": sys.version.split()[0],
|
|
127
|
+
"os": platform.platform(),
|
|
128
|
+
"app_version": _get_version(),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _get_version() -> str:
|
|
133
|
+
try:
|
|
134
|
+
from .update import get_version # noqa: PLC0415
|
|
135
|
+
|
|
136
|
+
return get_version()
|
|
137
|
+
except Exception:
|
|
138
|
+
return "unknown"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- Deduplication ----------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _error_fingerprint(exc: BaseException) -> str:
|
|
145
|
+
"""Stable hash for deduplicating identical errors within a session."""
|
|
146
|
+
raw = f"{type(exc).__name__}:{exc}"
|
|
147
|
+
return hashlib.md5(raw.encode(), usedforsecurity=False).hexdigest()[:12]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def already_reported(exc: BaseException) -> bool:
|
|
151
|
+
"""Return True if this error was already reported this session."""
|
|
152
|
+
fp = _error_fingerprint(exc)
|
|
153
|
+
if fp in _seen_hashes:
|
|
154
|
+
return True
|
|
155
|
+
_seen_hashes.add(fp)
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- GitHub URL Builder -----------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_issue_url(
|
|
163
|
+
error_data: dict[str, str],
|
|
164
|
+
diagnosis: str = "",
|
|
165
|
+
diff: str = "",
|
|
166
|
+
pr_url: str = "",
|
|
167
|
+
) -> str:
|
|
168
|
+
"""Build a pre-filled GitHub new-issue URL from sanitised error data.
|
|
169
|
+
|
|
170
|
+
The user still has to click *Submit new issue* on GitHub — we never post
|
|
171
|
+
anything automatically.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
error_data: Sanitised traceback dict from ``sanitize_traceback``.
|
|
175
|
+
diagnosis: Optional LLM diagnosis Markdown (from autonomous mode).
|
|
176
|
+
diff: Optional unified diff (from autonomous fix mode).
|
|
177
|
+
pr_url: Optional PR URL (from autonomous full mode).
|
|
178
|
+
"""
|
|
179
|
+
# Truncate traceback for URL length limits (browsers handle 2 MB, but
|
|
180
|
+
# GitHub's title+body limit is ~64 KB; we keep it well under that).
|
|
181
|
+
tb_preview = error_data["traceback"]
|
|
182
|
+
if len(tb_preview) > 3000:
|
|
183
|
+
tb_preview = tb_preview[:3000] + "\n... (truncated)"
|
|
184
|
+
|
|
185
|
+
title = urllib.parse.quote(
|
|
186
|
+
f"[auto-report] {error_data['error_type']}: "
|
|
187
|
+
f"{error_data['error_message'][:60]}",
|
|
188
|
+
safe="",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Build extra sections for autonomous improvement
|
|
192
|
+
extra_sections = ""
|
|
193
|
+
|
|
194
|
+
if diagnosis:
|
|
195
|
+
extra_sections += f"""
|
|
196
|
+
|
|
197
|
+
{diagnosis}
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
if diff:
|
|
201
|
+
# Truncate diff to avoid hitting URL size limits
|
|
202
|
+
diff_preview = diff[:5000]
|
|
203
|
+
if len(diff) > 5000:
|
|
204
|
+
diff_preview += "\n... (truncated)"
|
|
205
|
+
extra_sections += f"""
|
|
206
|
+
<details>
|
|
207
|
+
<summary><b>Proposed Fix (diff)</b></summary>
|
|
208
|
+
|
|
209
|
+
```diff
|
|
210
|
+
{diff_preview}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
</details>
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
if pr_url:
|
|
217
|
+
extra_sections += f"""
|
|
218
|
+
|
|
219
|
+
**PR created:** {pr_url}
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
body = urllib.parse.quote(
|
|
223
|
+
f"""\
|
|
224
|
+
## Error Report (auto-generated by luckyd-code)
|
|
225
|
+
|
|
226
|
+
**Error Type:** `{error_data['error_type']}`
|
|
227
|
+
**Message:** `{error_data['error_message']}`
|
|
228
|
+
**Version:** {error_data['app_version']}
|
|
229
|
+
**Python:** {error_data['python_version']}
|
|
230
|
+
**OS:** {error_data['os']}
|
|
231
|
+
|
|
232
|
+
<details>
|
|
233
|
+
<summary><b>Traceback</b></summary>
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
{tb_preview}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
</details>
|
|
240
|
+
{extra_sections}
|
|
241
|
+
---
|
|
242
|
+
*This issue was pre-filled by LuckyD Code's built-in error reporter.
|
|
243
|
+
The human user reviewed the content above before submitting.*
|
|
244
|
+
""",
|
|
245
|
+
safe="",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return f"{GITHUB_ISSUES_URL}?title={title}&body={body}"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --- Local Logging (offline / 'log' mode) -----------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _log_to_file(exc: BaseException) -> Path:
|
|
255
|
+
"""Write a sanitised error report to a local file. Returns the path."""
|
|
256
|
+
data = sanitize_traceback(exc)
|
|
257
|
+
from ._data_dir import data_path # noqa: PLC0415
|
|
258
|
+
|
|
259
|
+
log_dir = data_path("error-reports")
|
|
260
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
|
|
262
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
263
|
+
fname = log_dir / f"error-{timestamp}-{_error_fingerprint(exc)}.json"
|
|
264
|
+
fname.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
265
|
+
return fname
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --- Main API ---------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def capture_unhandled(exc: BaseException) -> bool:
|
|
272
|
+
"""Entry point for unhandled-exception reporting.
|
|
273
|
+
|
|
274
|
+
Behaviour is controlled by the ``error_reporting`` setting:
|
|
275
|
+
|
|
276
|
+
──────── ────────────────────────────────────────────────────
|
|
277
|
+
``"off"`` Do nothing (return False).
|
|
278
|
+
``"log"`` Write a sanitised report to a local file.
|
|
279
|
+
``"ask"`` Prompt the user, then open a GitHub Issue URL in
|
|
280
|
+
their browser if they consent (the default).
|
|
281
|
+
──────── ────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
Returns True if the browser was opened (or the file was written).
|
|
284
|
+
"""
|
|
285
|
+
mode = _get_reporting_mode()
|
|
286
|
+
|
|
287
|
+
if mode == "off":
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
if already_reported(exc):
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
if mode == "log":
|
|
294
|
+
path = _log_to_file(exc)
|
|
295
|
+
try:
|
|
296
|
+
from .log import get_logger # noqa: PLC0415
|
|
297
|
+
|
|
298
|
+
get_logger().info("Error report saved to %s", path)
|
|
299
|
+
except Exception:
|
|
300
|
+
pass
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
# mode == "ask" — interactive
|
|
304
|
+
return _ask_and_open(exc)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _get_reporting_mode() -> str:
|
|
308
|
+
"""Read the ``error_reporting`` setting (case-insensitive)."""
|
|
309
|
+
try:
|
|
310
|
+
from . import settings # noqa: PLC0415
|
|
311
|
+
|
|
312
|
+
s = settings.load_settings()
|
|
313
|
+
val = str(s.get("error_reporting", "ask")).strip().lower()
|
|
314
|
+
if val in ("off", "log", "ask"):
|
|
315
|
+
return val
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
return "ask"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _get_api_key() -> str:
|
|
322
|
+
"""Get the DeepSeek API key from config, .env, or environment."""
|
|
323
|
+
try:
|
|
324
|
+
from .config import Config # noqa: PLC0415
|
|
325
|
+
cfg = Config()
|
|
326
|
+
return cfg.api_key
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
return os.environ.get("DEEPSEEK_API_KEY", "")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _get_autonomous_mode() -> str:
|
|
333
|
+
"""Read the ``autonomous_improvement`` setting (case-insensitive).
|
|
334
|
+
|
|
335
|
+
──────────── ───────────────────────────────────────────────────
|
|
336
|
+
``"off"`` Just open the GitHub Issue URL.
|
|
337
|
+
(use this if you want the old behaviour)
|
|
338
|
+
``"analyze"`` Report + LLM diagnosis appended to the issue.
|
|
339
|
+
``"fix"`` Report + diagnosis + generate patch, show diff.
|
|
340
|
+
``"full"`` Report + diagnosis + patch + validate + create PR.
|
|
341
|
+
──────────── ───────────────────────────────────────────────────
|
|
342
|
+
"""
|
|
343
|
+
try:
|
|
344
|
+
from . import settings # noqa: PLC0415
|
|
345
|
+
s = settings.load_settings()
|
|
346
|
+
val = str(s.get("autonomous_improvement", "full")).strip().lower()
|
|
347
|
+
if val in ("off", "analyze", "fix", "full"):
|
|
348
|
+
return val
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
return "off"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _ask_and_open(exc: BaseException) -> bool:
|
|
355
|
+
"""Prompt the user and — if they consent — open a GitHub issue URL.
|
|
356
|
+
|
|
357
|
+
If ``autonomous_improvement`` is enabled, also runs LLM diagnosis, fix
|
|
358
|
+
generation, and/or PR creation depending on the setting level.
|
|
359
|
+
"""
|
|
360
|
+
try:
|
|
361
|
+
from rich.console import Console # noqa: PLC0415
|
|
362
|
+
|
|
363
|
+
console = Console()
|
|
364
|
+
except Exception:
|
|
365
|
+
console = None # type: ignore[assignment]
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
input_fn = __builtins__["input"] # type: ignore[index]
|
|
369
|
+
except (KeyError, TypeError):
|
|
370
|
+
input_fn = input
|
|
371
|
+
|
|
372
|
+
if console:
|
|
373
|
+
console.print(
|
|
374
|
+
"\n[bold yellow]:pensive: Oops! Something unexpected happened.[/bold yellow]"
|
|
375
|
+
)
|
|
376
|
+
console.print(
|
|
377
|
+
"[dim]Help improve LuckyD Code by reporting this? "
|
|
378
|
+
"A GitHub issue page will open in your browser for review — "
|
|
379
|
+
"[bold]you[/bold] decide whether to submit.[/dim]"
|
|
380
|
+
)
|
|
381
|
+
else:
|
|
382
|
+
print(
|
|
383
|
+
"\n:pensive: Oops! Something unexpected happened.\n"
|
|
384
|
+
"Help improve LuckyD Code by reporting this? "
|
|
385
|
+
"A GitHub issue page will open in your browser for review — "
|
|
386
|
+
"YOU decide whether to submit."
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
answer = input_fn(" Report issue? [Y/n]: ").strip().lower()
|
|
391
|
+
except (EOFError, KeyboardInterrupt):
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
if answer not in ("", "y", "yes"):
|
|
395
|
+
if console:
|
|
396
|
+
console.print("[dim]Skipped. You can report later at:\n"
|
|
397
|
+
" https://github.com/Dylanchess0320/LuckyD-Code/issues[/dim]")
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
error_data = sanitize_traceback(exc)
|
|
401
|
+
|
|
402
|
+
# ── Autonomous Improvement Pipeline ──────────────────────────
|
|
403
|
+
auto_mode = _get_autonomous_mode()
|
|
404
|
+
diagnosis_text = ""
|
|
405
|
+
pr_url = ""
|
|
406
|
+
diff_preview = ""
|
|
407
|
+
|
|
408
|
+
if auto_mode != "off":
|
|
409
|
+
api_key = _get_api_key()
|
|
410
|
+
if not api_key:
|
|
411
|
+
if console:
|
|
412
|
+
console.print("[dim]Autonomous improvement skipped: no API key configured.[/dim]")
|
|
413
|
+
else:
|
|
414
|
+
if console:
|
|
415
|
+
console.print(f"[dim]Running autonomous diagnosis (mode: {auto_mode})...[/dim]")
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
from .feedback_analyzer import analyze_error # noqa: PLC0415
|
|
419
|
+
|
|
420
|
+
diagnosis = analyze_error(exc, api_key)
|
|
421
|
+
if diagnosis:
|
|
422
|
+
diagnosis_text = diagnosis.to_markdown()
|
|
423
|
+
if console:
|
|
424
|
+
console.print(
|
|
425
|
+
f"\n[bold cyan]Diagnosis (confidence: {diagnosis.confidence}):[/bold cyan]"
|
|
426
|
+
)
|
|
427
|
+
console.print(f" {diagnosis.root_cause}")
|
|
428
|
+
console.print(f" Suggested: {diagnosis.fix_suggestion}")
|
|
429
|
+
else:
|
|
430
|
+
if console:
|
|
431
|
+
console.print("[dim]Diagnosis failed — the LLM could not determine root cause.[/dim]")
|
|
432
|
+
|
|
433
|
+
except Exception as diag_err:
|
|
434
|
+
if console:
|
|
435
|
+
console.print(f"[dim]Diagnosis error: {diag_err}[/dim]")
|
|
436
|
+
|
|
437
|
+
# "fix" and "full" mode: generate a fix
|
|
438
|
+
if auto_mode in ("fix", "full") and api_key:
|
|
439
|
+
try:
|
|
440
|
+
from .autonomous_fixer import ( # noqa: PLC0415
|
|
441
|
+
full_autonomous_pipeline,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
create_pr_flag = (auto_mode == "full")
|
|
445
|
+
|
|
446
|
+
if console:
|
|
447
|
+
console.print("[dim]Generating and validating fix...[/dim]")
|
|
448
|
+
|
|
449
|
+
fix_result = full_autonomous_pipeline(
|
|
450
|
+
exc, api_key, create_pr_flag=create_pr_flag,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
if fix_result.diff:
|
|
454
|
+
diff_preview = fix_result.diff[:3000]
|
|
455
|
+
if fix_result.diff:
|
|
456
|
+
diff_preview += "\n... (truncated)" if len(fix_result.diff) > 3000 else ""
|
|
457
|
+
|
|
458
|
+
if fix_result.success:
|
|
459
|
+
if console:
|
|
460
|
+
console.print(f"\n[bold green]Fix validated! All tests pass.[/bold green]")
|
|
461
|
+
if fix_result.pr_url:
|
|
462
|
+
console.print(f"[bold green]PR created: {fix_result.pr_url}[/bold green]")
|
|
463
|
+
pr_url = fix_result.pr_url
|
|
464
|
+
else:
|
|
465
|
+
console.print("[dim]Fix applied on branch: "
|
|
466
|
+
f"{fix_result.branch_name}[/dim]")
|
|
467
|
+
else:
|
|
468
|
+
if fix_result.error:
|
|
469
|
+
if console:
|
|
470
|
+
console.print(f"[yellow]Fix could not be completed: {fix_result.error}[/yellow]")
|
|
471
|
+
elif not fix_result.diff:
|
|
472
|
+
if console:
|
|
473
|
+
console.print("[yellow]Fix generation failed — "
|
|
474
|
+
"LLM could not produce a patch.[/yellow]")
|
|
475
|
+
else:
|
|
476
|
+
if console:
|
|
477
|
+
console.print(f"[yellow]Fix validation failed. "
|
|
478
|
+
f"Diff available on branch {fix_result.branch_name}.[/yellow]")
|
|
479
|
+
|
|
480
|
+
except Exception as fix_err:
|
|
481
|
+
if console:
|
|
482
|
+
console.print(f"[dim]Fix pipeline error: {fix_err}[/dim]")
|
|
483
|
+
|
|
484
|
+
# ── Build and open the issue URL ──────────────────────────────
|
|
485
|
+
url = build_issue_url(error_data, diagnosis=diagnosis_text, diff=diff_preview, pr_url=pr_url)
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
print(" Opening browser …")
|
|
489
|
+
webbrowser.open_new_tab(url)
|
|
490
|
+
return True
|
|
491
|
+
except Exception:
|
|
492
|
+
print(f" Could not open your browser. Copy this URL:\n\n {url}")
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def capture_and_log_only(exc: BaseException) -> None:
|
|
497
|
+
"""Non-interactive: log the error locally without any user prompt.
|
|
498
|
+
|
|
499
|
+
Useful for background threads / daemons where interaction is impossible.
|
|
500
|
+
"""
|
|
501
|
+
from .log import get_logger
|
|
502
|
+
|
|
503
|
+
data = sanitize_traceback(exc)
|
|
504
|
+
get_logger().error(
|
|
505
|
+
"Unhandled error: %s: %s",
|
|
506
|
+
data["error_type"],
|
|
507
|
+
data["error_message"],
|
|
508
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""All custom exceptions for LuckyD Code.
|
|
2
|
+
|
|
3
|
+
Import from here — not from individual modules — to keep the exception
|
|
4
|
+
hierarchy in one place and avoid circular imports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LuckyDCodeError(Exception):
|
|
9
|
+
"""Base class for all LuckyD Code errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Backwards-compatible alias — kept so any code (or user scripts) that
|
|
13
|
+
# imported DeepSeekAPIError still works without modification.
|
|
14
|
+
DeepSeekAPIError = LuckyDCodeError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthenticationError(LuckyDCodeError):
|
|
18
|
+
"""API key was rejected or is missing."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RetryableError(LuckyDCodeError):
|
|
22
|
+
"""Transient error that can be retried (rate limit, timeout, server error)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NonRetryableError(LuckyDCodeError):
|
|
26
|
+
"""Permanent error that must NOT be retried (bad request, auth failure)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ModelNotFoundError(NonRetryableError):
|
|
30
|
+
"""The requested model does not exist or is not available on this provider."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ContextLengthError(NonRetryableError):
|
|
34
|
+
"""Request exceeds the model's context-window limit."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolExecutionError(LuckyDCodeError):
|
|
38
|
+
"""A built-in tool raised an exception during execution."""
|
|
39
|
+
|
luckyd_code/export.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Conversation export — markdown and HTML."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def export_markdown(messages: list, filepath: Optional[str] = None) -> str:
|
|
9
|
+
"""Export conversation messages as markdown.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
messages: List of message dicts from ConversationContext.
|
|
13
|
+
filepath: Optional path to write the file. If omitted, returns the string.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
The markdown string.
|
|
17
|
+
"""
|
|
18
|
+
lines = [
|
|
19
|
+
"# Conversation Export\n",
|
|
20
|
+
f"_Exported: {datetime.now().isoformat()}_\n",
|
|
21
|
+
]
|
|
22
|
+
for msg in messages:
|
|
23
|
+
role = msg.get("role", "unknown")
|
|
24
|
+
content = str(msg.get("content", ""))
|
|
25
|
+
tool_calls = msg.get("tool_calls")
|
|
26
|
+
|
|
27
|
+
if role == "system":
|
|
28
|
+
lines.append(f"## System\n```\n{content}\n```\n")
|
|
29
|
+
elif role == "user":
|
|
30
|
+
lines.append(f"## User\n{content}\n")
|
|
31
|
+
elif role == "assistant":
|
|
32
|
+
if tool_calls:
|
|
33
|
+
for tc in tool_calls:
|
|
34
|
+
fn = tc.get("function", {})
|
|
35
|
+
args_str = fn.get("arguments", "")[:500]
|
|
36
|
+
lines.append(
|
|
37
|
+
f"## Assistant (tool: {fn.get('name')})\n"
|
|
38
|
+
f"```json\n{args_str}\n```\n"
|
|
39
|
+
)
|
|
40
|
+
if content:
|
|
41
|
+
lines.append(f"## Assistant\n{content}\n")
|
|
42
|
+
elif role == "tool":
|
|
43
|
+
tool_id = msg.get("tool_call_id", "?")
|
|
44
|
+
trunc = content[:500]
|
|
45
|
+
if len(content) > 500:
|
|
46
|
+
trunc += f"\n... (truncated, {len(content)} total chars)"
|
|
47
|
+
lines.append(f"## Tool Result ({tool_id})\n```\n{trunc}\n```\n")
|
|
48
|
+
|
|
49
|
+
output = "\n".join(lines)
|
|
50
|
+
if filepath:
|
|
51
|
+
Path(filepath).write_text(output, encoding="utf-8")
|
|
52
|
+
return output
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def export_html(messages: list, filepath: Optional[str] = None,
|
|
56
|
+
title: str = "Conversation Export") -> str:
|
|
57
|
+
"""Export conversation messages as a standalone HTML page.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
messages: List of message dicts from ConversationContext.
|
|
61
|
+
filepath: Optional path to write the file.
|
|
62
|
+
title: Page title.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The HTML string.
|
|
66
|
+
"""
|
|
67
|
+
parts = [
|
|
68
|
+
"<!DOCTYPE html>",
|
|
69
|
+
f"<html><head><meta charset='utf-8'><title>{title}</title>",
|
|
70
|
+
"<style>",
|
|
71
|
+
"body { font-family: -apple-system, sans-serif; max-width: 800px; "
|
|
72
|
+
"margin: 2em auto; padding: 0 1em; background: #fafafa; color: #333; }",
|
|
73
|
+
".msg { margin: 1em 0; padding: 1em; border-radius: 8px; }",
|
|
74
|
+
".system { background: #e8e8e8; }",
|
|
75
|
+
".user { background: #dbeafe; }",
|
|
76
|
+
".assistant { background: #dcfce7; }",
|
|
77
|
+
".tool { background: #fef3c7; font-family: monospace; font-size: 0.9em; }",
|
|
78
|
+
"pre { background: #1e1e1e; color: #d4d4d4; padding: 1em; border-radius: 4px; "
|
|
79
|
+
"overflow-x: auto; }",
|
|
80
|
+
".meta { font-size: 0.85em; color: #666; margin-bottom: 0.5em; }",
|
|
81
|
+
"</style></head><body>",
|
|
82
|
+
f"<h1>{title}</h1>",
|
|
83
|
+
f"<p class='meta'>Exported: {datetime.now().isoformat()}</p>",
|
|
84
|
+
"<hr>",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
for msg in messages:
|
|
88
|
+
role = msg.get("role", "unknown")
|
|
89
|
+
content = str(msg.get("content", ""))
|
|
90
|
+
tool_calls = msg.get("tool_calls")
|
|
91
|
+
|
|
92
|
+
if role == "system":
|
|
93
|
+
parts.append(f"<div class='msg system'><div class='meta'>System</div>"
|
|
94
|
+
f"<pre>{_escape_html(content)}</pre></div>")
|
|
95
|
+
elif role == "user":
|
|
96
|
+
parts.append(f"<div class='msg user'><div class='meta'>User</div>"
|
|
97
|
+
f"<pre>{_escape_html(content)}</pre></div>")
|
|
98
|
+
elif role == "assistant":
|
|
99
|
+
if tool_calls:
|
|
100
|
+
for tc in tool_calls:
|
|
101
|
+
fn = tc.get("function", {})
|
|
102
|
+
parts.append(
|
|
103
|
+
f"<div class='msg tool'><div class='meta'>Tool: "
|
|
104
|
+
f"{_escape_html(fn.get('name', ''))}</div>"
|
|
105
|
+
f"<pre>{_escape_html(fn.get('arguments', '')[:500])}</pre></div>"
|
|
106
|
+
)
|
|
107
|
+
if content:
|
|
108
|
+
parts.append(f"<div class='msg assistant'><div class='meta'>Assistant</div>"
|
|
109
|
+
f"<pre>{_escape_html(content)}</pre></div>")
|
|
110
|
+
elif role == "tool":
|
|
111
|
+
tid = msg.get("tool_call_id", "?")
|
|
112
|
+
trunc = content[:500]
|
|
113
|
+
parts.append(
|
|
114
|
+
f"<div class='msg tool'><div class='meta'>Tool Result ({tid})</div>"
|
|
115
|
+
f"<pre>{_escape_html(trunc)}</pre></div>"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
parts.append("</body></html>")
|
|
119
|
+
output = "\n".join(parts)
|
|
120
|
+
if filepath:
|
|
121
|
+
Path(filepath).write_text(output, encoding="utf-8")
|
|
122
|
+
return output
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _escape_html(text: str) -> str:
|
|
126
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|