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.
Files changed (129) hide show
  1. backend/__init__.py +1 -0
  2. backend/agents/__init__.py +6 -0
  3. backend/agents/assessment_agent.py +273 -0
  4. backend/agents/coding_agent.py +779 -0
  5. backend/agents/concept_categories.py +131 -0
  6. backend/agents/concept_detector.py +1217 -0
  7. backend/agents/debug_agent.py +166 -0
  8. backend/agents/teacher_agent.py +179 -0
  9. backend/cli/__init__.py +1 -0
  10. backend/cli/config_cmd.py +135 -0
  11. backend/cli/main.py +606 -0
  12. backend/daemon/__init__.py +1 -0
  13. backend/daemon/launcher.py +243 -0
  14. backend/daemon/server.py +453 -0
  15. backend/daemon/state.py +110 -0
  16. backend/daemon/static/assets/Gambarino-Regular-BjbcsURA.otf +0 -0
  17. backend/daemon/static/assets/abnfDiagram-VCTEODGH-CCJBE2aE.js +1 -0
  18. backend/daemon/static/assets/arc-BEvzHx4o.js +1 -0
  19. backend/daemon/static/assets/architecture-7GRP2DOG-DaWrPggL.js +1 -0
  20. backend/daemon/static/assets/architectureDiagram-5GKGNRK7-pR-klcZv.js +36 -0
  21. backend/daemon/static/assets/array-BifhSqXX.js +1 -0
  22. backend/daemon/static/assets/blockDiagram-I7D4REHJ-C504Gj6_.js +129 -0
  23. backend/daemon/static/assets/c4Diagram-7LVT6UL2-BjM04Mni.js +38 -0
  24. backend/daemon/static/assets/channel-DzSauwD3.js +1 -0
  25. backend/daemon/static/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  26. backend/daemon/static/assets/chunk-4HAMMTFA-EgoP78tp.js +62 -0
  27. backend/daemon/static/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  28. backend/daemon/static/assets/chunk-75Z2AOVW-EXNbuzun.js +2 -0
  29. backend/daemon/static/assets/chunk-DU6HZSFF-CF3OK3MZ.js +127 -0
  30. backend/daemon/static/assets/chunk-F27PBJKO-G71ylWJa.js +1 -0
  31. backend/daemon/static/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
  32. backend/daemon/static/assets/chunk-GMAD6QVW-2yfGg28o.js +72 -0
  33. backend/daemon/static/assets/chunk-GVQU2GXP-C_VeaX4U.js +1 -0
  34. backend/daemon/static/assets/chunk-IMKFNOWR-CNexRjjn.js +231 -0
  35. backend/daemon/static/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  36. backend/daemon/static/assets/chunk-P2QGCYS3-E4AByfsD.js +1 -0
  37. backend/daemon/static/assets/chunk-POPQ4Y6H-Bisbc2-3.js +1 -0
  38. backend/daemon/static/assets/chunk-PWAF6VOD-DaoPxZAa.js +1 -0
  39. backend/daemon/static/assets/chunk-SHT3W25Y-DarPToto.js +168 -0
  40. backend/daemon/static/assets/chunk-SVP7TREG-DvMOAiwI.js +88 -0
  41. backend/daemon/static/assets/chunk-TICWLB2K-DheuvyGM.js +206 -0
  42. backend/daemon/static/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
  43. backend/daemon/static/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  44. backend/daemon/static/assets/classDiagram-ZZMXUADV-Ys5zkCXW.js +1 -0
  45. backend/daemon/static/assets/classDiagram-v2-VYDZK3BY-Ys5zkCXW.js +1 -0
  46. backend/daemon/static/assets/cose-bilkent-JH36ORCC-DLPLnxrP.js +1 -0
  47. backend/daemon/static/assets/cynefin-OW5HDTMX-Dv1OY_0y.js +1 -0
  48. backend/daemon/static/assets/cynefinDiagram-5FMLGOSQ-Ur7MTCmF.js +62 -0
  49. backend/daemon/static/assets/cytoscape.esm-CECbKnxF.js +321 -0
  50. backend/daemon/static/assets/dagre-CJLTJMFW.js +1 -0
  51. backend/daemon/static/assets/dagre-GXQ25YYZ-R3BwTvng.js +4 -0
  52. backend/daemon/static/assets/defaultLocale-BFoDCU3G.js +1 -0
  53. backend/daemon/static/assets/diagram-S7CK7UJ4-BxIoEKb4.js +30 -0
  54. backend/daemon/static/assets/diagram-UQ7AKVKN-DO4cuWN-.js +41 -0
  55. backend/daemon/static/assets/diagram-VSXAHHWV-DW5imp5t.js +3 -0
  56. backend/daemon/static/assets/diagram-VX7I27RA-CdZ3k7wQ.js +24 -0
  57. backend/daemon/static/assets/diagram-Z3DM3KII-DPyjbneL.js +24 -0
  58. backend/daemon/static/assets/dist-DTg6UBE_.js +1 -0
  59. backend/daemon/static/assets/ebnfDiagram-PWID7BFC-BO7VQsye.js +1 -0
  60. backend/daemon/static/assets/erDiagram-RLTQ6QDP-CevvjECq.js +99 -0
  61. backend/daemon/static/assets/eventmodeling-NTZA5JFV-yNfKR6-v.js +1 -0
  62. backend/daemon/static/assets/flowDiagram-HODETNUW-B4GT41mU.js +1 -0
  63. backend/daemon/static/assets/ganttDiagram-EL5Y4UJY-DNW5fWw1.js +292 -0
  64. backend/daemon/static/assets/gitGraph-4MIJSDKK-DKgVkWaZ.js +1 -0
  65. backend/daemon/static/assets/gitGraphDiagram-WWUBYQGX-0S7OF9Aj.js +106 -0
  66. backend/daemon/static/assets/index-D3vj8REa.js +63 -0
  67. backend/daemon/static/assets/index-D4lMFaiv.css +1 -0
  68. backend/daemon/static/assets/info-A6RAGUB7-Bxy-SzRN.js +1 -0
  69. backend/daemon/static/assets/infoDiagram-27XIBGKW-ClzQji6X.js +2 -0
  70. backend/daemon/static/assets/init-C-OQMol4.js +1 -0
  71. backend/daemon/static/assets/ishikawaDiagram-5VMMS53U-B3Lo-sS3.js +70 -0
  72. backend/daemon/static/assets/journeyDiagram-3NMN7TZE-0KL6R2Rz.js +139 -0
  73. backend/daemon/static/assets/kanban-definition-UXKFOSKX-zt5NbEep.js +89 -0
  74. backend/daemon/static/assets/katex-CXMH3UgJ.js +257 -0
  75. backend/daemon/static/assets/line-CiAFRJVJ.js +1 -0
  76. backend/daemon/static/assets/linear-BI6yqEPV.js +1 -0
  77. backend/daemon/static/assets/logo_darkmode-BPDdj6GZ.png +0 -0
  78. backend/daemon/static/assets/logo_lightmode-C3ZWMgAH.png +0 -0
  79. backend/daemon/static/assets/mermaid-parser.core-DEadI1Ja.js +7 -0
  80. backend/daemon/static/assets/mindmap-definition-YA3MSWOX-TGKGYg5n.js +96 -0
  81. backend/daemon/static/assets/ordinal-BDEzSJ7C.js +1 -0
  82. backend/daemon/static/assets/packet-AYTQ26CC-CZTSuh5x.js +1 -0
  83. backend/daemon/static/assets/path-fybaL0A-.js +1 -0
  84. backend/daemon/static/assets/pegDiagram-XKGWAZYB-DGd8LACA.js +1 -0
  85. backend/daemon/static/assets/pie-WAS4IAKB-B59sPr3Z.js +1 -0
  86. backend/daemon/static/assets/pieDiagram-E7YTZNPT-CpwxCR3L.js +39 -0
  87. backend/daemon/static/assets/quadrantDiagram-AXDQQJYC-BwSeF_E_.js +7 -0
  88. backend/daemon/static/assets/radar-RG4KPBEZ-DAa4JvTb.js +1 -0
  89. backend/daemon/static/assets/railroad-74A4TZTK-BitdNgDt.js +1 -0
  90. backend/daemon/static/assets/railroad-abnf-HS5TGJTU-DCrNKqAH.js +1 -0
  91. backend/daemon/static/assets/railroad-ebnf-LZEXJU2U-DmEwx8OK.js +1 -0
  92. backend/daemon/static/assets/railroad-peg-WCYAUIDC-CPc8dTCP.js +1 -0
  93. backend/daemon/static/assets/railroadDiagram-O6MQD6OU-DuizuzwD.js +1 -0
  94. backend/daemon/static/assets/requirementDiagram-BXWQKSXE-BjMk0yS8.js +84 -0
  95. backend/daemon/static/assets/rough.esm-Dy-Kn_BL.js +1 -0
  96. backend/daemon/static/assets/sankeyDiagram-P5KCCOFB-0T_bhkmz.js +40 -0
  97. backend/daemon/static/assets/sequenceDiagram-WJ2MYXX4-Cwa-1Stp.js +162 -0
  98. backend/daemon/static/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
  99. backend/daemon/static/assets/src-BH-TyZbA.js +1 -0
  100. backend/daemon/static/assets/stateDiagram-D77RDMKH-BpQSg_QL.js +1 -0
  101. backend/daemon/static/assets/stateDiagram-v2-MP3YSRHH-BItVXKof.js +1 -0
  102. backend/daemon/static/assets/swimlanes-42K2YHIH-h_ED18Vy.js +1 -0
  103. backend/daemon/static/assets/swimlanesDiagram-VR7AAH4N-D0fo0LN-.js +8 -0
  104. backend/daemon/static/assets/timeline-definition-24CTP7MA-DKfSO33a.js +120 -0
  105. backend/daemon/static/assets/treeView-Q6P3EWNA-DAj9fxfC.js +1 -0
  106. backend/daemon/static/assets/treemap-WGGIJYW6-5IIXD9Zu.js +1 -0
  107. backend/daemon/static/assets/vennDiagram-4TSXK5OY-BoBvVEci.js +34 -0
  108. backend/daemon/static/assets/wardley-WFR3VGLG-CGsd7s_-.js +1 -0
  109. backend/daemon/static/assets/wardleyDiagram-VM6X3IG4-QHdK5NsY.js +78 -0
  110. backend/daemon/static/assets/xychartDiagram-S5SC5T6Z-MN_fdKCJ.js +7 -0
  111. backend/daemon/static/index.html +49 -0
  112. backend/database/__init__.py +1 -0
  113. backend/database/concept_slug.py +39 -0
  114. backend/database/concepts.py +804 -0
  115. backend/llm/__init__.py +5 -0
  116. backend/llm/client.py +333 -0
  117. backend/llm/config.py +254 -0
  118. backend/llm/key_setup.py +237 -0
  119. backend/main.py +13 -0
  120. backend/orchestrator/__init__.py +1 -0
  121. backend/orchestrator/events.py +52 -0
  122. backend/orchestrator/graph.py +316 -0
  123. backend/orchestrator/modes.py +125 -0
  124. codelith-0.1.0.dist-info/METADATA +301 -0
  125. codelith-0.1.0.dist-info/RECORD +129 -0
  126. codelith-0.1.0.dist-info/WHEEL +5 -0
  127. codelith-0.1.0.dist-info/entry_points.txt +2 -0
  128. codelith-0.1.0.dist-info/licenses/LICENSE +21 -0
  129. 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()
@@ -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()