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.
@@ -0,0 +1,2 @@
1
+ """Nebula Code — AI coding assistant for the terminal."""
2
+ __version__ = "0.6.0"
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ main()
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."}