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
backend/cli/main.py
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""CodeLith CLI: an interactive session that talks to the local daemon.
|
|
2
|
+
|
|
3
|
+
The CLI makes sure the daemon is running (starting it as a detached process if
|
|
4
|
+
needed — see ``backend.daemon.launcher``), then relays every line the user
|
|
5
|
+
types to the daemon's ``POST /chat`` endpoint and prints the reply. When the
|
|
6
|
+
CLI exits, the daemon keeps running in the background.
|
|
7
|
+
|
|
8
|
+
Run it from the repo root with::
|
|
9
|
+
|
|
10
|
+
python -m backend.cli.main
|
|
11
|
+
|
|
12
|
+
or, after ``pip install -e .``::
|
|
13
|
+
|
|
14
|
+
codelith
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from typing import Callable, Optional
|
|
27
|
+
|
|
28
|
+
from backend.cli import config_cmd
|
|
29
|
+
from backend.daemon import launcher
|
|
30
|
+
|
|
31
|
+
HOST = "127.0.0.1"
|
|
32
|
+
# LLM + tool calls can take a while: the coding agent may run up to
|
|
33
|
+
# ~15 tool rounds, each with an LLM call and up to a 60s command.
|
|
34
|
+
REQUEST_TIMEOUT_SECONDS = 600.0
|
|
35
|
+
EXIT_COMMANDS = {"exit", "quit", "q"}
|
|
36
|
+
RESET_COMMANDS = {"reset", "clear", "/reset"}
|
|
37
|
+
MODE_COMMANDS = {"mode"}
|
|
38
|
+
VALID_MODES = {"learn", "pair-programming", "autonomous"}
|
|
39
|
+
|
|
40
|
+
# How often the CLI checks the daemon for mode changes made elsewhere
|
|
41
|
+
# (e.g. the dashboard) so both directions stay in sync.
|
|
42
|
+
MODE_POLL_SECONDS = 2.0
|
|
43
|
+
MODE_POLL_TIMEOUT_SECONDS = 5.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Startup banner
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# ANSI-shadow style glyphs: six rows each, outlined blocks with a
|
|
51
|
+
# built-in drop shadow.
|
|
52
|
+
_LOGO_LETTERS: dict[str, tuple[str, ...]] = {
|
|
53
|
+
"C": (
|
|
54
|
+
" ██████╗ ",
|
|
55
|
+
"██╔════╝ ",
|
|
56
|
+
"██║ ",
|
|
57
|
+
"██║ ",
|
|
58
|
+
"╚██████╗ ",
|
|
59
|
+
" ╚═════╝ ",
|
|
60
|
+
),
|
|
61
|
+
"O": (
|
|
62
|
+
" ██████╗ ",
|
|
63
|
+
"██╔═══██╗",
|
|
64
|
+
"██║ ██║",
|
|
65
|
+
"██║ ██║",
|
|
66
|
+
"╚██████╔╝",
|
|
67
|
+
" ╚═════╝ ",
|
|
68
|
+
),
|
|
69
|
+
"D": (
|
|
70
|
+
"██████╗ ",
|
|
71
|
+
"██╔══██╗",
|
|
72
|
+
"██║ ██║",
|
|
73
|
+
"██║ ██║",
|
|
74
|
+
"██████╔╝",
|
|
75
|
+
"╚═════╝ ",
|
|
76
|
+
),
|
|
77
|
+
"E": (
|
|
78
|
+
"███████╗",
|
|
79
|
+
"██╔════╝",
|
|
80
|
+
"█████╗ ",
|
|
81
|
+
"██╔══╝ ",
|
|
82
|
+
"███████╗",
|
|
83
|
+
"╚══════╝",
|
|
84
|
+
),
|
|
85
|
+
"L": (
|
|
86
|
+
"██╗ ",
|
|
87
|
+
"██║ ",
|
|
88
|
+
"██║ ",
|
|
89
|
+
"██║ ",
|
|
90
|
+
"███████╗",
|
|
91
|
+
"╚══════╝",
|
|
92
|
+
),
|
|
93
|
+
"I": (
|
|
94
|
+
"████████╗",
|
|
95
|
+
"╚══██╔══╝",
|
|
96
|
+
" ██║ ",
|
|
97
|
+
" ██║ ",
|
|
98
|
+
"████████╗",
|
|
99
|
+
"╚═══════╝",
|
|
100
|
+
),
|
|
101
|
+
"T": (
|
|
102
|
+
"████████╗",
|
|
103
|
+
"╚══██╔══╝",
|
|
104
|
+
" ██║ ",
|
|
105
|
+
" ██║ ",
|
|
106
|
+
" ██║ ",
|
|
107
|
+
" ╚═╝ ",
|
|
108
|
+
),
|
|
109
|
+
"H": (
|
|
110
|
+
"██╗ ██╗",
|
|
111
|
+
"██║ ██║",
|
|
112
|
+
"███████║",
|
|
113
|
+
"██╔══██║",
|
|
114
|
+
"██║ ██║",
|
|
115
|
+
"╚═╝ ╚═╝",
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Vertical gradient across the logo rows: bright cyan → cyan → bright
|
|
120
|
+
# blue → blue → bright magenta → magenta.
|
|
121
|
+
_LOGO_GRADIENT = (
|
|
122
|
+
"\033[96m",
|
|
123
|
+
"\033[36m",
|
|
124
|
+
"\033[94m",
|
|
125
|
+
"\033[34m",
|
|
126
|
+
"\033[95m",
|
|
127
|
+
"\033[35m",
|
|
128
|
+
)
|
|
129
|
+
ANSI_DIM = "\033[2m"
|
|
130
|
+
ANSI_RESET = "\033[0m"
|
|
131
|
+
|
|
132
|
+
LOGO_ROWS = 6
|
|
133
|
+
REVEAL_DELAY_SECONDS = 0.1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def render_logo() -> str:
|
|
137
|
+
"""Render the product name as outlined shadow-style block letters."""
|
|
138
|
+
return render_logo_frames()[-1]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def render_logo_frames() -> list[str]:
|
|
142
|
+
"""Return the logo after each successive letter, for the reveal animation."""
|
|
143
|
+
letters = [_LOGO_LETTERS[ch] for ch in "CODELITH"]
|
|
144
|
+
return [
|
|
145
|
+
"\n".join(
|
|
146
|
+
" ".join(letter[row] for letter in letters[:count])
|
|
147
|
+
for row in range(LOGO_ROWS)
|
|
148
|
+
)
|
|
149
|
+
for count in range(1, len(letters) + 1)
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _colors_enabled() -> bool:
|
|
154
|
+
"""True when ANSI colours may be used (interactive tty, NO_COLOR unset)."""
|
|
155
|
+
if os.environ.get("NO_COLOR"):
|
|
156
|
+
return False
|
|
157
|
+
return sys.stdout.isatty()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def print_banner() -> None:
|
|
161
|
+
"""Print the big startup logo and tagline, revealing it letter by letter."""
|
|
162
|
+
if os.name == "nt":
|
|
163
|
+
os.system("") # enables ANSI escape processing on Windows consoles
|
|
164
|
+
frames = render_logo_frames()
|
|
165
|
+
lines = frames[-1].splitlines()
|
|
166
|
+
width = max(len(line) for line in lines)
|
|
167
|
+
colors = _colors_enabled()
|
|
168
|
+
# Animate only on a real terminal; piped output (logs, CI) gets the
|
|
169
|
+
# finished logo instantly, and NO_COLOR opts out of the flourish too.
|
|
170
|
+
animate = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
171
|
+
if animate:
|
|
172
|
+
cursor_up = f"\033[{LOGO_ROWS}A" # jump back above the previous frame
|
|
173
|
+
for index, frame in enumerate(frames):
|
|
174
|
+
if index:
|
|
175
|
+
sys.stdout.write(cursor_up)
|
|
176
|
+
for row, line in enumerate(frame.splitlines()):
|
|
177
|
+
if colors:
|
|
178
|
+
sys.stdout.write(f"{_LOGO_GRADIENT[row]}{line}{ANSI_RESET}\n")
|
|
179
|
+
else:
|
|
180
|
+
sys.stdout.write(line + "\n")
|
|
181
|
+
sys.stdout.flush()
|
|
182
|
+
time.sleep(REVEAL_DELAY_SECONDS)
|
|
183
|
+
else:
|
|
184
|
+
for row, line in enumerate(lines):
|
|
185
|
+
if colors:
|
|
186
|
+
print(f"{_LOGO_GRADIENT[row]}{line}{ANSI_RESET}")
|
|
187
|
+
else:
|
|
188
|
+
print(line)
|
|
189
|
+
print()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def chat_url(port: int) -> str:
|
|
193
|
+
"""Return the daemon's chat endpoint URL for the given port."""
|
|
194
|
+
return f"http://{HOST}:{port}/chat"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def modes_url(port: int) -> str:
|
|
198
|
+
"""Return the daemon's modes endpoint URL."""
|
|
199
|
+
return f"http://{HOST}:{port}/modes"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def mode_url(port: int) -> str:
|
|
203
|
+
"""Return the daemon's single-mode endpoint URL (GET/POST)."""
|
|
204
|
+
return f"http://{HOST}:{port}/mode"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def fetch_current_mode(port: int, session: str = "default") -> Optional[str]:
|
|
208
|
+
"""GET the daemon's stored mode for *session*, or None on any failure."""
|
|
209
|
+
try:
|
|
210
|
+
with urllib.request.urlopen(
|
|
211
|
+
f"{mode_url(port)}?session={session}", timeout=MODE_POLL_TIMEOUT_SECONDS
|
|
212
|
+
) as response:
|
|
213
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
214
|
+
mode = str(payload.get("mode", "")).strip()
|
|
215
|
+
return mode or None
|
|
216
|
+
except (OSError, ValueError, urllib.error.URLError):
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def push_mode(port: int, mode: str, session: str = "default") -> bool:
|
|
221
|
+
"""POST a mode change to the daemon so every client sees it.
|
|
222
|
+
|
|
223
|
+
Returns True when the daemon accepted the mode.
|
|
224
|
+
"""
|
|
225
|
+
body = json.dumps({"mode": mode, "session": session}).encode("utf-8")
|
|
226
|
+
request = urllib.request.Request(
|
|
227
|
+
mode_url(port),
|
|
228
|
+
data=body,
|
|
229
|
+
headers={"Content-Type": "application/json"},
|
|
230
|
+
method="POST",
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
with urllib.request.urlopen(request, timeout=MODE_POLL_TIMEOUT_SECONDS) as response:
|
|
234
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
235
|
+
return payload.get("status") == "ok"
|
|
236
|
+
except (OSError, ValueError, urllib.error.URLError):
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def start_mode_poller(
|
|
241
|
+
port: int,
|
|
242
|
+
session: str,
|
|
243
|
+
get_mode: "Callable[[], str]",
|
|
244
|
+
set_mode: "Callable[[str], None]",
|
|
245
|
+
) -> threading.Event:
|
|
246
|
+
"""Watch the daemon's stored mode and announce changes made elsewhere.
|
|
247
|
+
|
|
248
|
+
Runs in a background thread. When the dashboard (or another client)
|
|
249
|
+
switches the mode, *set_mode* applies it locally and the change is
|
|
250
|
+
printed so the terminal user notices. Returns a stop event.
|
|
251
|
+
"""
|
|
252
|
+
stop = threading.Event()
|
|
253
|
+
|
|
254
|
+
def _poll() -> None:
|
|
255
|
+
while not stop.wait(MODE_POLL_SECONDS):
|
|
256
|
+
remote = fetch_current_mode(port, session)
|
|
257
|
+
if remote and remote != get_mode():
|
|
258
|
+
set_mode(remote)
|
|
259
|
+
print(f"(mode changed to: {remote})")
|
|
260
|
+
|
|
261
|
+
threading.Thread(target=_poll, daemon=True).start()
|
|
262
|
+
return stop
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def send_message(
|
|
266
|
+
port: int,
|
|
267
|
+
message: str,
|
|
268
|
+
workspace: str = "",
|
|
269
|
+
session: str = "default",
|
|
270
|
+
mode: str = "learn",
|
|
271
|
+
) -> tuple[str, str, list[dict], str, list[dict]]:
|
|
272
|
+
"""POST ``message`` to the daemon's /chat endpoint.
|
|
273
|
+
|
|
274
|
+
Returns ``(reply, session_id, concepts, teaching, tool_calls_log)`` so
|
|
275
|
+
the caller can track conversation state and print a trace of the tools
|
|
276
|
+
the agent used. Concepts themselves are stored for the dashboard, not
|
|
277
|
+
printed here.
|
|
278
|
+
"""
|
|
279
|
+
body = json.dumps(
|
|
280
|
+
{"message": message, "workspace": workspace, "session": session, "mode": mode}
|
|
281
|
+
).encode("utf-8")
|
|
282
|
+
request = urllib.request.Request(
|
|
283
|
+
chat_url(port),
|
|
284
|
+
data=body,
|
|
285
|
+
headers={"Content-Type": "application/json"},
|
|
286
|
+
method="POST",
|
|
287
|
+
)
|
|
288
|
+
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
|
|
289
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
290
|
+
return (
|
|
291
|
+
str(payload.get("message", "")),
|
|
292
|
+
str(payload.get("session", session)),
|
|
293
|
+
payload.get("concepts", []),
|
|
294
|
+
payload.get("teaching", ""),
|
|
295
|
+
payload.get("tool_calls_log", []),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# Label shown per tool in the activity trace.
|
|
300
|
+
TOOL_LABELS = {
|
|
301
|
+
"read_file": "Read",
|
|
302
|
+
"write_file": "Wrote",
|
|
303
|
+
"edit_file": "Edited",
|
|
304
|
+
"run_command": "Ran",
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _shorten(text: str, limit: int = 60) -> str:
|
|
309
|
+
"""Collapse whitespace and truncate *text* to *limit* characters."""
|
|
310
|
+
text = " ".join(text.split())
|
|
311
|
+
if len(text) > limit:
|
|
312
|
+
return text[: limit - 1] + "…"
|
|
313
|
+
return text
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def print_activity_trace(tool_calls_log: list[dict]) -> None:
|
|
317
|
+
"""Print a summary of the tool calls the coding agent made.
|
|
318
|
+
|
|
319
|
+
Each entry looks like ``{"function": {"name", "arguments"}}`` where
|
|
320
|
+
*arguments* is a JSON string. Entries whose arguments fail to parse
|
|
321
|
+
(e.g. from the text-fallback extractor) are shown without details.
|
|
322
|
+
"""
|
|
323
|
+
if not tool_calls_log:
|
|
324
|
+
return
|
|
325
|
+
print("─" * 8 + " agent activity " + "─" * 8)
|
|
326
|
+
for tc in tool_calls_log:
|
|
327
|
+
fn = tc.get("function", {}) or {}
|
|
328
|
+
name = str(fn.get("name", "?"))
|
|
329
|
+
label = TOOL_LABELS.get(name, name)
|
|
330
|
+
try:
|
|
331
|
+
args = json.loads(fn.get("arguments", "") or "{}")
|
|
332
|
+
except (json.JSONDecodeError, TypeError):
|
|
333
|
+
args = None
|
|
334
|
+
if not isinstance(args, dict):
|
|
335
|
+
print(f" ▸ {label} (no details)")
|
|
336
|
+
continue
|
|
337
|
+
if name in ("read_file", "write_file", "edit_file"):
|
|
338
|
+
detail = _shorten(str(args.get("file_path", "")))
|
|
339
|
+
elif name == "run_command":
|
|
340
|
+
detail = _shorten(str(args.get("command", "")))
|
|
341
|
+
else:
|
|
342
|
+
detail = _shorten(str(fn.get("arguments", "")))
|
|
343
|
+
print(f" ▸ {label} {detail}")
|
|
344
|
+
print("─" * 24)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
NODE_LABELS = {
|
|
348
|
+
"coding_agent": "Coding agent",
|
|
349
|
+
"debug_agent": "Debug agent",
|
|
350
|
+
"detect_concepts": "Detecting concepts",
|
|
351
|
+
"assessment_agent": "Assessment agent",
|
|
352
|
+
"teacher_agent": "Teacher agent",
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def stream_url(port: int) -> str:
|
|
357
|
+
"""Return the daemon's streaming chat endpoint URL for the given port."""
|
|
358
|
+
return f"http://{HOST}:{port}/chat/stream"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _streaming_turn(
|
|
362
|
+
port: int,
|
|
363
|
+
message: str,
|
|
364
|
+
workspace: str,
|
|
365
|
+
session: str,
|
|
366
|
+
mode: str,
|
|
367
|
+
) -> Optional[dict]:
|
|
368
|
+
"""Send *message* to the daemon's SSE endpoint and print events live.
|
|
369
|
+
|
|
370
|
+
Prints ``▸ Read file ✓``-style lines as the agent works. Returns the
|
|
371
|
+
final ``result`` event payload, or ``None`` when the daemon does not
|
|
372
|
+
offer the streaming endpoint (older daemon → caller should fall back
|
|
373
|
+
to the blocking ``send_message``).
|
|
374
|
+
"""
|
|
375
|
+
body = json.dumps(
|
|
376
|
+
{"message": message, "workspace": workspace, "session": session, "mode": mode}
|
|
377
|
+
).encode("utf-8")
|
|
378
|
+
request = urllib.request.Request(
|
|
379
|
+
stream_url(port),
|
|
380
|
+
data=body,
|
|
381
|
+
headers={"Content-Type": "application/json"},
|
|
382
|
+
method="POST",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
result: Optional[dict] = None
|
|
386
|
+
pending = False # a tool_start line is open without its ✓/✗ yet
|
|
387
|
+
got_line = False
|
|
388
|
+
|
|
389
|
+
def _close_pending() -> None:
|
|
390
|
+
nonlocal pending
|
|
391
|
+
if pending:
|
|
392
|
+
print()
|
|
393
|
+
pending = False
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as resp:
|
|
397
|
+
for raw in resp:
|
|
398
|
+
got_line = True
|
|
399
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
400
|
+
if not line.startswith("data:"):
|
|
401
|
+
continue
|
|
402
|
+
try:
|
|
403
|
+
event = json.loads(line[5:].strip())
|
|
404
|
+
except json.JSONDecodeError:
|
|
405
|
+
continue
|
|
406
|
+
etype = event.get("type")
|
|
407
|
+
if etype == "tool_start":
|
|
408
|
+
_close_pending()
|
|
409
|
+
tool = str(event.get("tool", "?"))
|
|
410
|
+
label = TOOL_LABELS.get(tool, tool)
|
|
411
|
+
print(
|
|
412
|
+
f" ▸ {label} {_shorten(str(event.get('detail', '')))}",
|
|
413
|
+
end="",
|
|
414
|
+
flush=True,
|
|
415
|
+
)
|
|
416
|
+
pending = True
|
|
417
|
+
elif etype == "tool_done":
|
|
418
|
+
if pending:
|
|
419
|
+
print(" ✓" if event.get("ok", True) else " ✗")
|
|
420
|
+
pending = False
|
|
421
|
+
elif etype == "node":
|
|
422
|
+
_close_pending()
|
|
423
|
+
node = str(event.get("node", ""))
|
|
424
|
+
label = NODE_LABELS.get(node, node)
|
|
425
|
+
if label:
|
|
426
|
+
print(f"· {label}…")
|
|
427
|
+
elif etype == "status":
|
|
428
|
+
_close_pending()
|
|
429
|
+
msg = str(event.get("message", "")).strip()
|
|
430
|
+
if msg:
|
|
431
|
+
print(f" {msg}")
|
|
432
|
+
elif etype == "result":
|
|
433
|
+
result = event
|
|
434
|
+
elif etype == "error":
|
|
435
|
+
_close_pending()
|
|
436
|
+
print(f"(agent error: {event.get('message', 'unknown')})")
|
|
437
|
+
except urllib.error.HTTPError:
|
|
438
|
+
# Endpoint missing (daemon older than this CLI) — use fallback.
|
|
439
|
+
return None
|
|
440
|
+
except (OSError, ValueError) as exc:
|
|
441
|
+
_close_pending()
|
|
442
|
+
if not got_line:
|
|
443
|
+
return None
|
|
444
|
+
print(f"(stream interrupted: {exc})")
|
|
445
|
+
return result
|
|
446
|
+
_close_pending()
|
|
447
|
+
# A completed stream must not be re-run via the fallback — that would
|
|
448
|
+
# execute the same request twice. Missing ``result`` (e.g. the agent
|
|
449
|
+
# errored) still counts as a finished turn.
|
|
450
|
+
return result if result is not None else {"message": "", "session": session, "concepts": [], "teaching": ""}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
DASHBOARD_OPEN_DELAY_SECONDS = 2.5 # let the welcome screen settle first
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def run_session(port: int) -> None:
|
|
457
|
+
"""Print the banner and loop until the user exits."""
|
|
458
|
+
workspace = os.getcwd()
|
|
459
|
+
state = {"session": "default", "mode": fetch_current_mode(port, "default") or "learn"}
|
|
460
|
+
session = state["session"]
|
|
461
|
+
mode = state["mode"]
|
|
462
|
+
|
|
463
|
+
print(f"Workspace: {workspace}")
|
|
464
|
+
print(f"Mode: {mode}")
|
|
465
|
+
print()
|
|
466
|
+
|
|
467
|
+
# Keep the terminal in sync with mode changes made on the dashboard:
|
|
468
|
+
# the poller prints "(mode changed to: ...)" when that happens.
|
|
469
|
+
stop_mode_poller = start_mode_poller(
|
|
470
|
+
port,
|
|
471
|
+
session,
|
|
472
|
+
get_mode=lambda: state["mode"],
|
|
473
|
+
set_mode=lambda new: state.__setitem__("mode", new),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
while True:
|
|
477
|
+
try:
|
|
478
|
+
line = input("> ")
|
|
479
|
+
except EOFError:
|
|
480
|
+
print()
|
|
481
|
+
break
|
|
482
|
+
text = line.strip()
|
|
483
|
+
if not text:
|
|
484
|
+
continue
|
|
485
|
+
if text.lower() in EXIT_COMMANDS:
|
|
486
|
+
break
|
|
487
|
+
if text.lower() in RESET_COMMANDS:
|
|
488
|
+
session = "default"
|
|
489
|
+
print("(conversation reset)")
|
|
490
|
+
continue
|
|
491
|
+
# Mode switching (pushed to the daemon so the dashboard sees it too)
|
|
492
|
+
if text.lower().startswith("mode "):
|
|
493
|
+
new_mode = text[5:].strip().lower()
|
|
494
|
+
if new_mode in VALID_MODES:
|
|
495
|
+
if push_mode(port, new_mode, session):
|
|
496
|
+
state["mode"] = new_mode
|
|
497
|
+
print(f"(mode: {new_mode})")
|
|
498
|
+
else:
|
|
499
|
+
print("(could not set mode: daemon unreachable)")
|
|
500
|
+
else:
|
|
501
|
+
print(f"(unknown mode: {new_mode})")
|
|
502
|
+
print(f"(valid modes: {', '.join(sorted(VALID_MODES))})")
|
|
503
|
+
continue
|
|
504
|
+
if text.lower() in MODE_COMMANDS:
|
|
505
|
+
print(f"Current mode: {state['mode']}")
|
|
506
|
+
print(f"Available modes: {', '.join(sorted(VALID_MODES))}")
|
|
507
|
+
continue
|
|
508
|
+
try:
|
|
509
|
+
# Read the current mode at turn start so a dashboard-side switch
|
|
510
|
+
# takes effect on the very next message, even mid-poll.
|
|
511
|
+
mode = fetch_current_mode(port, session) or state["mode"]
|
|
512
|
+
state["mode"] = mode
|
|
513
|
+
result = _streaming_turn(
|
|
514
|
+
port, text, workspace=workspace, session=session, mode=mode
|
|
515
|
+
)
|
|
516
|
+
if result is None:
|
|
517
|
+
# Daemon predates the streaming endpoint — fall back.
|
|
518
|
+
reply, session, _concepts, teaching, tool_calls_log = send_message(
|
|
519
|
+
port, text, workspace=workspace, session=session, mode=mode
|
|
520
|
+
)
|
|
521
|
+
result = {
|
|
522
|
+
"message": reply,
|
|
523
|
+
"session": session,
|
|
524
|
+
"concepts": _concepts,
|
|
525
|
+
"teaching": teaching,
|
|
526
|
+
}
|
|
527
|
+
print_activity_trace(tool_calls_log)
|
|
528
|
+
except (OSError, ValueError) as exc:
|
|
529
|
+
print(f"(daemon unreachable: {exc})")
|
|
530
|
+
continue
|
|
531
|
+
reply = result.get("message", "")
|
|
532
|
+
session = result.get("session", session)
|
|
533
|
+
teaching = result.get("teaching", "")
|
|
534
|
+
# Concepts are NOT dumped in the terminal — the daemon persists them
|
|
535
|
+
# and the dashboard polls for them. The teacher agent's teaching
|
|
536
|
+
# message (printed below, when present) tells the user the concepts
|
|
537
|
+
# are available with explanations on the dashboard.
|
|
538
|
+
if teaching:
|
|
539
|
+
print(teaching)
|
|
540
|
+
print(reply)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
544
|
+
"""Dispatch CLI subcommands, or start the interactive chat session.
|
|
545
|
+
|
|
546
|
+
``codelith config ...`` never starts the daemon and never prompts for
|
|
547
|
+
anything — inspection stays side-effect free. With no recognized
|
|
548
|
+
subcommand, the normal chat session runs (banner, daemon, loop).
|
|
549
|
+
"""
|
|
550
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
551
|
+
|
|
552
|
+
# Subcommand dispatch must run BEFORE any banner/daemon work so
|
|
553
|
+
# non-LLM commands (--help-style inspection) stay quiet and fast.
|
|
554
|
+
if argv and argv[0] == "config":
|
|
555
|
+
return config_cmd.main(argv[1:])
|
|
556
|
+
if argv and argv[0] == "setup":
|
|
557
|
+
from backend.llm.key_setup import run_setup
|
|
558
|
+
|
|
559
|
+
return run_setup(argv[1] if len(argv) > 1 else None)
|
|
560
|
+
|
|
561
|
+
# Windows consoles default to cp1252 and raise UnicodeEncodeError on
|
|
562
|
+
# non-Latin-1 output; LLM replies can contain emoji or other Unicode,
|
|
563
|
+
# so force UTF-8 and replace any undisplayable characters.
|
|
564
|
+
for stream in (sys.stdout, sys.stderr):
|
|
565
|
+
try:
|
|
566
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
567
|
+
except (AttributeError, ValueError):
|
|
568
|
+
pass
|
|
569
|
+
print_banner()
|
|
570
|
+
# First-run key setup: after banner, before the daemon/first chat, so
|
|
571
|
+
# the very first session has working providers. No-op (beyond a
|
|
572
|
+
# hint line) when keys exist or the session isn't interactive.
|
|
573
|
+
try:
|
|
574
|
+
from backend.llm.key_setup import ensure_keys_at_startup
|
|
575
|
+
|
|
576
|
+
ensure_keys_at_startup()
|
|
577
|
+
except Exception: # noqa: BLE001 - setup must never block startup
|
|
578
|
+
pass
|
|
579
|
+
try:
|
|
580
|
+
_, port, _ = launcher.start()
|
|
581
|
+
except SystemExit as exc:
|
|
582
|
+
print(f"Could not start the daemon: {exc}", file=sys.stderr)
|
|
583
|
+
return 1
|
|
584
|
+
# The daemon is confirmed serving now (start() polls /health before
|
|
585
|
+
# returning). Print a clickable link immediately, then open the
|
|
586
|
+
# browser a couple of seconds later — a window yanked open instantly
|
|
587
|
+
# can startle first-timers, and the pause lets the welcome screen
|
|
588
|
+
# settle first. The timer runs in a daemon thread: quitting before
|
|
589
|
+
# it fires must never block exit.
|
|
590
|
+
print(f"Dashboard live at http://localhost:{port}/")
|
|
591
|
+
_dashboard_timer = threading.Timer(
|
|
592
|
+
DASHBOARD_OPEN_DELAY_SECONDS, launcher.open_dashboard, args=(port,)
|
|
593
|
+
)
|
|
594
|
+
_dashboard_timer.daemon = True
|
|
595
|
+
_dashboard_timer.start()
|
|
596
|
+
print("Paste this URL in your browser to see the dashboard.")
|
|
597
|
+
try:
|
|
598
|
+
run_session(port)
|
|
599
|
+
except KeyboardInterrupt:
|
|
600
|
+
print()
|
|
601
|
+
print("Session ended - the daemon keeps running in the background.")
|
|
602
|
+
return 0
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
if __name__ == "__main__":
|
|
606
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CodeLith local daemon: FastAPI server, launcher, and state management."""
|