codelith 0.1.0__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.
- backend/__init__.py +1 -0
- backend/agents/__init__.py +6 -0
- backend/agents/assessment_agent.py +273 -0
- backend/agents/coding_agent.py +779 -0
- backend/agents/concept_categories.py +131 -0
- backend/agents/concept_detector.py +1217 -0
- backend/agents/debug_agent.py +166 -0
- backend/agents/teacher_agent.py +179 -0
- backend/cli/__init__.py +1 -0
- backend/cli/config_cmd.py +135 -0
- backend/cli/main.py +606 -0
- backend/daemon/__init__.py +1 -0
- backend/daemon/launcher.py +243 -0
- backend/daemon/server.py +453 -0
- backend/daemon/state.py +110 -0
- backend/daemon/static/assets/Gambarino-Regular-BjbcsURA.otf +0 -0
- backend/daemon/static/assets/abnfDiagram-VCTEODGH-CCJBE2aE.js +1 -0
- backend/daemon/static/assets/arc-BEvzHx4o.js +1 -0
- backend/daemon/static/assets/architecture-7GRP2DOG-DaWrPggL.js +1 -0
- backend/daemon/static/assets/architectureDiagram-5GKGNRK7-pR-klcZv.js +36 -0
- backend/daemon/static/assets/array-BifhSqXX.js +1 -0
- backend/daemon/static/assets/blockDiagram-I7D4REHJ-C504Gj6_.js +129 -0
- backend/daemon/static/assets/c4Diagram-7LVT6UL2-BjM04Mni.js +38 -0
- backend/daemon/static/assets/channel-DzSauwD3.js +1 -0
- backend/daemon/static/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- backend/daemon/static/assets/chunk-4HAMMTFA-EgoP78tp.js +62 -0
- backend/daemon/static/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- backend/daemon/static/assets/chunk-75Z2AOVW-EXNbuzun.js +2 -0
- backend/daemon/static/assets/chunk-DU6HZSFF-CF3OK3MZ.js +127 -0
- backend/daemon/static/assets/chunk-F27PBJKO-G71ylWJa.js +1 -0
- backend/daemon/static/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
- backend/daemon/static/assets/chunk-GMAD6QVW-2yfGg28o.js +72 -0
- backend/daemon/static/assets/chunk-GVQU2GXP-C_VeaX4U.js +1 -0
- backend/daemon/static/assets/chunk-IMKFNOWR-CNexRjjn.js +231 -0
- backend/daemon/static/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- backend/daemon/static/assets/chunk-P2QGCYS3-E4AByfsD.js +1 -0
- backend/daemon/static/assets/chunk-POPQ4Y6H-Bisbc2-3.js +1 -0
- backend/daemon/static/assets/chunk-PWAF6VOD-DaoPxZAa.js +1 -0
- backend/daemon/static/assets/chunk-SHT3W25Y-DarPToto.js +168 -0
- backend/daemon/static/assets/chunk-SVP7TREG-DvMOAiwI.js +88 -0
- backend/daemon/static/assets/chunk-TICWLB2K-DheuvyGM.js +206 -0
- backend/daemon/static/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- backend/daemon/static/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- backend/daemon/static/assets/classDiagram-ZZMXUADV-Ys5zkCXW.js +1 -0
- backend/daemon/static/assets/classDiagram-v2-VYDZK3BY-Ys5zkCXW.js +1 -0
- backend/daemon/static/assets/cose-bilkent-JH36ORCC-DLPLnxrP.js +1 -0
- backend/daemon/static/assets/cynefin-OW5HDTMX-Dv1OY_0y.js +1 -0
- backend/daemon/static/assets/cynefinDiagram-5FMLGOSQ-Ur7MTCmF.js +62 -0
- backend/daemon/static/assets/cytoscape.esm-CECbKnxF.js +321 -0
- backend/daemon/static/assets/dagre-CJLTJMFW.js +1 -0
- backend/daemon/static/assets/dagre-GXQ25YYZ-R3BwTvng.js +4 -0
- backend/daemon/static/assets/defaultLocale-BFoDCU3G.js +1 -0
- backend/daemon/static/assets/diagram-S7CK7UJ4-BxIoEKb4.js +30 -0
- backend/daemon/static/assets/diagram-UQ7AKVKN-DO4cuWN-.js +41 -0
- backend/daemon/static/assets/diagram-VSXAHHWV-DW5imp5t.js +3 -0
- backend/daemon/static/assets/diagram-VX7I27RA-CdZ3k7wQ.js +24 -0
- backend/daemon/static/assets/diagram-Z3DM3KII-DPyjbneL.js +24 -0
- backend/daemon/static/assets/dist-DTg6UBE_.js +1 -0
- backend/daemon/static/assets/ebnfDiagram-PWID7BFC-BO7VQsye.js +1 -0
- backend/daemon/static/assets/erDiagram-RLTQ6QDP-CevvjECq.js +99 -0
- backend/daemon/static/assets/eventmodeling-NTZA5JFV-yNfKR6-v.js +1 -0
- backend/daemon/static/assets/flowDiagram-HODETNUW-B4GT41mU.js +1 -0
- backend/daemon/static/assets/ganttDiagram-EL5Y4UJY-DNW5fWw1.js +292 -0
- backend/daemon/static/assets/gitGraph-4MIJSDKK-DKgVkWaZ.js +1 -0
- backend/daemon/static/assets/gitGraphDiagram-WWUBYQGX-0S7OF9Aj.js +106 -0
- backend/daemon/static/assets/index-D3vj8REa.js +63 -0
- backend/daemon/static/assets/index-D4lMFaiv.css +1 -0
- backend/daemon/static/assets/info-A6RAGUB7-Bxy-SzRN.js +1 -0
- backend/daemon/static/assets/infoDiagram-27XIBGKW-ClzQji6X.js +2 -0
- backend/daemon/static/assets/init-C-OQMol4.js +1 -0
- backend/daemon/static/assets/ishikawaDiagram-5VMMS53U-B3Lo-sS3.js +70 -0
- backend/daemon/static/assets/journeyDiagram-3NMN7TZE-0KL6R2Rz.js +139 -0
- backend/daemon/static/assets/kanban-definition-UXKFOSKX-zt5NbEep.js +89 -0
- backend/daemon/static/assets/katex-CXMH3UgJ.js +257 -0
- backend/daemon/static/assets/line-CiAFRJVJ.js +1 -0
- backend/daemon/static/assets/linear-BI6yqEPV.js +1 -0
- backend/daemon/static/assets/logo_darkmode-BPDdj6GZ.png +0 -0
- backend/daemon/static/assets/logo_lightmode-C3ZWMgAH.png +0 -0
- backend/daemon/static/assets/mermaid-parser.core-DEadI1Ja.js +7 -0
- backend/daemon/static/assets/mindmap-definition-YA3MSWOX-TGKGYg5n.js +96 -0
- backend/daemon/static/assets/ordinal-BDEzSJ7C.js +1 -0
- backend/daemon/static/assets/packet-AYTQ26CC-CZTSuh5x.js +1 -0
- backend/daemon/static/assets/path-fybaL0A-.js +1 -0
- backend/daemon/static/assets/pegDiagram-XKGWAZYB-DGd8LACA.js +1 -0
- backend/daemon/static/assets/pie-WAS4IAKB-B59sPr3Z.js +1 -0
- backend/daemon/static/assets/pieDiagram-E7YTZNPT-CpwxCR3L.js +39 -0
- backend/daemon/static/assets/quadrantDiagram-AXDQQJYC-BwSeF_E_.js +7 -0
- backend/daemon/static/assets/radar-RG4KPBEZ-DAa4JvTb.js +1 -0
- backend/daemon/static/assets/railroad-74A4TZTK-BitdNgDt.js +1 -0
- backend/daemon/static/assets/railroad-abnf-HS5TGJTU-DCrNKqAH.js +1 -0
- backend/daemon/static/assets/railroad-ebnf-LZEXJU2U-DmEwx8OK.js +1 -0
- backend/daemon/static/assets/railroad-peg-WCYAUIDC-CPc8dTCP.js +1 -0
- backend/daemon/static/assets/railroadDiagram-O6MQD6OU-DuizuzwD.js +1 -0
- backend/daemon/static/assets/requirementDiagram-BXWQKSXE-BjMk0yS8.js +84 -0
- backend/daemon/static/assets/rough.esm-Dy-Kn_BL.js +1 -0
- backend/daemon/static/assets/sankeyDiagram-P5KCCOFB-0T_bhkmz.js +40 -0
- backend/daemon/static/assets/sequenceDiagram-WJ2MYXX4-Cwa-1Stp.js +162 -0
- backend/daemon/static/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- backend/daemon/static/assets/src-BH-TyZbA.js +1 -0
- backend/daemon/static/assets/stateDiagram-D77RDMKH-BpQSg_QL.js +1 -0
- backend/daemon/static/assets/stateDiagram-v2-MP3YSRHH-BItVXKof.js +1 -0
- backend/daemon/static/assets/swimlanes-42K2YHIH-h_ED18Vy.js +1 -0
- backend/daemon/static/assets/swimlanesDiagram-VR7AAH4N-D0fo0LN-.js +8 -0
- backend/daemon/static/assets/timeline-definition-24CTP7MA-DKfSO33a.js +120 -0
- backend/daemon/static/assets/treeView-Q6P3EWNA-DAj9fxfC.js +1 -0
- backend/daemon/static/assets/treemap-WGGIJYW6-5IIXD9Zu.js +1 -0
- backend/daemon/static/assets/vennDiagram-4TSXK5OY-BoBvVEci.js +34 -0
- backend/daemon/static/assets/wardley-WFR3VGLG-CGsd7s_-.js +1 -0
- backend/daemon/static/assets/wardleyDiagram-VM6X3IG4-QHdK5NsY.js +78 -0
- backend/daemon/static/assets/xychartDiagram-S5SC5T6Z-MN_fdKCJ.js +7 -0
- backend/daemon/static/index.html +49 -0
- backend/database/__init__.py +1 -0
- backend/database/concept_slug.py +39 -0
- backend/database/concepts.py +804 -0
- backend/llm/__init__.py +5 -0
- backend/llm/client.py +333 -0
- backend/llm/config.py +254 -0
- backend/llm/key_setup.py +237 -0
- backend/main.py +13 -0
- backend/orchestrator/__init__.py +1 -0
- backend/orchestrator/events.py +52 -0
- backend/orchestrator/graph.py +316 -0
- backend/orchestrator/modes.py +125 -0
- codelith-0.1.0.dist-info/METADATA +301 -0
- codelith-0.1.0.dist-info/RECORD +129 -0
- codelith-0.1.0.dist-info/WHEEL +5 -0
- codelith-0.1.0.dist-info/entry_points.txt +2 -0
- codelith-0.1.0.dist-info/licenses/LICENSE +21 -0
- codelith-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import state
|
|
12
|
+
|
|
13
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
14
|
+
SERVER_MODULE = "backend.daemon.server"
|
|
15
|
+
HOST = "127.0.0.1"
|
|
16
|
+
DEFAULT_PORT = 8765 # Match the dashboard's hardcoded port
|
|
17
|
+
READY_TIMEOUT_SECONDS = 15.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _find_venv_python() -> str:
|
|
21
|
+
"""Find the project's venv Python, or fall back to sys.executable."""
|
|
22
|
+
# Check for .venv in project root
|
|
23
|
+
venv_python = REPO_ROOT / ".venv" / "Scripts" / "python.exe"
|
|
24
|
+
if venv_python.exists():
|
|
25
|
+
return str(venv_python)
|
|
26
|
+
# Unix
|
|
27
|
+
venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
|
28
|
+
if venv_python.exists():
|
|
29
|
+
return str(venv_python)
|
|
30
|
+
return sys.executable
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _find_free_port(host: str = HOST, preferred: int = DEFAULT_PORT) -> int:
|
|
34
|
+
"""Try *preferred* port first; fall back to a random free port."""
|
|
35
|
+
import socket
|
|
36
|
+
|
|
37
|
+
# Try preferred port first
|
|
38
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
39
|
+
try:
|
|
40
|
+
sock.bind((host, preferred))
|
|
41
|
+
return preferred
|
|
42
|
+
except OSError:
|
|
43
|
+
pass # Port in use, fall back
|
|
44
|
+
|
|
45
|
+
# Fall back to random port
|
|
46
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
47
|
+
sock.bind((host, 0))
|
|
48
|
+
return sock.getsockname()[1]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _wait_until_ready(port: int, timeout: float = READY_TIMEOUT_SECONDS) -> bool:
|
|
52
|
+
"""Poll the daemon's /health endpoint until it responds or we time out."""
|
|
53
|
+
url = f"http://{HOST}:{port}/health"
|
|
54
|
+
deadline = time.monotonic() + timeout
|
|
55
|
+
while time.monotonic() < deadline:
|
|
56
|
+
try:
|
|
57
|
+
with urllib.request.urlopen(url, timeout=1.0) as resp:
|
|
58
|
+
if resp.status == 200:
|
|
59
|
+
return True
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
time.sleep(0.2)
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def open_dashboard(port: int = DEFAULT_PORT) -> bool:
|
|
67
|
+
"""Open the dashboard in the default browser. True on success.
|
|
68
|
+
|
|
69
|
+
Call only once the daemon is confirmed ready: ``start`` guarantees
|
|
70
|
+
that for a fresh start (it polls /health), and an adopted port is
|
|
71
|
+
answering by definition.
|
|
72
|
+
"""
|
|
73
|
+
url = f"http://localhost:{port}/"
|
|
74
|
+
try:
|
|
75
|
+
import webbrowser
|
|
76
|
+
|
|
77
|
+
return webbrowser.open(url, new=2, autoraise=True)
|
|
78
|
+
except Exception: # noqa: BLE001 - a browser failure must not kill the CLI
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _daemon_command(port: int) -> tuple[list[str], dict[str, str]]:
|
|
83
|
+
"""Return ``(argv, env)`` for the detached daemon process.
|
|
84
|
+
|
|
85
|
+
Detects the project's venv Python so the daemon always has access to
|
|
86
|
+
installed packages, regardless of which Python was used to invoke the
|
|
87
|
+
launcher.
|
|
88
|
+
"""
|
|
89
|
+
env = dict(os.environ)
|
|
90
|
+
python = _find_venv_python()
|
|
91
|
+
argv = [python, "-m", SERVER_MODULE, "--host", HOST, "--port", str(port)]
|
|
92
|
+
return argv, env
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _start_detached(port: int) -> subprocess.Popen:
|
|
96
|
+
"""Spawn the daemon server in a detached process with its own session."""
|
|
97
|
+
state.ensure_state_dir()
|
|
98
|
+
log = (state.state_dir() / "daemon.log").open("a", encoding="utf-8")
|
|
99
|
+
argv, env = _daemon_command(port)
|
|
100
|
+
kwargs: dict = {
|
|
101
|
+
"cwd": str(REPO_ROOT),
|
|
102
|
+
"env": env,
|
|
103
|
+
"stdin": subprocess.DEVNULL,
|
|
104
|
+
"stdout": log,
|
|
105
|
+
"stderr": subprocess.STDOUT,
|
|
106
|
+
}
|
|
107
|
+
if sys.platform == "win32":
|
|
108
|
+
# Hide the console window without using DETACHED_PROCESS
|
|
109
|
+
# (DETACHED_PROCESS can break subprocess environment on Windows).
|
|
110
|
+
si = subprocess.STARTUPINFO()
|
|
111
|
+
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
112
|
+
si.wShowWindow = 0 # SW_HIDE
|
|
113
|
+
kwargs["startupinfo"] = si
|
|
114
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
115
|
+
else:
|
|
116
|
+
kwargs["start_new_session"] = True
|
|
117
|
+
return subprocess.Popen(argv, **kwargs)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def start() -> tuple[int, int, bool]:
|
|
121
|
+
"""Start the daemon if necessary, or adopt an already-running one.
|
|
122
|
+
|
|
123
|
+
The "already running" check is port-based (127.0.0.1:<default port>
|
|
124
|
+
accepting connections): if something healthy answers on the dashboard
|
|
125
|
+
port, reuse it instead of starting a second daemon. A stale pid file
|
|
126
|
+
pointing at a dead process — or at a port nobody listens on — must
|
|
127
|
+
never cause a duplicate daemon. Quiet on the already-running paths;
|
|
128
|
+
callers decide what to show the user (the CLI prints a dashboard
|
|
129
|
+
link instead of daemon bookkeeping).
|
|
130
|
+
|
|
131
|
+
Returns ``(pid, port, started_now)``; ``pid`` is ``None`` for an
|
|
132
|
+
adopted daemon this process did not spawn.
|
|
133
|
+
"""
|
|
134
|
+
running = state.is_running()
|
|
135
|
+
if running:
|
|
136
|
+
pid, port = running
|
|
137
|
+
return pid, port, False
|
|
138
|
+
|
|
139
|
+
# Something already serves the dashboard port (e.g. a daemon started
|
|
140
|
+
# elsewhere, with pid state this install can't see) → adopt it rather
|
|
141
|
+
# than spawn a rival that would lose the port race anyway.
|
|
142
|
+
if state.port_open(DEFAULT_PORT):
|
|
143
|
+
return None, DEFAULT_PORT, False
|
|
144
|
+
|
|
145
|
+
# Clear stale state left behind by a crashed or stopped daemon.
|
|
146
|
+
state.clear_state()
|
|
147
|
+
|
|
148
|
+
port = _find_free_port()
|
|
149
|
+
proc = _start_detached(port)
|
|
150
|
+
state.write_state(proc.pid, port)
|
|
151
|
+
if not _wait_until_ready(port):
|
|
152
|
+
_print_log_tail()
|
|
153
|
+
raise SystemExit(
|
|
154
|
+
f"Daemon failed to become ready within {READY_TIMEOUT_SECONDS:.0f}s; "
|
|
155
|
+
f"see {state.state_dir() / 'daemon.log'}"
|
|
156
|
+
)
|
|
157
|
+
return proc.pid, port, True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def status() -> None:
|
|
161
|
+
"""Print whether the daemon is running and its PID/port."""
|
|
162
|
+
running = state.is_running()
|
|
163
|
+
if running:
|
|
164
|
+
pid, port = running
|
|
165
|
+
print(f"Daemon is running (pid {pid}, port {port}).")
|
|
166
|
+
return
|
|
167
|
+
pid = state.read_pid()
|
|
168
|
+
port = state.read_port()
|
|
169
|
+
if pid is not None or port is not None:
|
|
170
|
+
print(f"Daemon is not running (stale state: pid={pid}, port={port}).")
|
|
171
|
+
else:
|
|
172
|
+
print("Daemon is not running.")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _terminate(pid: int) -> None:
|
|
176
|
+
if sys.platform == "win32":
|
|
177
|
+
subprocess.run(
|
|
178
|
+
["taskkill", "/PID", str(pid), "/F"],
|
|
179
|
+
check=False,
|
|
180
|
+
capture_output=True,
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
import os
|
|
184
|
+
import signal
|
|
185
|
+
|
|
186
|
+
os.kill(pid, signal.SIGTERM)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def stop() -> bool:
|
|
190
|
+
"""Stop the running daemon and clear its state. Returns True if one was running."""
|
|
191
|
+
pid = state.read_pid()
|
|
192
|
+
if pid is None:
|
|
193
|
+
print("No daemon state found; nothing to stop.")
|
|
194
|
+
return False
|
|
195
|
+
if state.pid_alive(pid):
|
|
196
|
+
_terminate(pid)
|
|
197
|
+
print(f"Stopped daemon (pid {pid}).")
|
|
198
|
+
else:
|
|
199
|
+
print(f"State refers to dead pid {pid}; cleaning up.")
|
|
200
|
+
state.clear_state()
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _print_log_tail() -> None:
|
|
205
|
+
log = state.state_dir() / "daemon.log"
|
|
206
|
+
try:
|
|
207
|
+
tail = "\n".join(log.read_text(encoding="utf-8").splitlines()[-20:])
|
|
208
|
+
except OSError:
|
|
209
|
+
return
|
|
210
|
+
if tail:
|
|
211
|
+
print("--- daemon.log tail ---")
|
|
212
|
+
print(tail)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main(argv: list[str] | None = None) -> None:
|
|
216
|
+
parser = argparse.ArgumentParser(
|
|
217
|
+
prog="codelith-daemon",
|
|
218
|
+
description="Manage the CodeLith local daemon.",
|
|
219
|
+
)
|
|
220
|
+
parser.add_argument("command", choices=["start", "status", "stop", "open"])
|
|
221
|
+
args = parser.parse_args(argv)
|
|
222
|
+
|
|
223
|
+
if args.command == "start":
|
|
224
|
+
pid, port, started = start()
|
|
225
|
+
# Bookkeeping belongs in this explicit management entrypoint; the
|
|
226
|
+
# chat flow (cli.main) prints a dashboard link instead.
|
|
227
|
+
if started:
|
|
228
|
+
print(f"Daemon started (pid {pid}, port {port}).")
|
|
229
|
+
elif pid is not None:
|
|
230
|
+
print(f"Daemon already running (pid {pid}, port {port}).")
|
|
231
|
+
else:
|
|
232
|
+
print(f"Port {port} is already serving the dashboard; reusing it.")
|
|
233
|
+
elif args.command == "status":
|
|
234
|
+
status()
|
|
235
|
+
elif args.command == "stop":
|
|
236
|
+
stop()
|
|
237
|
+
elif args.command == "open":
|
|
238
|
+
_pid, port, _started = start()
|
|
239
|
+
open_dashboard(port)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
main()
|
backend/daemon/server.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import queue
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
12
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
13
|
+
from fastapi.responses import StreamingResponse
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from backend.orchestrator.graph import run_graph
|
|
17
|
+
from backend.orchestrator.modes import list_modes
|
|
18
|
+
from backend.database.concepts import (
|
|
19
|
+
load_concepts,
|
|
20
|
+
get_progress,
|
|
21
|
+
clear_concepts,
|
|
22
|
+
clear_assessments,
|
|
23
|
+
clear_teachings,
|
|
24
|
+
get_pending_assessments,
|
|
25
|
+
get_all_assessments,
|
|
26
|
+
get_assessment_counts,
|
|
27
|
+
submit_assessment_answer,
|
|
28
|
+
get_assessment_progress,
|
|
29
|
+
get_teachings,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
33
|
+
DEFAULT_PORT = 8765
|
|
34
|
+
|
|
35
|
+
# Built dashboard, shipped inside the package (frontend's Vite build
|
|
36
|
+
# writes here directly — see frontend/vite.config.ts). Anchored to
|
|
37
|
+
# this module's location, never the process CWD: after `pip install
|
|
38
|
+
# codelith` the daemon may be started from any directory, and a
|
|
39
|
+
# relative "static" would either crash at startup or silently serve
|
|
40
|
+
# the wrong folder.
|
|
41
|
+
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
42
|
+
|
|
43
|
+
app = FastAPI(title="CodeLith Daemon")
|
|
44
|
+
|
|
45
|
+
# Allow the Vite dev server (and any localhost origin) to call our API.
|
|
46
|
+
app.add_middleware(
|
|
47
|
+
CORSMiddleware,
|
|
48
|
+
allow_origins=["*"],
|
|
49
|
+
allow_credentials=True,
|
|
50
|
+
allow_methods=["*"],
|
|
51
|
+
allow_headers=["*"],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# In-memory conversation history keyed by session id.
|
|
55
|
+
# A new CLI session always sends "new_session" first, then subsequent
|
|
56
|
+
# messages carry the same session id so context is preserved.
|
|
57
|
+
_conversations: dict[str, list[dict]] = {}
|
|
58
|
+
MAX_HISTORY_TURNS = 4 # keep last N user+assistant pairs to stay within TPM limits
|
|
59
|
+
|
|
60
|
+
# In-memory session modes keyed by session id. The daemon is the source of
|
|
61
|
+
# truth so the CLI and the dashboard always agree on the active mode:
|
|
62
|
+
# either client can change it and the other picks the change up.
|
|
63
|
+
_session_modes: dict[str, str] = {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ChatMessage(BaseModel):
|
|
67
|
+
"""Payload accepted by the chat endpoint."""
|
|
68
|
+
|
|
69
|
+
message: str = ""
|
|
70
|
+
workspace: str = "" # user's project root
|
|
71
|
+
session: str = "default" # conversation session id
|
|
72
|
+
mode: str = "learn" # session mode: learn, pair-programming, autonomous
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class QuestionMessage(BaseModel):
|
|
76
|
+
"""A user question for the dashboard chat."""
|
|
77
|
+
|
|
78
|
+
question: str = ""
|
|
79
|
+
session: str = "default"
|
|
80
|
+
history: list[dict] = Field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AssessmentAnswer(BaseModel):
|
|
84
|
+
"""An answer to an assessment question."""
|
|
85
|
+
|
|
86
|
+
assessment_id: str
|
|
87
|
+
answer: str
|
|
88
|
+
session: str = "default"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ModeChange(BaseModel):
|
|
92
|
+
"""Payload for POST /mode (mode switch from CLI or dashboard)."""
|
|
93
|
+
|
|
94
|
+
mode: str = ""
|
|
95
|
+
session: str = "default"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.get("/health")
|
|
99
|
+
def health() -> dict:
|
|
100
|
+
"""Liveness probe."""
|
|
101
|
+
return {"status": "ok"}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.post("/chat")
|
|
105
|
+
def chat(payload: Optional[ChatMessage] = None) -> dict:
|
|
106
|
+
"""Chat endpoint: forward the message (with history) to the graph."""
|
|
107
|
+
if payload is None:
|
|
108
|
+
return {"message": "", "session": "default"}
|
|
109
|
+
|
|
110
|
+
text = payload.message
|
|
111
|
+
workspace = payload.workspace or None
|
|
112
|
+
session_id = payload.session or "default"
|
|
113
|
+
# The daemon's stored mode wins: the CLI and dashboard may send stale
|
|
114
|
+
# local state, and both hit the same session.
|
|
115
|
+
mode = _session_modes.get(session_id, payload.mode or "learn")
|
|
116
|
+
|
|
117
|
+
# Append the new user message to this session's history.
|
|
118
|
+
history = _conversations.setdefault(session_id, [])
|
|
119
|
+
history.append({"role": "user", "content": text})
|
|
120
|
+
|
|
121
|
+
# Trim history to stay within TPM limits (keep last N pairs)
|
|
122
|
+
if len(history) > MAX_HISTORY_TURNS * 2:
|
|
123
|
+
history[:] = history[-MAX_HISTORY_TURNS * 2:]
|
|
124
|
+
|
|
125
|
+
result = run_graph(
|
|
126
|
+
text,
|
|
127
|
+
workspace_root=workspace,
|
|
128
|
+
history=history,
|
|
129
|
+
mode=mode,
|
|
130
|
+
session=session_id,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Store the assistant reply so the next turn sees it.
|
|
134
|
+
history.append({"role": "assistant", "content": result["reply"]})
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"message": result["reply"],
|
|
138
|
+
"session": session_id,
|
|
139
|
+
"concepts": result.get("concepts", []),
|
|
140
|
+
"teaching": result.get("teaching", ""),
|
|
141
|
+
"tool_calls_log": result.get("tool_calls_log", []),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- Dashboard API endpoints ------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.post("/chat/stream")
|
|
149
|
+
def chat_stream(payload: Optional[ChatMessage] = None) -> StreamingResponse:
|
|
150
|
+
"""Streaming chat endpoint (SSE).
|
|
151
|
+
|
|
152
|
+
Pushes live activity events while the agent works, then the final
|
|
153
|
+
result. Each SSE ``data:`` line is a JSON object with a ``type``:
|
|
154
|
+
|
|
155
|
+
- ``node`` — a graph node started (coding_agent, teacher_agent, ...)
|
|
156
|
+
- ``status`` — a short status line (e.g. "Thinking…")
|
|
157
|
+
- ``tool_start`` — a tool call began ({tool, detail})
|
|
158
|
+
- ``tool_done`` — the tool finished ({tool, detail, ok})
|
|
159
|
+
- ``result`` — the final chat payload (same shape as POST /chat)
|
|
160
|
+
- ``error`` — the graph run failed
|
|
161
|
+
"""
|
|
162
|
+
if payload is None:
|
|
163
|
+
payload = ChatMessage()
|
|
164
|
+
|
|
165
|
+
text = payload.message
|
|
166
|
+
workspace = payload.workspace or None
|
|
167
|
+
session_id = payload.session or "default"
|
|
168
|
+
mode = payload.mode or "learn"
|
|
169
|
+
|
|
170
|
+
# Append the new user message to this session's history.
|
|
171
|
+
history = _conversations.setdefault(session_id, [])
|
|
172
|
+
history.append({"role": "user", "content": text})
|
|
173
|
+
if len(history) > MAX_HISTORY_TURNS * 2:
|
|
174
|
+
history[:] = history[-MAX_HISTORY_TURNS * 2:]
|
|
175
|
+
|
|
176
|
+
events: queue.Queue = queue.Queue()
|
|
177
|
+
SENTINEL = object()
|
|
178
|
+
|
|
179
|
+
def _sink(event: dict) -> None:
|
|
180
|
+
events.put(event)
|
|
181
|
+
|
|
182
|
+
def _run_graph() -> None:
|
|
183
|
+
try:
|
|
184
|
+
result = run_graph(
|
|
185
|
+
text,
|
|
186
|
+
workspace_root=workspace,
|
|
187
|
+
history=history,
|
|
188
|
+
mode=mode,
|
|
189
|
+
session=session_id,
|
|
190
|
+
event_sink=_sink,
|
|
191
|
+
)
|
|
192
|
+
# Mirror POST /chat: store the assistant reply so the next
|
|
193
|
+
# turn sees it, and send the final result through the queue.
|
|
194
|
+
history.append({"role": "assistant", "content": result["reply"]})
|
|
195
|
+
events.put({
|
|
196
|
+
"type": "result",
|
|
197
|
+
"message": result["reply"],
|
|
198
|
+
"session": session_id,
|
|
199
|
+
"concepts": result.get("concepts", []),
|
|
200
|
+
"teaching": result.get("teaching", ""),
|
|
201
|
+
"tool_calls_log": result.get("tool_calls_log", []),
|
|
202
|
+
})
|
|
203
|
+
except Exception as exc: # noqa: BLE001 — streamed to the client
|
|
204
|
+
events.put({"type": "error", "message": str(exc)})
|
|
205
|
+
finally:
|
|
206
|
+
events.put(SENTINEL)
|
|
207
|
+
|
|
208
|
+
worker = threading.Thread(target=_run_graph, daemon=True)
|
|
209
|
+
|
|
210
|
+
def _generate():
|
|
211
|
+
worker.start()
|
|
212
|
+
try:
|
|
213
|
+
while True:
|
|
214
|
+
try:
|
|
215
|
+
item = events.get(timeout=120.0)
|
|
216
|
+
except queue.Empty:
|
|
217
|
+
yield "data: {\"type\": \"error\", \"message\": \"agent timed out\"}\n\n"
|
|
218
|
+
break
|
|
219
|
+
if item is SENTINEL:
|
|
220
|
+
break
|
|
221
|
+
yield f"data: {json.dumps(item)}\n\n"
|
|
222
|
+
finally:
|
|
223
|
+
worker.join(timeout=1.0)
|
|
224
|
+
|
|
225
|
+
return StreamingResponse(
|
|
226
|
+
_generate(),
|
|
227
|
+
media_type="text/event-stream",
|
|
228
|
+
headers={
|
|
229
|
+
"Cache-Control": "no-cache",
|
|
230
|
+
"X-Accel-Buffering": "no",
|
|
231
|
+
"Connection": "keep-alive",
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@app.get("/modes")
|
|
237
|
+
def modes() -> dict:
|
|
238
|
+
"""Return available session modes."""
|
|
239
|
+
return {"modes": list_modes()}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.get("/mode")
|
|
243
|
+
def get_mode_endpoint(session: str = "default") -> dict:
|
|
244
|
+
"""Return the active session mode for *session*."""
|
|
245
|
+
from backend.orchestrator.modes import DEFAULT_MODE
|
|
246
|
+
|
|
247
|
+
return {"mode": _session_modes.get(session, DEFAULT_MODE)}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.post("/mode")
|
|
251
|
+
def set_mode_endpoint(payload: Optional[ModeChange] = None) -> dict:
|
|
252
|
+
"""Set the active session mode.
|
|
253
|
+
|
|
254
|
+
Accepts ``{"mode": "learn", "session": "default"}`` from either the
|
|
255
|
+
CLI (``mode <name>``) or the dashboard. Every subsequent chat turn in
|
|
256
|
+
that session runs in the new mode, whichever client sent it.
|
|
257
|
+
"""
|
|
258
|
+
from backend.orchestrator.modes import get_mode
|
|
259
|
+
|
|
260
|
+
if payload is None:
|
|
261
|
+
return {"status": "error", "message": "No payload provided"}
|
|
262
|
+
|
|
263
|
+
mode = (payload.mode or "").strip()
|
|
264
|
+
session = payload.session or "default"
|
|
265
|
+
if get_mode(mode).name != mode:
|
|
266
|
+
return {"status": "error", "message": f"Unknown mode: {mode}"}
|
|
267
|
+
|
|
268
|
+
_session_modes[session] = mode
|
|
269
|
+
return {"status": "ok", "mode": mode, "session": session}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@app.get("/concepts")
|
|
273
|
+
def concepts(session: str = "default") -> dict:
|
|
274
|
+
"""Return all stored concepts for a session."""
|
|
275
|
+
return {"concepts": load_concepts(session)}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.get("/progress")
|
|
279
|
+
def progress(session: str = "default") -> dict:
|
|
280
|
+
"""Return learning progress summary for a session."""
|
|
281
|
+
return get_progress(session)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@app.post("/question")
|
|
285
|
+
def question(payload: Optional[QuestionMessage] = None) -> dict:
|
|
286
|
+
"""Answer a user question using the LLM (without file operations)."""
|
|
287
|
+
from backend.llm.client import generate_reply
|
|
288
|
+
|
|
289
|
+
if payload is None:
|
|
290
|
+
return {"answer": ""}
|
|
291
|
+
|
|
292
|
+
question_text = payload.question
|
|
293
|
+
if not question_text.strip():
|
|
294
|
+
return {"answer": "Please ask a question."}
|
|
295
|
+
|
|
296
|
+
answer = generate_reply(question_text, history=payload.history)
|
|
297
|
+
return {"answer": answer}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@app.delete("/concepts")
|
|
301
|
+
def delete_concepts(session: str = "default") -> dict:
|
|
302
|
+
"""Clear all stored concepts for a session."""
|
|
303
|
+
clear_concepts(session)
|
|
304
|
+
return {"status": "cleared"}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@app.delete("/assessments")
|
|
308
|
+
def delete_assessments(session: str = "default") -> dict:
|
|
309
|
+
"""Clear all stored assessments for a session."""
|
|
310
|
+
clear_assessments(session)
|
|
311
|
+
return {"status": "cleared"}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.delete("/teachings")
|
|
315
|
+
def delete_teachings(session: str = "default") -> dict:
|
|
316
|
+
"""Clear all stored teachings for a session."""
|
|
317
|
+
clear_teachings(session)
|
|
318
|
+
return {"status": "cleared"}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- Assessment endpoints --------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.get("/assessments")
|
|
325
|
+
def assessments(session: str = "default") -> dict:
|
|
326
|
+
"""Return all assessments (pending and answered) for a session."""
|
|
327
|
+
return {
|
|
328
|
+
"assessments": get_all_assessments(session),
|
|
329
|
+
"counts": get_assessment_counts(session),
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@app.get("/assessments/pending")
|
|
334
|
+
def pending_assessments(session: str = "default") -> dict:
|
|
335
|
+
"""Return the current (first unanswered) assessment for a session."""
|
|
336
|
+
return {
|
|
337
|
+
"assessments": get_pending_assessments(session),
|
|
338
|
+
"counts": get_assessment_counts(session),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@app.post("/assessments/answer")
|
|
343
|
+
def answer_assessment(payload: Optional[AssessmentAnswer] = None) -> dict:
|
|
344
|
+
"""Grade and record an answer to an assessment question.
|
|
345
|
+
|
|
346
|
+
Grading happens server-side via the LLM; the client-provided verdict
|
|
347
|
+
is ignored. Correct answers close the question; incorrect ones keep
|
|
348
|
+
it open with feedback so the learner can retry.
|
|
349
|
+
"""
|
|
350
|
+
if payload is None:
|
|
351
|
+
return {"status": "error", "message": "No payload provided"}
|
|
352
|
+
|
|
353
|
+
assessments = get_all_assessments(payload.session)
|
|
354
|
+
assessment = next(
|
|
355
|
+
(a for a in assessments if a.get("id") == payload.assessment_id), None
|
|
356
|
+
)
|
|
357
|
+
if assessment is None:
|
|
358
|
+
return {"status": "error", "message": "Assessment not found"}
|
|
359
|
+
|
|
360
|
+
from backend.llm.client import grade_answer
|
|
361
|
+
|
|
362
|
+
grade = grade_answer(
|
|
363
|
+
question=assessment.get("question", ""),
|
|
364
|
+
answer=payload.answer,
|
|
365
|
+
concept_name=assessment.get("concept_name", ""),
|
|
366
|
+
concept_category=assessment.get("concept_category", ""),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
result = submit_assessment_answer(
|
|
370
|
+
session=payload.session,
|
|
371
|
+
assessment_id=payload.assessment_id,
|
|
372
|
+
answer=payload.answer,
|
|
373
|
+
correct=grade["correct"],
|
|
374
|
+
feedback=grade["feedback"],
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if result is None: # pragma: no cover - assessment existed a moment ago
|
|
378
|
+
return {"status": "error", "message": "Assessment not found"}
|
|
379
|
+
return {
|
|
380
|
+
"status": "ok",
|
|
381
|
+
"assessment": result,
|
|
382
|
+
"correct": grade["correct"],
|
|
383
|
+
"feedback": grade["feedback"],
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@app.get("/assessments/progress")
|
|
388
|
+
def assessment_progress(session: str = "default") -> dict:
|
|
389
|
+
"""Return assessment performance summary."""
|
|
390
|
+
return get_assessment_progress(session)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# --- Teaching endpoints (for dashboard) ------------------------------------
|
|
394
|
+
# NOTE: these must stay ABOVE the catch-all static mount at the bottom
|
|
395
|
+
# of this module. FastAPI matches routes in registration order, so a
|
|
396
|
+
# mount registered first shadows every route defined after it — GET
|
|
397
|
+
# /teachings silently 404'd that way and diagrams vanished from the
|
|
398
|
+
# dashboard (see tests/daemon/test_route_order.py).
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@app.get("/teachings")
|
|
402
|
+
def teachings(session: str = "default") -> dict:
|
|
403
|
+
"""Return all teaching entries for a session."""
|
|
404
|
+
return {"teachings": get_teachings(session)}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@app.websocket("/ws")
|
|
408
|
+
async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
409
|
+
"""Stub WebSocket endpoint: accept connections and echo messages back."""
|
|
410
|
+
await websocket.accept()
|
|
411
|
+
try:
|
|
412
|
+
while True:
|
|
413
|
+
message = await websocket.receive_text()
|
|
414
|
+
await websocket.send_text(message)
|
|
415
|
+
except WebSocketDisconnect:
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# --- Dashboard static serving (always LAST) --------------------------------
|
|
420
|
+
|
|
421
|
+
# Mounted AFTER every API route so API paths always win, and only when
|
|
422
|
+
# the built dashboard is present: a source checkout without `npm run
|
|
423
|
+
# build` (or a dev workflow using the Vite server) runs API-only with
|
|
424
|
+
# a logged note instead of crashing the daemon at import time.
|
|
425
|
+
if STATIC_DIR.is_dir():
|
|
426
|
+
from fastapi.staticfiles import StaticFiles
|
|
427
|
+
|
|
428
|
+
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="dashboard")
|
|
429
|
+
else:
|
|
430
|
+
import sys
|
|
431
|
+
|
|
432
|
+
print(
|
|
433
|
+
"[codelith] dashboard build not found — running API-only. "
|
|
434
|
+
"Run `npm run build` in frontend/ to serve the dashboard from the daemon.",
|
|
435
|
+
file=sys.stderr,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def run(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
|
|
440
|
+
"""Serve the daemon app."""
|
|
441
|
+
uvicorn.run(app, host=host, port=port)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def main(argv: list[str] | None = None) -> None:
|
|
445
|
+
parser = argparse.ArgumentParser(description="CodeLith local daemon server")
|
|
446
|
+
parser.add_argument("--host", default=DEFAULT_HOST, help=f"bind host (default: {DEFAULT_HOST})")
|
|
447
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"bind port (default: {DEFAULT_PORT})")
|
|
448
|
+
args = parser.parse_args(argv)
|
|
449
|
+
run(host=args.host, port=args.port)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
if __name__ == "__main__":
|
|
453
|
+
main()
|