codelith 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/__init__.py +1 -0
- backend/agents/__init__.py +6 -0
- backend/agents/assessment_agent.py +273 -0
- backend/agents/coding_agent.py +779 -0
- backend/agents/concept_categories.py +131 -0
- backend/agents/concept_detector.py +1217 -0
- backend/agents/debug_agent.py +166 -0
- backend/agents/teacher_agent.py +179 -0
- backend/cli/__init__.py +1 -0
- backend/cli/config_cmd.py +135 -0
- backend/cli/main.py +606 -0
- backend/daemon/__init__.py +1 -0
- backend/daemon/launcher.py +243 -0
- backend/daemon/server.py +453 -0
- backend/daemon/state.py +110 -0
- backend/daemon/static/assets/Gambarino-Regular-BjbcsURA.otf +0 -0
- backend/daemon/static/assets/abnfDiagram-VCTEODGH-CCJBE2aE.js +1 -0
- backend/daemon/static/assets/arc-BEvzHx4o.js +1 -0
- backend/daemon/static/assets/architecture-7GRP2DOG-DaWrPggL.js +1 -0
- backend/daemon/static/assets/architectureDiagram-5GKGNRK7-pR-klcZv.js +36 -0
- backend/daemon/static/assets/array-BifhSqXX.js +1 -0
- backend/daemon/static/assets/blockDiagram-I7D4REHJ-C504Gj6_.js +129 -0
- backend/daemon/static/assets/c4Diagram-7LVT6UL2-BjM04Mni.js +38 -0
- backend/daemon/static/assets/channel-DzSauwD3.js +1 -0
- backend/daemon/static/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- backend/daemon/static/assets/chunk-4HAMMTFA-EgoP78tp.js +62 -0
- backend/daemon/static/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- backend/daemon/static/assets/chunk-75Z2AOVW-EXNbuzun.js +2 -0
- backend/daemon/static/assets/chunk-DU6HZSFF-CF3OK3MZ.js +127 -0
- backend/daemon/static/assets/chunk-F27PBJKO-G71ylWJa.js +1 -0
- backend/daemon/static/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
- backend/daemon/static/assets/chunk-GMAD6QVW-2yfGg28o.js +72 -0
- backend/daemon/static/assets/chunk-GVQU2GXP-C_VeaX4U.js +1 -0
- backend/daemon/static/assets/chunk-IMKFNOWR-CNexRjjn.js +231 -0
- backend/daemon/static/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- backend/daemon/static/assets/chunk-P2QGCYS3-E4AByfsD.js +1 -0
- backend/daemon/static/assets/chunk-POPQ4Y6H-Bisbc2-3.js +1 -0
- backend/daemon/static/assets/chunk-PWAF6VOD-DaoPxZAa.js +1 -0
- backend/daemon/static/assets/chunk-SHT3W25Y-DarPToto.js +168 -0
- backend/daemon/static/assets/chunk-SVP7TREG-DvMOAiwI.js +88 -0
- backend/daemon/static/assets/chunk-TICWLB2K-DheuvyGM.js +206 -0
- backend/daemon/static/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- backend/daemon/static/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- backend/daemon/static/assets/classDiagram-ZZMXUADV-Ys5zkCXW.js +1 -0
- backend/daemon/static/assets/classDiagram-v2-VYDZK3BY-Ys5zkCXW.js +1 -0
- backend/daemon/static/assets/cose-bilkent-JH36ORCC-DLPLnxrP.js +1 -0
- backend/daemon/static/assets/cynefin-OW5HDTMX-Dv1OY_0y.js +1 -0
- backend/daemon/static/assets/cynefinDiagram-5FMLGOSQ-Ur7MTCmF.js +62 -0
- backend/daemon/static/assets/cytoscape.esm-CECbKnxF.js +321 -0
- backend/daemon/static/assets/dagre-CJLTJMFW.js +1 -0
- backend/daemon/static/assets/dagre-GXQ25YYZ-R3BwTvng.js +4 -0
- backend/daemon/static/assets/defaultLocale-BFoDCU3G.js +1 -0
- backend/daemon/static/assets/diagram-S7CK7UJ4-BxIoEKb4.js +30 -0
- backend/daemon/static/assets/diagram-UQ7AKVKN-DO4cuWN-.js +41 -0
- backend/daemon/static/assets/diagram-VSXAHHWV-DW5imp5t.js +3 -0
- backend/daemon/static/assets/diagram-VX7I27RA-CdZ3k7wQ.js +24 -0
- backend/daemon/static/assets/diagram-Z3DM3KII-DPyjbneL.js +24 -0
- backend/daemon/static/assets/dist-DTg6UBE_.js +1 -0
- backend/daemon/static/assets/ebnfDiagram-PWID7BFC-BO7VQsye.js +1 -0
- backend/daemon/static/assets/erDiagram-RLTQ6QDP-CevvjECq.js +99 -0
- backend/daemon/static/assets/eventmodeling-NTZA5JFV-yNfKR6-v.js +1 -0
- backend/daemon/static/assets/flowDiagram-HODETNUW-B4GT41mU.js +1 -0
- backend/daemon/static/assets/ganttDiagram-EL5Y4UJY-DNW5fWw1.js +292 -0
- backend/daemon/static/assets/gitGraph-4MIJSDKK-DKgVkWaZ.js +1 -0
- backend/daemon/static/assets/gitGraphDiagram-WWUBYQGX-0S7OF9Aj.js +106 -0
- backend/daemon/static/assets/index-D3vj8REa.js +63 -0
- backend/daemon/static/assets/index-D4lMFaiv.css +1 -0
- backend/daemon/static/assets/info-A6RAGUB7-Bxy-SzRN.js +1 -0
- backend/daemon/static/assets/infoDiagram-27XIBGKW-ClzQji6X.js +2 -0
- backend/daemon/static/assets/init-C-OQMol4.js +1 -0
- backend/daemon/static/assets/ishikawaDiagram-5VMMS53U-B3Lo-sS3.js +70 -0
- backend/daemon/static/assets/journeyDiagram-3NMN7TZE-0KL6R2Rz.js +139 -0
- backend/daemon/static/assets/kanban-definition-UXKFOSKX-zt5NbEep.js +89 -0
- backend/daemon/static/assets/katex-CXMH3UgJ.js +257 -0
- backend/daemon/static/assets/line-CiAFRJVJ.js +1 -0
- backend/daemon/static/assets/linear-BI6yqEPV.js +1 -0
- backend/daemon/static/assets/logo_darkmode-BPDdj6GZ.png +0 -0
- backend/daemon/static/assets/logo_lightmode-C3ZWMgAH.png +0 -0
- backend/daemon/static/assets/mermaid-parser.core-DEadI1Ja.js +7 -0
- backend/daemon/static/assets/mindmap-definition-YA3MSWOX-TGKGYg5n.js +96 -0
- backend/daemon/static/assets/ordinal-BDEzSJ7C.js +1 -0
- backend/daemon/static/assets/packet-AYTQ26CC-CZTSuh5x.js +1 -0
- backend/daemon/static/assets/path-fybaL0A-.js +1 -0
- backend/daemon/static/assets/pegDiagram-XKGWAZYB-DGd8LACA.js +1 -0
- backend/daemon/static/assets/pie-WAS4IAKB-B59sPr3Z.js +1 -0
- backend/daemon/static/assets/pieDiagram-E7YTZNPT-CpwxCR3L.js +39 -0
- backend/daemon/static/assets/quadrantDiagram-AXDQQJYC-BwSeF_E_.js +7 -0
- backend/daemon/static/assets/radar-RG4KPBEZ-DAa4JvTb.js +1 -0
- backend/daemon/static/assets/railroad-74A4TZTK-BitdNgDt.js +1 -0
- backend/daemon/static/assets/railroad-abnf-HS5TGJTU-DCrNKqAH.js +1 -0
- backend/daemon/static/assets/railroad-ebnf-LZEXJU2U-DmEwx8OK.js +1 -0
- backend/daemon/static/assets/railroad-peg-WCYAUIDC-CPc8dTCP.js +1 -0
- backend/daemon/static/assets/railroadDiagram-O6MQD6OU-DuizuzwD.js +1 -0
- backend/daemon/static/assets/requirementDiagram-BXWQKSXE-BjMk0yS8.js +84 -0
- backend/daemon/static/assets/rough.esm-Dy-Kn_BL.js +1 -0
- backend/daemon/static/assets/sankeyDiagram-P5KCCOFB-0T_bhkmz.js +40 -0
- backend/daemon/static/assets/sequenceDiagram-WJ2MYXX4-Cwa-1Stp.js +162 -0
- backend/daemon/static/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- backend/daemon/static/assets/src-BH-TyZbA.js +1 -0
- backend/daemon/static/assets/stateDiagram-D77RDMKH-BpQSg_QL.js +1 -0
- backend/daemon/static/assets/stateDiagram-v2-MP3YSRHH-BItVXKof.js +1 -0
- backend/daemon/static/assets/swimlanes-42K2YHIH-h_ED18Vy.js +1 -0
- backend/daemon/static/assets/swimlanesDiagram-VR7AAH4N-D0fo0LN-.js +8 -0
- backend/daemon/static/assets/timeline-definition-24CTP7MA-DKfSO33a.js +120 -0
- backend/daemon/static/assets/treeView-Q6P3EWNA-DAj9fxfC.js +1 -0
- backend/daemon/static/assets/treemap-WGGIJYW6-5IIXD9Zu.js +1 -0
- backend/daemon/static/assets/vennDiagram-4TSXK5OY-BoBvVEci.js +34 -0
- backend/daemon/static/assets/wardley-WFR3VGLG-CGsd7s_-.js +1 -0
- backend/daemon/static/assets/wardleyDiagram-VM6X3IG4-QHdK5NsY.js +78 -0
- backend/daemon/static/assets/xychartDiagram-S5SC5T6Z-MN_fdKCJ.js +7 -0
- backend/daemon/static/index.html +49 -0
- backend/database/__init__.py +1 -0
- backend/database/concept_slug.py +39 -0
- backend/database/concepts.py +804 -0
- backend/llm/__init__.py +5 -0
- backend/llm/client.py +333 -0
- backend/llm/config.py +254 -0
- backend/llm/key_setup.py +237 -0
- backend/main.py +13 -0
- backend/orchestrator/__init__.py +1 -0
- backend/orchestrator/events.py +52 -0
- backend/orchestrator/graph.py +316 -0
- backend/orchestrator/modes.py +125 -0
- codelith-0.1.0.dist-info/METADATA +301 -0
- codelith-0.1.0.dist-info/RECORD +129 -0
- codelith-0.1.0.dist-info/WHEEL +5 -0
- codelith-0.1.0.dist-info/entry_points.txt +2 -0
- codelith-0.1.0.dist-info/licenses/LICENSE +21 -0
- codelith-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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})"
|
backend/cli/__init__.py
ADDED
|
@@ -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())
|