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,785 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code Agent - Uses Claude Code CLI for autonomous work.
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
- Memory system (long-term + short-term like ChatGPT)
|
|
6
|
+
- Agent handoff (designer sees what thinker did)
|
|
7
|
+
- Cost tracking per project
|
|
8
|
+
- 30-minute timeout for complex tasks
|
|
9
|
+
- Clean folder structure (each agent has its own folder)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import signal
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Optional
|
|
19
|
+
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.markdown import Markdown
|
|
23
|
+
|
|
24
|
+
from nullabot.core.memory import ProjectMemory, UsageTracker, UserMemory
|
|
25
|
+
from nullabot.core.rate_limiter import ClaudeCodeRateLimiter
|
|
26
|
+
from nullabot.core.reliability import ExitDetector, ProgressTracker
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
# Timeout: 30 minutes (was 10)
|
|
31
|
+
DEFAULT_TIMEOUT = 1800
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ClaudeAgent:
|
|
35
|
+
"""
|
|
36
|
+
Agent that uses Claude Code CLI directly.
|
|
37
|
+
|
|
38
|
+
Each agent works in its own subdirectory:
|
|
39
|
+
project/
|
|
40
|
+
thinker/ - Research & ideation output
|
|
41
|
+
designer/ - UI/UX design output
|
|
42
|
+
coder/ - Source code output
|
|
43
|
+
.nullabot/ - Shared state, memory, usage
|
|
44
|
+
|
|
45
|
+
Features:
|
|
46
|
+
- Uses your Claude Code subscription (no API key needed)
|
|
47
|
+
- Memory system for context sharing between agents
|
|
48
|
+
- Cost tracking per project
|
|
49
|
+
- Automatic handoff context from previous agents
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
PROMPTS = {
|
|
53
|
+
"thinker": """You are a Research & Ideation specialist working autonomously.
|
|
54
|
+
|
|
55
|
+
Your task: {task}
|
|
56
|
+
|
|
57
|
+
{context}
|
|
58
|
+
|
|
59
|
+
## Your Workspace
|
|
60
|
+
You are working in the `thinker/` folder of this project.
|
|
61
|
+
Write all your output files here.
|
|
62
|
+
{previous_work_hint}
|
|
63
|
+
|
|
64
|
+
## Instructions
|
|
65
|
+
1. Think deeply about this topic
|
|
66
|
+
2. Research and explore multiple angles
|
|
67
|
+
3. Write your findings to files:
|
|
68
|
+
- research/ - Research notes and findings
|
|
69
|
+
- ideas/ - Brainstormed ideas
|
|
70
|
+
- analysis/ - Detailed analysis
|
|
71
|
+
- recommendations.md - Final recommendations
|
|
72
|
+
|
|
73
|
+
Work continuously. After each major step, summarize your progress.
|
|
74
|
+
Focus on creating valuable, well-organized output files.
|
|
75
|
+
|
|
76
|
+
IMPORTANT:
|
|
77
|
+
- At the end of your response, include a STATUS line like:
|
|
78
|
+
STATUS: Working on [current task] | Files: [count] | Progress: [percentage]%
|
|
79
|
+
- Also include a SUMMARY line with what you accomplished:
|
|
80
|
+
SUMMARY: [Brief description of what you did this cycle]""",
|
|
81
|
+
|
|
82
|
+
"designer": """You are a UI/UX Design specialist working autonomously.
|
|
83
|
+
|
|
84
|
+
Your task: {task}
|
|
85
|
+
|
|
86
|
+
{context}
|
|
87
|
+
|
|
88
|
+
## Your Workspace
|
|
89
|
+
You are working in the `designer/` folder of this project.
|
|
90
|
+
Write all your output files here.
|
|
91
|
+
|
|
92
|
+
{previous_work_hint}
|
|
93
|
+
|
|
94
|
+
## Instructions
|
|
95
|
+
1. FIRST: Read the thinker's work in `../thinker/` folder if it exists
|
|
96
|
+
2. Analyze the design requirements based on research findings
|
|
97
|
+
3. Create detailed component specifications
|
|
98
|
+
4. Define design tokens and standards
|
|
99
|
+
5. Write everything to files:
|
|
100
|
+
- components/ - Component specs (one file per component)
|
|
101
|
+
- patterns/ - UI pattern documentation
|
|
102
|
+
- tokens/ - Design tokens (colors, spacing, typography)
|
|
103
|
+
- design-system.md - Master design system document
|
|
104
|
+
|
|
105
|
+
IMPORTANT:
|
|
106
|
+
- At the end of your response, include a STATUS line like:
|
|
107
|
+
STATUS: Working on [current task] | Files: [count] | Progress: [percentage]%
|
|
108
|
+
- Also include a SUMMARY line with what you accomplished:
|
|
109
|
+
SUMMARY: [Brief description of what you did this cycle]""",
|
|
110
|
+
|
|
111
|
+
"coder": """You are a Software Engineer working autonomously.
|
|
112
|
+
|
|
113
|
+
Your task: {task}
|
|
114
|
+
|
|
115
|
+
{context}
|
|
116
|
+
|
|
117
|
+
## Your Workspace
|
|
118
|
+
You are working in the `coder/` folder of this project.
|
|
119
|
+
Write all your output files here.
|
|
120
|
+
|
|
121
|
+
{previous_work_hint}
|
|
122
|
+
|
|
123
|
+
## Instructions
|
|
124
|
+
1. FIRST: Read previous work:
|
|
125
|
+
- `../thinker/` - Research and requirements
|
|
126
|
+
- `../designer/` - UI/UX specs and components
|
|
127
|
+
2. Understand the requirements and design decisions already made
|
|
128
|
+
3. Plan the architecture based on previous work
|
|
129
|
+
4. Write clean, production-ready code
|
|
130
|
+
5. Create comprehensive tests
|
|
131
|
+
6. Organize files properly:
|
|
132
|
+
- src/ - Source code
|
|
133
|
+
- tests/ - Test files
|
|
134
|
+
- docs/ - Documentation
|
|
135
|
+
- ARCHITECTURE.md - Design decisions
|
|
136
|
+
|
|
137
|
+
IMPORTANT:
|
|
138
|
+
- At the end of your response, include a STATUS line like:
|
|
139
|
+
STATUS: Working on [current task] | Files: [count] | Progress: [percentage]%
|
|
140
|
+
- Also include a SUMMARY line with what you accomplished:
|
|
141
|
+
SUMMARY: [Brief description of what you did this cycle]""",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
workspace: Path,
|
|
147
|
+
agent_type: str = "thinker",
|
|
148
|
+
model: str = "opus",
|
|
149
|
+
on_notify: Optional[Callable[[str, str, dict], Any]] = None,
|
|
150
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
151
|
+
user_id: int = None,
|
|
152
|
+
base_dir: Path = None,
|
|
153
|
+
):
|
|
154
|
+
"""
|
|
155
|
+
Initialize Claude Agent.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
workspace: Project directory (not agent-specific)
|
|
159
|
+
agent_type: Type of agent (thinker, designer, coder)
|
|
160
|
+
model: Model to use (opus, sonnet, haiku)
|
|
161
|
+
on_notify: Callback for notifications (event_type, message, data)
|
|
162
|
+
timeout: Timeout in seconds (default 30 min)
|
|
163
|
+
user_id: User ID for user-level memory
|
|
164
|
+
base_dir: Base directory (nullabot repo root) for user memory
|
|
165
|
+
"""
|
|
166
|
+
# Project-level paths
|
|
167
|
+
self.project_dir = workspace.resolve()
|
|
168
|
+
self.project_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
|
|
170
|
+
# Agent-specific workspace (project/agent_type/)
|
|
171
|
+
self.agent_type = agent_type
|
|
172
|
+
self.workspace = self.project_dir / agent_type
|
|
173
|
+
self.workspace.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
|
|
175
|
+
self.model = model
|
|
176
|
+
self.on_notify = on_notify
|
|
177
|
+
self.timeout = timeout
|
|
178
|
+
self.user_id = user_id
|
|
179
|
+
|
|
180
|
+
# Shared state at project level (.nullabot/)
|
|
181
|
+
self.state_dir = self.project_dir / ".nullabot"
|
|
182
|
+
self.state_dir.mkdir(exist_ok=True)
|
|
183
|
+
|
|
184
|
+
# Agent-specific state file
|
|
185
|
+
self.state_file = self.state_dir / f"state_{agent_type}.json"
|
|
186
|
+
self.log_file = self.state_dir / f"log_{agent_type}.jsonl"
|
|
187
|
+
|
|
188
|
+
# Base dir (nullabot repo root, default: grandparent of project)
|
|
189
|
+
self.base_dir = base_dir or self.project_dir.parent.parent
|
|
190
|
+
|
|
191
|
+
# Shared memory and usage at project level
|
|
192
|
+
self.memory = ProjectMemory(self.project_dir)
|
|
193
|
+
self.usage = UsageTracker(self.project_dir, self.base_dir)
|
|
194
|
+
|
|
195
|
+
# User-level memory (at {base_dir}/users/{id}/)
|
|
196
|
+
self.user_memory = UserMemory(self.base_dir, user_id) if user_id else None
|
|
197
|
+
|
|
198
|
+
# Control flags
|
|
199
|
+
self._should_stop = False
|
|
200
|
+
self._is_running = False
|
|
201
|
+
|
|
202
|
+
# Rate limiter - tracks 5-hour and weekly limits
|
|
203
|
+
self.rate_limiter = ClaudeCodeRateLimiter(
|
|
204
|
+
plan="max_200",
|
|
205
|
+
auto_wait=False, # Don't auto-wait, exit gracefully instead
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Exit detector - tracks when to stop
|
|
209
|
+
self.exit_detector = ExitDetector(
|
|
210
|
+
max_cycles=100,
|
|
211
|
+
max_cycles_no_progress=5,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Progress tracker
|
|
215
|
+
self.progress_tracker = ProgressTracker()
|
|
216
|
+
|
|
217
|
+
# Signal handlers
|
|
218
|
+
signal.signal(signal.SIGINT, self._handle_signal)
|
|
219
|
+
signal.signal(signal.SIGTERM, self._handle_signal)
|
|
220
|
+
|
|
221
|
+
def _handle_signal(self, signum: int, frame: Any) -> None:
|
|
222
|
+
"""Handle shutdown signals."""
|
|
223
|
+
console.print("\n[yellow]Stopping agent gracefully...[/yellow]")
|
|
224
|
+
self._should_stop = True
|
|
225
|
+
self._notify("stop_requested", "🛑 Stop requested, saving state...")
|
|
226
|
+
|
|
227
|
+
async def _notify(self, event: str, message: str, data: dict = None) -> None:
|
|
228
|
+
"""Send notification via callback."""
|
|
229
|
+
if self.on_notify:
|
|
230
|
+
try:
|
|
231
|
+
result = self.on_notify(event, message, data or {})
|
|
232
|
+
if asyncio.iscoroutine(result):
|
|
233
|
+
await result
|
|
234
|
+
except Exception as e:
|
|
235
|
+
console.print(f"[dim]Notification error: {e}[/dim]")
|
|
236
|
+
|
|
237
|
+
def _load_state(self) -> dict:
|
|
238
|
+
"""Load agent state from disk."""
|
|
239
|
+
if self.state_file.exists():
|
|
240
|
+
try:
|
|
241
|
+
return json.loads(self.state_file.read_text())
|
|
242
|
+
except:
|
|
243
|
+
pass
|
|
244
|
+
return {
|
|
245
|
+
"task": None,
|
|
246
|
+
"status": "idle",
|
|
247
|
+
"cycles": 0,
|
|
248
|
+
"files_created": [],
|
|
249
|
+
"last_checkpoint": None,
|
|
250
|
+
"started_at": None,
|
|
251
|
+
"total_time_seconds": 0,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def _save_state(self, state: dict) -> None:
|
|
255
|
+
"""Save agent state to disk."""
|
|
256
|
+
state["updated_at"] = datetime.now().isoformat()
|
|
257
|
+
self.state_file.write_text(json.dumps(state, indent=2))
|
|
258
|
+
|
|
259
|
+
def _log(self, event: str, data: dict = None) -> None:
|
|
260
|
+
"""Append to log file."""
|
|
261
|
+
entry = {
|
|
262
|
+
"timestamp": datetime.now().isoformat(),
|
|
263
|
+
"event": event,
|
|
264
|
+
"data": data or {},
|
|
265
|
+
}
|
|
266
|
+
with open(self.log_file, "a") as f:
|
|
267
|
+
f.write(json.dumps(entry) + "\n")
|
|
268
|
+
|
|
269
|
+
def _count_files(self) -> tuple[int, list[str]]:
|
|
270
|
+
"""Count files in workspace (excluding .nullabot)."""
|
|
271
|
+
files = []
|
|
272
|
+
for f in self.workspace.rglob("*"):
|
|
273
|
+
if f.is_file() and ".nullabot" not in str(f):
|
|
274
|
+
rel = str(f.relative_to(self.workspace))
|
|
275
|
+
files.append(rel)
|
|
276
|
+
return len(files), files[:20]
|
|
277
|
+
|
|
278
|
+
def _extract_status(self, response: str) -> Optional[str]:
|
|
279
|
+
"""Extract STATUS line from response."""
|
|
280
|
+
for line in response.split("\n"):
|
|
281
|
+
if line.strip().startswith("STATUS:"):
|
|
282
|
+
return line.strip()
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
def _extract_summary(self, response: str) -> Optional[str]:
|
|
286
|
+
"""Extract SUMMARY line from response."""
|
|
287
|
+
for line in response.split("\n"):
|
|
288
|
+
if line.strip().startswith("SUMMARY:"):
|
|
289
|
+
return line.strip().replace("SUMMARY:", "").strip()
|
|
290
|
+
# Fallback: first 200 chars
|
|
291
|
+
return response[:200].replace("\n", " ").strip()
|
|
292
|
+
|
|
293
|
+
def _make_progress_bar(self, percentage: float, width: int = 10) -> str:
|
|
294
|
+
"""Create a text progress bar for notifications."""
|
|
295
|
+
filled = int(width * percentage / 100)
|
|
296
|
+
empty = width - filled
|
|
297
|
+
bar = "█" * filled + "░" * empty
|
|
298
|
+
return f"[{bar}]"
|
|
299
|
+
|
|
300
|
+
async def _run_claude_async(
|
|
301
|
+
self,
|
|
302
|
+
prompt: str,
|
|
303
|
+
) -> tuple[str, bool]:
|
|
304
|
+
"""Run Claude Code CLI asynchronously."""
|
|
305
|
+
cmd = [
|
|
306
|
+
"claude",
|
|
307
|
+
"-p",
|
|
308
|
+
"--output-format", "text",
|
|
309
|
+
"--model", self.model,
|
|
310
|
+
"--max-turns", "50",
|
|
311
|
+
"--dangerously-skip-permissions",
|
|
312
|
+
prompt,
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
process = await asyncio.create_subprocess_exec(
|
|
317
|
+
*cmd,
|
|
318
|
+
stdout=asyncio.subprocess.PIPE,
|
|
319
|
+
stderr=asyncio.subprocess.PIPE,
|
|
320
|
+
cwd=str(self.workspace),
|
|
321
|
+
env={
|
|
322
|
+
**os.environ,
|
|
323
|
+
"CLAUDE_CODE_ENTRYPOINT": "nullabot",
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
stdout, stderr = await asyncio.wait_for(
|
|
328
|
+
process.communicate(),
|
|
329
|
+
timeout=self.timeout,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
output = stdout.decode().strip()
|
|
333
|
+
if process.returncode != 0:
|
|
334
|
+
error = stderr.decode() or "Unknown error"
|
|
335
|
+
return f"Error: {error}", False
|
|
336
|
+
|
|
337
|
+
return output, True
|
|
338
|
+
|
|
339
|
+
except asyncio.TimeoutError:
|
|
340
|
+
timeout_min = self.timeout // 60
|
|
341
|
+
return f"Error: Claude timed out after {timeout_min} minutes", False
|
|
342
|
+
except Exception as e:
|
|
343
|
+
return f"Error: {str(e)}", False
|
|
344
|
+
|
|
345
|
+
def _get_previous_work_hint(self) -> str:
|
|
346
|
+
"""Get hint about where to find previous agents' work."""
|
|
347
|
+
hints = []
|
|
348
|
+
|
|
349
|
+
# Check what other agents have done
|
|
350
|
+
if self.agent_type == "designer":
|
|
351
|
+
thinker_dir = self.project_dir / "thinker"
|
|
352
|
+
if thinker_dir.exists() and any(thinker_dir.iterdir()):
|
|
353
|
+
hints.append("📂 Thinker's research is available in `../thinker/`")
|
|
354
|
+
|
|
355
|
+
elif self.agent_type == "coder":
|
|
356
|
+
thinker_dir = self.project_dir / "thinker"
|
|
357
|
+
designer_dir = self.project_dir / "designer"
|
|
358
|
+
|
|
359
|
+
if thinker_dir.exists() and any(thinker_dir.iterdir()):
|
|
360
|
+
hints.append("📂 Thinker's research is available in `../thinker/`")
|
|
361
|
+
if designer_dir.exists() and any(designer_dir.iterdir()):
|
|
362
|
+
hints.append("📂 Designer's specs are available in `../designer/`")
|
|
363
|
+
|
|
364
|
+
if hints:
|
|
365
|
+
return "## Previous Work Available\n" + "\n".join(hints)
|
|
366
|
+
return ""
|
|
367
|
+
|
|
368
|
+
def get_system_prompt(self, task: str) -> str:
|
|
369
|
+
"""Get system prompt for agent type with memory context."""
|
|
370
|
+
template = self.PROMPTS.get(self.agent_type, self.PROMPTS["thinker"])
|
|
371
|
+
|
|
372
|
+
# Build user-level context (global preferences across all projects)
|
|
373
|
+
user_context = self.user_memory.build_user_context() if self.user_memory else ""
|
|
374
|
+
|
|
375
|
+
# Build project-level context
|
|
376
|
+
project_context = self.memory.build_context_for_agent(self.agent_type)
|
|
377
|
+
|
|
378
|
+
# Combine contexts
|
|
379
|
+
context_parts = []
|
|
380
|
+
if user_context:
|
|
381
|
+
context_parts.append(f"## User Preferences (global)\n\n{user_context}")
|
|
382
|
+
if project_context:
|
|
383
|
+
context_parts.append(f"## Project Context (from previous work)\n\n{project_context}")
|
|
384
|
+
|
|
385
|
+
if context_parts:
|
|
386
|
+
context = "\n\n".join(context_parts)
|
|
387
|
+
else:
|
|
388
|
+
context = "## Project Context\n\nThis is a new project. No previous work exists yet."
|
|
389
|
+
|
|
390
|
+
# Add hints about where previous work is located
|
|
391
|
+
previous_work_hint = self._get_previous_work_hint()
|
|
392
|
+
|
|
393
|
+
return template.format(
|
|
394
|
+
task=task,
|
|
395
|
+
context=context,
|
|
396
|
+
previous_work_hint=previous_work_hint
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
async def start(self, task: str, continuous: bool = True) -> None:
|
|
400
|
+
"""
|
|
401
|
+
Start the agent on a task.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
task: What to work on
|
|
405
|
+
continuous: Keep working in cycles until stopped
|
|
406
|
+
"""
|
|
407
|
+
if self._is_running:
|
|
408
|
+
console.print("[red]Agent already running[/red]")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
self._is_running = True
|
|
412
|
+
self._should_stop = False
|
|
413
|
+
start_time = datetime.now()
|
|
414
|
+
|
|
415
|
+
# Load or initialize state
|
|
416
|
+
state = self._load_state()
|
|
417
|
+
|
|
418
|
+
# Check for resume
|
|
419
|
+
can_resume = (
|
|
420
|
+
state.get("task") == task and
|
|
421
|
+
(state.get("last_checkpoint") or state.get("cycles", 0) > 0)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
if can_resume:
|
|
425
|
+
console.print("[green]Resuming from checkpoint...[/green]")
|
|
426
|
+
await self._notify("resume", f"🔄 Resuming `{self.workspace.name}` from cycle {state.get('cycles', 0)}")
|
|
427
|
+
|
|
428
|
+
checkpoint = state.get("last_checkpoint", "")
|
|
429
|
+
if checkpoint:
|
|
430
|
+
resume_context = f"\n\nPrevious progress:\n{checkpoint}\n\nContinue from where you left off."
|
|
431
|
+
else:
|
|
432
|
+
resume_context = "\n\nContinue working on this task. You've made some progress already."
|
|
433
|
+
|
|
434
|
+
state["status"] = "running"
|
|
435
|
+
else:
|
|
436
|
+
resume_context = ""
|
|
437
|
+
state = {
|
|
438
|
+
"task": task,
|
|
439
|
+
"agent_type": self.agent_type,
|
|
440
|
+
"status": "running",
|
|
441
|
+
"cycles": 0,
|
|
442
|
+
"files_created": [],
|
|
443
|
+
"started_at": datetime.now().isoformat(),
|
|
444
|
+
"last_checkpoint": None,
|
|
445
|
+
"total_time_seconds": 0,
|
|
446
|
+
}
|
|
447
|
+
# Note in short-term memory
|
|
448
|
+
self.memory.note(f"Started {self.agent_type} agent with task: {task[:100]}")
|
|
449
|
+
|
|
450
|
+
# Extract rules from user's task prompt
|
|
451
|
+
if self.user_memory:
|
|
452
|
+
self.user_memory.extract_from_response(task)
|
|
453
|
+
|
|
454
|
+
self._save_state(state)
|
|
455
|
+
self._log("started", {"task": task})
|
|
456
|
+
|
|
457
|
+
# Get usage summary for notification
|
|
458
|
+
usage_summary = self.usage.get_summary()
|
|
459
|
+
|
|
460
|
+
# Send start notification
|
|
461
|
+
await self._notify(
|
|
462
|
+
"start",
|
|
463
|
+
f"🚀 *{self.agent_type.upper()}* started on `{self.workspace.name}`\n\n"
|
|
464
|
+
f"📋 *Task:* {task[:200]}\n"
|
|
465
|
+
f"🤖 *Model:* {self.model}\n"
|
|
466
|
+
f"💰 *Project cost so far:* ${usage_summary['total_cost_usd']:.2f}",
|
|
467
|
+
{"task": task, "model": self.model}
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
console.print(Panel(
|
|
471
|
+
f"[bold green]Starting {self.agent_type} agent[/bold green]\n\n"
|
|
472
|
+
f"[bold]Task:[/bold] {task}\n"
|
|
473
|
+
f"[bold]Workspace:[/bold] {self.workspace}\n"
|
|
474
|
+
f"[bold]Model:[/bold] {self.model}\n"
|
|
475
|
+
f"[bold]Timeout:[/bold] {self.timeout // 60} minutes\n"
|
|
476
|
+
f"[bold]Project cost:[/bold] ${usage_summary['total_cost_usd']:.2f}\n\n"
|
|
477
|
+
"[dim]Press Ctrl+C to stop gracefully[/dim]",
|
|
478
|
+
title="Nullabot",
|
|
479
|
+
))
|
|
480
|
+
|
|
481
|
+
# Build initial prompt with memory context
|
|
482
|
+
system_prompt = self.get_system_prompt(task)
|
|
483
|
+
current_prompt = system_prompt + resume_context
|
|
484
|
+
|
|
485
|
+
cycle = state.get("cycles", 0)
|
|
486
|
+
errors_in_row = 0
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
while not self._should_stop:
|
|
490
|
+
cycle += 1
|
|
491
|
+
cycle_start = datetime.now()
|
|
492
|
+
|
|
493
|
+
console.print(f"\n[bold blue]═══ Cycle {cycle} ═══[/bold blue]\n")
|
|
494
|
+
|
|
495
|
+
# Notify cycle start
|
|
496
|
+
await self._notify(
|
|
497
|
+
"cycle_start",
|
|
498
|
+
f"🔄 *Cycle {cycle}* starting...",
|
|
499
|
+
{"cycle": cycle}
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Run Claude
|
|
503
|
+
response, success = await self._run_claude_async(current_prompt)
|
|
504
|
+
|
|
505
|
+
cycle_duration = (datetime.now() - cycle_start).total_seconds()
|
|
506
|
+
|
|
507
|
+
# Track usage
|
|
508
|
+
usage_info = self.usage.record_cycle(
|
|
509
|
+
model=self.model,
|
|
510
|
+
agent_type=self.agent_type,
|
|
511
|
+
input_tokens=len(current_prompt) // 4, # Rough estimate
|
|
512
|
+
output_tokens=len(response) // 4 if success else 0,
|
|
513
|
+
duration_seconds=cycle_duration,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
if not success:
|
|
517
|
+
errors_in_row += 1
|
|
518
|
+
console.print(f"[red]{response}[/red]")
|
|
519
|
+
self._log("error", {"message": response})
|
|
520
|
+
|
|
521
|
+
# Check if this is a rate limit error (5-hour limit reached)
|
|
522
|
+
if self.rate_limiter.is_limit_error(response):
|
|
523
|
+
is_limit, wait_time = self.rate_limiter.record_error(response)
|
|
524
|
+
|
|
525
|
+
# Mark the GLOBAL 5-hour window as reached
|
|
526
|
+
self.usage.mark_limit_reached()
|
|
527
|
+
|
|
528
|
+
if wait_time > 0:
|
|
529
|
+
wait_hours = wait_time / 3600
|
|
530
|
+
wait_minutes = (wait_time % 3600) / 60
|
|
531
|
+
|
|
532
|
+
await self._notify(
|
|
533
|
+
"rate_limit",
|
|
534
|
+
f"⏰ *5-hour limit reached!*\n\n"
|
|
535
|
+
f"Claude Code subscription limit hit at 100%.\n"
|
|
536
|
+
f"Window resets in ~{wait_hours:.1f}h ({wait_minutes:.0f}m)\n\n"
|
|
537
|
+
f"🔄 *Cycles completed:* {cycle}\n"
|
|
538
|
+
f"💰 *Cost this session:* ${self.usage.get_summary()['total_cost_usd']:.2f}\n\n"
|
|
539
|
+
f"Agent will stop. Run again after the window resets.",
|
|
540
|
+
{"cycle": cycle, "wait_time": wait_time}
|
|
541
|
+
)
|
|
542
|
+
else:
|
|
543
|
+
await self._notify(
|
|
544
|
+
"rate_limit",
|
|
545
|
+
f"⏰ *Rate limit reached!*\n\n"
|
|
546
|
+
f"`{response[:200]}`\n\n"
|
|
547
|
+
f"Agent stopping gracefully.",
|
|
548
|
+
{"cycle": cycle}
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
console.print(f"\n[yellow]Rate limit reached. Stopping agent.[/yellow]")
|
|
552
|
+
break
|
|
553
|
+
|
|
554
|
+
# Notify error
|
|
555
|
+
await self._notify(
|
|
556
|
+
"error",
|
|
557
|
+
f"❌ *Error in cycle {cycle}:*\n`{response[:300]}`",
|
|
558
|
+
{"cycle": cycle, "error": response}
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
if "not found" in response.lower():
|
|
562
|
+
await self._notify("fatal", "💀 Claude CLI not found! Install it first.")
|
|
563
|
+
break
|
|
564
|
+
|
|
565
|
+
# "Reached max turns" is not really an error - it's normal completion
|
|
566
|
+
if "max turns" in response.lower() or "reached max" in response.lower():
|
|
567
|
+
console.print(f"[yellow]Claude reached max turns limit. Cycle complete.[/yellow]")
|
|
568
|
+
errors_in_row = 0 # Don't count as error
|
|
569
|
+
# Continue to next cycle
|
|
570
|
+
await asyncio.sleep(5)
|
|
571
|
+
continue
|
|
572
|
+
|
|
573
|
+
if errors_in_row >= 3:
|
|
574
|
+
await self._notify("fatal", f"💀 Too many errors ({errors_in_row}). Stopping.")
|
|
575
|
+
break
|
|
576
|
+
|
|
577
|
+
await asyncio.sleep(10)
|
|
578
|
+
continue
|
|
579
|
+
|
|
580
|
+
errors_in_row = 0
|
|
581
|
+
|
|
582
|
+
# Record success for rate limiter tracking
|
|
583
|
+
self.rate_limiter.record_success(self.model)
|
|
584
|
+
|
|
585
|
+
# Check exit conditions (max cycles, no progress, etc.)
|
|
586
|
+
should_exit, exit_reason = self.exit_detector.should_exit(
|
|
587
|
+
current_cycle=cycle,
|
|
588
|
+
cycles_no_progress=0, # Will be updated below
|
|
589
|
+
progress_tracker=self.progress_tracker,
|
|
590
|
+
)
|
|
591
|
+
if should_exit:
|
|
592
|
+
await self._notify(
|
|
593
|
+
"exit_condition",
|
|
594
|
+
f"🛑 *Exit condition reached:* `{exit_reason}`\n\n"
|
|
595
|
+
f"Completed {cycle} cycles.",
|
|
596
|
+
{"cycle": cycle, "reason": exit_reason}
|
|
597
|
+
)
|
|
598
|
+
console.print(f"\n[yellow]Exit condition: {exit_reason}[/yellow]")
|
|
599
|
+
break
|
|
600
|
+
|
|
601
|
+
# Display response (truncated)
|
|
602
|
+
console.print(Markdown(response[:2000]))
|
|
603
|
+
self._log("response", {"content": response[:1000]})
|
|
604
|
+
|
|
605
|
+
# Extract and save summary to memory
|
|
606
|
+
summary = self._extract_summary(response)
|
|
607
|
+
self.memory.note(f"Cycle {cycle}: {summary}")
|
|
608
|
+
self.memory.extract_memories_from_response(response)
|
|
609
|
+
|
|
610
|
+
# Extract user preferences to global memory
|
|
611
|
+
if self.user_memory:
|
|
612
|
+
self.user_memory.extract_from_response(response)
|
|
613
|
+
|
|
614
|
+
# Count files
|
|
615
|
+
file_count, file_list = self._count_files()
|
|
616
|
+
|
|
617
|
+
# Track file purposes (basic)
|
|
618
|
+
for f in file_list:
|
|
619
|
+
if f not in self.memory.get_file_purposes():
|
|
620
|
+
# Try to infer purpose from path
|
|
621
|
+
if "research" in f.lower():
|
|
622
|
+
self.memory.set_file_purpose(f, "Research findings")
|
|
623
|
+
elif "design" in f.lower() or "component" in f.lower():
|
|
624
|
+
self.memory.set_file_purpose(f, "Design specifications")
|
|
625
|
+
elif "src" in f.lower() or f.endswith(".py") or f.endswith(".js"):
|
|
626
|
+
self.memory.set_file_purpose(f, "Source code")
|
|
627
|
+
|
|
628
|
+
# Extract status line
|
|
629
|
+
status_line = self._extract_status(response) or f"Cycle {cycle} complete"
|
|
630
|
+
|
|
631
|
+
# Update state
|
|
632
|
+
state["cycles"] = cycle
|
|
633
|
+
state["last_checkpoint"] = response[:2000]
|
|
634
|
+
state["status"] = "running"
|
|
635
|
+
state["files_created"] = file_list
|
|
636
|
+
state["total_time_seconds"] = (datetime.now() - start_time).total_seconds()
|
|
637
|
+
self._save_state(state)
|
|
638
|
+
|
|
639
|
+
# Send cycle complete notification with window usage
|
|
640
|
+
window_pct = usage_info.get('window_usage_pct', 0)
|
|
641
|
+
window_bar = self._make_progress_bar(window_pct)
|
|
642
|
+
|
|
643
|
+
await self._notify(
|
|
644
|
+
"cycle_complete",
|
|
645
|
+
f"✅ *Cycle {cycle}* complete ({cycle_duration:.0f}s)\n\n"
|
|
646
|
+
f"📁 *Files:* {file_count}\n"
|
|
647
|
+
f"⏱ *5hr window:* {window_bar} {window_pct:.0f}%\n"
|
|
648
|
+
f"💰 *Est. cost:* ${usage_info['cycle_cost']:.2f} (Total: ${usage_info['total_cost']:.2f})\n"
|
|
649
|
+
f"📊 {status_line}\n\n"
|
|
650
|
+
f"💬 _{summary[:200]}_",
|
|
651
|
+
{
|
|
652
|
+
"cycle": cycle,
|
|
653
|
+
"duration": cycle_duration,
|
|
654
|
+
"file_count": file_count,
|
|
655
|
+
"files": file_list[:10],
|
|
656
|
+
"summary": summary,
|
|
657
|
+
"cost": usage_info,
|
|
658
|
+
}
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
# Warn if approaching 5-hour limit
|
|
662
|
+
if window_pct >= 90:
|
|
663
|
+
await self._notify(
|
|
664
|
+
"limit_warning",
|
|
665
|
+
f"⚠️ *Warning:* 5-hour window at {window_pct:.0f}%!\n"
|
|
666
|
+
f"Agent will stop soon to prevent hitting the limit.",
|
|
667
|
+
{"window_pct": window_pct}
|
|
668
|
+
)
|
|
669
|
+
if window_pct >= 95:
|
|
670
|
+
console.print(f"\n[yellow]Approaching 5-hour limit ({window_pct:.0f}%). Stopping gracefully.[/yellow]")
|
|
671
|
+
break
|
|
672
|
+
|
|
673
|
+
if not continuous:
|
|
674
|
+
break
|
|
675
|
+
|
|
676
|
+
# Check for completion signals
|
|
677
|
+
completion_signals = ["task complete", "all done", "finished", "nothing left"]
|
|
678
|
+
if any(sig in response.lower() for sig in completion_signals):
|
|
679
|
+
await self._notify(
|
|
680
|
+
"maybe_complete",
|
|
681
|
+
f"🤔 Agent thinks it might be done. Continue? (auto-continues in 30s)",
|
|
682
|
+
{"cycle": cycle}
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
# Prepare next cycle prompt (include recent memory context)
|
|
686
|
+
recent_context = "\n".join(self.memory.get_short_term_context(3))
|
|
687
|
+
current_prompt = f"""Continue working on: {task}
|
|
688
|
+
|
|
689
|
+
Recent activity:
|
|
690
|
+
{recent_context}
|
|
691
|
+
|
|
692
|
+
Your previous output was:
|
|
693
|
+
{response[:1500]}
|
|
694
|
+
|
|
695
|
+
Continue making progress. Create or update files as needed.
|
|
696
|
+
If you've completed everything, summarize what you accomplished.
|
|
697
|
+
|
|
698
|
+
Remember to include STATUS and SUMMARY lines at the end."""
|
|
699
|
+
|
|
700
|
+
# Brief pause between cycles
|
|
701
|
+
console.print("\n[dim]Next cycle in 5 seconds...[/dim]")
|
|
702
|
+
await asyncio.sleep(5)
|
|
703
|
+
|
|
704
|
+
except Exception as e:
|
|
705
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
706
|
+
self._log("error", {"message": str(e)})
|
|
707
|
+
await self._notify("error", f"❌ *Fatal error:*\n`{str(e)[:300]}`")
|
|
708
|
+
|
|
709
|
+
finally:
|
|
710
|
+
total_time = (datetime.now() - start_time).total_seconds()
|
|
711
|
+
file_count, file_list = self._count_files()
|
|
712
|
+
|
|
713
|
+
# Save final summary to memory for next agent
|
|
714
|
+
final_summary = f"Completed {cycle} cycles. Created {file_count} files. Last status: {status_line if 'status_line' in dir() else 'N/A'}"
|
|
715
|
+
self.memory.save_agent_summary(self.agent_type, final_summary)
|
|
716
|
+
self.memory.remember(f"{self.agent_type} completed task: {task[:100]}", category="milestone")
|
|
717
|
+
|
|
718
|
+
# Update user memory with project summary (global)
|
|
719
|
+
if self.user_memory:
|
|
720
|
+
project_name = self.project_dir.name
|
|
721
|
+
self.user_memory.update_project_summary(
|
|
722
|
+
project_name,
|
|
723
|
+
f"{self.agent_type}: {task[:100]} ({cycle} cycles, {file_count} files)"
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
state["status"] = "paused" if self._should_stop else "completed"
|
|
727
|
+
state["total_time_seconds"] = total_time
|
|
728
|
+
state["files_created"] = file_list
|
|
729
|
+
self._save_state(state)
|
|
730
|
+
self._log("stopped", {"cycles": cycle, "total_time": total_time})
|
|
731
|
+
self._is_running = False
|
|
732
|
+
|
|
733
|
+
# Get final usage
|
|
734
|
+
usage_summary = self.usage.get_summary()
|
|
735
|
+
|
|
736
|
+
# Send completion notification
|
|
737
|
+
hours = int(total_time // 3600)
|
|
738
|
+
minutes = int((total_time % 3600) // 60)
|
|
739
|
+
time_str = f"{hours}h {minutes}m" if hours else f"{minutes}m"
|
|
740
|
+
|
|
741
|
+
window_pct = usage_summary.get('window_usage_pct', 0)
|
|
742
|
+
window_hours = usage_summary.get('window_hours', 0)
|
|
743
|
+
window_bar = self._make_progress_bar(window_pct)
|
|
744
|
+
|
|
745
|
+
await self._notify(
|
|
746
|
+
"complete",
|
|
747
|
+
f"🏁 *Agent finished!*\n\n"
|
|
748
|
+
f"📁 *Project:* `{self.workspace.name}`\n"
|
|
749
|
+
f"🔄 *Cycles:* {cycle}\n"
|
|
750
|
+
f"⏱ *Duration:* {time_str}\n"
|
|
751
|
+
f"📂 *Files created:* {file_count}\n"
|
|
752
|
+
f"⏱ *5hr window:* {window_bar} {window_pct:.0f}% ({window_hours:.1f}h used)\n"
|
|
753
|
+
f"💰 *Est. cost:* ${usage_summary['total_cost_usd']:.2f}\n\n"
|
|
754
|
+
f"*Files:*\n" + "\n".join(f"• `{f}`" for f in file_list[:10]),
|
|
755
|
+
{
|
|
756
|
+
"cycles": cycle,
|
|
757
|
+
"duration": total_time,
|
|
758
|
+
"file_count": file_count,
|
|
759
|
+
"files": file_list,
|
|
760
|
+
"cost": usage_summary,
|
|
761
|
+
}
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
console.print(f"\n[green]Agent stopped. Completed {cycle} cycles in {time_str}.[/green]")
|
|
765
|
+
console.print(f"[dim]Workspace: {self.workspace}[/dim]")
|
|
766
|
+
console.print(f"[dim]5-hour window: {window_pct:.0f}% used ({window_hours:.1f}h)[/dim]")
|
|
767
|
+
console.print(f"[dim]Est. cost: ${usage_summary['total_cost_usd']:.2f}[/dim]")
|
|
768
|
+
|
|
769
|
+
def stop(self) -> None:
|
|
770
|
+
"""Request agent to stop."""
|
|
771
|
+
self._should_stop = True
|
|
772
|
+
|
|
773
|
+
def status(self) -> dict:
|
|
774
|
+
"""Get current agent status."""
|
|
775
|
+
state = self._load_state()
|
|
776
|
+
file_count, files = self._count_files()
|
|
777
|
+
state["current_file_count"] = file_count
|
|
778
|
+
state["files"] = files
|
|
779
|
+
state["usage"] = self.usage.get_summary()
|
|
780
|
+
state["memory"] = {
|
|
781
|
+
"long_term_count": len(self.memory.get_long_term_memories()),
|
|
782
|
+
"short_term_count": len(self.memory.get_short_term_context()),
|
|
783
|
+
"agent_summaries": list(self.memory.get_all_agent_summaries().keys()),
|
|
784
|
+
}
|
|
785
|
+
return state
|