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,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
|