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/verify.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Verification pipeline — multi-pass validation of code changes.
|
|
2
|
+
|
|
3
|
+
Runs after every file write/edit to ensure correctness before the agent
|
|
4
|
+
declares completion. The pipeline is:
|
|
5
|
+
|
|
6
|
+
1. Syntax check (fast, always runs)
|
|
7
|
+
2. Lint check (optional, configurable)
|
|
8
|
+
3. Test suite (if applicable)
|
|
9
|
+
4. Consistency check (project patterns)
|
|
10
|
+
5. Task completeness gate
|
|
11
|
+
|
|
12
|
+
All verifications return a ``VerificationResult`` so the agent loop can
|
|
13
|
+
decide whether to fix-and-retry or proceed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ast
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------ #
|
|
27
|
+
# Data model
|
|
28
|
+
# ------------------------------------------------------------------ #
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class VerificationResult:
|
|
32
|
+
"""Outcome of running the verification pipeline."""
|
|
33
|
+
|
|
34
|
+
passed: bool
|
|
35
|
+
stage: str # "syntax" | "lint" | "test" | "consistency" | "task"
|
|
36
|
+
message: str # human-readable result
|
|
37
|
+
fix_hint: Optional[str] = None # what the model should do to fix it
|
|
38
|
+
raw_output: str = "" # full tool output for context
|
|
39
|
+
duration_ms: float = 0.0
|
|
40
|
+
|
|
41
|
+
def to_agent_feedback(self) -> str:
|
|
42
|
+
"""Format this result as a user message the agent can act on."""
|
|
43
|
+
if self.passed:
|
|
44
|
+
return f"[verify ✓] {self.stage} passed ({self.duration_ms:.0f}ms)"
|
|
45
|
+
lines = [
|
|
46
|
+
f"[verify ✗] {self.stage} FAILED:",
|
|
47
|
+
f" {self.message}",
|
|
48
|
+
]
|
|
49
|
+
if self.fix_hint:
|
|
50
|
+
lines.append(f" Fix: {self.fix_hint}")
|
|
51
|
+
if self.raw_output:
|
|
52
|
+
lines.append(f" ```\n{self.raw_output[:1500]}\n ```")
|
|
53
|
+
return "\n".join(lines)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------ #
|
|
57
|
+
# Verification stages
|
|
58
|
+
# ------------------------------------------------------------------ #
|
|
59
|
+
|
|
60
|
+
def verify_syntax(file_path: str) -> VerificationResult:
|
|
61
|
+
"""Check Python syntax with py_compile."""
|
|
62
|
+
import time
|
|
63
|
+
t0 = time.time()
|
|
64
|
+
try:
|
|
65
|
+
import py_compile
|
|
66
|
+
py_compile.compile(file_path, doraise=True)
|
|
67
|
+
elapsed = (time.time() - t0) * 1000
|
|
68
|
+
return VerificationResult(
|
|
69
|
+
passed=True, stage="syntax", message="Syntax OK",
|
|
70
|
+
duration_ms=elapsed,
|
|
71
|
+
)
|
|
72
|
+
except py_compile.PyCompileError as e:
|
|
73
|
+
elapsed = (time.time() - t0) * 1000
|
|
74
|
+
return VerificationResult(
|
|
75
|
+
passed=False, stage="syntax",
|
|
76
|
+
message=f"Syntax error in {file_path}",
|
|
77
|
+
fix_hint=f"Fix the Python syntax error at {e}",
|
|
78
|
+
raw_output=str(e),
|
|
79
|
+
duration_ms=elapsed,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def verify_lint(file_path: str, cwd: Optional[str] = None, project_root: Optional[str] = None) -> Optional[VerificationResult]:
|
|
84
|
+
"""Run ruff/flake8 on the changed file. Returns None if no linter is available.
|
|
85
|
+
|
|
86
|
+
Linters are invoked from *project_root* (when provided) so they pick up
|
|
87
|
+
the project-level ``pyproject.toml`` / ``.flake8`` config instead of
|
|
88
|
+
falling back to built-in defaults.
|
|
89
|
+
"""
|
|
90
|
+
import time
|
|
91
|
+
t0 = time.time()
|
|
92
|
+
|
|
93
|
+
# Prefer project_root so linters find pyproject.toml / .flake8 config.
|
|
94
|
+
# Fall back to the explicit cwd arg, then the file's parent directory.
|
|
95
|
+
run_cwd = project_root or cwd or str(Path(file_path).parent)
|
|
96
|
+
|
|
97
|
+
linters = [
|
|
98
|
+
("ruff", ["ruff", "check", file_path, "--output-format=concise"]),
|
|
99
|
+
("flake8", ["flake8", file_path, "--max-line-length=120"]),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
for name, cmd in linters:
|
|
103
|
+
try:
|
|
104
|
+
result = subprocess.run(
|
|
105
|
+
cmd,
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
timeout=30,
|
|
109
|
+
cwd=run_cwd,
|
|
110
|
+
)
|
|
111
|
+
elapsed = (time.time() - t0) * 1000
|
|
112
|
+
combined = (result.stdout + result.stderr).strip()
|
|
113
|
+
if result.returncode == 0 and not combined:
|
|
114
|
+
return VerificationResult(
|
|
115
|
+
passed=True, stage="lint",
|
|
116
|
+
message=f"{name}: no issues",
|
|
117
|
+
duration_ms=elapsed,
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
return VerificationResult(
|
|
121
|
+
passed=False, stage="lint",
|
|
122
|
+
message=f"{name} found issues in {file_path}",
|
|
123
|
+
fix_hint="Fix the lint issues listed above",
|
|
124
|
+
raw_output=combined[:2000],
|
|
125
|
+
duration_ms=elapsed,
|
|
126
|
+
)
|
|
127
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
return None # no linter available — not a failure
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def verify_consistency(file_path: str, project_root: str) -> Optional[VerificationResult]:
|
|
134
|
+
"""Check that the file follows project conventions.
|
|
135
|
+
|
|
136
|
+
Currently checks:
|
|
137
|
+
- Imports follow project structure (no circular imports)
|
|
138
|
+
- File uses same encoding/style as the project
|
|
139
|
+
- Type hints are present (if project uses them)
|
|
140
|
+
|
|
141
|
+
Returns None if no checks apply (not a failure).
|
|
142
|
+
"""
|
|
143
|
+
import time
|
|
144
|
+
t0 = time.time()
|
|
145
|
+
|
|
146
|
+
p = Path(file_path)
|
|
147
|
+
if not p.exists() or p.suffix != ".py":
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
issues: list[str] = []
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
tree = ast.parse(p.read_text(encoding="utf-8"))
|
|
154
|
+
except SyntaxError:
|
|
155
|
+
return None # syntax check will catch this
|
|
156
|
+
|
|
157
|
+
# Check for __init__.py imports that might actually cause circular imports
|
|
158
|
+
# Only flag when a submodule import could form a true cycle:
|
|
159
|
+
# e.g. __init__.py does "from .foo import Bar" and foo.py does "from . import Bar"
|
|
160
|
+
# Simple imports in __init__.py are standard Python practice — not an issue.
|
|
161
|
+
if p.name == "__init__.py":
|
|
162
|
+
package_dir = p.parent
|
|
163
|
+
module_name = p.parent.name
|
|
164
|
+
for node in ast.walk(tree):
|
|
165
|
+
if isinstance(node, ast.ImportFrom) and node.module is not None:
|
|
166
|
+
# Flag only if the imported submodule also imports from this package
|
|
167
|
+
target = node.module
|
|
168
|
+
if target.startswith("."):
|
|
169
|
+
# Should not happen (dots tracked by level), but handle gracefully
|
|
170
|
+
target = target.lstrip(".")
|
|
171
|
+
# Resolve the actual module name.
|
|
172
|
+
# level=0 → absolute import, level=1 → same package, level=2 → parent, etc.
|
|
173
|
+
if node.level == 0:
|
|
174
|
+
# Absolute import — target is already a fully-qualified module name
|
|
175
|
+
target_module = target
|
|
176
|
+
else:
|
|
177
|
+
parts = module_name.split(".")
|
|
178
|
+
base_parts = parts[:len(parts) - (node.level - 1)]
|
|
179
|
+
target_module = ".".join(base_parts + [target]) if base_parts else target
|
|
180
|
+
# Check if the target module might import back from this package
|
|
181
|
+
target_file = package_dir / (target_module.replace(".", os.sep) + ".py")
|
|
182
|
+
if target_file.exists():
|
|
183
|
+
try:
|
|
184
|
+
target_tree = ast.parse(target_file.read_text(encoding="utf-8"))
|
|
185
|
+
for target_node in ast.walk(target_tree):
|
|
186
|
+
if isinstance(target_node, ast.ImportFrom):
|
|
187
|
+
if target_node.module and module_name in target_node.module:
|
|
188
|
+
issues.append(
|
|
189
|
+
f"Circular import detected: {p.name} imports from "
|
|
190
|
+
f"'{target_module}' which also imports from '{module_name}'. "
|
|
191
|
+
f"Consider lazy imports."
|
|
192
|
+
)
|
|
193
|
+
break
|
|
194
|
+
except (SyntaxError, OSError):
|
|
195
|
+
pass
|
|
196
|
+
# do NOT break here — continue scanning remaining imports in __init__.py
|
|
197
|
+
|
|
198
|
+
# Check for bare except clauses
|
|
199
|
+
for node in ast.walk(tree):
|
|
200
|
+
if isinstance(node, ast.ExceptHandler):
|
|
201
|
+
if node.type is None:
|
|
202
|
+
issues.append("Bare except: clause — replace with 'except Exception'")
|
|
203
|
+
elif isinstance(node.type, ast.Tuple) or (
|
|
204
|
+
isinstance(node.type, ast.Name) and node.type.id == "Exception"
|
|
205
|
+
):
|
|
206
|
+
pass # ok
|
|
207
|
+
elif isinstance(node.type, ast.Name) and node.type.id == "BaseException":
|
|
208
|
+
issues.append("Catching BaseException — use Exception instead")
|
|
209
|
+
|
|
210
|
+
# Check for mutable default arguments
|
|
211
|
+
for node in ast.walk(tree):
|
|
212
|
+
if isinstance(node, ast.FunctionDef):
|
|
213
|
+
for default in node.args.defaults + node.args.kw_defaults:
|
|
214
|
+
if isinstance(default, (ast.List, ast.Dict, ast.Set)):
|
|
215
|
+
issues.append(
|
|
216
|
+
f"Mutable default argument in {node.name}() — "
|
|
217
|
+
f"use None and set default in function body"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
elapsed = (time.time() - t0) * 1000
|
|
221
|
+
|
|
222
|
+
if issues:
|
|
223
|
+
return VerificationResult(
|
|
224
|
+
passed=False, stage="consistency",
|
|
225
|
+
message=f"Found {len(issues)} consistency issue(s)",
|
|
226
|
+
fix_hint="\n".join(f" • {i}" for i in issues),
|
|
227
|
+
raw_output="\n".join(issues),
|
|
228
|
+
duration_ms=elapsed,
|
|
229
|
+
)
|
|
230
|
+
return VerificationResult(
|
|
231
|
+
passed=True, stage="consistency",
|
|
232
|
+
message="No consistency issues found",
|
|
233
|
+
duration_ms=elapsed,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------ #
|
|
238
|
+
# Full pipeline
|
|
239
|
+
# ------------------------------------------------------------------ #
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def run_verify_pipeline(
|
|
243
|
+
file_path: str,
|
|
244
|
+
project_root: str,
|
|
245
|
+
run_lint: bool = True,
|
|
246
|
+
run_consistency: bool = True,
|
|
247
|
+
run_tests: bool = False,
|
|
248
|
+
test_runner_cmd: Optional[str] = None,
|
|
249
|
+
) -> list[VerificationResult]:
|
|
250
|
+
"""Run all applicable verification stages on a file.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
file_path: Absolute path to the file to verify.
|
|
254
|
+
project_root: Project root for context.
|
|
255
|
+
run_lint: If True, attempt lint check.
|
|
256
|
+
run_consistency: If True, run AST-based consistency checks.
|
|
257
|
+
run_tests: If True, run the project test suite.
|
|
258
|
+
test_runner_cmd: Shell command to run tests.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
List of VerificationResult, one per stage that ran.
|
|
262
|
+
"""
|
|
263
|
+
results: list[VerificationResult] = []
|
|
264
|
+
import time
|
|
265
|
+
|
|
266
|
+
# Stage 1: Syntax (always, mandatory)
|
|
267
|
+
results.append(verify_syntax(file_path))
|
|
268
|
+
|
|
269
|
+
# If syntax failed, skip remaining stages (file is broken)
|
|
270
|
+
if not results[-1].passed:
|
|
271
|
+
return results
|
|
272
|
+
|
|
273
|
+
# Stage 2: Lint (optional, best-effort)
|
|
274
|
+
if run_lint:
|
|
275
|
+
lint_result = verify_lint(file_path, project_root=project_root)
|
|
276
|
+
if lint_result is not None:
|
|
277
|
+
results.append(lint_result)
|
|
278
|
+
|
|
279
|
+
# Stage 3: Consistency (optional)
|
|
280
|
+
if run_consistency:
|
|
281
|
+
consistency_result = verify_consistency(file_path, project_root)
|
|
282
|
+
if consistency_result is not None:
|
|
283
|
+
results.append(consistency_result)
|
|
284
|
+
|
|
285
|
+
# Stage 4: Tests (optional, run if requested)
|
|
286
|
+
if run_tests and test_runner_cmd:
|
|
287
|
+
# Allowlist check: only permit simple pytest / unittest invocations
|
|
288
|
+
# to prevent shell injection if the command comes from settings or LLM.
|
|
289
|
+
import shlex
|
|
290
|
+
_allowed_runners = ("pytest", "python -m pytest", "python -m unittest", "tox", "uv run pytest")
|
|
291
|
+
_cmd_stripped = test_runner_cmd.strip()
|
|
292
|
+
if not any(_cmd_stripped.startswith(r) for r in _allowed_runners):
|
|
293
|
+
results.append(VerificationResult(
|
|
294
|
+
passed=False, stage="test",
|
|
295
|
+
message=f"Blocked: test_runner_cmd '{_cmd_stripped[:80]}' is not an allowed test runner",
|
|
296
|
+
fix_hint="Use pytest, python -m pytest, python -m unittest, tox, or uv run pytest",
|
|
297
|
+
))
|
|
298
|
+
return results
|
|
299
|
+
t0 = time.time()
|
|
300
|
+
try:
|
|
301
|
+
proc = subprocess.run(
|
|
302
|
+
test_runner_cmd,
|
|
303
|
+
shell=True,
|
|
304
|
+
capture_output=True,
|
|
305
|
+
text=True,
|
|
306
|
+
timeout=120,
|
|
307
|
+
cwd=project_root,
|
|
308
|
+
)
|
|
309
|
+
elapsed = (time.time() - t0) * 1000
|
|
310
|
+
combined = (proc.stdout + proc.stderr).strip()
|
|
311
|
+
if proc.returncode == 0:
|
|
312
|
+
results.append(VerificationResult(
|
|
313
|
+
passed=True, stage="test",
|
|
314
|
+
message="All tests passed",
|
|
315
|
+
raw_output=combined[:1000],
|
|
316
|
+
duration_ms=elapsed,
|
|
317
|
+
))
|
|
318
|
+
else:
|
|
319
|
+
results.append(VerificationResult(
|
|
320
|
+
passed=False, stage="test",
|
|
321
|
+
message=f"Tests failed (exit code {proc.returncode})",
|
|
322
|
+
fix_hint="Fix the failing tests before proceeding",
|
|
323
|
+
raw_output=combined[:3000],
|
|
324
|
+
duration_ms=elapsed,
|
|
325
|
+
))
|
|
326
|
+
except subprocess.TimeoutExpired:
|
|
327
|
+
results.append(VerificationResult(
|
|
328
|
+
passed=False, stage="test",
|
|
329
|
+
message="Test run timed out (120s)",
|
|
330
|
+
fix_hint="Check for infinite loops or hanging tests",
|
|
331
|
+
duration_ms=120000,
|
|
332
|
+
))
|
|
333
|
+
except Exception as e:
|
|
334
|
+
results.append(VerificationResult(
|
|
335
|
+
passed=False, stage="test",
|
|
336
|
+
message=f"Could not run tests: {e}",
|
|
337
|
+
duration_ms=(time.time() - t0) * 1000,
|
|
338
|
+
))
|
|
339
|
+
|
|
340
|
+
return results
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def pipeline_all_passed(results: list[VerificationResult]) -> bool:
|
|
344
|
+
"""True if all mandatory stages passed."""
|
|
345
|
+
for r in results:
|
|
346
|
+
if not r.passed and r.stage in ("syntax", "test", "consistency"):
|
|
347
|
+
return False
|
|
348
|
+
return True
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def pipeline_feedback(results: list[VerificationResult]) -> str:
|
|
352
|
+
"""Build a single feedback message from all verification results."""
|
|
353
|
+
if not results:
|
|
354
|
+
return ""
|
|
355
|
+
passed = sum(1 for r in results if r.passed)
|
|
356
|
+
total = len(results)
|
|
357
|
+
lines = [f"## Verification: {passed}/{total} passed\n"]
|
|
358
|
+
for r in results:
|
|
359
|
+
lines.append(r.to_agent_feedback())
|
|
360
|
+
return "\n".join(lines)
|
luckyd_code/web_app.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Web UI server for LuckyD Code."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
# Module-level imports so test patches to luckyd_code.web_app.X still resolve
|
|
12
|
+
from .api import stream_chat
|
|
13
|
+
from .config import Config
|
|
14
|
+
from .context import ConversationContext
|
|
15
|
+
from .tools import get_default_registry
|
|
16
|
+
from . import memory as memory_module
|
|
17
|
+
from .mcp.client import MCPManager
|
|
18
|
+
from .log import get_logger
|
|
19
|
+
from .web_routes import WebAppState
|
|
20
|
+
|
|
21
|
+
logger = get_logger()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_app(config: Optional[Config] = None) -> FastAPI:
|
|
25
|
+
if config is None:
|
|
26
|
+
config = Config()
|
|
27
|
+
try:
|
|
28
|
+
config.validate()
|
|
29
|
+
except ValueError as e:
|
|
30
|
+
logger.warning(f"Config validation: {e}")
|
|
31
|
+
|
|
32
|
+
registry = get_default_registry()
|
|
33
|
+
|
|
34
|
+
# Wire up agent tools so SubAgent and AgentHandoff work from the web UI
|
|
35
|
+
from .tools.agent_tools import set_repl as _set_repl
|
|
36
|
+
|
|
37
|
+
class _WebRepl:
|
|
38
|
+
"""Minimal repl-like object the agent tools need."""
|
|
39
|
+
config: Config
|
|
40
|
+
registry: Any
|
|
41
|
+
|
|
42
|
+
_web_repl = _WebRepl()
|
|
43
|
+
_web_repl.config = config
|
|
44
|
+
_web_repl.registry = registry
|
|
45
|
+
_set_repl(_web_repl)
|
|
46
|
+
mcp = MCPManager()
|
|
47
|
+
settings = {}
|
|
48
|
+
try:
|
|
49
|
+
from . import settings as cfg
|
|
50
|
+
settings = cfg.load_settings()
|
|
51
|
+
mcp.load_from_config(settings)
|
|
52
|
+
except Exception:
|
|
53
|
+
logger.warning("Failed to load MCP servers", exc_info=True)
|
|
54
|
+
context = ConversationContext(config.system_prompt, max_messages=100)
|
|
55
|
+
|
|
56
|
+
# Load project memory (MEMORY.md) merged with session memories for a single memory block
|
|
57
|
+
from .memory import MemoryManager
|
|
58
|
+
web_memory_mgr = MemoryManager()
|
|
59
|
+
md = memory_module.load_claude_md()
|
|
60
|
+
session_memories = web_memory_mgr.get_all_memories_formatted()
|
|
61
|
+
if md and session_memories:
|
|
62
|
+
merged = md + "\n\n" + session_memories
|
|
63
|
+
elif session_memories:
|
|
64
|
+
merged = session_memories
|
|
65
|
+
else:
|
|
66
|
+
merged = md or ""
|
|
67
|
+
|
|
68
|
+
if merged:
|
|
69
|
+
context.messages.insert(1, {
|
|
70
|
+
"role": "user",
|
|
71
|
+
"content": f"<claude-md>{merged}</claude-md>",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
# Smart project indexing
|
|
75
|
+
from .indexer import index_project
|
|
76
|
+
project_context = index_project()
|
|
77
|
+
if project_context:
|
|
78
|
+
idx = 2 if md else 1
|
|
79
|
+
has_context = any(
|
|
80
|
+
isinstance(m.get("content"), str) and m["content"].startswith("<project-context>")
|
|
81
|
+
for m in context.messages
|
|
82
|
+
)
|
|
83
|
+
if not has_context:
|
|
84
|
+
context.messages.insert(idx, {
|
|
85
|
+
"role": "user",
|
|
86
|
+
"content": f"<project-context>\n{project_context}\n</project-context>",
|
|
87
|
+
})
|
|
88
|
+
logger.info(f"Project indexed ({project_context.count(chr(10)) + 1} items)")
|
|
89
|
+
|
|
90
|
+
app = FastAPI(title="LuckyD Code")
|
|
91
|
+
|
|
92
|
+
# --- Auth middleware ---
|
|
93
|
+
web_token = settings.get("web_token", "")
|
|
94
|
+
if web_token:
|
|
95
|
+
@app.middleware("http")
|
|
96
|
+
async def auth_middleware(request: Request, call_next):
|
|
97
|
+
if request.url.path in ("/", "/manifest.json", "/sw.js",
|
|
98
|
+
"/icon-192.png", "/icon-512.png"):
|
|
99
|
+
return await call_next(request)
|
|
100
|
+
auth = request.headers.get("authorization", "")
|
|
101
|
+
if auth != f"Bearer {web_token}":
|
|
102
|
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
|
103
|
+
return await call_next(request)
|
|
104
|
+
|
|
105
|
+
# --- Rate limiting middleware (token bucket, per-IP) ---
|
|
106
|
+
rate_limit_buckets: dict = defaultdict(lambda: {"tokens": 60, "last": time.time()})
|
|
107
|
+
RATE_LIMIT_RATE = 60
|
|
108
|
+
RATE_LIMIT_WINDOW = 60.0
|
|
109
|
+
|
|
110
|
+
@app.middleware("http")
|
|
111
|
+
async def rate_limit_middleware(request: Request, call_next):
|
|
112
|
+
if request.url.path in ("/", "/manifest.json", "/sw.js",
|
|
113
|
+
"/icon-192.png", "/icon-512.png"):
|
|
114
|
+
return await call_next(request)
|
|
115
|
+
ip = request.client.host if request.client else "unknown"
|
|
116
|
+
bucket = rate_limit_buckets[ip]
|
|
117
|
+
now = time.time()
|
|
118
|
+
elapsed = now - bucket["last"]
|
|
119
|
+
bucket["last"] = now
|
|
120
|
+
bucket["tokens"] = min(bucket["tokens"] + elapsed * (RATE_LIMIT_RATE / RATE_LIMIT_WINDOW), RATE_LIMIT_RATE)
|
|
121
|
+
if bucket["tokens"] < 1:
|
|
122
|
+
return JSONResponse({"error": "Rate limit exceeded"}, status_code=429)
|
|
123
|
+
bucket["tokens"] -= 1
|
|
124
|
+
return await call_next(request)
|
|
125
|
+
|
|
126
|
+
# --- Attach shared state ---
|
|
127
|
+
app.state.web_state = WebAppState(
|
|
128
|
+
config=config,
|
|
129
|
+
context=context,
|
|
130
|
+
registry=registry,
|
|
131
|
+
mcp=mcp,
|
|
132
|
+
web_memory_mgr=web_memory_mgr,
|
|
133
|
+
settings=settings,
|
|
134
|
+
rate_limit_buckets=rate_limit_buckets,
|
|
135
|
+
memory_module=memory_module,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# --- Register routers ---
|
|
139
|
+
from .web_routes import static, files, brain, sessions, settings as settings_routes
|
|
140
|
+
from .web_routes import memories, project, review, background, cost, update, misc, ws
|
|
141
|
+
|
|
142
|
+
app.include_router(static.router)
|
|
143
|
+
app.include_router(files.router)
|
|
144
|
+
app.include_router(brain.router)
|
|
145
|
+
app.include_router(sessions.router)
|
|
146
|
+
app.include_router(settings_routes.router)
|
|
147
|
+
app.include_router(memories.router)
|
|
148
|
+
app.include_router(project.router)
|
|
149
|
+
app.include_router(review.router)
|
|
150
|
+
app.include_router(background.router)
|
|
151
|
+
app.include_router(cost.router)
|
|
152
|
+
app.include_router(update.router)
|
|
153
|
+
app.include_router(misc.router)
|
|
154
|
+
app.include_router(ws.router)
|
|
155
|
+
|
|
156
|
+
return app
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
_app_instance = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_app() -> FastAPI:
|
|
163
|
+
"""Lazy-init the app instance. create_app() runs only on first call."""
|
|
164
|
+
global _app_instance
|
|
165
|
+
if _app_instance is None:
|
|
166
|
+
_app_instance = create_app()
|
|
167
|
+
return _app_instance
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run_web(host: str = "127.0.0.1", port: int = 8000):
|
|
171
|
+
"""Run the web UI server.
|
|
172
|
+
|
|
173
|
+
Defaults to localhost only (127.0.0.1) for security.
|
|
174
|
+
Pass host='0.0.0.0' explicitly to expose on the local network.
|
|
175
|
+
"""
|
|
176
|
+
uvicorn.run(get_app(), host=host, port=port)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared state for web route handlers, attached to app.state.web_state."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class WebAppState:
|
|
9
|
+
"""Mutable shared state injected into every request via app.state.web_state."""
|
|
10
|
+
config: Any = None
|
|
11
|
+
context: Any = None
|
|
12
|
+
registry: Any = None
|
|
13
|
+
mcp: Any = None
|
|
14
|
+
web_memory_mgr: Any = None
|
|
15
|
+
settings: Dict[str, Any] = field(default_factory=dict)
|
|
16
|
+
rate_limit_buckets: Dict[str, Dict[str, float]] = field(default_factory=dict)
|
|
17
|
+
memory_module: Any = None
|
|
18
|
+
brain_module: Any = None
|
|
19
|
+
|
|
20
|
+
# Imported lazily by routes
|
|
21
|
+
knowledge_graph: Any = None
|
|
22
|
+
retriever: Any = None
|
|
23
|
+
context_assembler: Any = None
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Background agent task routes."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Request
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from ..log import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger()
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BackgroundStart(BaseModel):
|
|
14
|
+
task: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/api/background")
|
|
18
|
+
async def background_list(request: Request):
|
|
19
|
+
try:
|
|
20
|
+
from ..background import BackgroundAgent
|
|
21
|
+
state = request.app.state.web_state
|
|
22
|
+
bg = BackgroundAgent(state.config)
|
|
23
|
+
bg.load_history()
|
|
24
|
+
statuses = bg.get_status()
|
|
25
|
+
return {"tasks": statuses}
|
|
26
|
+
except Exception as e:
|
|
27
|
+
logger.warning(f"background_list error: {e}")
|
|
28
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/api/background/start")
|
|
32
|
+
async def background_start(request: Request, data: BackgroundStart):
|
|
33
|
+
try:
|
|
34
|
+
from ..background import BackgroundAgent
|
|
35
|
+
state = request.app.state.web_state
|
|
36
|
+
task = data.task or ""
|
|
37
|
+
if not task:
|
|
38
|
+
return JSONResponse({"error": "task description required"}, status_code=400)
|
|
39
|
+
bg = BackgroundAgent(state.config)
|
|
40
|
+
task_id = bg.start_task(task)
|
|
41
|
+
return {"task_id": task_id, "status": "started"}
|
|
42
|
+
except Exception as e:
|
|
43
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.get("/api/background/status/{task_id}")
|
|
47
|
+
async def background_status(request: Request, task_id: str):
|
|
48
|
+
try:
|
|
49
|
+
from ..background import BackgroundAgent
|
|
50
|
+
state = request.app.state.web_state
|
|
51
|
+
bg = BackgroundAgent(state.config)
|
|
52
|
+
bg.load_history()
|
|
53
|
+
statuses = bg.get_status(task_id)
|
|
54
|
+
if statuses:
|
|
55
|
+
return {"task": statuses[0]}
|
|
56
|
+
return JSONResponse({"error": "Task not found"}, status_code=404)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.get("/api/background/result/{task_id}")
|
|
62
|
+
async def background_result(request: Request, task_id: str):
|
|
63
|
+
try:
|
|
64
|
+
from ..background import BackgroundAgent
|
|
65
|
+
state = request.app.state.web_state
|
|
66
|
+
bg = BackgroundAgent(state.config)
|
|
67
|
+
bg.load_history()
|
|
68
|
+
result = bg.get_result(task_id)
|
|
69
|
+
if result:
|
|
70
|
+
return {"result": result}
|
|
71
|
+
return JSONResponse({"error": "Result not available"}, status_code=404)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|