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
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."""