nebulacode 0.6.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.
- nebula_code/__init__.py +2 -0
- nebula_code/__main__.py +2 -0
- nebula_code/agent.py +229 -0
- nebula_code/cli.py +519 -0
- nebula_code/config.py +34 -0
- nebula_code/nebulax.py +147 -0
- nebula_code/tools.py +242 -0
- nebula_code/tunnel.py +188 -0
- nebula_code/ui.py +89 -0
- nebulacode-0.6.0.dist-info/METADATA +94 -0
- nebulacode-0.6.0.dist-info/RECORD +13 -0
- nebulacode-0.6.0.dist-info/WHEEL +4 -0
- nebulacode-0.6.0.dist-info/entry_points.txt +2 -0
nebula_code/__init__.py
ADDED
nebula_code/__main__.py
ADDED
nebula_code/agent.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, AsyncIterator, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
SYSTEM_PROMPT = """\
|
|
8
|
+
You are Nebula Code, an expert AI coding assistant running in the terminal.
|
|
9
|
+
You help users read, write, debug, and understand code in any language.
|
|
10
|
+
|
|
11
|
+
You have access to these tools:
|
|
12
|
+
{tools}
|
|
13
|
+
|
|
14
|
+
STRICT OUTPUT FORMAT — every reply must be ONLY a JSON object, no markdown fences, no other text:
|
|
15
|
+
Use a tool: {{"thought": "<why>", "tool": "<name>", "input": "<input>"}}
|
|
16
|
+
Final answer: {{"thought": "<why>", "tool": "done", "input": "<your full response to the user>"}}
|
|
17
|
+
|
|
18
|
+
Coding workflow:
|
|
19
|
+
1. Start by exploring the project (list_files, read_file) before making any changes.
|
|
20
|
+
2. Make changes with write_file or patch_file (prefer patch_file for small edits).
|
|
21
|
+
3. Verify with run_command (tests, linter, build step).
|
|
22
|
+
4. Summarise what you did in the "done" response.
|
|
23
|
+
|
|
24
|
+
If the user asks a simple question, answer immediately with "done".
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
FIX_PROMPT = (
|
|
28
|
+
"Your reply was not valid JSON. Reply with ONLY a JSON object — no backticks, no other text:\n"
|
|
29
|
+
'{"thought": "...", "tool": "<tool or done>", "input": "..."}'
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_json(text: str) -> Optional[dict]:
|
|
34
|
+
text = text.strip()
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(text)
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
pass
|
|
39
|
+
# strip code fences
|
|
40
|
+
stripped = re.sub(r"```(?:json)?\s*|\s*```", "", text).strip()
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(stripped)
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
pass
|
|
45
|
+
# balanced-brace scan
|
|
46
|
+
start = text.find("{")
|
|
47
|
+
if start != -1:
|
|
48
|
+
depth, in_str, esc = 0, False, False
|
|
49
|
+
for i, ch in enumerate(text[start:], start):
|
|
50
|
+
if esc:
|
|
51
|
+
esc = False
|
|
52
|
+
continue
|
|
53
|
+
if ch == "\\" and in_str:
|
|
54
|
+
esc = True
|
|
55
|
+
continue
|
|
56
|
+
if ch == '"':
|
|
57
|
+
in_str = not in_str
|
|
58
|
+
continue
|
|
59
|
+
if in_str:
|
|
60
|
+
continue
|
|
61
|
+
if ch == "{":
|
|
62
|
+
depth += 1
|
|
63
|
+
elif ch == "}":
|
|
64
|
+
depth -= 1
|
|
65
|
+
if depth == 0:
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(text[start : i + 1])
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
break
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class OllamaClient:
|
|
74
|
+
def __init__(self, base_url: str = "http://localhost:11434"):
|
|
75
|
+
self.base_url = base_url.rstrip("/")
|
|
76
|
+
|
|
77
|
+
async def list_models(self) -> List[str]:
|
|
78
|
+
async with httpx.AsyncClient(timeout=10) as c:
|
|
79
|
+
r = await c.get(f"{self.base_url}/api/tags")
|
|
80
|
+
r.raise_for_status()
|
|
81
|
+
return [m["name"] for m in r.json().get("models", [])]
|
|
82
|
+
|
|
83
|
+
async def _stream_raw(
|
|
84
|
+
self,
|
|
85
|
+
model: str,
|
|
86
|
+
messages: List[Dict],
|
|
87
|
+
system: str,
|
|
88
|
+
temperature: float,
|
|
89
|
+
) -> AsyncIterator[str]:
|
|
90
|
+
payload = {
|
|
91
|
+
"model": model,
|
|
92
|
+
"messages": messages,
|
|
93
|
+
"system": system,
|
|
94
|
+
"stream": True,
|
|
95
|
+
"options": {"temperature": temperature},
|
|
96
|
+
}
|
|
97
|
+
async with httpx.AsyncClient(timeout=None) as c:
|
|
98
|
+
async with c.stream("POST", f"{self.base_url}/api/chat", json=payload) as resp:
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
async for line in resp.aiter_lines():
|
|
101
|
+
if not line:
|
|
102
|
+
continue
|
|
103
|
+
data = json.loads(line)
|
|
104
|
+
chunk = data.get("message", {}).get("content", "")
|
|
105
|
+
if chunk:
|
|
106
|
+
yield chunk
|
|
107
|
+
if data.get("done"):
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
async def full_response(
|
|
111
|
+
self,
|
|
112
|
+
model: str,
|
|
113
|
+
messages: List[Dict],
|
|
114
|
+
system: str,
|
|
115
|
+
temperature: float = 0.1,
|
|
116
|
+
) -> str:
|
|
117
|
+
text = ""
|
|
118
|
+
async for chunk in self._stream_raw(model, messages, system, temperature):
|
|
119
|
+
text += chunk
|
|
120
|
+
return text
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Agent:
|
|
124
|
+
"""
|
|
125
|
+
ReAct agent with persistent conversation history.
|
|
126
|
+
|
|
127
|
+
Accepts either an OllamaClient or a NebulaXClient as the LLM backend —
|
|
128
|
+
both expose the same `full_response(model, messages, system, temperature)`
|
|
129
|
+
and `list_models()` interface.
|
|
130
|
+
|
|
131
|
+
Yields events consumed by the CLI:
|
|
132
|
+
{"type": "tool", "name": str, "input": str, "output": str, "thought": str}
|
|
133
|
+
{"type": "done", "text": str, "steps": List[Dict]}
|
|
134
|
+
{"type": "error", "text": str}
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
model: str,
|
|
140
|
+
ollama_url: str = "http://localhost:11434",
|
|
141
|
+
max_steps: int = 20,
|
|
142
|
+
temperature: float = 0.1,
|
|
143
|
+
backend=None, # OllamaClient or NebulaXClient; overrides ollama_url
|
|
144
|
+
) -> None:
|
|
145
|
+
self.model = model
|
|
146
|
+
self.backend = backend if backend is not None else OllamaClient(ollama_url)
|
|
147
|
+
self.max_steps = max_steps
|
|
148
|
+
self.temperature = temperature
|
|
149
|
+
self.history: List[Dict[str, str]] = []
|
|
150
|
+
|
|
151
|
+
async def run(
|
|
152
|
+
self,
|
|
153
|
+
user_message: str,
|
|
154
|
+
tool_funcs: Dict[str, Any],
|
|
155
|
+
tool_descriptions: Dict[str, str],
|
|
156
|
+
) -> AsyncIterator[Dict]:
|
|
157
|
+
tool_list = "\n".join(f" {k}: {v}" for k, v in tool_descriptions.items())
|
|
158
|
+
system = SYSTEM_PROMPT.format(tools=tool_list)
|
|
159
|
+
|
|
160
|
+
self.history.append({"role": "user", "content": user_message})
|
|
161
|
+
messages = list(self.history)
|
|
162
|
+
run_steps: List[Dict] = []
|
|
163
|
+
tools_used: List[str] = []
|
|
164
|
+
|
|
165
|
+
for step_num in range(self.max_steps):
|
|
166
|
+
raw = await self.backend.full_response(
|
|
167
|
+
self.model, messages, system, self.temperature
|
|
168
|
+
)
|
|
169
|
+
parsed = _extract_json(raw)
|
|
170
|
+
|
|
171
|
+
if not parsed:
|
|
172
|
+
fix_msgs = messages + [
|
|
173
|
+
{"role": "assistant", "content": raw or "(empty)"},
|
|
174
|
+
{"role": "user", "content": FIX_PROMPT},
|
|
175
|
+
]
|
|
176
|
+
raw = await self.backend.full_response(
|
|
177
|
+
self.model, fix_msgs, system, self.temperature
|
|
178
|
+
)
|
|
179
|
+
parsed = _extract_json(raw)
|
|
180
|
+
if not parsed:
|
|
181
|
+
yield {"type": "error", "text": f"Invalid JSON from model:\n{raw[:400]}"}
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
messages.append({"role": "assistant", "content": raw})
|
|
185
|
+
|
|
186
|
+
tool_name = parsed.get("tool", "")
|
|
187
|
+
tool_input = str(parsed.get("input", ""))
|
|
188
|
+
thought = parsed.get("thought", "")
|
|
189
|
+
|
|
190
|
+
if tool_name == "done":
|
|
191
|
+
answer = tool_input
|
|
192
|
+
self.history.append({"role": "assistant", "content": answer})
|
|
193
|
+
run_steps.append({"step": step_num + 1, "thought": thought, "tool": "done", "input": tool_input, "output": None})
|
|
194
|
+
yield {
|
|
195
|
+
"type": "done",
|
|
196
|
+
"text": answer,
|
|
197
|
+
"thought": thought,
|
|
198
|
+
"steps": run_steps,
|
|
199
|
+
"tools_used": list(set(tools_used)),
|
|
200
|
+
}
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
if tool_name in tool_funcs:
|
|
204
|
+
try:
|
|
205
|
+
output = tool_funcs[tool_name](tool_input)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
output = f"Tool error: {e}"
|
|
208
|
+
else:
|
|
209
|
+
output = f"Unknown tool '{tool_name}'. Available: {', '.join(tool_funcs)}"
|
|
210
|
+
|
|
211
|
+
tools_used.append(tool_name)
|
|
212
|
+
run_steps.append({
|
|
213
|
+
"step": step_num + 1,
|
|
214
|
+
"thought": thought,
|
|
215
|
+
"tool": tool_name,
|
|
216
|
+
"input": tool_input,
|
|
217
|
+
"output": output,
|
|
218
|
+
})
|
|
219
|
+
yield {
|
|
220
|
+
"type": "tool",
|
|
221
|
+
"name": tool_name,
|
|
222
|
+
"input": tool_input,
|
|
223
|
+
"output": output,
|
|
224
|
+
"thought": thought,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
messages.append({"role": "user", "content": f"Tool result:\n{output}"})
|
|
228
|
+
|
|
229
|
+
yield {"type": "error", "text": "Reached max steps without finishing."}
|