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
luckyd_code/indexer.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Smart project indexing — scan project structure and inject into context."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from .log import get_logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_gitignore(path: Path) -> list[str]:
|
|
12
|
+
"""Load .gitignore patterns."""
|
|
13
|
+
patterns = []
|
|
14
|
+
gitignore = path / ".gitignore"
|
|
15
|
+
if gitignore.exists():
|
|
16
|
+
try:
|
|
17
|
+
for line in gitignore.read_text(encoding="utf-8").splitlines():
|
|
18
|
+
line = line.strip()
|
|
19
|
+
if line and not line.startswith("#"):
|
|
20
|
+
patterns.append(line)
|
|
21
|
+
except Exception:
|
|
22
|
+
get_logger().warning("Could not load .gitignore", exc_info=True)
|
|
23
|
+
return patterns
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_ignored(name: str, patterns: list[str]) -> bool:
|
|
27
|
+
"""Check if a name matches gitignore patterns."""
|
|
28
|
+
for p in patterns:
|
|
29
|
+
if p.endswith("/") and name == p.rstrip("/"):
|
|
30
|
+
return True
|
|
31
|
+
if name == p:
|
|
32
|
+
return True
|
|
33
|
+
if p.startswith("*.") and name.endswith(p[1:]):
|
|
34
|
+
return True
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
IGNORED_DIRS = {
|
|
39
|
+
".git", "__pycache__", "node_modules", ".venv", "venv", "env",
|
|
40
|
+
".tox", ".eggs", "dist", "build", ".next", ".nuxt",
|
|
41
|
+
"target", "vendor", ".bundle", ".claude", ".vscode", ".idea",
|
|
42
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
IGNORED_EXTS = {
|
|
46
|
+
".pyc", ".pyo", ".so", ".o", ".class", ".jar",
|
|
47
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
|
|
48
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
49
|
+
".zip", ".tar", ".gz", ".rar", ".7z",
|
|
50
|
+
".exe", ".msi", ".dmg", ".pkg",
|
|
51
|
+
".log", ".tmp",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def scan_project(root: Path, max_depth: int = 3, max_items: int = 80) -> dict:
|
|
56
|
+
"""Scan a project directory and return structured metadata."""
|
|
57
|
+
root = root.resolve()
|
|
58
|
+
gitignore_patterns = _load_gitignore(root)
|
|
59
|
+
|
|
60
|
+
info: dict[str, Any] = {
|
|
61
|
+
"name": root.name,
|
|
62
|
+
"path": str(root),
|
|
63
|
+
"languages": set(),
|
|
64
|
+
"frameworks": [],
|
|
65
|
+
"config_files": {},
|
|
66
|
+
"file_tree": [],
|
|
67
|
+
"dependency_files": [],
|
|
68
|
+
"entry_points": [],
|
|
69
|
+
"total_files": 0,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
dirs_to_scan = [(root, 0)]
|
|
73
|
+
scanned = 0
|
|
74
|
+
|
|
75
|
+
while dirs_to_scan and scanned < max_items:
|
|
76
|
+
current, depth = dirs_to_scan.pop(0)
|
|
77
|
+
|
|
78
|
+
if depth > max_depth:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
entries = sorted(current.iterdir(), key=lambda x: (not x.is_dir(), x.name))
|
|
83
|
+
except PermissionError:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
indent = " " * depth
|
|
87
|
+
for entry in entries:
|
|
88
|
+
if scanned >= max_items:
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
name = entry.name
|
|
92
|
+
if name in IGNORED_DIRS or _is_ignored(name, gitignore_patterns):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
if entry.is_dir():
|
|
96
|
+
if not name.startswith(".") and not name.endswith(".egg-info"):
|
|
97
|
+
info["file_tree"].append(f"{indent}{name}/")
|
|
98
|
+
dirs_to_scan.append((entry, depth + 1))
|
|
99
|
+
scanned += 1
|
|
100
|
+
else:
|
|
101
|
+
ext = entry.suffix.lower()
|
|
102
|
+
if ext in IGNORED_EXTS:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
info["file_tree"].append(f"{indent}{name}")
|
|
106
|
+
info["total_files"] += 1
|
|
107
|
+
scanned += 1
|
|
108
|
+
|
|
109
|
+
# Detect language
|
|
110
|
+
lang = _detect_language(ext)
|
|
111
|
+
if lang:
|
|
112
|
+
info["languages"].add(lang)
|
|
113
|
+
|
|
114
|
+
# Detect dependency files
|
|
115
|
+
if name in ("package.json", "pyproject.toml", "Cargo.toml",
|
|
116
|
+
"requirements.txt", "Gemfile", "go.mod", "CMakeLists.txt",
|
|
117
|
+
"composer.json", "Pipfile", "build.gradle", "pom.xml"):
|
|
118
|
+
info["dependency_files"].append(name)
|
|
119
|
+
deps = _extract_deps(entry, name)
|
|
120
|
+
if deps:
|
|
121
|
+
info["config_files"][name] = deps
|
|
122
|
+
|
|
123
|
+
# Detect entry points
|
|
124
|
+
if name in ("main.py", "index.js", "app.js", "cli.py",
|
|
125
|
+
"main.go", "main.rs", "index.ts", "app.ts"):
|
|
126
|
+
info["entry_points"].append(str(entry.relative_to(root)))
|
|
127
|
+
|
|
128
|
+
# Detect framework
|
|
129
|
+
info["frameworks"] = _detect_frameworks(info["config_files"], info["dependency_files"])
|
|
130
|
+
|
|
131
|
+
# Convert languages set to sorted list
|
|
132
|
+
info["languages"] = sorted(info["languages"])
|
|
133
|
+
|
|
134
|
+
return info
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _detect_language(ext: str) -> Optional[str]:
|
|
138
|
+
mapping = {
|
|
139
|
+
".py": "Python", ".pyi": "Python",
|
|
140
|
+
".js": "JavaScript", ".jsx": "JavaScript/JSX",
|
|
141
|
+
".ts": "TypeScript", ".tsx": "TypeScript/TSX",
|
|
142
|
+
".rs": "Rust",
|
|
143
|
+
".go": "Go",
|
|
144
|
+
".java": "Java",
|
|
145
|
+
".kt": "Kotlin",
|
|
146
|
+
".rb": "Ruby",
|
|
147
|
+
".php": "PHP",
|
|
148
|
+
".c": "C", ".h": "C",
|
|
149
|
+
".cpp": "C++", ".hpp": "C++", ".cc": "C++",
|
|
150
|
+
".cs": "C#",
|
|
151
|
+
".swift": "Swift",
|
|
152
|
+
".toml": "TOML", ".yaml": "YAML", ".yml": "YAML",
|
|
153
|
+
".json": "JSON", ".html": "HTML", ".css": "CSS",
|
|
154
|
+
".md": "Markdown",
|
|
155
|
+
".sql": "SQL",
|
|
156
|
+
".sh": "Shell", ".bat": "Batch",
|
|
157
|
+
".pl": "Perl",
|
|
158
|
+
".lua": "Lua",
|
|
159
|
+
".r": "R",
|
|
160
|
+
".scala": "Scala",
|
|
161
|
+
}
|
|
162
|
+
return mapping.get(ext)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _detect_frameworks(config_files: dict, dep_files: list) -> list[str]:
|
|
166
|
+
frameworks = []
|
|
167
|
+
for fname, deps in config_files.items():
|
|
168
|
+
deps_lower = {d.lower() for d in deps}
|
|
169
|
+
# Python
|
|
170
|
+
if "django" in deps_lower:
|
|
171
|
+
frameworks.append("Django")
|
|
172
|
+
if "flask" in deps_lower:
|
|
173
|
+
frameworks.append("Flask")
|
|
174
|
+
if "fastapi" in deps_lower:
|
|
175
|
+
frameworks.append("FastAPI")
|
|
176
|
+
# JS/TS
|
|
177
|
+
if "react" in deps_lower or "next" in deps_lower:
|
|
178
|
+
frameworks.append("React/Next.js")
|
|
179
|
+
if "vue" in deps_lower:
|
|
180
|
+
frameworks.append("Vue")
|
|
181
|
+
if "express" in deps_lower or "@nestjs/core" in deps_lower:
|
|
182
|
+
frameworks.append("Express/NestJS")
|
|
183
|
+
if "tailwindcss" in deps_lower:
|
|
184
|
+
frameworks.append("Tailwind CSS")
|
|
185
|
+
# Rust
|
|
186
|
+
if "actix-web" in deps_lower or "axum" in deps_lower:
|
|
187
|
+
frameworks.append("Actix/Axum")
|
|
188
|
+
# Other
|
|
189
|
+
if "spring-boot" in deps_lower:
|
|
190
|
+
frameworks.append("Spring Boot")
|
|
191
|
+
|
|
192
|
+
return list(set(frameworks))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _extract_deps(filepath: Path, name: str) -> list[str]:
|
|
196
|
+
"""Extract dependency names from a config file."""
|
|
197
|
+
try:
|
|
198
|
+
if name == "package.json":
|
|
199
|
+
data = json.loads(filepath.read_text(encoding="utf-8"))
|
|
200
|
+
return list(data.get("dependencies", {}).keys()) + list(data.get("devDependencies", {}).keys())
|
|
201
|
+
elif name == "pyproject.toml":
|
|
202
|
+
content = filepath.read_text(encoding="utf-8")
|
|
203
|
+
deps = []
|
|
204
|
+
for line in content.splitlines():
|
|
205
|
+
line = line.strip()
|
|
206
|
+
if "=" in line and not line.startswith("[") and not line.startswith("#"):
|
|
207
|
+
parts = line.split("=")
|
|
208
|
+
key = parts[0].strip().strip('"').strip("'")
|
|
209
|
+
if key and not key.startswith("_"):
|
|
210
|
+
deps.append(key)
|
|
211
|
+
return deps[:30]
|
|
212
|
+
elif name == "requirements.txt":
|
|
213
|
+
content = filepath.read_text(encoding="utf-8")
|
|
214
|
+
deps = []
|
|
215
|
+
for line in content.splitlines():
|
|
216
|
+
line = line.strip()
|
|
217
|
+
if line and not line.startswith("#") and not line.startswith("-"):
|
|
218
|
+
dep = line.split(">=")[0].split("==")[0].split("~=")[0].strip()
|
|
219
|
+
deps.append(dep)
|
|
220
|
+
return deps[:30]
|
|
221
|
+
elif name == "Cargo.toml":
|
|
222
|
+
content = filepath.read_text(encoding="utf-8")
|
|
223
|
+
deps = []
|
|
224
|
+
in_deps = False
|
|
225
|
+
for line in content.splitlines():
|
|
226
|
+
line = line.strip()
|
|
227
|
+
if line == "[dependencies]":
|
|
228
|
+
in_deps = True
|
|
229
|
+
continue
|
|
230
|
+
if in_deps and line.startswith("["):
|
|
231
|
+
break
|
|
232
|
+
if in_deps and "=" in line:
|
|
233
|
+
dep = line.split("=")[0].strip().strip('"').strip("'")
|
|
234
|
+
if dep:
|
|
235
|
+
deps.append(dep)
|
|
236
|
+
return deps[:20]
|
|
237
|
+
except Exception:
|
|
238
|
+
get_logger().warning("Could not extract dependencies from %s", name, exc_info=True)
|
|
239
|
+
return []
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def format_project_context(info: dict) -> str:
|
|
243
|
+
"""Format project info into a concise context string for the AI."""
|
|
244
|
+
parts = [f"# Project: {info['name']}"]
|
|
245
|
+
|
|
246
|
+
if info["languages"]:
|
|
247
|
+
parts.append(f"Languages: {', '.join(info['languages'])}")
|
|
248
|
+
|
|
249
|
+
if info["frameworks"]:
|
|
250
|
+
parts.append(f"Frameworks: {', '.join(info['frameworks'])}")
|
|
251
|
+
|
|
252
|
+
if info["entry_points"]:
|
|
253
|
+
parts.append(f"Entry points: {', '.join(info['entry_points'])}")
|
|
254
|
+
|
|
255
|
+
if info["dependency_files"]:
|
|
256
|
+
parts.append(f"Config files: {', '.join(info['dependency_files'])}")
|
|
257
|
+
|
|
258
|
+
if info["total_files"]:
|
|
259
|
+
parts.append(f"Total source files: {info['total_files']}")
|
|
260
|
+
|
|
261
|
+
# File tree
|
|
262
|
+
if info["file_tree"]:
|
|
263
|
+
parts.append("\nFile tree:")
|
|
264
|
+
parts.append("\n".join(info["file_tree"]))
|
|
265
|
+
|
|
266
|
+
return "\n".join(parts)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def index_project(project_dir: str = None) -> str:
|
|
270
|
+
"""Scan a project and return formatted context. Runs in <1s."""
|
|
271
|
+
if project_dir:
|
|
272
|
+
root = Path(project_dir).resolve()
|
|
273
|
+
else:
|
|
274
|
+
root = Path(os.getcwd()).resolve()
|
|
275
|
+
|
|
276
|
+
if not root.exists():
|
|
277
|
+
return ""
|
|
278
|
+
|
|
279
|
+
info = scan_project(root)
|
|
280
|
+
return format_project_context(info)
|
luckyd_code/init.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Project initialization tool."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
MEMORY_FILENAMES = ["MEMORY.md", "CLAUDE.md"] # check both for backward compat
|
|
8
|
+
|
|
9
|
+
DEFAULT_MEMORY_MD = """# MEMORY.md
|
|
10
|
+
|
|
11
|
+
## Project Overview
|
|
12
|
+
<!-- Describe what this project does -->
|
|
13
|
+
|
|
14
|
+
## Tech Stack
|
|
15
|
+
<!-- List the key technologies used -->
|
|
16
|
+
|
|
17
|
+
## Development Setup
|
|
18
|
+
<!-- How to get started -->
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
<!-- Common commands for dev, test, build, lint -->
|
|
22
|
+
|
|
23
|
+
## Guidelines
|
|
24
|
+
<!-- Project-specific coding conventions -->
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init_project():
|
|
29
|
+
"""Initialize the project memory file (MEMORY.md).
|
|
30
|
+
|
|
31
|
+
Creates MEMORY.md if neither MEMORY.md nor CLAUDE.md exists.
|
|
32
|
+
"""
|
|
33
|
+
cwd = Path(os.getcwd())
|
|
34
|
+
for name in MEMORY_FILENAMES:
|
|
35
|
+
if (cwd / name).exists():
|
|
36
|
+
return f"{name} already exists."
|
|
37
|
+
path = cwd / "MEMORY.md"
|
|
38
|
+
path.write_text(DEFAULT_MEMORY_MD, encoding="utf-8")
|
|
39
|
+
return "Created MEMORY.md. Edit it with project-specific instructions."
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Custom keybinding support."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .log import get_logger
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_BINDINGS = {
|
|
12
|
+
"submit": "enter",
|
|
13
|
+
"newline": "alt-enter",
|
|
14
|
+
"cancel": "ctrl-c",
|
|
15
|
+
"history-up": "ctrl-p",
|
|
16
|
+
"history-down": "ctrl-n",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
from ._data_dir import data_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_keybindings_path() -> Path:
|
|
24
|
+
return data_path("keybindings.json")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_keybindings() -> dict:
|
|
28
|
+
path = get_keybindings_path()
|
|
29
|
+
if path.exists():
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(path.read_text())
|
|
32
|
+
if isinstance(data, dict):
|
|
33
|
+
return data
|
|
34
|
+
except Exception:
|
|
35
|
+
get_logger().warning("Could not load keybindings from %s", path, exc_info=True)
|
|
36
|
+
return {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_key_sequence(key: str) -> tuple:
|
|
40
|
+
"""Convert a key string into a tuple of prompt_toolkit key names.
|
|
41
|
+
|
|
42
|
+
prompt_toolkit does not understand 'alt-X' — alt combos must be passed
|
|
43
|
+
as the two-key sequence ('escape', 'X').
|
|
44
|
+
"""
|
|
45
|
+
if key.startswith("alt-"):
|
|
46
|
+
remainder = key[4:] # e.g. 'enter', 'a', etc.
|
|
47
|
+
return ("escape", remainder)
|
|
48
|
+
return (key,)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def apply_keybindings() -> KeyBindings:
|
|
52
|
+
"""Create KeyBindings from config file, falling back to defaults."""
|
|
53
|
+
user = load_keybindings()
|
|
54
|
+
bindings = {**DEFAULT_BINDINGS, **user}
|
|
55
|
+
|
|
56
|
+
kb = KeyBindings()
|
|
57
|
+
|
|
58
|
+
submit_key = bindings.get("submit", "enter")
|
|
59
|
+
newline_key = bindings.get("newline", "alt-enter")
|
|
60
|
+
|
|
61
|
+
# Enter submits the prompt (works even in multiline mode)
|
|
62
|
+
try:
|
|
63
|
+
@kb.add(*_parse_key_sequence(submit_key))
|
|
64
|
+
def _submit(event):
|
|
65
|
+
event.current_buffer.validate_and_handle()
|
|
66
|
+
except Exception:
|
|
67
|
+
get_logger().warning("Failed to register submit keybinding '%s'", submit_key, exc_info=True)
|
|
68
|
+
|
|
69
|
+
# Alt+Enter inserts a newline
|
|
70
|
+
try:
|
|
71
|
+
@kb.add(*_parse_key_sequence(newline_key))
|
|
72
|
+
def _newline(event):
|
|
73
|
+
event.current_buffer.insert_text("\n")
|
|
74
|
+
except Exception:
|
|
75
|
+
get_logger().warning("Failed to register newline keybinding '%s'", newline_key, exc_info=True)
|
|
76
|
+
|
|
77
|
+
return kb
|
luckyd_code/log.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Structured logging for DeepSeek Code."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from ._data_dir import data_path
|
|
9
|
+
|
|
10
|
+
_LOG_DIR = data_path("logs")
|
|
11
|
+
_initialized = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def setup_logging(level: str = "INFO", log_file: str | None = None) -> logging.Logger:
|
|
15
|
+
"""Initialize logging system. Safe to call multiple times."""
|
|
16
|
+
global _initialized
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("luckyd_code")
|
|
19
|
+
if _initialized:
|
|
20
|
+
return logger
|
|
21
|
+
|
|
22
|
+
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
|
23
|
+
|
|
24
|
+
# Console handler (warnings and above)
|
|
25
|
+
console = logging.StreamHandler(sys.stderr)
|
|
26
|
+
console.setLevel(logging.WARNING)
|
|
27
|
+
console.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
28
|
+
logger.addHandler(console)
|
|
29
|
+
|
|
30
|
+
# File handler (all levels)
|
|
31
|
+
if log_file:
|
|
32
|
+
log_path = Path(log_file)
|
|
33
|
+
else:
|
|
34
|
+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
log_path = _LOG_DIR / f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
fh = logging.FileHandler(str(log_path), encoding="utf-8")
|
|
39
|
+
fh.setLevel(getattr(logging, level.upper(), logging.INFO))
|
|
40
|
+
fh.setFormatter(logging.Formatter(
|
|
41
|
+
"%(asctime)s [%(levelname)s] %(message)s",
|
|
42
|
+
datefmt="%H:%M:%S",
|
|
43
|
+
))
|
|
44
|
+
logger.addHandler(fh)
|
|
45
|
+
logger.info(f"Logging initialized: {log_path}")
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.warning(f"Could not create log file: {e}")
|
|
48
|
+
|
|
49
|
+
_initialized = True
|
|
50
|
+
return logger
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_logger() -> logging.Logger:
|
|
54
|
+
"""Get the project logger. Initializes with defaults if not already set up."""
|
|
55
|
+
return setup_logging()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Basic MCP (Model Context Protocol) client.
|
|
2
|
+
|
|
3
|
+
Connects to MCP servers via stdio JSON-RPC and registers their tools.
|
|
4
|
+
MCP spec: https://modelcontextprotocol.io
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Optional, cast
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("luckyd_code.mcp")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MCPServer:
|
|
17
|
+
"""A connected MCP server with health checks and reconnection."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, name: str, command: str, args: list[str] | None = None):
|
|
20
|
+
self.name = name
|
|
21
|
+
self.command = command
|
|
22
|
+
self.args = args or []
|
|
23
|
+
self.process: Optional[subprocess.Popen] = None
|
|
24
|
+
self.tools: list[dict] = []
|
|
25
|
+
self._request_id = 0
|
|
26
|
+
self._max_retries = 2
|
|
27
|
+
|
|
28
|
+
def connect(self):
|
|
29
|
+
"""Start the MCP server process."""
|
|
30
|
+
try:
|
|
31
|
+
import sys
|
|
32
|
+
# On Windows, use shell=True for .cmd/.bat files (like npx, which is npx.cmd)
|
|
33
|
+
use_shell = sys.platform == "win32"
|
|
34
|
+
self.process = subprocess.Popen(
|
|
35
|
+
[self.command] + self.args,
|
|
36
|
+
stdin=subprocess.PIPE,
|
|
37
|
+
stdout=subprocess.PIPE,
|
|
38
|
+
stderr=subprocess.PIPE,
|
|
39
|
+
text=True,
|
|
40
|
+
shell=use_shell,
|
|
41
|
+
)
|
|
42
|
+
# Poll stderr in a non-blocking way — log any startup errors
|
|
43
|
+
import threading
|
|
44
|
+
def _log_stderr(proc):
|
|
45
|
+
for line in iter(proc.stderr.readline, ""):
|
|
46
|
+
if line:
|
|
47
|
+
logger.debug("[mcp:%s] %s", self.name, line.rstrip())
|
|
48
|
+
t = threading.Thread(target=_log_stderr, args=(self.process,), daemon=True)
|
|
49
|
+
t.start()
|
|
50
|
+
except Exception as e:
|
|
51
|
+
return f"Failed to start MCP server '{self.name}': {e}"
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def _ensure_running(self) -> bool:
|
|
55
|
+
"""Check if the server is running and attempt reconnect if not."""
|
|
56
|
+
if self.process and self.process.poll() is None:
|
|
57
|
+
return True
|
|
58
|
+
# Attempt reconnection
|
|
59
|
+
for attempt in range(self._max_retries):
|
|
60
|
+
logger.info("Reconnecting MCP server '%s' (attempt %d/%d)",
|
|
61
|
+
self.name, attempt + 1, self._max_retries)
|
|
62
|
+
self.process = None
|
|
63
|
+
error = self.connect()
|
|
64
|
+
if error is None:
|
|
65
|
+
# Rediscover tools
|
|
66
|
+
self.tools = self.list_tools()
|
|
67
|
+
return True
|
|
68
|
+
time.sleep(1)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def _send_request(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]:
|
|
72
|
+
"""Send a JSON-RPC request and get response."""
|
|
73
|
+
if not self._ensure_running():
|
|
74
|
+
return {"error": "Server not running"}
|
|
75
|
+
|
|
76
|
+
self._request_id += 1
|
|
77
|
+
request = {
|
|
78
|
+
"jsonrpc": "2.0",
|
|
79
|
+
"id": self._request_id,
|
|
80
|
+
"method": method,
|
|
81
|
+
"params": params or {},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
if self.process is None or self.process.stdin is None or self.process.stdout is None:
|
|
86
|
+
return {"error": "Server process not available"}
|
|
87
|
+
self.process.stdin.write(json.dumps(request) + "\n")
|
|
88
|
+
self.process.stdin.flush()
|
|
89
|
+
line = self.process.stdout.readline()
|
|
90
|
+
if not line:
|
|
91
|
+
return {"error": "Empty response from server"}
|
|
92
|
+
result: dict[str, object] = json.loads(line)
|
|
93
|
+
return result
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.warning("MCP request error for '%s': %s", self.name, e)
|
|
96
|
+
return {"error": str(e)}
|
|
97
|
+
|
|
98
|
+
def list_tools(self) -> list[dict[str, object]]:
|
|
99
|
+
"""List available tools from this server."""
|
|
100
|
+
response = self._send_request("tools/list")
|
|
101
|
+
if "error" in response:
|
|
102
|
+
return []
|
|
103
|
+
result: dict[str, object] = cast(dict[str, object], response.get("result", {}))
|
|
104
|
+
tools: list[dict[str, object]] = cast(list[dict[str, object]], result.get("tools", []))
|
|
105
|
+
return tools
|
|
106
|
+
|
|
107
|
+
def call_tool(self, name: str, arguments: dict[str, Any]) -> str:
|
|
108
|
+
"""Call a tool on this server."""
|
|
109
|
+
response = self._send_request("tools/call", {
|
|
110
|
+
"name": name,
|
|
111
|
+
"arguments": arguments,
|
|
112
|
+
})
|
|
113
|
+
if "error" in response:
|
|
114
|
+
return f"MCP error: {response['error']}"
|
|
115
|
+
result: dict[str, object] = cast(dict[str, object], response.get("result", {}))
|
|
116
|
+
content: list[dict[str, object]] = cast(list[dict[str, object]], result.get("content", []))
|
|
117
|
+
return "\n".join(
|
|
118
|
+
c.get("text", "") for c in content if c.get("type") == "text"
|
|
119
|
+
) or json.dumps(content)
|
|
120
|
+
|
|
121
|
+
def close(self):
|
|
122
|
+
if self.process:
|
|
123
|
+
try:
|
|
124
|
+
self.process.terminate()
|
|
125
|
+
self.process.wait(timeout=5)
|
|
126
|
+
except Exception:
|
|
127
|
+
self.process.kill()
|
|
128
|
+
self.process = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class MCPManager:
|
|
132
|
+
"""Manages MCP server connections."""
|
|
133
|
+
|
|
134
|
+
def __init__(self):
|
|
135
|
+
self.servers: list[MCPServer] = []
|
|
136
|
+
|
|
137
|
+
def load_from_config(self, config: dict):
|
|
138
|
+
"""Load MCP servers from settings config."""
|
|
139
|
+
servers_config = config.get("mcpServers", {})
|
|
140
|
+
if not servers_config:
|
|
141
|
+
servers_config = config.get("mcp_servers", {})
|
|
142
|
+
|
|
143
|
+
for name, cfg in servers_config.items():
|
|
144
|
+
server = MCPServer(
|
|
145
|
+
name=name,
|
|
146
|
+
command=cfg.get("command", ""),
|
|
147
|
+
args=cfg.get("args", []),
|
|
148
|
+
)
|
|
149
|
+
error = server.connect()
|
|
150
|
+
if error:
|
|
151
|
+
logger.warning("Failed to start MCP server '%s': %s", name, error)
|
|
152
|
+
continue
|
|
153
|
+
server.tools = server.list_tools()
|
|
154
|
+
if server.tools:
|
|
155
|
+
self.servers.append(server)
|
|
156
|
+
logger.info("MCP server '%s' loaded with %d tools", name, len(server.tools))
|
|
157
|
+
|
|
158
|
+
def get_all_tools(self) -> list[dict]:
|
|
159
|
+
"""Get all tools from all servers as OpenAI tool definitions."""
|
|
160
|
+
tools = []
|
|
161
|
+
for server in self.servers:
|
|
162
|
+
for tool in server.tools:
|
|
163
|
+
tools.append({
|
|
164
|
+
"type": "function",
|
|
165
|
+
"function": {
|
|
166
|
+
"name": f"mcp_{tool['name']}",
|
|
167
|
+
"description": tool.get("description", f"MCP tool: {tool['name']}"),
|
|
168
|
+
"parameters": tool.get("inputSchema", {}),
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
return tools
|
|
172
|
+
|
|
173
|
+
def execute(self, tool_name: str, arguments: dict) -> str:
|
|
174
|
+
"""Execute a tool by its full name (mcp_<name>)."""
|
|
175
|
+
name = tool_name[len("mcp_"):] if tool_name.startswith("mcp_") else tool_name
|
|
176
|
+
for server in self.servers:
|
|
177
|
+
for tool in server.tools:
|
|
178
|
+
if tool["name"] == name:
|
|
179
|
+
return server.call_tool(name, arguments)
|
|
180
|
+
return f"MCP tool '{name}' not found"
|
|
181
|
+
|
|
182
|
+
def close_all(self):
|
|
183
|
+
for server in self.servers:
|
|
184
|
+
server.close()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .manager import (
|
|
2
|
+
MemoryManager,
|
|
3
|
+
get_project_memory_dir,
|
|
4
|
+
load_claude_md,
|
|
5
|
+
save_claude_md,
|
|
6
|
+
load_memory_index,
|
|
7
|
+
save_memory,
|
|
8
|
+
list_memories,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"MemoryManager",
|
|
13
|
+
"get_project_memory_dir",
|
|
14
|
+
"load_claude_md",
|
|
15
|
+
"save_claude_md",
|
|
16
|
+
"load_memory_index",
|
|
17
|
+
"save_memory",
|
|
18
|
+
"list_memories",
|
|
19
|
+
]
|