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,166 @@
1
+ """Debug Agent — runs tests, reads errors, fixes code, re-runs.
2
+
3
+ This agent is invoked when tests fail. It enters a loop:
4
+
5
+ Run tests → read error → fix code → run tests → repeat
6
+
7
+ It uses the same tool infrastructure as the coding agent (read_file,
8
+ write_file, edit_file, run_command) but has a tighter system prompt
9
+ focused on debugging and a capped retry count to avoid infinite loops.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Any
16
+
17
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
18
+
19
+ from backend.agents.coding_agent import (
20
+ MAX_OUTPUT_SIZE,
21
+ MAX_TOOL_ROUNDS,
22
+ MAX_WRITE_SIZE,
23
+ COMMAND_TIMEOUT,
24
+ TOOL_DEFINITIONS,
25
+ _create_with_retry,
26
+ _parse_tool_args,
27
+ _read_file,
28
+ _write_file,
29
+ _edit_file,
30
+ _run_command,
31
+ workspace_context,
32
+ )
33
+ from backend.llm.client import (
34
+ AGENT_MAX_TOKENS,
35
+ resolve_agent_api_key,
36
+ get_agent_client,
37
+ )
38
+ from backend.llm.config import get_model
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # System prompt
42
+ # ---------------------------------------------------------------------------
43
+
44
+ SYSTEM_PROMPT = """You are a debug agent. Fix failing tests. Read the error, find the source, fix it, re-run tests. Use tools. Keep replies short."""
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Agent node
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def debug_agent_node(state: dict[str, Any]) -> dict[str, Any]:
52
+ """LangGraph node: run tests, diagnose failures, fix code, re-run.
53
+
54
+ Expects ``state["messages"]`` to contain the prior conversation (the
55
+ coding agent's work and the test output that triggered this node).
56
+ Expects ``state["workspace_root"]`` to be a string path.
57
+
58
+ Returns updated ``messages`` with the debug agent's reply.
59
+ """
60
+ messages: list[BaseMessage] = state.get("messages", [])
61
+ workspace_root: str = state.get("workspace_root", os.getcwd())
62
+
63
+ api_key = resolve_agent_api_key()
64
+ if not api_key:
65
+ reply = (
66
+ "Debug agent needs an OpenRouter API key. Set OPENROUTER_API_KEY "
67
+ "environment variable and try again."
68
+ )
69
+ return {"messages": messages + [AIMessage(content=reply)]}
70
+
71
+ try:
72
+ client = get_agent_client()
73
+ except ValueError as exc:
74
+ return {"messages": messages + [AIMessage(content=str(exc))]}
75
+
76
+ # Build the conversation. The ENVIRONMENT block grounds the agent in
77
+ # the real workspace root and shell, like the coding agent.
78
+ api_messages: list[dict[str, Any]] = [
79
+ {"role": "system", "content": SYSTEM_PROMPT + workspace_context(workspace_root)}
80
+ ]
81
+ for msg in messages:
82
+ if isinstance(msg, HumanMessage):
83
+ api_messages.append({"role": "user", "content": msg.content})
84
+ elif isinstance(msg, AIMessage):
85
+ api_messages.append({"role": "assistant", "content": msg.content})
86
+
87
+ # Tool-use loop — same as coding agent but scoped to debugging.
88
+ for _ in range(MAX_TOOL_ROUNDS):
89
+ try:
90
+ completion = _create_with_retry(client, api_messages)
91
+ except Exception as exc:
92
+ reply_text = f"(LLM error: {exc})"
93
+ return {"messages": messages + [AIMessage(content=reply_text)]}
94
+
95
+ choice = completion.choices[0]
96
+ message = choice.message
97
+
98
+ if message.tool_calls:
99
+ assistant_msg: dict[str, Any] = {
100
+ "role": "assistant",
101
+ "content": message.content,
102
+ "tool_calls": [
103
+ {
104
+ "id": tc.id,
105
+ "type": "function",
106
+ "function": {
107
+ "name": tc.function.name,
108
+ "arguments": tc.function.arguments,
109
+ },
110
+ }
111
+ for tc in message.tool_calls
112
+ ],
113
+ }
114
+ api_messages.append(assistant_msg)
115
+
116
+ for tool_call in message.tool_calls:
117
+ fn = tool_call.function
118
+
119
+ args = _parse_tool_args(fn.arguments)
120
+ if not args:
121
+ result = (
122
+ f"Error: could not parse arguments for '{fn.name}' "
123
+ "as JSON. Re-emit the tool call with valid JSON arguments."
124
+ )
125
+ elif fn.name == "read_file":
126
+ result = _read_file(
127
+ args.get("file_path", ""),
128
+ workspace_root,
129
+ )
130
+ elif fn.name == "write_file":
131
+ result = _write_file(
132
+ args.get("file_path", ""),
133
+ args.get("content", ""),
134
+ workspace_root,
135
+ )
136
+ elif fn.name == "edit_file":
137
+ result = _edit_file(
138
+ args.get("file_path", ""),
139
+ args.get("old_string", ""),
140
+ args.get("new_string", ""),
141
+ workspace_root,
142
+ )
143
+ elif fn.name == "run_command":
144
+ result, _exit_code, _stderr_present = _run_command(
145
+ args.get("command", ""),
146
+ workspace_root,
147
+ )
148
+ else:
149
+ result = f"Error: unknown tool '{fn.name}'."
150
+
151
+ api_messages.append(
152
+ {
153
+ "role": "tool",
154
+ "tool_call_id": tool_call.id,
155
+ "content": result,
156
+ }
157
+ )
158
+ else:
159
+ # No tool calls — final text reply.
160
+ reply_text = message.content or ""
161
+ return {"messages": messages + [AIMessage(content=reply_text)]}
162
+
163
+ # Exhausted tool rounds.
164
+ last_msg = api_messages[-1]
165
+ reply_text = last_msg.get("content", "(debug agent exceeded tool rounds)")
166
+ return {"messages": messages + [AIMessage(content=reply_text)]}
@@ -0,0 +1,179 @@
1
+ """Teacher Agent — saves teaching content and answers user questions.
2
+
3
+ Concept detection no longer happens here: the shared ``detect_concepts``
4
+ node (see :mod:`backend.agents.concept_detector`) runs once per turn and
5
+ writes ``state["concepts_detected"]``. This node reads that shared result
6
+ and turns the new concepts into teaching entries on the dashboard.
7
+
8
+ When the user asks a question in the terminal, the teacher agent answers
9
+ it there directly instead.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
17
+
18
+ from backend.llm.client import resolve_api_key, get_client
19
+ from backend.llm.config import get_model
20
+
21
+ # Shims for backwards compatibility — the detection engine lives in
22
+ # backend.agents.concept_detector now. These names were importable from
23
+ # here before the refactor; keep the re-exports until all call sites
24
+ # have moved over.
25
+ from backend.agents.concept_detector import ( # noqa: F401
26
+ CONCEPT_PATTERNS,
27
+ DetectedConcept,
28
+ detect_concepts_from_file,
29
+ detect_concepts_from_tool_calls,
30
+ detect_concepts_with_llm,
31
+ )
32
+
33
+
34
+ def teacher_agent_node(state: dict[str, Any]) -> dict[str, Any]:
35
+ """LangGraph node: teach the concepts the shared detect_concepts node found.
36
+
37
+ Expects ``state["concepts_detected"]`` — written by the detect_concepts
38
+ node each turn as a list of dicts with ``name``, ``category``,
39
+ ``description``, and ``diagram`` keys.
40
+
41
+ Expects ``state["concepts"]`` for already-known concepts so we don't
42
+ re-teach them.
43
+
44
+ Saves teaching content to the dashboard (via database) rather than
45
+ printing it in the terminal. Only a brief notification is shown
46
+ in the terminal; the full teaching is available on the dashboard.
47
+
48
+ When the user asks a question in the terminal, the teacher agent
49
+ answers there directly.
50
+ """
51
+ from backend.database.concepts import save_teaching
52
+
53
+ messages: list[BaseMessage] = state.get("messages", [])
54
+ concepts: list[dict[str, Any]] = state.get("concepts", [])
55
+ session: str = state.get("session", "default")
56
+
57
+ # Check if this is a user question — if so, answer it in the terminal
58
+ last_user_msg = None
59
+ for msg in reversed(messages):
60
+ if isinstance(msg, HumanMessage):
61
+ last_user_msg = msg
62
+ break
63
+
64
+ if last_user_msg and _is_user_question(last_user_msg.content):
65
+ # User asked a question — answer it directly in the terminal
66
+ answer = _answer_user_question(last_user_msg.content, concepts)
67
+ return {
68
+ "messages": messages + [AIMessage(content=answer)],
69
+ "concepts": concepts,
70
+ }
71
+
72
+ # Concepts detected this turn by the shared detect_concepts node.
73
+ # Already filtered against state["concepts"] during detection, but
74
+ # keep the known-name guard as defense in depth against duplicates.
75
+ detected: list[dict[str, Any]] = state.get("concepts_detected", [])
76
+ known_names: set[str] = {c["name"] for c in concepts}
77
+
78
+ new_concepts: list[dict[str, Any]] = []
79
+ for c in detected:
80
+ if c["name"] not in known_names:
81
+ known_names.add(c["name"])
82
+ new_concepts.append(c)
83
+
84
+ if not new_concepts:
85
+ return {}
86
+
87
+ # Save teaching content to the dashboard (not terminal). The
88
+ # per-concept Mermaid diagram comes straight from the detection
89
+ # result — previously the teacher node re-detected concepts itself
90
+ # and dropped the diagram field, losing the visuals.
91
+ #
92
+ # save_teaching deduplicates by concept identity (slug) + code
93
+ # hash: an unchanged concept re-detected later is a store no-op, so
94
+ # its explanation and diagram stay consistent even when it
95
+ # resurfaces in a different file.
96
+ teaching_entries = []
97
+ for c in new_concepts:
98
+ teaching_entry = {
99
+ "slug": c.get("slug"),
100
+ "concept_name": c["name"],
101
+ "concept_category": c["category"],
102
+ "explanation": c["description"],
103
+ "decision": c.get("decision", ""),
104
+ "source_file": c.get("source_file", ""),
105
+ "diagram": c.get("diagram", ""),
106
+ "content_hash": c.get("content_hash", ""),
107
+ }
108
+ save_teaching(session, teaching_entry)
109
+ teaching_entries.append(teaching_entry)
110
+
111
+ # Brief notification in terminal — full teaching is on the dashboard.
112
+ # Deliberately lists no concept names here: the terminal stays clean and
113
+ # the user is pointed to the dashboard for explanations.
114
+ count = len(new_concepts)
115
+ teaching_text = (
116
+ f"📚 {count} new coding concept(s) detected! "
117
+ f"They're available with explanations on the dashboard "
118
+ f"(Concepts section)."
119
+ )
120
+
121
+ return {
122
+ "messages": messages + [AIMessage(content=teaching_text)],
123
+ "concepts": concepts + new_concepts,
124
+ }
125
+
126
+
127
+ def _is_user_question(text: str) -> bool:
128
+ """Check if the user's message looks like a question about concepts."""
129
+ text_lower = text.lower().strip()
130
+ question_indicators = [
131
+ "what is", "what are", "what does", "what's",
132
+ "how does", "how do", "how to", "how can",
133
+ "why", "when should", "when do", "when to",
134
+ "can you explain", "tell me about", "describe",
135
+ "difference between", "vs", "versus",
136
+ "how does this work", "explain this",
137
+ ]
138
+ return any(indicator in text_lower for indicator in question_indicators)
139
+
140
+
141
+ def _answer_user_question(question: str, concepts: list[dict[str, Any]]) -> str:
142
+ """Answer a user's question using the LLM, with concept context."""
143
+ api_key = resolve_api_key()
144
+ if not api_key:
145
+ return "I need an API key to answer questions. Please set GROQ_API_KEY."
146
+
147
+ # Build context from known concepts
148
+ concept_context = ""
149
+ if concepts:
150
+ concept_lines = []
151
+ for c in concepts[-10:]: # Last 10 concepts for context
152
+ concept_lines.append(
153
+ f"- {c['name']} ({c.get('category', 'General')}): "
154
+ f"{c.get('description', '')}"
155
+ )
156
+ concept_context = "\n".join(concept_lines)
157
+
158
+ system_prompt = (
159
+ "You are a helpful coding teacher. Answer the user's question about "
160
+ "programming concepts. Be clear, concise, and educational. "
161
+ "Use examples when helpful."
162
+ )
163
+
164
+ if concept_context:
165
+ system_prompt += f"\n\nRecent concepts in the user's code:\n{concept_context}"
166
+
167
+ try:
168
+ client = get_client()
169
+ completion = client.chat.completions.create(
170
+ model=get_model("teaching"),
171
+ messages=[
172
+ {"role": "system", "content": system_prompt},
173
+ {"role": "user", "content": question},
174
+ ],
175
+ max_completion_tokens=1024,
176
+ )
177
+ return completion.choices[0].message.content or "I couldn't generate an answer."
178
+ except Exception as exc:
179
+ return f"(Error answering question: {exc})"
@@ -0,0 +1 @@
1
+ """CodeLith command-line interface: interactive CodeLith sessions over the daemon."""
@@ -0,0 +1,135 @@
1
+ """``codelith config`` — inspect and customize model roles.
2
+
3
+ This command group never starts the daemon and never touches the
4
+ network: ``show``, ``set``, and ``unset`` only read/write
5
+ ``~/.codelith/config.toml`` and print current resolution. It exists
6
+ purely for users who *want* to change models; running plain CodeLith
7
+ never creates the config file.
8
+
9
+ Usage::
10
+
11
+ codelith config # show every role + resolved model
12
+ codelith config show # same
13
+ codelith config set <role> <model-slug>
14
+ codelith config unset <role> # fall back to env var / default
15
+
16
+ The exit code is 2 for usage/validation errors so shell scripts can
17
+ distinguish them from plain "nothing to do" output.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import sys
24
+
25
+ from backend.llm import config as llm_config
26
+ from backend.llm.config import MODEL_ROLES, get_model, set_model, unset_model
27
+
28
+
29
+ def _print_show() -> None:
30
+ """Print every role, its override source, and the resolved model."""
31
+ import os
32
+
33
+ path = llm_config.config_path()
34
+ exists = path.exists()
35
+ print(f"Config file: {path}" + ("" if exists else " (not created — using built-in defaults)"))
36
+ print()
37
+ header = f"{'Role':<12} {'Source':<10} Model"
38
+ print(header)
39
+ print("-" * len(header))
40
+ for role in sorted(MODEL_ROLES):
41
+ env_name = MODEL_ROLES[role]["env"]
42
+ if (os.environ.get(env_name) or "").strip():
43
+ source = f"env:{env_name}"
44
+ elif exists and get_model(role) != MODEL_ROLES[role]["default"] and not (
45
+ os.environ.get(env_name) or ""
46
+ ).strip():
47
+ # get_model returned something different from the default and
48
+ # no env var is set, so it must have come from the file.
49
+ source = "config.toml"
50
+ else:
51
+ source = "default"
52
+ print(f"{role:<12} {source:<10} {get_model(role)}")
53
+ print()
54
+ print("Change a model: codelith config set <role> <provider/model>")
55
+ print("Reset a role: codelith config unset <role>")
56
+ print(f"Valid roles: {', '.join(sorted(MODEL_ROLES))}")
57
+
58
+
59
+ def _cmd_show(_args: argparse.Namespace) -> int:
60
+ _print_show()
61
+ return 0
62
+
63
+
64
+ def _cmd_set(args: argparse.Namespace) -> int:
65
+ role, slug = args.role, args.slug
66
+ if role not in MODEL_ROLES:
67
+ print(
68
+ f"Unknown role: {role!r}. Valid roles: {', '.join(sorted(MODEL_ROLES))}",
69
+ file=sys.stderr,
70
+ )
71
+ return 2
72
+ try:
73
+ set_model(role, slug)
74
+ except ValueError as exc:
75
+ print(f"Error: {exc}", file=sys.stderr)
76
+ return 2
77
+ print(f"{role} = {slug}")
78
+ print(f"Saved to {llm_config.config_path()}")
79
+ return 0
80
+
81
+
82
+ def _cmd_unset(args: argparse.Namespace) -> int:
83
+ role = args.role
84
+ if role not in MODEL_ROLES:
85
+ print(
86
+ f"Unknown role: {role!r}. Valid roles: {', '.join(sorted(MODEL_ROLES))}",
87
+ file=sys.stderr,
88
+ )
89
+ return 2
90
+ if unset_model(role):
91
+ print(f"{role} reset (now follows env var / built-in default).")
92
+ else:
93
+ print(f"{role} has no override in {llm_config.config_path()}; nothing to unset.")
94
+ return 0
95
+
96
+
97
+ def build_parser() -> argparse.ArgumentParser:
98
+ """Return the ``codelith config`` argument parser (with subcommands)."""
99
+ parser = argparse.ArgumentParser(
100
+ prog="codelith config",
101
+ description="Inspect or customize per-role model choices. "
102
+ "Never starts the daemon; never creates config.toml unless you set a model.",
103
+ )
104
+ sub = parser.add_subparsers(dest="config_command")
105
+
106
+ sub.add_parser("show", help="show every role and its resolved model")
107
+
108
+ p_set = sub.add_parser("set", help="set the model for one role")
109
+ p_set.add_argument("role", help=f"model role ({', '.join(sorted(MODEL_ROLES))})")
110
+ p_set.add_argument("slug", help='model slug, e.g. "qwen/qwen3-coder-next" or "openai/gpt-oss-120b"')
111
+
112
+ p_unset = sub.add_parser("unset", help="remove a role's override")
113
+ p_unset.add_argument("role", help="model role to reset")
114
+
115
+ # `codelith config` with no subcommand → show.
116
+ parser.set_defaults(config_command="show", handler=_cmd_show)
117
+ sub.choices["show"].set_defaults(handler=_cmd_show) # type: ignore[index]
118
+ sub.choices["set"].set_defaults(handler=_cmd_set) # type: ignore[index]
119
+ sub.choices["unset"].set_defaults(handler=_cmd_unset) # type: ignore[index]
120
+ return parser
121
+
122
+
123
+ def main(argv: list[str] | None = None) -> int:
124
+ """Entry point for ``codelith config ...``."""
125
+ parser = build_parser()
126
+ args = parser.parse_args(argv)
127
+ handler = getattr(args, "handler", None)
128
+ if handler is None: # pragma: no cover - set_defaults makes this unreachable
129
+ parser.print_help()
130
+ return 2
131
+ return int(handler(args))
132
+
133
+
134
+ if __name__ == "__main__": # pragma: no cover
135
+ raise SystemExit(main())