nullabot 1.0.1__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.
- nullabot/__init__.py +3 -0
- nullabot/agents/__init__.py +7 -0
- nullabot/agents/claude_agent.py +785 -0
- nullabot/bot/__init__.py +5 -0
- nullabot/bot/telegram.py +1729 -0
- nullabot/cli.py +740 -0
- nullabot/core/__init__.py +13 -0
- nullabot/core/claude_code.py +303 -0
- nullabot/core/memory.py +864 -0
- nullabot/core/project.py +194 -0
- nullabot/core/rate_limiter.py +484 -0
- nullabot/core/reliability.py +420 -0
- nullabot/core/sandbox.py +143 -0
- nullabot/core/state.py +214 -0
- nullabot-1.0.1.dist-info/METADATA +130 -0
- nullabot-1.0.1.dist-info/RECORD +19 -0
- nullabot-1.0.1.dist-info/WHEEL +4 -0
- nullabot-1.0.1.dist-info/entry_points.txt +2 -0
- nullabot-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Core modules for Nullabot - Uses Claude Code CLI"""
|
|
2
|
+
|
|
3
|
+
from nullabot.core.project import Project, ProjectManager
|
|
4
|
+
from nullabot.core.state import AgentState, Checkpoint
|
|
5
|
+
from nullabot.core.sandbox import Sandbox
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Project",
|
|
9
|
+
"ProjectManager",
|
|
10
|
+
"AgentState",
|
|
11
|
+
"Checkpoint",
|
|
12
|
+
"Sandbox",
|
|
13
|
+
]
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code CLI Client - Uses claude command instead of API.
|
|
3
|
+
|
|
4
|
+
This allows Nullabot to use your Claude Code subscription ($200/month)
|
|
5
|
+
instead of requiring a separate Anthropic API key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ClaudeResponse(BaseModel):
|
|
19
|
+
"""Response from Claude Code CLI."""
|
|
20
|
+
|
|
21
|
+
content: str
|
|
22
|
+
tool_calls: list[dict[str, Any]] = []
|
|
23
|
+
cost_usd: float = 0.0
|
|
24
|
+
duration_ms: int = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClaudeCodeClient:
|
|
28
|
+
"""
|
|
29
|
+
Client that uses Claude Code CLI as the backend.
|
|
30
|
+
|
|
31
|
+
Instead of calling Anthropic API directly, this spawns
|
|
32
|
+
`claude` processes to use your Claude Code subscription.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
working_dir: Optional[Path] = None,
|
|
38
|
+
model: str = "opus", # opus, sonnet, haiku
|
|
39
|
+
max_turns: int = 1,
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
Initialize Claude Code client.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
working_dir: Directory for Claude to work in (sandbox)
|
|
46
|
+
model: Model to use (opus, sonnet, haiku)
|
|
47
|
+
max_turns: Max conversation turns per request
|
|
48
|
+
"""
|
|
49
|
+
self.working_dir = working_dir or Path.cwd()
|
|
50
|
+
self.model = model
|
|
51
|
+
self.max_turns = max_turns
|
|
52
|
+
|
|
53
|
+
# Verify claude is installed
|
|
54
|
+
self._verify_claude_installed()
|
|
55
|
+
|
|
56
|
+
def _verify_claude_installed(self) -> None:
|
|
57
|
+
"""Check if claude CLI is available."""
|
|
58
|
+
try:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
["claude", "--version"],
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
timeout=10,
|
|
64
|
+
)
|
|
65
|
+
if result.returncode != 0:
|
|
66
|
+
raise RuntimeError("claude command failed")
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
"Claude Code CLI not found. Install it first:\n"
|
|
70
|
+
" npm install -g @anthropic-ai/claude-code"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _build_command(
|
|
74
|
+
self,
|
|
75
|
+
prompt: str,
|
|
76
|
+
system_prompt: Optional[str] = None,
|
|
77
|
+
allowed_tools: Optional[list[str]] = None,
|
|
78
|
+
) -> list[str]:
|
|
79
|
+
"""Build the claude command."""
|
|
80
|
+
cmd = [
|
|
81
|
+
"claude",
|
|
82
|
+
"-p", # Print mode (non-interactive)
|
|
83
|
+
"--output-format", "json", # JSON output for parsing
|
|
84
|
+
"--model", self.model,
|
|
85
|
+
"--max-turns", str(self.max_turns),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# Add allowed tools if specified
|
|
89
|
+
if allowed_tools:
|
|
90
|
+
cmd.extend(["--allowedTools", ",".join(allowed_tools)])
|
|
91
|
+
|
|
92
|
+
# Add the prompt
|
|
93
|
+
cmd.append(prompt)
|
|
94
|
+
|
|
95
|
+
return cmd
|
|
96
|
+
|
|
97
|
+
def _parse_response(self, output: str) -> ClaudeResponse:
|
|
98
|
+
"""Parse JSON output from claude CLI."""
|
|
99
|
+
try:
|
|
100
|
+
# Claude outputs JSON with result
|
|
101
|
+
data = json.loads(output)
|
|
102
|
+
|
|
103
|
+
# Extract content from response
|
|
104
|
+
content = ""
|
|
105
|
+
tool_calls = []
|
|
106
|
+
|
|
107
|
+
if isinstance(data, dict):
|
|
108
|
+
# Handle different response formats
|
|
109
|
+
if "result" in data:
|
|
110
|
+
content = data["result"]
|
|
111
|
+
elif "content" in data:
|
|
112
|
+
content = data["content"]
|
|
113
|
+
elif "message" in data:
|
|
114
|
+
content = data.get("message", "")
|
|
115
|
+
else:
|
|
116
|
+
content = str(data)
|
|
117
|
+
|
|
118
|
+
# Extract cost info if available
|
|
119
|
+
cost = data.get("cost_usd", 0.0)
|
|
120
|
+
duration = data.get("duration_ms", 0)
|
|
121
|
+
else:
|
|
122
|
+
content = str(data)
|
|
123
|
+
cost = 0.0
|
|
124
|
+
duration = 0
|
|
125
|
+
|
|
126
|
+
return ClaudeResponse(
|
|
127
|
+
content=content,
|
|
128
|
+
tool_calls=tool_calls,
|
|
129
|
+
cost_usd=cost,
|
|
130
|
+
duration_ms=duration,
|
|
131
|
+
)
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
# If not JSON, treat as plain text
|
|
134
|
+
return ClaudeResponse(content=output.strip())
|
|
135
|
+
|
|
136
|
+
def chat(
|
|
137
|
+
self,
|
|
138
|
+
prompt: str,
|
|
139
|
+
system_prompt: Optional[str] = None,
|
|
140
|
+
allowed_tools: Optional[list[str]] = None,
|
|
141
|
+
timeout: int = 300,
|
|
142
|
+
) -> ClaudeResponse:
|
|
143
|
+
"""
|
|
144
|
+
Send a prompt to Claude Code CLI.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
prompt: The prompt to send
|
|
148
|
+
system_prompt: Optional system prompt (prepended to prompt)
|
|
149
|
+
allowed_tools: List of allowed tools (None = all tools)
|
|
150
|
+
timeout: Timeout in seconds
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
ClaudeResponse with content and metadata
|
|
154
|
+
"""
|
|
155
|
+
# Combine system prompt with user prompt if provided
|
|
156
|
+
full_prompt = prompt
|
|
157
|
+
if system_prompt:
|
|
158
|
+
full_prompt = f"{system_prompt}\n\n---\n\n{prompt}"
|
|
159
|
+
|
|
160
|
+
cmd = self._build_command(full_prompt, system_prompt, allowed_tools)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = subprocess.run(
|
|
164
|
+
cmd,
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
cwd=str(self.working_dir),
|
|
168
|
+
timeout=timeout,
|
|
169
|
+
env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "nullabot"},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if result.returncode != 0:
|
|
173
|
+
error_msg = result.stderr or result.stdout or "Unknown error"
|
|
174
|
+
return ClaudeResponse(content=f"Error: {error_msg}")
|
|
175
|
+
|
|
176
|
+
return self._parse_response(result.stdout)
|
|
177
|
+
|
|
178
|
+
except subprocess.TimeoutExpired:
|
|
179
|
+
return ClaudeResponse(content="Error: Request timed out")
|
|
180
|
+
except Exception as e:
|
|
181
|
+
return ClaudeResponse(content=f"Error: {str(e)}")
|
|
182
|
+
|
|
183
|
+
async def chat_async(
|
|
184
|
+
self,
|
|
185
|
+
prompt: str,
|
|
186
|
+
system_prompt: Optional[str] = None,
|
|
187
|
+
allowed_tools: Optional[list[str]] = None,
|
|
188
|
+
timeout: int = 300,
|
|
189
|
+
) -> ClaudeResponse:
|
|
190
|
+
"""Async version of chat."""
|
|
191
|
+
# Combine system prompt with user prompt if provided
|
|
192
|
+
full_prompt = prompt
|
|
193
|
+
if system_prompt:
|
|
194
|
+
full_prompt = f"{system_prompt}\n\n---\n\n{prompt}"
|
|
195
|
+
|
|
196
|
+
cmd = self._build_command(full_prompt, system_prompt, allowed_tools)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
process = await asyncio.create_subprocess_exec(
|
|
200
|
+
*cmd,
|
|
201
|
+
stdout=asyncio.subprocess.PIPE,
|
|
202
|
+
stderr=asyncio.subprocess.PIPE,
|
|
203
|
+
cwd=str(self.working_dir),
|
|
204
|
+
env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "nullabot"},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
stdout, stderr = await asyncio.wait_for(
|
|
208
|
+
process.communicate(),
|
|
209
|
+
timeout=timeout,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if process.returncode != 0:
|
|
213
|
+
error_msg = stderr.decode() or stdout.decode() or "Unknown error"
|
|
214
|
+
return ClaudeResponse(content=f"Error: {error_msg}")
|
|
215
|
+
|
|
216
|
+
return self._parse_response(stdout.decode())
|
|
217
|
+
|
|
218
|
+
except asyncio.TimeoutError:
|
|
219
|
+
return ClaudeResponse(content="Error: Request timed out")
|
|
220
|
+
except Exception as e:
|
|
221
|
+
return ClaudeResponse(content=f"Error: {str(e)}")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ClaudeCodeAgent:
|
|
225
|
+
"""
|
|
226
|
+
Simplified agent that lets Claude Code do the heavy lifting.
|
|
227
|
+
|
|
228
|
+
Instead of managing tools ourselves, we let Claude Code
|
|
229
|
+
handle file operations directly in the sandboxed workspace.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
workspace: Path,
|
|
235
|
+
model: str = "opus",
|
|
236
|
+
):
|
|
237
|
+
self.workspace = workspace.resolve()
|
|
238
|
+
self.workspace.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
|
|
240
|
+
self.client = ClaudeCodeClient(
|
|
241
|
+
working_dir=self.workspace,
|
|
242
|
+
model=model,
|
|
243
|
+
max_turns=5, # Allow multiple turns for complex tasks
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# State file for persistence
|
|
247
|
+
self.state_file = self.workspace / ".nullabot_state.json"
|
|
248
|
+
|
|
249
|
+
def _load_state(self) -> dict:
|
|
250
|
+
"""Load agent state."""
|
|
251
|
+
if self.state_file.exists():
|
|
252
|
+
return json.loads(self.state_file.read_text())
|
|
253
|
+
return {"history": [], "checkpoint": None}
|
|
254
|
+
|
|
255
|
+
def _save_state(self, state: dict) -> None:
|
|
256
|
+
"""Save agent state."""
|
|
257
|
+
self.state_file.write_text(json.dumps(state, indent=2, default=str))
|
|
258
|
+
|
|
259
|
+
async def run(
|
|
260
|
+
self,
|
|
261
|
+
task: str,
|
|
262
|
+
system_prompt: Optional[str] = None,
|
|
263
|
+
on_output: Optional[callable] = None,
|
|
264
|
+
) -> str:
|
|
265
|
+
"""
|
|
266
|
+
Run a task using Claude Code.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
task: What to do
|
|
270
|
+
system_prompt: Custom system prompt
|
|
271
|
+
on_output: Callback for streaming output
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Final response content
|
|
275
|
+
"""
|
|
276
|
+
state = self._load_state()
|
|
277
|
+
|
|
278
|
+
# Build context from history
|
|
279
|
+
context = ""
|
|
280
|
+
if state.get("checkpoint"):
|
|
281
|
+
context = f"\n\nPrevious progress:\n{state['checkpoint']}\n\nContinue from where you left off.\n"
|
|
282
|
+
|
|
283
|
+
full_task = f"{task}{context}"
|
|
284
|
+
|
|
285
|
+
# Run Claude
|
|
286
|
+
response = await self.client.chat_async(
|
|
287
|
+
prompt=full_task,
|
|
288
|
+
system_prompt=system_prompt,
|
|
289
|
+
allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Update state
|
|
293
|
+
state["history"].append({
|
|
294
|
+
"task": task,
|
|
295
|
+
"response": response.content[:500], # Truncate for storage
|
|
296
|
+
})
|
|
297
|
+
state["checkpoint"] = response.content[:1000]
|
|
298
|
+
self._save_state(state)
|
|
299
|
+
|
|
300
|
+
if on_output:
|
|
301
|
+
on_output(response.content)
|
|
302
|
+
|
|
303
|
+
return response.content
|