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