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,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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")