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,103 @@
1
+ """Simple permissions system for tool execution."""
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from .._data_dir import project_data_path
8
+
9
+ _logger = logging.getLogger("luckyd_code.permissions")
10
+
11
+
12
+ # Risk levels
13
+ RISK_SAFE = "safe" # Always allowed
14
+ RISK_MEDIUM = "medium" # Prompt user
15
+ RISK_HIGH = "high" # Always prompt
16
+
17
+ TOOL_RISKS = {
18
+ "Read": RISK_SAFE,
19
+ "Glob": RISK_SAFE,
20
+ "Grep": RISK_SAFE,
21
+ "WebFetch": RISK_SAFE,
22
+ "WebSearch": RISK_SAFE,
23
+ "Write": RISK_MEDIUM,
24
+ "Edit": RISK_MEDIUM,
25
+ "Bash": RISK_HIGH,
26
+ "GitStatus": RISK_MEDIUM,
27
+ "GitDiff": RISK_MEDIUM,
28
+ "GitLog": RISK_SAFE,
29
+ "GitBranch": RISK_SAFE,
30
+ "GitAdd": RISK_MEDIUM,
31
+ "GitCommit": RISK_HIGH,
32
+ "GitPush": RISK_HIGH,
33
+ "GitPR": RISK_HIGH,
34
+ "GitWorktree": RISK_HIGH,
35
+ "SubAgent": RISK_MEDIUM,
36
+ "AgentHandoff": RISK_MEDIUM,
37
+ "BrainSearch": RISK_SAFE,
38
+ "BrainStatus": RISK_SAFE,
39
+ }
40
+
41
+
42
+ def _get_settings_path() -> Path:
43
+ return project_data_path("settings.local.json")
44
+
45
+
46
+ def _load_allowlist() -> set[str]:
47
+ path = _get_settings_path()
48
+ if path.exists():
49
+ try:
50
+ data = json.loads(path.read_text())
51
+ return set(data.get("allowed_tools", []))
52
+ except Exception:
53
+ _logger.warning("Failed to load allowlist from %s", path, exc_info=True)
54
+ return set()
55
+ return set()
56
+
57
+
58
+ def _save_to_allowlist(tool_name: str):
59
+ path = _get_settings_path()
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ allowlist = _load_allowlist()
62
+ allowlist.add(tool_name)
63
+ data = {"allowed_tools": list(allowlist)}
64
+ path.write_text(json.dumps(data, indent=2))
65
+
66
+
67
+ def check_permission(tool_name: str) -> bool:
68
+ """Check if a tool is allowed. Returns True if allowed, False if blocked."""
69
+ if tool_name in _load_allowlist():
70
+ return True
71
+
72
+ risk = TOOL_RISKS.get(tool_name, RISK_HIGH)
73
+ if risk == RISK_SAFE:
74
+ return True
75
+ if risk == RISK_MEDIUM:
76
+ return _prompt_user(tool_name, risk)
77
+ return _prompt_user(tool_name, risk)
78
+
79
+
80
+ def _prompt_user(tool_name: str, risk: str) -> bool:
81
+ """Prompt user for permission. Returns True if approved."""
82
+ risk_label = {"medium": "moderate risk", "high": "HIGH risk"}.get(risk, risk)
83
+ print(f"\n[Permission] Allow {tool_name}? ({risk_label})")
84
+ print(" a = allow once | y = always allow | n = deny | s = skip")
85
+ attempts = 0
86
+ while attempts < 3:
87
+ try:
88
+ choice = input(" [a/y/n/s]: ").strip().lower()
89
+ except (EOFError, KeyboardInterrupt):
90
+ print()
91
+ return False
92
+
93
+ if choice == "y":
94
+ _save_to_allowlist(tool_name)
95
+ return True
96
+ if choice in ("a", ""):
97
+ return True
98
+ if choice in ("n", "s"):
99
+ return False
100
+ attempts += 1
101
+ print(f" Invalid input '{choice}' — enter a (allow once), y (always allow), n (deny), or s (skip)")
102
+ print(" Too many invalid attempts. Denying permission.")
103
+ return False
luckyd_code/planner.py ADDED
@@ -0,0 +1,361 @@
1
+ """AI-powered planning module — decompose tasks into structured, executable steps.
2
+
3
+ The planner uses the configured AI API to break down complex tasks into ordered steps
4
+ with agent assignments, dependency tracking, and time estimates. Plans are saved
5
+ as structured Markdown files in the project data directory for persistence across sessions.
6
+ """
7
+
8
+ import json
9
+ from dataclasses import dataclass, field, asdict
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+
14
+ # ------------------------------------------------------------------ #
15
+ # Plan data model
16
+ # ------------------------------------------------------------------ #
17
+
18
+ @dataclass
19
+ class PlanStep:
20
+ id: int
21
+ title: str
22
+ description: str
23
+ agent: str # researcher | coder | reviewer | tester | user
24
+ depends_on: list[int] = field(default_factory=list)
25
+ estimated_minutes: int = 5
26
+ status: str = "pending" # pending | in_progress | done | skipped
27
+
28
+
29
+ @dataclass
30
+ class Plan:
31
+ name: str
32
+ goal: str
33
+ steps: list[PlanStep] = field(default_factory=list)
34
+ created_at: str = ""
35
+ updated_at: str = ""
36
+
37
+ def to_markdown(self) -> str:
38
+ from datetime import datetime
39
+ lines = [
40
+ f"# Plan: {self.name}",
41
+ f"\n**Goal:** {self.goal}",
42
+ f"\n**Created:** {self.created_at or datetime.now().isoformat()}",
43
+ "\n## Steps\n",
44
+ ]
45
+ status_icon = {"pending": "⬜", "in_progress": "🔄", "done": "✅", "skipped": "⏭️", "error": "❌"}
46
+ for step in self.steps:
47
+ icon = status_icon.get(step.status, "⬜")
48
+ deps = f" _(after steps {step.depends_on})_" if step.depends_on else ""
49
+ lines.append(f"### {icon} Step {step.id}: {step.title}{deps}")
50
+ lines.append(f"**Agent:** `{step.agent}` · **Est.:** {step.estimated_minutes} min")
51
+ lines.append(f"\n{step.description}\n")
52
+ return "\n".join(lines)
53
+
54
+ def summary(self) -> str:
55
+ done = sum(1 for s in self.steps if s.status == "done")
56
+ total = len(self.steps)
57
+ total_min = sum(s.estimated_minutes for s in self.steps)
58
+ return f"{done}/{total} steps done · ~{total_min} min total"
59
+
60
+
61
+ # ------------------------------------------------------------------ #
62
+ # Storage helpers
63
+ # ------------------------------------------------------------------ #
64
+
65
+ from ._data_dir import project_data_path
66
+
67
+
68
+ def _plans_dir() -> Path:
69
+ p = project_data_path("plans")
70
+ p.mkdir(parents=True, exist_ok=True)
71
+ return p
72
+
73
+
74
+ def _plan_path(name: str) -> Path:
75
+ return _plans_dir() / f"{name}.md"
76
+
77
+
78
+ def _plan_json_path(name: str) -> Path:
79
+ return _plans_dir() / f"{name}.json"
80
+
81
+
82
+ def save_plan(plan: Plan) -> str:
83
+ """Persist a plan as both Markdown (human-readable) and JSON (machine-readable)."""
84
+ from datetime import datetime
85
+ if not plan.created_at:
86
+ plan.created_at = datetime.now().isoformat()
87
+ plan.updated_at = datetime.now().isoformat()
88
+
89
+ _plan_path(plan.name).write_text(plan.to_markdown(), encoding="utf-8")
90
+ _plan_json_path(plan.name).write_text(
91
+ json.dumps(asdict(plan), indent=2), encoding="utf-8"
92
+ )
93
+ return str(_plan_path(plan.name))
94
+
95
+
96
+ def load_plan(name: str) -> Optional[Plan]:
97
+ """Load a plan from JSON (preferred) or fall back to Markdown."""
98
+ json_path = _plan_json_path(name)
99
+ if json_path.exists():
100
+ try:
101
+ data = json.loads(json_path.read_text(encoding="utf-8"))
102
+ steps = [PlanStep(**s) for s in data.get("steps", [])]
103
+ return Plan(
104
+ name=data["name"],
105
+ goal=data["goal"],
106
+ steps=steps,
107
+ created_at=data.get("created_at", ""),
108
+ updated_at=data.get("updated_at", ""),
109
+ )
110
+ except Exception:
111
+ pass
112
+ return None
113
+
114
+
115
+ def list_plans() -> str:
116
+ """List all saved plans with a one-line summary each."""
117
+ files = sorted(_plans_dir().glob("*.json"))
118
+ if not files:
119
+ return "No plans yet. Use /plan create <name> <goal> to create one."
120
+ lines = []
121
+ for f in files:
122
+ plan = load_plan(f.stem)
123
+ if plan:
124
+ lines.append(f" • {plan.name}: {plan.goal[:60]} — {plan.summary()}")
125
+ else:
126
+ lines.append(f" • {f.stem}")
127
+ return "\n".join(lines)
128
+
129
+
130
+ def read_plan(name: str) -> str:
131
+ """Return the Markdown representation of a plan."""
132
+ plan = load_plan(name)
133
+ if plan:
134
+ return plan.to_markdown()
135
+ md_path = _plan_path(name)
136
+ if md_path.exists():
137
+ return md_path.read_text(encoding="utf-8")
138
+ return f"Plan '{name}' not found."
139
+
140
+
141
+ def delete_plan(name: str) -> str:
142
+ """Delete a plan (both Markdown and JSON files)."""
143
+ removed = []
144
+ for p in [_plan_path(name), _plan_json_path(name)]:
145
+ if p.exists():
146
+ p.unlink()
147
+ removed.append(p.name)
148
+ return f"Deleted: {', '.join(removed)}" if removed else f"Plan '{name}' not found."
149
+
150
+
151
+ def update_step_status(name: str, step_id: int, status: str) -> str:
152
+ """Mark a step as done/in_progress/skipped and re-save the plan."""
153
+ valid = {"pending", "in_progress", "done", "skipped", "error"}
154
+ if status not in valid:
155
+ return f"Invalid status '{status}'. Must be one of: {', '.join(sorted(valid))}"
156
+ plan = load_plan(name)
157
+ if not plan:
158
+ return f"Plan '{name}' not found."
159
+ for step in plan.steps:
160
+ if step.id == step_id:
161
+ step.status = status
162
+ save_plan(plan)
163
+ return f"Step {step_id} marked as '{status}'. {plan.summary()}"
164
+ return f"Step {step_id} not found in plan '{name}'."
165
+
166
+
167
+ # ------------------------------------------------------------------ #
168
+ # AI-powered plan generation
169
+ # ------------------------------------------------------------------ #
170
+
171
+ _PLANNER_SYSTEM = """You are an expert software engineering planner.
172
+ Break down the user's goal into a concrete, ordered list of steps.
173
+ Each step must be assigned to one of these agents: researcher, coder, reviewer, tester.
174
+ Use 'user' for steps that require human input or decisions.
175
+
176
+ Respond with ONLY a valid JSON object — no preamble, no markdown fences — in this exact schema:
177
+ {
178
+ "steps": [
179
+ {
180
+ "id": 1,
181
+ "title": "Short title",
182
+ "description": "Detailed description of what to do and how",
183
+ "agent": "researcher|coder|reviewer|tester|user",
184
+ "depends_on": [],
185
+ "estimated_minutes": 10
186
+ }
187
+ ]
188
+ }
189
+
190
+ Guidelines:
191
+ - Keep steps atomic and independently executable.
192
+ - Use depends_on to express ordering (step 3 depends on step 1 means [1]).
193
+ - Parallel steps that can run simultaneously should have the same depends_on list.
194
+ - estimated_minutes should be realistic (5-120).
195
+ - Minimum 3 steps, maximum 15 steps.
196
+ """
197
+
198
+
199
+ def ai_create_plan(name: str, goal: str, config) -> Plan:
200
+ """Use the DeepSeek API to decompose a goal into a structured Plan.
201
+
202
+ Falls back to a minimal placeholder plan if the API call fails.
203
+
204
+ Args:
205
+ name: Short identifier for the plan (used as filename).
206
+ goal: Natural-language description of what needs to be accomplished.
207
+ config: App config object with api_key, base_url, model attributes.
208
+
209
+ Returns:
210
+ A populated ``Plan`` instance (already saved to disk).
211
+ """
212
+ from openai import OpenAI
213
+ import httpx
214
+
215
+ steps: list[PlanStep] = []
216
+
217
+ try:
218
+ client = OpenAI(
219
+ api_key=config.api_key,
220
+ base_url=config.base_url,
221
+ http_client=httpx.Client(timeout=30),
222
+ )
223
+ resp = client.chat.completions.create(
224
+ model="deepseek-v4-flash", # Flash is fast and cheap for planning
225
+ messages=[
226
+ {"role": "system", "content": _PLANNER_SYSTEM},
227
+ {"role": "user", "content": f"Goal: {goal}"},
228
+ ],
229
+ max_tokens=2000,
230
+ temperature=0.3,
231
+ )
232
+ raw = (resp.choices[0].message.content or "").strip()
233
+
234
+ # Strip any accidental markdown code fences
235
+ if raw.startswith("```"):
236
+ raw = raw.split("```")[1]
237
+ if raw.startswith("json"):
238
+ raw = raw[4:]
239
+ raw = raw.strip()
240
+
241
+ data = json.loads(raw)
242
+ for s in data.get("steps", []):
243
+ steps.append(PlanStep(
244
+ id=int(s.get("id", len(steps) + 1)),
245
+ title=str(s.get("title", "Untitled")),
246
+ description=str(s.get("description", "")),
247
+ agent=str(s.get("agent", "coder")),
248
+ depends_on=[int(d) for d in s.get("depends_on", [])],
249
+ estimated_minutes=int(s.get("estimated_minutes", 10)),
250
+ ))
251
+ except Exception as exc:
252
+ # Fallback: single placeholder step so the plan is never empty
253
+ steps = [
254
+ PlanStep(
255
+ id=1,
256
+ title="Investigate and implement",
257
+ description=f"Complete the goal: {goal}\n\n(Plan generation failed: {exc})",
258
+ agent="coder",
259
+ depends_on=[],
260
+ estimated_minutes=30,
261
+ )
262
+ ]
263
+
264
+ plan = Plan(name=name, goal=goal, steps=steps)
265
+ save_plan(plan)
266
+ return plan
267
+
268
+
269
+ # ------------------------------------------------------------------ #
270
+ # Interactive plan approval and execution
271
+ # ------------------------------------------------------------------ #
272
+
273
+ def plan_and_approve(goal: str, config, session=None) -> Optional["Plan"]:
274
+ """Generate a plan with AI, show it, and ask the user to approve it.
275
+
276
+ Returns the approved ``Plan`` if the user confirms, or ``None`` if
277
+ the user rejects it or presses Ctrl-C.
278
+ """
279
+ import re
280
+ from datetime import datetime
281
+ from rich.console import Console
282
+ from rich.markdown import Markdown
283
+ from rich.prompt import Confirm
284
+
285
+ _console = Console()
286
+
287
+ # Build a safe plan name from the goal
288
+ slug = re.sub(r"[^a-z0-9]+", "_", goal[:30].lower()).strip("_") or "plan"
289
+ name = f"{slug}_{datetime.now().strftime('%H%M%S')}"
290
+
291
+ _console.print(f"\n[bold cyan]Creating plan for:[/bold cyan] {goal}")
292
+ _console.print("[dim]Generating steps with AI...[/dim]")
293
+
294
+ plan = ai_create_plan(name, goal, config)
295
+
296
+ _console.print(f"\n[bold]Generated Plan: {plan.name}[/bold]")
297
+ _console.print(Markdown(plan.to_markdown()))
298
+ _console.print(f"\n[dim]{plan.summary()}[/dim]")
299
+
300
+ try:
301
+ approved = Confirm.ask("\nProceed with this plan?", default=True)
302
+ except (KeyboardInterrupt, EOFError):
303
+ approved = False
304
+
305
+ if not approved:
306
+ _console.print("[yellow]Plan rejected. Refine the task description and try again.[/yellow]")
307
+ return None
308
+
309
+ return plan
310
+
311
+
312
+ def execute_plan(plan: "Plan", task: str, config) -> str:
313
+ """Execute an approved plan step-by-step, running each step through a SubAgent.
314
+
315
+ Returns a summary string of all step outcomes.
316
+ """
317
+ from .agent import SubAgent
318
+
319
+ results: list[str] = []
320
+ for step in plan.steps:
321
+ if step.status == "skipped":
322
+ results.append(f"⏭\u202f Step {step.id} skipped: {step.title}")
323
+ continue
324
+
325
+ step.status = "in_progress"
326
+ save_plan(plan)
327
+
328
+ agent_prompt = (
329
+ f"You are the {step.agent} agent working on a multi-step plan.\n"
330
+ f"Overall goal: {task}\n"
331
+ f"Your step ({step.id}/{len(plan.steps)}): {step.title}\n\n"
332
+ f"{step.description}"
333
+ )
334
+
335
+ try:
336
+ agent = SubAgent(config, agent_prompt)
337
+ result = agent.run()
338
+ step.status = "done"
339
+ results.append(f"✅ Step {step.id}: {step.title}\n {result[:300]}")
340
+ except Exception as exc:
341
+ step.status = "error"
342
+ results.append(f"❌ Step {step.id}: {step.title}\n Error: {exc}")
343
+
344
+ save_plan(plan)
345
+
346
+ return "\n\n".join(results) + f"\n\n**{plan.summary()}**"
347
+
348
+
349
+ # ------------------------------------------------------------------ #
350
+ # Legacy shim — keep old callers working
351
+ # ------------------------------------------------------------------ #
352
+
353
+ def create_plan_file(name: str, content: str) -> str:
354
+ """Write raw content to a plan file (legacy, no AI decomposition)."""
355
+ path = _plan_path(name)
356
+ path.write_text(f"# Plan: {name}\n\n{content}", encoding="utf-8")
357
+ return str(path)
358
+
359
+
360
+ def get_plans_dir() -> str:
361
+ return str(_plans_dir())
luckyd_code/plugins.py ADDED
@@ -0,0 +1,91 @@
1
+ """Plugin system — auto-discover and load user plugins from ~/.deepseek-code/plugins/.
2
+
3
+ Plugins are .py files placed in ~/.deepseek-code/plugins/. Each plugin exports a
4
+ ``register(registry)`` function that receives the ToolRegistry to add tools.
5
+
6
+ Example plugin (~/.deepseek-code/plugins/hello.py)::
7
+
8
+ from luckyd_code.tools.registry import Tool
9
+
10
+ class HelloTool(Tool):
11
+ name = "Hello"
12
+ description = "Say hello to someone."
13
+ parameters = {
14
+ "type": "object",
15
+ "properties": {
16
+ "name": {"type": "string", "description": "Name to greet"},
17
+ },
18
+ "required": ["name"],
19
+ }
20
+
21
+ def run(self, name: str = "world") -> str:
22
+ return f"Hello, {name}! Plugin system works."
23
+
24
+ def register(registry):
25
+ registry.register(HelloTool())
26
+ """
27
+
28
+
29
+ import importlib.util
30
+ import logging
31
+ from pathlib import Path
32
+ from typing import Any, Callable
33
+
34
+ from .tools.registry import ToolRegistry
35
+
36
+ logger = logging.getLogger("luckyd_code.plugins")
37
+
38
+ from ._data_dir import data_path
39
+
40
+ PLUGIN_DIR = data_path("plugins")
41
+
42
+
43
+ def discover_plugins() -> list[Path]:
44
+ """Find all .py files in the plugins directory."""
45
+ if not PLUGIN_DIR.exists():
46
+ return []
47
+ return sorted(PLUGIN_DIR.glob("*.py"))
48
+
49
+
50
+ def load_plugin(path: Path) -> Callable[..., Any] | None:
51
+ """Load a single plugin file and return its ``register`` function.
52
+
53
+ Returns None if the plugin is invalid or has no register function.
54
+ """
55
+ try:
56
+ spec = importlib.util.spec_from_file_location(path.stem, path)
57
+ if not spec or not spec.loader:
58
+ logger.warning("Could not load plugin: %s", path.name)
59
+ return None
60
+
61
+ module = importlib.util.module_from_spec(spec)
62
+ spec.loader.exec_module(module)
63
+
64
+ if not hasattr(module, "register"):
65
+ logger.warning("Plugin '%s' has no register() function, skipping", path.name)
66
+ return None
67
+
68
+ register: Callable[..., Any] = module.register
69
+ return register
70
+
71
+ except Exception as e:
72
+ logger.error("Failed to load plugin '%s': %s", path.name, e)
73
+ return None
74
+
75
+
76
+ def load_all_plugins(registry: ToolRegistry) -> int:
77
+ """Discover and load all plugins into the registry.
78
+
79
+ Returns the number of plugins successfully loaded.
80
+ """
81
+ count = 0
82
+ for path in discover_plugins():
83
+ register_fn = load_plugin(path)
84
+ if register_fn:
85
+ try:
86
+ register_fn(registry)
87
+ count += 1
88
+ logger.info("Loaded plugin: %s", path.name)
89
+ except Exception as e:
90
+ logger.error("Plugin '%s' register() failed: %s", path.name, e)
91
+ return count
luckyd_code/py.typed ADDED
File without changes
luckyd_code/retry.py ADDED
@@ -0,0 +1,57 @@
1
+ """Retry logic with exponential backoff for API calls."""
2
+
3
+ import time
4
+ import random
5
+ from functools import wraps
6
+
7
+ from .exceptions import RetryableError, NonRetryableError, ModelNotFoundError
8
+ from .log import get_logger
9
+
10
+
11
+ def with_retry(
12
+ max_retries: int = 3,
13
+ base_delay: float = 1.0,
14
+ max_delay: float = 30.0,
15
+ jitter: bool = True,
16
+ ):
17
+ """Decorator that retries a function on retryable errors with exponential backoff.
18
+
19
+ Args:
20
+ max_retries: Maximum number of retry attempts (default 3)
21
+ base_delay: Initial delay in seconds (default 1.0)
22
+ max_delay: Maximum delay in seconds (default 30.0)
23
+ jitter: Add random jitter to delay (default True)
24
+ """
25
+ def decorator(func):
26
+ @wraps(func)
27
+ def wrapper(*args, **kwargs):
28
+ last_exception = None
29
+ delay = base_delay
30
+ logger = get_logger()
31
+
32
+ for attempt in range(max_retries + 1):
33
+ try:
34
+ return func(*args, **kwargs)
35
+ except RetryableError as e:
36
+ last_exception = e
37
+ if attempt < max_retries:
38
+ actual_delay = delay
39
+ if jitter:
40
+ actual_delay = delay * (0.5 + random.random() * 0.5)
41
+ logger.warning("Attempt %d failed, retrying in %.1fs...", attempt + 1, actual_delay)
42
+ time.sleep(actual_delay)
43
+ delay = min(delay * 2, max_delay)
44
+ except (NonRetryableError, ModelNotFoundError) as e:
45
+ raise e
46
+ except Exception:
47
+ # Unclassified errors - retry once on the first attempt, then give up
48
+ if attempt == 0:
49
+ actual_delay = delay * (0.5 + random.random() * 0.5)
50
+ logger.warning("Transient error, retrying in %.1fs...", actual_delay)
51
+ time.sleep(actual_delay)
52
+ else:
53
+ raise
54
+
55
+ raise last_exception or RuntimeError("Max retries exceeded")
56
+ return wrapper
57
+ return decorator