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,41 @@
|
|
|
1
|
+
"""Handle /sessions commands."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def handle_sessions_command(repl, args):
|
|
5
|
+
"""Handle /sessions list|save|load|delete commands."""
|
|
6
|
+
from ..cli_utils import console
|
|
7
|
+
from ..sessions import save_session, load_session, list_sessions, delete_session
|
|
8
|
+
|
|
9
|
+
if not args:
|
|
10
|
+
console.print("[yellow]Usage: /sessions list|save <name>|load <name>|delete <name>[/yellow]")
|
|
11
|
+
return
|
|
12
|
+
|
|
13
|
+
sub = args[0].lower()
|
|
14
|
+
|
|
15
|
+
if sub == "list":
|
|
16
|
+
result = list_sessions()
|
|
17
|
+
console.print(result)
|
|
18
|
+
|
|
19
|
+
elif sub == "save":
|
|
20
|
+
name = " ".join(args[1:]) if len(args) > 1 else "unnamed"
|
|
21
|
+
result = save_session(name, repl.context)
|
|
22
|
+
console.print(f"[green]{result}[/green]")
|
|
23
|
+
|
|
24
|
+
elif sub == "load":
|
|
25
|
+
if len(args) < 2:
|
|
26
|
+
console.print("[yellow]Usage: /sessions load <name>[/yellow]")
|
|
27
|
+
return
|
|
28
|
+
name = " ".join(args[1:])
|
|
29
|
+
result = load_session(name, repl.context)
|
|
30
|
+
console.print(f"[cyan]{result}[/cyan]")
|
|
31
|
+
|
|
32
|
+
elif sub == "delete":
|
|
33
|
+
if len(args) < 2:
|
|
34
|
+
console.print("[yellow]Usage: /sessions delete <name>[/yellow]")
|
|
35
|
+
return
|
|
36
|
+
name = " ".join(args[1:])
|
|
37
|
+
result = delete_session(name)
|
|
38
|
+
console.print(f"[yellow]{result}[/yellow]")
|
|
39
|
+
|
|
40
|
+
else:
|
|
41
|
+
console.print(f"[red]Unknown: /sessions {sub}[/red]")
|
luckyd_code/cli_entry.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""CLI entry point — argument parsing, first-run wizard, and main dispatch."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Patch asyncio to allow nested event loops. This is needed because
|
|
9
|
+
# BackgroundAgent / KnowledgeGraph initialisation may start a loop before
|
|
10
|
+
# prompt_toolkit's synchronous session.prompt() runs, which would otherwise
|
|
11
|
+
# raise: RuntimeError: asyncio.run() cannot be called from a running event loop
|
|
12
|
+
try:
|
|
13
|
+
import nest_asyncio
|
|
14
|
+
nest_asyncio.apply()
|
|
15
|
+
except ImportError:
|
|
16
|
+
pass # Installed below; safe to ignore on first import before pip install
|
|
17
|
+
|
|
18
|
+
from .config import Config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_args(argv=None):
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
description="LuckyD Code — AI coding assistant in your terminal",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("--model", default=None, help="Model to use (default: deepseek-v4-flash)")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--provider", default=None, choices=["deepseek"],
|
|
28
|
+
help="API provider (default: deepseek)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("--dir", default=None, help="Working directory (default: current)")
|
|
31
|
+
parser.add_argument("--temperature", type=float, default=None, help="Model temperature (default: 0.7)")
|
|
32
|
+
parser.add_argument("--system-prompt", default=None, help="Path to custom system prompt file")
|
|
33
|
+
parser.add_argument("--version", action="store_true", help="Show version and exit")
|
|
34
|
+
parser.add_argument("--update", action="store_true", help="Update to latest version and exit")
|
|
35
|
+
parser.add_argument("--web", action="store_true", help="Launch web UI server")
|
|
36
|
+
parser.add_argument("--port", type=int, default=8000, help="Web UI port (default: 8000)")
|
|
37
|
+
parser.add_argument("--host", default="0.0.0.0", help="Web UI host (default: 0.0.0.0)")
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--daemon", action="store_true",
|
|
40
|
+
help="Start the background audit daemon alongside the REPL",
|
|
41
|
+
)
|
|
42
|
+
return parser.parse_args(argv)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv=None):
|
|
46
|
+
args = parse_args(argv)
|
|
47
|
+
|
|
48
|
+
# Lazy imports — only load the heavy CLI/web modules when actually running
|
|
49
|
+
from .update import get_version, do_update
|
|
50
|
+
|
|
51
|
+
if args.version:
|
|
52
|
+
print(f"LuckyD Code v{get_version()}")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
if args.update:
|
|
56
|
+
print("Updating LuckyD Code...")
|
|
57
|
+
print(do_update())
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
if args.web:
|
|
61
|
+
from .web_app import run_web
|
|
62
|
+
try:
|
|
63
|
+
run_web(host=args.host, port=args.port)
|
|
64
|
+
except ValueError as e:
|
|
65
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
66
|
+
return 1
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
if args.dir:
|
|
70
|
+
os.chdir(args.dir)
|
|
71
|
+
|
|
72
|
+
config = Config.from_args(args)
|
|
73
|
+
|
|
74
|
+
if args.system_prompt:
|
|
75
|
+
try:
|
|
76
|
+
with open(args.system_prompt, encoding="utf-8") as f:
|
|
77
|
+
custom = f.read().strip()
|
|
78
|
+
if custom:
|
|
79
|
+
config.system_prompt = custom
|
|
80
|
+
except Exception as e:
|
|
81
|
+
print(f"Warning: could not load system prompt from {args.system_prompt}: {e}", file=sys.stderr)
|
|
82
|
+
|
|
83
|
+
# First-run wizard: interactive API key setup
|
|
84
|
+
if not config.api_key:
|
|
85
|
+
print()
|
|
86
|
+
print("=" * 50)
|
|
87
|
+
print(" Welcome to LuckyD Code!")
|
|
88
|
+
print("=" * 50)
|
|
89
|
+
print()
|
|
90
|
+
print("No API key found. Let's get you set up.")
|
|
91
|
+
print()
|
|
92
|
+
print("You'll need a DeepSeek API key.")
|
|
93
|
+
print(" Get one at: https://platform.deepseek.com")
|
|
94
|
+
print()
|
|
95
|
+
print("Paste your API key below (it will be saved to .env):")
|
|
96
|
+
try:
|
|
97
|
+
key = input(" DEEPSEEK_API_KEY = ").strip()
|
|
98
|
+
except (EOFError, KeyboardInterrupt):
|
|
99
|
+
print("\n\nSetup cancelled. Run again when you have an API key.")
|
|
100
|
+
return 1
|
|
101
|
+
if key:
|
|
102
|
+
env_path = Path(__file__).resolve().parent.parent / ".env"
|
|
103
|
+
if "DEEPSEEK_API_KEY=" in (env_path.read_text() if env_path.exists() else ""):
|
|
104
|
+
lines = env_path.read_text().splitlines()
|
|
105
|
+
new_lines = []
|
|
106
|
+
for line in lines:
|
|
107
|
+
if line.startswith("DEEPSEEK_API_KEY="):
|
|
108
|
+
new_lines.append(f"DEEPSEEK_API_KEY={key}")
|
|
109
|
+
else:
|
|
110
|
+
new_lines.append(line)
|
|
111
|
+
env_path.write_text("\n".join(new_lines) + "\n")
|
|
112
|
+
else:
|
|
113
|
+
with env_path.open("a") as f:
|
|
114
|
+
f.write(f"\nDEEPSEEK_API_KEY={key}\n")
|
|
115
|
+
config.api_key = key
|
|
116
|
+
print("✓ API key saved to .env")
|
|
117
|
+
else:
|
|
118
|
+
print("No key entered. Set DEEPSEEK_API_KEY in .env and try again.")
|
|
119
|
+
return 1
|
|
120
|
+
print()
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
config.validate()
|
|
124
|
+
except ValueError as e:
|
|
125
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
from .cli import Repl
|
|
129
|
+
daemon_enabled = getattr(args, "daemon", False)
|
|
130
|
+
repl = Repl(config, daemon=daemon_enabled)
|
|
131
|
+
try:
|
|
132
|
+
repl.run()
|
|
133
|
+
except KeyboardInterrupt:
|
|
134
|
+
print("\nGoodbye!")
|
|
135
|
+
return 0
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
# ── Error Reporter: safe, opt-in issue creation ──────────
|
|
138
|
+
# This only fires for *unexpected* exceptions (not KeyboardInterrupt).
|
|
139
|
+
# The user is prompted before anything is sent. See error_reporter.py.
|
|
140
|
+
from .error_reporter import capture_unhandled
|
|
141
|
+
capture_unhandled(exc)
|
|
142
|
+
raise # re-raise so the process exits with a non-zero code
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
sys.exit(main())
|
luckyd_code/cli_utils.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Shared I/O utilities for the CLI REPL."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import time as _time
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from . import settings as cfg
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
_logger = logging.getLogger("luckyd_code.cli_utils")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def play_completion_sound(success: bool = True):
|
|
17
|
+
"""Play a notification sound when a response completes."""
|
|
18
|
+
try:
|
|
19
|
+
settings = cfg.load_settings()
|
|
20
|
+
enabled = settings.get("completion_sound", True)
|
|
21
|
+
if not enabled:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
if sys.platform == "win32":
|
|
25
|
+
import winsound
|
|
26
|
+
if success:
|
|
27
|
+
winsound.PlaySound("SystemExclamation", winsound.SND_ALIAS | winsound.SND_ASYNC | winsound.SND_NODEFAULT)
|
|
28
|
+
else:
|
|
29
|
+
winsound.PlaySound("SystemHand", winsound.SND_ALIAS | winsound.SND_ASYNC | winsound.SND_NODEFAULT)
|
|
30
|
+
else:
|
|
31
|
+
if success:
|
|
32
|
+
sys.stdout.write("\a")
|
|
33
|
+
else:
|
|
34
|
+
for _ in range(3):
|
|
35
|
+
sys.stdout.write("\a")
|
|
36
|
+
_time.sleep(0.08)
|
|
37
|
+
sys.stdout.flush()
|
|
38
|
+
except Exception:
|
|
39
|
+
_logger.debug("Completion sound failed, falling back to terminal bell", exc_info=True)
|
|
40
|
+
try:
|
|
41
|
+
if success:
|
|
42
|
+
sys.stdout.write("\a")
|
|
43
|
+
else:
|
|
44
|
+
for _ in range(3):
|
|
45
|
+
sys.stdout.write("\a")
|
|
46
|
+
_time.sleep(0.08)
|
|
47
|
+
sys.stdout.flush()
|
|
48
|
+
except Exception:
|
|
49
|
+
_logger.debug("Terminal bell also failed")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def init_prompt_session():
|
|
53
|
+
"""Create prompt session with custom keybindings."""
|
|
54
|
+
from prompt_toolkit import PromptSession
|
|
55
|
+
from prompt_toolkit.history import FileHistory
|
|
56
|
+
from .keybindings import apply_keybindings
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
return PromptSession(
|
|
60
|
+
history=FileHistory(".luckyd_history"),
|
|
61
|
+
key_bindings=apply_keybindings(),
|
|
62
|
+
multiline=True,
|
|
63
|
+
)
|
|
64
|
+
except Exception:
|
|
65
|
+
_logger.debug("PromptSession creation failed, trying fallback", exc_info=True)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
from prompt_toolkit.output.vt100 import Vt100_Output
|
|
69
|
+
from prompt_toolkit.data_structures import Size
|
|
70
|
+
import shutil
|
|
71
|
+
|
|
72
|
+
def _get_size():
|
|
73
|
+
ts = shutil.get_terminal_size()
|
|
74
|
+
return Size(rows=ts.lines, columns=ts.columns)
|
|
75
|
+
|
|
76
|
+
return PromptSession(
|
|
77
|
+
history=FileHistory(".luckyd_history"),
|
|
78
|
+
key_bindings=apply_keybindings(),
|
|
79
|
+
output=Vt100_Output(sys.stdout, get_size=_get_size),
|
|
80
|
+
multiline=True,
|
|
81
|
+
)
|
|
82
|
+
except Exception:
|
|
83
|
+
_logger.warning("Failed to create prompt session", exc_info=True)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_input(session) -> Optional[str]:
|
|
88
|
+
"""Read a line of input from the user."""
|
|
89
|
+
if session:
|
|
90
|
+
try:
|
|
91
|
+
return session.prompt(">>> ")
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
return None
|
|
94
|
+
except EOFError:
|
|
95
|
+
return "__EOF__"
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
lines = []
|
|
99
|
+
while True:
|
|
100
|
+
line = input(">>> " if not lines else "... ")
|
|
101
|
+
if line.rstrip().endswith("\\"):
|
|
102
|
+
lines.append(line.rstrip()[:-1])
|
|
103
|
+
else:
|
|
104
|
+
lines.append(line)
|
|
105
|
+
break
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
except EOFError:
|
|
108
|
+
if lines:
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
return "__EOF__"
|
|
111
|
+
except KeyboardInterrupt:
|
|
112
|
+
return None
|
luckyd_code/config.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Configuration management with validation and file persistence."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .log import get_logger
|
|
9
|
+
from ._data_dir import data_path, legacy_path
|
|
10
|
+
|
|
11
|
+
DEFAULT_SYSTEM_PROMPT = """You are LuckyD Code, an AI coding assistant in a terminal.
|
|
12
|
+
|
|
13
|
+
You can answer ANY question — coding, general knowledge, everyday questions, or anything else the user asks. You are a helpful general-purpose assistant who happens to specialise in code.
|
|
14
|
+
|
|
15
|
+
## Core Rules
|
|
16
|
+
- For coding tasks: use Bash/Read/Write/Edit/Glob/Grep tools as needed, working directory is the project root
|
|
17
|
+
- For general questions: answer directly and concisely from your knowledge
|
|
18
|
+
- Think step by step, but only show the final answer
|
|
19
|
+
- No unnecessary padding, greetings, or filler — just useful output
|
|
20
|
+
|
|
21
|
+
## Pre-Edit Checklist (MANDATORY before any Write/Edit)
|
|
22
|
+
1. **Read the file first** — Never edit a file you haven't read this turn
|
|
23
|
+
2. **Understand the context** — Check how the code is used elsewhere (Grep for callers/imports)
|
|
24
|
+
3. **Match existing patterns** — Follow the project's conventions, naming, style
|
|
25
|
+
4. **Plan the minimal change** — Only touch what's necessary; no refactoring sprees
|
|
26
|
+
5. **Preview mentally** — What will break? What edge cases exist?
|
|
27
|
+
|
|
28
|
+
## Post-Edit Verification
|
|
29
|
+
After every Write or Edit:
|
|
30
|
+
- Your changes will be automatically verified (syntax, consistency, tests)
|
|
31
|
+
- If verification fails, you MUST fix the issues — don't ignore them
|
|
32
|
+
- Tests failing after your change? Fix them immediately
|
|
33
|
+
|
|
34
|
+
## Self-Critique
|
|
35
|
+
Before presenting your final answer, review it:
|
|
36
|
+
- Did I actually solve the user's problem?
|
|
37
|
+
- Could this be done more simply?
|
|
38
|
+
- Are there edge cases I missed?
|
|
39
|
+
- Is the code safe (no injection, no leaked secrets)?
|
|
40
|
+
|
|
41
|
+
## Quality Standards
|
|
42
|
+
- **Correctness over cleverness** — Working code beats elegant code
|
|
43
|
+
- **Minimal diffs** — Small, focused changes are easier to review and revert
|
|
44
|
+
- **Error handling** — Don't let errors pass silently
|
|
45
|
+
- **No dead code** — Remove unused imports, variables, functions
|
|
46
|
+
- **Type safety** — Use type hints where the project uses them"""
|
|
47
|
+
|
|
48
|
+
CONFIG_FILE = data_path("config.json")
|
|
49
|
+
_LEGACY_CONFIG_FILE = legacy_path("config.json")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_config_file() -> dict[str, Any]:
|
|
53
|
+
"""Load persistent config from the data directory.
|
|
54
|
+
|
|
55
|
+
Checks the primary location first, then falls back to the legacy
|
|
56
|
+
location for backward compatibility.
|
|
57
|
+
"""
|
|
58
|
+
for path in (CONFIG_FILE, _LEGACY_CONFIG_FILE):
|
|
59
|
+
if path.exists():
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
63
|
+
get_logger().warning(f"Could not load config file: {e}")
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_config_file(config: dict):
|
|
68
|
+
"""Save config to the data directory."""
|
|
69
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
try:
|
|
71
|
+
CONFIG_FILE.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
72
|
+
except OSError as e:
|
|
73
|
+
get_logger().warning(f"Could not save config file: {e}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Config:
|
|
77
|
+
"""Application configuration with validation."""
|
|
78
|
+
|
|
79
|
+
def __init__(self):
|
|
80
|
+
saved = load_config_file()
|
|
81
|
+
|
|
82
|
+
self.provider: str = saved.get("provider", "deepseek")
|
|
83
|
+
self.base_url: str = saved.get("base_url", "https://api.deepseek.com/v1")
|
|
84
|
+
self.api_key: str = self._resolve_api_key()
|
|
85
|
+
self.model: str = saved.get("model", "deepseek-v4-flash")
|
|
86
|
+
self.max_tokens: int = saved.get("max_tokens", 4096)
|
|
87
|
+
self.temperature: float = saved.get("temperature", 0.7)
|
|
88
|
+
self.system_prompt: str = saved.get("system_prompt", DEFAULT_SYSTEM_PROMPT)
|
|
89
|
+
self.working_directory: str = saved.get("working_directory", os.getcwd())
|
|
90
|
+
self.max_context_messages: int = saved.get("max_context_messages", 100)
|
|
91
|
+
self.log_level: str = saved.get("log_level", "INFO")
|
|
92
|
+
|
|
93
|
+
def _resolve_api_key(self) -> str:
|
|
94
|
+
provider_env = f"{self.provider.upper()}_API_KEY"
|
|
95
|
+
for path in (Path(__file__).parent.parent / ".env", Path(".env")):
|
|
96
|
+
if path.exists():
|
|
97
|
+
try:
|
|
98
|
+
lines = path.read_text().splitlines()
|
|
99
|
+
for line in lines:
|
|
100
|
+
line = line.strip()
|
|
101
|
+
# Match both DEEPSEEK_API_KEY and <PROVIDER>_API_KEY
|
|
102
|
+
if line.startswith(f"{provider_env}="):
|
|
103
|
+
return line.split("=", 1)[1].strip("\"'")
|
|
104
|
+
if line.startswith("DEEPSEEK_API_KEY=") and self.provider == "deepseek":
|
|
105
|
+
return line.split("=", 1)[1].strip("\"'")
|
|
106
|
+
except Exception:
|
|
107
|
+
get_logger().warning("Could not read .env file: %s", path, exc_info=True)
|
|
108
|
+
|
|
109
|
+
# Fallback: environment variables (provider-specific first, then legacy)
|
|
110
|
+
key = os.environ.get(provider_env) or os.environ.get("DEEPSEEK_API_KEY")
|
|
111
|
+
if key:
|
|
112
|
+
return key
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
def validate(self):
|
|
116
|
+
"""Validate config and raise ValueError with clear message on failure."""
|
|
117
|
+
errors = []
|
|
118
|
+
|
|
119
|
+
# Supported providers (must match _provider_urls in from_args)
|
|
120
|
+
_valid_providers = {"deepseek"}
|
|
121
|
+
if self.provider not in _valid_providers:
|
|
122
|
+
errors.append(
|
|
123
|
+
f"provider must be one of {sorted(_valid_providers)} (got: {self.provider})"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if not self.api_key:
|
|
127
|
+
key_env = f"{self.provider.upper()}_API_KEY"
|
|
128
|
+
errors.append(
|
|
129
|
+
f"{key_env} is not set. "
|
|
130
|
+
"Set it as an environment variable or in a .env file."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if not self.base_url:
|
|
134
|
+
errors.append("base_url is not set")
|
|
135
|
+
elif not self.base_url.startswith(("http://", "https://")):
|
|
136
|
+
errors.append(f"base_url must start with http:// or https:// (got: {self.base_url})")
|
|
137
|
+
|
|
138
|
+
if self.max_tokens < 1 or self.max_tokens > 32000:
|
|
139
|
+
errors.append(f"max_tokens must be between 1 and 32000 (got: {self.max_tokens})")
|
|
140
|
+
|
|
141
|
+
if self.temperature < 0 or self.temperature > 2:
|
|
142
|
+
errors.append(f"temperature must be between 0 and 2 (got: {self.temperature})")
|
|
143
|
+
|
|
144
|
+
if self.max_context_messages < 2:
|
|
145
|
+
errors.append(f"max_context_messages must be at least 2 (got: {self.max_context_messages})")
|
|
146
|
+
|
|
147
|
+
if errors:
|
|
148
|
+
raise ValueError("\n".join(errors))
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict:
|
|
151
|
+
"""Export config as dict (excluding API key)."""
|
|
152
|
+
return {
|
|
153
|
+
"provider": self.provider,
|
|
154
|
+
"base_url": self.base_url,
|
|
155
|
+
"model": self.model,
|
|
156
|
+
"max_tokens": self.max_tokens,
|
|
157
|
+
"temperature": self.temperature,
|
|
158
|
+
"max_context_messages": self.max_context_messages,
|
|
159
|
+
"log_level": self.log_level,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
def save(self):
|
|
163
|
+
"""Persist current config (excluding API key) to config file."""
|
|
164
|
+
save_config_file(self.to_dict())
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_args(cls, args=None):
|
|
168
|
+
cfg = cls()
|
|
169
|
+
if args:
|
|
170
|
+
if hasattr(args, "model") and args.model:
|
|
171
|
+
cfg.model = args.model
|
|
172
|
+
if hasattr(args, "temperature") and args.temperature is not None:
|
|
173
|
+
cfg.temperature = args.temperature
|
|
174
|
+
if hasattr(args, "system_prompt") and args.system_prompt:
|
|
175
|
+
cfg.system_prompt = args.system_prompt
|
|
176
|
+
if hasattr(args, "dir") and args.dir:
|
|
177
|
+
cfg.working_directory = args.dir
|
|
178
|
+
if hasattr(args, "provider") and args.provider:
|
|
179
|
+
cfg.provider = args.provider
|
|
180
|
+
# Only override base_url if none was persisted — derive it
|
|
181
|
+
# from the provider name rather than hardcoding DeepSeek.
|
|
182
|
+
if "base_url" not in load_config_file():
|
|
183
|
+
_provider_urls = {
|
|
184
|
+
"deepseek": "https://api.deepseek.com/v1",
|
|
185
|
+
}
|
|
186
|
+
cfg.base_url = _provider_urls.get(
|
|
187
|
+
cfg.provider, cfg.base_url
|
|
188
|
+
)
|
|
189
|
+
# Re-resolve key now that provider is set from args
|
|
190
|
+
cfg.api_key = cfg._resolve_api_key()
|
|
191
|
+
return cfg
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Module-level convenience helpers (used by generator tools)
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def get_api_key() -> str:
|
|
199
|
+
"""Return the resolved DeepSeek API key."""
|
|
200
|
+
return Config()._resolve_api_key()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_base_url() -> str:
|
|
204
|
+
"""Return the configured API base URL."""
|
|
205
|
+
return Config().base_url
|