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
nullabot/core/memory.py
ADDED
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Memory System - Like ChatGPT's memory.
|
|
3
|
+
|
|
4
|
+
Three levels:
|
|
5
|
+
1. User Memory (global): Preferences, patterns, learnings across ALL projects
|
|
6
|
+
2. Project Memory (long-term): Decisions, context for ONE project
|
|
7
|
+
3. Session Memory (short-term): Current session context
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UserMemory:
|
|
17
|
+
"""
|
|
18
|
+
User-level memory shared across ALL projects.
|
|
19
|
+
|
|
20
|
+
Stores:
|
|
21
|
+
- User preferences (coding style, frameworks, etc.)
|
|
22
|
+
- Learned patterns (what user likes/dislikes)
|
|
23
|
+
- Global context (company info, common requirements)
|
|
24
|
+
|
|
25
|
+
Storage location: {base_dir}/users/{user_id}/memory.json
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, base_dir: Path, user_id: int):
|
|
29
|
+
"""
|
|
30
|
+
Initialize user memory.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
base_dir: Base directory (nullabot repo root)
|
|
34
|
+
user_id: User ID (required - from Telegram user ID)
|
|
35
|
+
"""
|
|
36
|
+
if not user_id:
|
|
37
|
+
raise ValueError("user_id is required for UserMemory")
|
|
38
|
+
|
|
39
|
+
self.base_dir = Path(base_dir)
|
|
40
|
+
self.user_id = user_id
|
|
41
|
+
|
|
42
|
+
# Location: {base_dir}/users/{user_id}/
|
|
43
|
+
self.memory_dir = self.base_dir / "users" / str(user_id)
|
|
44
|
+
self.memory_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
self.memory_file = self.memory_dir / "memory.json"
|
|
46
|
+
self._memory = self._load()
|
|
47
|
+
|
|
48
|
+
def _load(self) -> dict:
|
|
49
|
+
"""Load user memory from disk."""
|
|
50
|
+
if self.memory_file.exists():
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(self.memory_file.read_text())
|
|
53
|
+
except:
|
|
54
|
+
pass
|
|
55
|
+
return {
|
|
56
|
+
"preferences": [], # User preferences
|
|
57
|
+
"patterns": [], # Learned patterns
|
|
58
|
+
"context": [], # Global context
|
|
59
|
+
"rules": [], # Explicit rules: DO this / DON'T do this
|
|
60
|
+
"projects_summary": {}, # Brief summary of each project
|
|
61
|
+
"created_at": datetime.now().isoformat(),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def _save(self) -> None:
|
|
65
|
+
"""Save user memory to disk."""
|
|
66
|
+
self._memory["updated_at"] = datetime.now().isoformat()
|
|
67
|
+
self.memory_file.write_text(json.dumps(self._memory, indent=2))
|
|
68
|
+
|
|
69
|
+
# === Preferences ===
|
|
70
|
+
|
|
71
|
+
def add_preference(self, preference: str, category: str = "general") -> None:
|
|
72
|
+
"""
|
|
73
|
+
Store a user preference.
|
|
74
|
+
|
|
75
|
+
Examples:
|
|
76
|
+
- "Prefers React over Vue"
|
|
77
|
+
- "Always use TypeScript"
|
|
78
|
+
- "Likes minimal UI design"
|
|
79
|
+
"""
|
|
80
|
+
# Check for duplicates
|
|
81
|
+
for p in self._memory["preferences"]:
|
|
82
|
+
if p["preference"].lower() == preference.lower():
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
self._memory["preferences"].append({
|
|
86
|
+
"preference": preference,
|
|
87
|
+
"category": category,
|
|
88
|
+
"timestamp": datetime.now().isoformat(),
|
|
89
|
+
})
|
|
90
|
+
# Keep last 50 preferences
|
|
91
|
+
self._memory["preferences"] = self._memory["preferences"][-50:]
|
|
92
|
+
self._save()
|
|
93
|
+
|
|
94
|
+
def get_preferences(self, category: str = None) -> list[str]:
|
|
95
|
+
"""Get user preferences."""
|
|
96
|
+
prefs = self._memory["preferences"]
|
|
97
|
+
if category:
|
|
98
|
+
prefs = [p for p in prefs if p.get("category") == category]
|
|
99
|
+
return [p["preference"] for p in prefs]
|
|
100
|
+
|
|
101
|
+
# === Patterns ===
|
|
102
|
+
|
|
103
|
+
def learn_pattern(self, pattern: str) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Learn a pattern about the user.
|
|
106
|
+
|
|
107
|
+
Examples:
|
|
108
|
+
- "User often asks for detailed comments"
|
|
109
|
+
- "User prefers functional programming style"
|
|
110
|
+
"""
|
|
111
|
+
for p in self._memory["patterns"]:
|
|
112
|
+
if p["pattern"].lower() == pattern.lower():
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
self._memory["patterns"].append({
|
|
116
|
+
"pattern": pattern,
|
|
117
|
+
"timestamp": datetime.now().isoformat(),
|
|
118
|
+
})
|
|
119
|
+
self._memory["patterns"] = self._memory["patterns"][-30:]
|
|
120
|
+
self._save()
|
|
121
|
+
|
|
122
|
+
def get_patterns(self) -> list[str]:
|
|
123
|
+
"""Get learned patterns."""
|
|
124
|
+
return [p["pattern"] for p in self._memory["patterns"]]
|
|
125
|
+
|
|
126
|
+
# === Rules (DO / DON'T) ===
|
|
127
|
+
|
|
128
|
+
def add_rule(self, rule: str, rule_type: str = "do") -> None:
|
|
129
|
+
"""
|
|
130
|
+
Store an explicit user rule.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
rule: The rule text
|
|
134
|
+
rule_type: "do" (always do this) or "dont" (never do this)
|
|
135
|
+
|
|
136
|
+
Examples:
|
|
137
|
+
- ("Use TypeScript everywhere", "do")
|
|
138
|
+
- ("Never use console.log in production", "dont")
|
|
139
|
+
- ("Always add error handling", "do")
|
|
140
|
+
"""
|
|
141
|
+
# Check for duplicates
|
|
142
|
+
for r in self._memory.get("rules", []):
|
|
143
|
+
if r["rule"].lower() == rule.lower():
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if "rules" not in self._memory:
|
|
147
|
+
self._memory["rules"] = []
|
|
148
|
+
|
|
149
|
+
self._memory["rules"].append({
|
|
150
|
+
"rule": rule,
|
|
151
|
+
"type": rule_type, # "do" or "dont"
|
|
152
|
+
"timestamp": datetime.now().isoformat(),
|
|
153
|
+
})
|
|
154
|
+
# Keep last 50 rules
|
|
155
|
+
self._memory["rules"] = self._memory["rules"][-50:]
|
|
156
|
+
self._save()
|
|
157
|
+
|
|
158
|
+
def get_rules(self, rule_type: str = None) -> list[dict]:
|
|
159
|
+
"""Get user rules, optionally filtered by type."""
|
|
160
|
+
rules = self._memory.get("rules", [])
|
|
161
|
+
if rule_type:
|
|
162
|
+
rules = [r for r in rules if r.get("type") == rule_type]
|
|
163
|
+
return rules
|
|
164
|
+
|
|
165
|
+
def remove_rule(self, rule: str) -> bool:
|
|
166
|
+
"""Remove a rule by its text."""
|
|
167
|
+
rules = self._memory.get("rules", [])
|
|
168
|
+
for i, r in enumerate(rules):
|
|
169
|
+
if r["rule"].lower() == rule.lower():
|
|
170
|
+
rules.pop(i)
|
|
171
|
+
self._save()
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
# === Global Context ===
|
|
176
|
+
|
|
177
|
+
def add_context(self, context: str) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Add global context.
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
- "User works at a real estate company"
|
|
183
|
+
- "Target market is Mongolia"
|
|
184
|
+
"""
|
|
185
|
+
for c in self._memory["context"]:
|
|
186
|
+
if c["context"].lower() == context.lower():
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
self._memory["context"].append({
|
|
190
|
+
"context": context,
|
|
191
|
+
"timestamp": datetime.now().isoformat(),
|
|
192
|
+
})
|
|
193
|
+
self._memory["context"] = self._memory["context"][-20:]
|
|
194
|
+
self._save()
|
|
195
|
+
|
|
196
|
+
def get_context(self) -> list[str]:
|
|
197
|
+
"""Get global context."""
|
|
198
|
+
return [c["context"] for c in self._memory["context"]]
|
|
199
|
+
|
|
200
|
+
# === Project Summaries ===
|
|
201
|
+
|
|
202
|
+
def update_project_summary(self, project_name: str, summary: str) -> None:
|
|
203
|
+
"""Update summary for a project (for cross-project awareness)."""
|
|
204
|
+
self._memory["projects_summary"][project_name] = {
|
|
205
|
+
"summary": summary,
|
|
206
|
+
"timestamp": datetime.now().isoformat(),
|
|
207
|
+
}
|
|
208
|
+
self._save()
|
|
209
|
+
|
|
210
|
+
def get_project_summaries(self) -> dict[str, str]:
|
|
211
|
+
"""Get summaries of all projects."""
|
|
212
|
+
return {
|
|
213
|
+
name: data["summary"]
|
|
214
|
+
for name, data in self._memory["projects_summary"].items()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# === Build Context ===
|
|
218
|
+
|
|
219
|
+
def build_user_context(self) -> str:
|
|
220
|
+
"""Build user context string for prompts."""
|
|
221
|
+
lines = []
|
|
222
|
+
|
|
223
|
+
# Rules are most important - show first
|
|
224
|
+
do_rules = self.get_rules("do")
|
|
225
|
+
dont_rules = self.get_rules("dont")
|
|
226
|
+
|
|
227
|
+
if do_rules or dont_rules:
|
|
228
|
+
lines.append("## User Rules (IMPORTANT - Follow these!)")
|
|
229
|
+
if do_rules:
|
|
230
|
+
lines.append("✅ ALWAYS DO:")
|
|
231
|
+
for r in do_rules[-10:]:
|
|
232
|
+
lines.append(f" - {r['rule']}")
|
|
233
|
+
if dont_rules:
|
|
234
|
+
lines.append("❌ NEVER DO:")
|
|
235
|
+
for r in dont_rules[-10:]:
|
|
236
|
+
lines.append(f" - {r['rule']}")
|
|
237
|
+
lines.append("")
|
|
238
|
+
|
|
239
|
+
prefs = self.get_preferences()
|
|
240
|
+
if prefs:
|
|
241
|
+
lines.append("## User Preferences")
|
|
242
|
+
for p in prefs[-10:]:
|
|
243
|
+
lines.append(f"- {p}")
|
|
244
|
+
lines.append("")
|
|
245
|
+
|
|
246
|
+
patterns = self.get_patterns()
|
|
247
|
+
if patterns:
|
|
248
|
+
lines.append("## Known Patterns")
|
|
249
|
+
for p in patterns[-5:]:
|
|
250
|
+
lines.append(f"- {p}")
|
|
251
|
+
lines.append("")
|
|
252
|
+
|
|
253
|
+
context = self.get_context()
|
|
254
|
+
if context:
|
|
255
|
+
lines.append("## User Context")
|
|
256
|
+
for c in context[-5:]:
|
|
257
|
+
lines.append(f"- {c}")
|
|
258
|
+
lines.append("")
|
|
259
|
+
|
|
260
|
+
return "\n".join(lines) if lines else ""
|
|
261
|
+
|
|
262
|
+
def extract_from_response(self, response: str) -> None:
|
|
263
|
+
"""Auto-extract user preferences and rules from response."""
|
|
264
|
+
response_lower = response.lower()
|
|
265
|
+
|
|
266
|
+
# Look for explicit DONT rules
|
|
267
|
+
dont_phrases = [
|
|
268
|
+
"don't ever", "dont ever", "never use", "never do",
|
|
269
|
+
"don't use", "dont use", "stop using", "avoid",
|
|
270
|
+
"we cannot", "we can't", "don't want", "dont want",
|
|
271
|
+
]
|
|
272
|
+
for phrase in dont_phrases:
|
|
273
|
+
if phrase in response_lower:
|
|
274
|
+
for line in response.split("\n"):
|
|
275
|
+
if phrase in line.lower() and len(line) < 150:
|
|
276
|
+
self.add_rule(line.strip(), "dont")
|
|
277
|
+
break
|
|
278
|
+
|
|
279
|
+
# Look for explicit DO rules
|
|
280
|
+
do_phrases = [
|
|
281
|
+
"always use", "use this everywhere", "always do",
|
|
282
|
+
"make sure to", "from now on", "use this one",
|
|
283
|
+
"remember to", "must use", "should always",
|
|
284
|
+
]
|
|
285
|
+
for phrase in do_phrases:
|
|
286
|
+
if phrase in response_lower:
|
|
287
|
+
for line in response.split("\n"):
|
|
288
|
+
if phrase in line.lower() and len(line) < 150:
|
|
289
|
+
self.add_rule(line.strip(), "do")
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
# Look for preference indicators
|
|
293
|
+
pref_phrases = [
|
|
294
|
+
("prefer", "preference"),
|
|
295
|
+
("like to", "pattern"),
|
|
296
|
+
("style is", "pattern"),
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
for phrase, category in pref_phrases:
|
|
300
|
+
if phrase in response_lower:
|
|
301
|
+
for line in response.split("\n"):
|
|
302
|
+
if phrase in line.lower() and len(line) < 100:
|
|
303
|
+
if category == "preference":
|
|
304
|
+
self.add_preference(line.strip())
|
|
305
|
+
else:
|
|
306
|
+
self.learn_pattern(line.strip())
|
|
307
|
+
break
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class ProjectMemory:
|
|
311
|
+
"""
|
|
312
|
+
Memory system for a project.
|
|
313
|
+
|
|
314
|
+
Stores:
|
|
315
|
+
- Long-term: Important facts, decisions, learnings (persists forever)
|
|
316
|
+
- Short-term: Recent context, current session info (cleared on new session)
|
|
317
|
+
- Agent summaries: What each agent type has accomplished
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
def __init__(self, project_path: Path):
|
|
321
|
+
self.project_path = project_path
|
|
322
|
+
self.nullabot_dir = project_path / ".nullabot"
|
|
323
|
+
self.nullabot_dir.mkdir(exist_ok=True)
|
|
324
|
+
|
|
325
|
+
self.memory_file = self.nullabot_dir / "memory.json"
|
|
326
|
+
self._memory = self._load()
|
|
327
|
+
|
|
328
|
+
def _load(self) -> dict:
|
|
329
|
+
"""Load memory from disk."""
|
|
330
|
+
if self.memory_file.exists():
|
|
331
|
+
try:
|
|
332
|
+
return json.loads(self.memory_file.read_text())
|
|
333
|
+
except:
|
|
334
|
+
pass
|
|
335
|
+
return {
|
|
336
|
+
"long_term": [],
|
|
337
|
+
"short_term": [],
|
|
338
|
+
"agent_summaries": {
|
|
339
|
+
"thinker": None,
|
|
340
|
+
"designer": None,
|
|
341
|
+
"coder": None,
|
|
342
|
+
},
|
|
343
|
+
"key_decisions": [],
|
|
344
|
+
"files_purpose": {}, # file path -> what it's for
|
|
345
|
+
"created_at": datetime.now().isoformat(),
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
def _save(self) -> None:
|
|
349
|
+
"""Save memory to disk."""
|
|
350
|
+
self._memory["updated_at"] = datetime.now().isoformat()
|
|
351
|
+
self.memory_file.write_text(json.dumps(self._memory, indent=2))
|
|
352
|
+
|
|
353
|
+
# === Long-term Memory ===
|
|
354
|
+
|
|
355
|
+
def remember(self, fact: str, category: str = "general") -> None:
|
|
356
|
+
"""
|
|
357
|
+
Store a long-term memory (persists across sessions).
|
|
358
|
+
|
|
359
|
+
Use for: Important decisions, user preferences, key learnings
|
|
360
|
+
"""
|
|
361
|
+
self._memory["long_term"].append({
|
|
362
|
+
"fact": fact,
|
|
363
|
+
"category": category,
|
|
364
|
+
"timestamp": datetime.now().isoformat(),
|
|
365
|
+
})
|
|
366
|
+
# Keep last 100 long-term memories
|
|
367
|
+
self._memory["long_term"] = self._memory["long_term"][-100:]
|
|
368
|
+
self._save()
|
|
369
|
+
|
|
370
|
+
def get_long_term_memories(self, category: str = None, limit: int = 20) -> list[str]:
|
|
371
|
+
"""Get long-term memories, optionally filtered by category."""
|
|
372
|
+
memories = self._memory["long_term"]
|
|
373
|
+
if category:
|
|
374
|
+
memories = [m for m in memories if m.get("category") == category]
|
|
375
|
+
return [m["fact"] for m in memories[-limit:]]
|
|
376
|
+
|
|
377
|
+
# === Short-term Memory ===
|
|
378
|
+
|
|
379
|
+
def note(self, context: str) -> None:
|
|
380
|
+
"""
|
|
381
|
+
Store short-term context (current session).
|
|
382
|
+
|
|
383
|
+
Use for: What we're working on now, recent actions
|
|
384
|
+
"""
|
|
385
|
+
self._memory["short_term"].append({
|
|
386
|
+
"context": context,
|
|
387
|
+
"timestamp": datetime.now().isoformat(),
|
|
388
|
+
})
|
|
389
|
+
# Keep last 20 short-term notes
|
|
390
|
+
self._memory["short_term"] = self._memory["short_term"][-20:]
|
|
391
|
+
self._save()
|
|
392
|
+
|
|
393
|
+
def get_short_term_context(self, limit: int = 10) -> list[str]:
|
|
394
|
+
"""Get recent short-term context."""
|
|
395
|
+
return [n["context"] for n in self._memory["short_term"][-limit:]]
|
|
396
|
+
|
|
397
|
+
def clear_short_term(self) -> None:
|
|
398
|
+
"""Clear short-term memory (new session)."""
|
|
399
|
+
self._memory["short_term"] = []
|
|
400
|
+
self._save()
|
|
401
|
+
|
|
402
|
+
# === Agent Summaries (for handoff) ===
|
|
403
|
+
|
|
404
|
+
def save_agent_summary(self, agent_type: str, summary: str) -> None:
|
|
405
|
+
"""
|
|
406
|
+
Save what an agent accomplished (for handoff to next agent).
|
|
407
|
+
"""
|
|
408
|
+
self._memory["agent_summaries"][agent_type] = {
|
|
409
|
+
"summary": summary,
|
|
410
|
+
"timestamp": datetime.now().isoformat(),
|
|
411
|
+
}
|
|
412
|
+
self._save()
|
|
413
|
+
|
|
414
|
+
def get_agent_summary(self, agent_type: str) -> Optional[str]:
|
|
415
|
+
"""Get what an agent accomplished."""
|
|
416
|
+
data = self._memory["agent_summaries"].get(agent_type)
|
|
417
|
+
if data:
|
|
418
|
+
return data.get("summary")
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
def get_all_agent_summaries(self) -> dict[str, str]:
|
|
422
|
+
"""Get summaries from all agents that have run."""
|
|
423
|
+
summaries = {}
|
|
424
|
+
for agent_type, data in self._memory["agent_summaries"].items():
|
|
425
|
+
if data and data.get("summary"):
|
|
426
|
+
summaries[agent_type] = data["summary"]
|
|
427
|
+
return summaries
|
|
428
|
+
|
|
429
|
+
# === Key Decisions ===
|
|
430
|
+
|
|
431
|
+
def record_decision(self, decision: str, reasoning: str = "") -> None:
|
|
432
|
+
"""Record a key decision made during the project."""
|
|
433
|
+
self._memory["key_decisions"].append({
|
|
434
|
+
"decision": decision,
|
|
435
|
+
"reasoning": reasoning,
|
|
436
|
+
"timestamp": datetime.now().isoformat(),
|
|
437
|
+
})
|
|
438
|
+
self._memory["key_decisions"] = self._memory["key_decisions"][-50:]
|
|
439
|
+
self._save()
|
|
440
|
+
|
|
441
|
+
def get_decisions(self, limit: int = 10) -> list[dict]:
|
|
442
|
+
"""Get recent key decisions."""
|
|
443
|
+
return self._memory["key_decisions"][-limit:]
|
|
444
|
+
|
|
445
|
+
# === File Purpose Tracking ===
|
|
446
|
+
|
|
447
|
+
def set_file_purpose(self, file_path: str, purpose: str) -> None:
|
|
448
|
+
"""Track what a file is for."""
|
|
449
|
+
self._memory["files_purpose"][file_path] = purpose
|
|
450
|
+
self._save()
|
|
451
|
+
|
|
452
|
+
def get_file_purposes(self) -> dict[str, str]:
|
|
453
|
+
"""Get purposes of all tracked files."""
|
|
454
|
+
return self._memory["files_purpose"]
|
|
455
|
+
|
|
456
|
+
# === Context Building ===
|
|
457
|
+
|
|
458
|
+
def build_context_for_agent(self, agent_type: str) -> str:
|
|
459
|
+
"""
|
|
460
|
+
Build full context for an agent, including:
|
|
461
|
+
- What other agents have done
|
|
462
|
+
- Long-term memories
|
|
463
|
+
- Recent short-term context
|
|
464
|
+
- Key decisions
|
|
465
|
+
"""
|
|
466
|
+
lines = []
|
|
467
|
+
|
|
468
|
+
# Previous agent work
|
|
469
|
+
summaries = self.get_all_agent_summaries()
|
|
470
|
+
if summaries:
|
|
471
|
+
lines.append("## Previous Work on This Project\n")
|
|
472
|
+
for atype, summary in summaries.items():
|
|
473
|
+
if atype != agent_type: # Don't include own summary
|
|
474
|
+
emoji = {"thinker": "🧠", "designer": "🎨", "coder": "💻"}.get(atype, "🤖")
|
|
475
|
+
lines.append(f"### {emoji} {atype.upper()} completed:\n{summary}\n")
|
|
476
|
+
|
|
477
|
+
# Key decisions
|
|
478
|
+
decisions = self.get_decisions(5)
|
|
479
|
+
if decisions:
|
|
480
|
+
lines.append("## Key Decisions Made\n")
|
|
481
|
+
for d in decisions:
|
|
482
|
+
lines.append(f"- {d['decision']}")
|
|
483
|
+
lines.append("")
|
|
484
|
+
|
|
485
|
+
# Long-term memories
|
|
486
|
+
memories = self.get_long_term_memories(limit=10)
|
|
487
|
+
if memories:
|
|
488
|
+
lines.append("## Important Context\n")
|
|
489
|
+
for m in memories:
|
|
490
|
+
lines.append(f"- {m}")
|
|
491
|
+
lines.append("")
|
|
492
|
+
|
|
493
|
+
# Recent context
|
|
494
|
+
recent = self.get_short_term_context(5)
|
|
495
|
+
if recent:
|
|
496
|
+
lines.append("## Recent Activity\n")
|
|
497
|
+
for r in recent:
|
|
498
|
+
lines.append(f"- {r}")
|
|
499
|
+
lines.append("")
|
|
500
|
+
|
|
501
|
+
# File purposes
|
|
502
|
+
files = self.get_file_purposes()
|
|
503
|
+
if files:
|
|
504
|
+
lines.append("## Project Files\n")
|
|
505
|
+
for fpath, purpose in list(files.items())[:15]:
|
|
506
|
+
lines.append(f"- `{fpath}`: {purpose}")
|
|
507
|
+
lines.append("")
|
|
508
|
+
|
|
509
|
+
return "\n".join(lines) if lines else ""
|
|
510
|
+
|
|
511
|
+
def extract_memories_from_response(self, response: str) -> None:
|
|
512
|
+
"""
|
|
513
|
+
Auto-extract important info from agent response.
|
|
514
|
+
Look for decisions, facts, file creations.
|
|
515
|
+
"""
|
|
516
|
+
response_lower = response.lower()
|
|
517
|
+
|
|
518
|
+
# Look for decision indicators
|
|
519
|
+
decision_phrases = ["decided to", "choosing", "will use", "going with", "selected"]
|
|
520
|
+
for phrase in decision_phrases:
|
|
521
|
+
if phrase in response_lower:
|
|
522
|
+
# Extract sentence containing the phrase
|
|
523
|
+
for line in response.split("\n"):
|
|
524
|
+
if phrase in line.lower() and len(line) < 200:
|
|
525
|
+
self.record_decision(line.strip())
|
|
526
|
+
break
|
|
527
|
+
|
|
528
|
+
# Look for file creation indicators
|
|
529
|
+
file_phrases = ["created", "wrote", "generated", "saved"]
|
|
530
|
+
for line in response.split("\n"):
|
|
531
|
+
line_lower = line.lower()
|
|
532
|
+
for phrase in file_phrases:
|
|
533
|
+
if phrase in line_lower and ("file" in line_lower or "/" in line):
|
|
534
|
+
# Try to extract file info
|
|
535
|
+
if len(line) < 150:
|
|
536
|
+
self.note(line.strip())
|
|
537
|
+
break
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class GlobalWindowTracker:
|
|
541
|
+
"""
|
|
542
|
+
Track the GLOBAL 5-hour window for Claude Code subscription.
|
|
543
|
+
|
|
544
|
+
This is SHARED across all projects because Claude Code CLI uses
|
|
545
|
+
a single 5-hour rolling window for the entire subscription.
|
|
546
|
+
"""
|
|
547
|
+
|
|
548
|
+
FIVE_HOUR_WINDOW = 5 * 60 * 60 # 18000 seconds
|
|
549
|
+
|
|
550
|
+
def __init__(self, base_dir: Path):
|
|
551
|
+
"""
|
|
552
|
+
Initialize global window tracker.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
base_dir: Base directory (nullabot repo root) for storing global state.
|
|
556
|
+
"""
|
|
557
|
+
if not base_dir:
|
|
558
|
+
raise ValueError("base_dir is required for GlobalWindowTracker")
|
|
559
|
+
|
|
560
|
+
self.base_dir = Path(base_dir)
|
|
561
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
562
|
+
self.window_file = self.base_dir / "global_window.json"
|
|
563
|
+
self._data = self._load()
|
|
564
|
+
|
|
565
|
+
def _load(self) -> dict:
|
|
566
|
+
"""Load global window state."""
|
|
567
|
+
default = {
|
|
568
|
+
"window_start": None,
|
|
569
|
+
"window_duration": 0.0,
|
|
570
|
+
"is_limit_reached": False,
|
|
571
|
+
"limit_reached_at": None,
|
|
572
|
+
"last_updated": None,
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if self.window_file.exists():
|
|
576
|
+
try:
|
|
577
|
+
data = json.loads(self.window_file.read_text())
|
|
578
|
+
for key, val in default.items():
|
|
579
|
+
if key not in data:
|
|
580
|
+
data[key] = val
|
|
581
|
+
return data
|
|
582
|
+
except:
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
return default
|
|
586
|
+
|
|
587
|
+
def _save(self) -> None:
|
|
588
|
+
"""Save global window state."""
|
|
589
|
+
self._data["last_updated"] = datetime.now().isoformat()
|
|
590
|
+
self.window_file.write_text(json.dumps(self._data, indent=2))
|
|
591
|
+
|
|
592
|
+
def _check_window_reset(self) -> None:
|
|
593
|
+
"""Check if 5-hour window has expired and reset if needed."""
|
|
594
|
+
if not self._data.get("window_start"):
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
window_start = datetime.fromisoformat(self._data["window_start"])
|
|
599
|
+
elapsed = (datetime.now() - window_start).total_seconds()
|
|
600
|
+
|
|
601
|
+
# If more than 5 hours since window start, reset
|
|
602
|
+
if elapsed >= self.FIVE_HOUR_WINDOW:
|
|
603
|
+
self._data["window_start"] = None
|
|
604
|
+
self._data["window_duration"] = 0.0
|
|
605
|
+
self._data["is_limit_reached"] = False
|
|
606
|
+
self._data["limit_reached_at"] = None
|
|
607
|
+
self._save()
|
|
608
|
+
except:
|
|
609
|
+
pass
|
|
610
|
+
|
|
611
|
+
def record_usage(self, duration_seconds: float) -> dict:
|
|
612
|
+
"""
|
|
613
|
+
Record usage time in the global 5-hour window.
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
dict with window status info
|
|
617
|
+
"""
|
|
618
|
+
self._check_window_reset()
|
|
619
|
+
|
|
620
|
+
# Start window if not started
|
|
621
|
+
if not self._data.get("window_start"):
|
|
622
|
+
self._data["window_start"] = datetime.now().isoformat()
|
|
623
|
+
self._data["window_duration"] = 0.0
|
|
624
|
+
|
|
625
|
+
# Add duration
|
|
626
|
+
self._data["window_duration"] += duration_seconds
|
|
627
|
+
self._save()
|
|
628
|
+
|
|
629
|
+
return self.get_status()
|
|
630
|
+
|
|
631
|
+
def mark_limit_reached(self) -> None:
|
|
632
|
+
"""Mark that the 5-hour limit was reached (detected from error)."""
|
|
633
|
+
self._data["is_limit_reached"] = True
|
|
634
|
+
self._data["limit_reached_at"] = datetime.now().isoformat()
|
|
635
|
+
self._data["window_duration"] = self.FIVE_HOUR_WINDOW # Set to 100%
|
|
636
|
+
self._save()
|
|
637
|
+
|
|
638
|
+
def get_status(self) -> dict:
|
|
639
|
+
"""Get current window status."""
|
|
640
|
+
self._check_window_reset()
|
|
641
|
+
|
|
642
|
+
window_duration = self._data.get("window_duration", 0.0)
|
|
643
|
+
window_pct = min(100, (window_duration / self.FIVE_HOUR_WINDOW) * 100)
|
|
644
|
+
remaining = max(0, self.FIVE_HOUR_WINDOW - window_duration)
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
"window_start": self._data.get("window_start"),
|
|
648
|
+
"used_seconds": round(window_duration, 1),
|
|
649
|
+
"used_hours": round(window_duration / 3600, 2),
|
|
650
|
+
"remaining_seconds": round(remaining, 1),
|
|
651
|
+
"remaining_hours": round(remaining / 3600, 2),
|
|
652
|
+
"usage_pct": round(window_pct, 1),
|
|
653
|
+
"is_limit_reached": self._data.get("is_limit_reached", False),
|
|
654
|
+
"is_near_limit": window_pct >= 90,
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
# Global singleton instance
|
|
659
|
+
_global_window_tracker: Optional[GlobalWindowTracker] = None
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def get_global_window_tracker(base_dir: Path) -> GlobalWindowTracker:
|
|
663
|
+
"""Get or create the global window tracker singleton."""
|
|
664
|
+
global _global_window_tracker
|
|
665
|
+
if _global_window_tracker is None:
|
|
666
|
+
_global_window_tracker = GlobalWindowTracker(base_dir)
|
|
667
|
+
return _global_window_tracker
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class UsageTracker:
|
|
671
|
+
"""
|
|
672
|
+
Track usage for Claude Code subscription.
|
|
673
|
+
|
|
674
|
+
Per-project stats (cycles, cost) + global 5-hour window tracking.
|
|
675
|
+
"""
|
|
676
|
+
|
|
677
|
+
# 5-hour window in seconds
|
|
678
|
+
FIVE_HOUR_WINDOW = 5 * 60 * 60 # 18000 seconds
|
|
679
|
+
|
|
680
|
+
# Estimated cost per hour (for $200/month Max plan)
|
|
681
|
+
COST_PER_HOUR = {
|
|
682
|
+
"opus": 8.33, # ~$200/24 hours weekly limit
|
|
683
|
+
"sonnet": 0.83, # ~$200/240 hours weekly limit
|
|
684
|
+
"haiku": 0.42, # Cheaper
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
def __init__(self, project_path: Path, base_dir: Path = None):
|
|
688
|
+
self.project_path = project_path
|
|
689
|
+
self.nullabot_dir = project_path / ".nullabot"
|
|
690
|
+
self.nullabot_dir.mkdir(exist_ok=True)
|
|
691
|
+
|
|
692
|
+
self.usage_file = self.nullabot_dir / "usage.json"
|
|
693
|
+
self._usage = self._load()
|
|
694
|
+
|
|
695
|
+
# Base dir for global tracking (default: grandparent of project)
|
|
696
|
+
self.base_dir = base_dir or project_path.parent.parent
|
|
697
|
+
|
|
698
|
+
# Use global window tracker for 5-hour limit
|
|
699
|
+
self._global_window = get_global_window_tracker(self.base_dir)
|
|
700
|
+
|
|
701
|
+
def _load(self) -> dict:
|
|
702
|
+
"""Load usage from disk, migrating old format if needed."""
|
|
703
|
+
default = {
|
|
704
|
+
"total_cycles": 0,
|
|
705
|
+
"total_duration_seconds": 0.0,
|
|
706
|
+
"total_cost_usd": 0.0,
|
|
707
|
+
"current_window_start": None,
|
|
708
|
+
"current_window_duration": 0.0,
|
|
709
|
+
"sessions": [],
|
|
710
|
+
"by_model": {},
|
|
711
|
+
"by_agent": {},
|
|
712
|
+
"created_at": datetime.now().isoformat(),
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if self.usage_file.exists():
|
|
716
|
+
try:
|
|
717
|
+
data = json.loads(self.usage_file.read_text())
|
|
718
|
+
|
|
719
|
+
# Migrate old format to new format
|
|
720
|
+
if "total_duration_seconds" not in data:
|
|
721
|
+
# Old format had tokens, estimate duration from sessions
|
|
722
|
+
total_duration = 0.0
|
|
723
|
+
for session in data.get("sessions", []):
|
|
724
|
+
total_duration += session.get("duration", 0)
|
|
725
|
+
|
|
726
|
+
data["total_duration_seconds"] = total_duration
|
|
727
|
+
data["current_window_start"] = data.get("current_window_start")
|
|
728
|
+
data["current_window_duration"] = data.get("current_window_duration", 0.0)
|
|
729
|
+
|
|
730
|
+
# Migrate by_model format
|
|
731
|
+
for model, info in data.get("by_model", {}).items():
|
|
732
|
+
if "duration" not in info:
|
|
733
|
+
info["duration"] = 0.0
|
|
734
|
+
|
|
735
|
+
# Migrate by_agent format
|
|
736
|
+
for agent, info in data.get("by_agent", {}).items():
|
|
737
|
+
if "duration" not in info:
|
|
738
|
+
info["duration"] = 0.0
|
|
739
|
+
|
|
740
|
+
# Ensure all required keys exist
|
|
741
|
+
for key, val in default.items():
|
|
742
|
+
if key not in data:
|
|
743
|
+
data[key] = val
|
|
744
|
+
|
|
745
|
+
return data
|
|
746
|
+
except:
|
|
747
|
+
pass
|
|
748
|
+
|
|
749
|
+
return default
|
|
750
|
+
|
|
751
|
+
def _save(self) -> None:
|
|
752
|
+
"""Save usage to disk."""
|
|
753
|
+
self._usage["updated_at"] = datetime.now().isoformat()
|
|
754
|
+
self.usage_file.write_text(json.dumps(self._usage, indent=2))
|
|
755
|
+
|
|
756
|
+
def _check_window_reset(self) -> None:
|
|
757
|
+
"""Check if 5-hour window has reset and update accordingly."""
|
|
758
|
+
if not self._usage.get("current_window_start"):
|
|
759
|
+
self._usage["current_window_start"] = datetime.now().isoformat()
|
|
760
|
+
self._usage["current_window_duration"] = 0.0
|
|
761
|
+
return
|
|
762
|
+
|
|
763
|
+
try:
|
|
764
|
+
window_start = datetime.fromisoformat(self._usage["current_window_start"])
|
|
765
|
+
elapsed = (datetime.now() - window_start).total_seconds()
|
|
766
|
+
|
|
767
|
+
# If more than 5 hours since window start, reset
|
|
768
|
+
if elapsed >= self.FIVE_HOUR_WINDOW:
|
|
769
|
+
self._usage["current_window_start"] = datetime.now().isoformat()
|
|
770
|
+
self._usage["current_window_duration"] = 0.0
|
|
771
|
+
except:
|
|
772
|
+
self._usage["current_window_start"] = datetime.now().isoformat()
|
|
773
|
+
self._usage["current_window_duration"] = 0.0
|
|
774
|
+
|
|
775
|
+
def record_cycle(
|
|
776
|
+
self,
|
|
777
|
+
model: str,
|
|
778
|
+
agent_type: str,
|
|
779
|
+
input_tokens: int = 0, # Kept for compatibility but not used
|
|
780
|
+
output_tokens: int = 0, # Kept for compatibility but not used
|
|
781
|
+
duration_seconds: float = 0,
|
|
782
|
+
) -> dict:
|
|
783
|
+
"""Record a cycle's usage based on actual duration."""
|
|
784
|
+
# Calculate cost based on time (more accurate for subscriptions)
|
|
785
|
+
hourly_rate = self.COST_PER_HOUR.get(model, self.COST_PER_HOUR["opus"])
|
|
786
|
+
cost = (duration_seconds / 3600) * hourly_rate
|
|
787
|
+
|
|
788
|
+
# Update project totals
|
|
789
|
+
self._usage["total_cycles"] += 1
|
|
790
|
+
self._usage["total_duration_seconds"] += duration_seconds
|
|
791
|
+
self._usage["total_cost_usd"] += cost
|
|
792
|
+
|
|
793
|
+
# By model
|
|
794
|
+
if model not in self._usage["by_model"]:
|
|
795
|
+
self._usage["by_model"][model] = {"cycles": 0, "duration": 0.0, "cost": 0.0}
|
|
796
|
+
self._usage["by_model"][model]["cycles"] += 1
|
|
797
|
+
self._usage["by_model"][model]["duration"] += duration_seconds
|
|
798
|
+
self._usage["by_model"][model]["cost"] += cost
|
|
799
|
+
|
|
800
|
+
# By agent
|
|
801
|
+
if agent_type not in self._usage["by_agent"]:
|
|
802
|
+
self._usage["by_agent"][agent_type] = {"cycles": 0, "duration": 0.0, "cost": 0.0}
|
|
803
|
+
self._usage["by_agent"][agent_type]["cycles"] += 1
|
|
804
|
+
self._usage["by_agent"][agent_type]["duration"] += duration_seconds
|
|
805
|
+
self._usage["by_agent"][agent_type]["cost"] += cost
|
|
806
|
+
|
|
807
|
+
# Session log
|
|
808
|
+
self._usage["sessions"].append({
|
|
809
|
+
"timestamp": datetime.now().isoformat(),
|
|
810
|
+
"model": model,
|
|
811
|
+
"agent": agent_type,
|
|
812
|
+
"duration": round(duration_seconds, 1),
|
|
813
|
+
"cost": round(cost, 4),
|
|
814
|
+
})
|
|
815
|
+
# Keep last 500 sessions
|
|
816
|
+
self._usage["sessions"] = self._usage["sessions"][-500:]
|
|
817
|
+
|
|
818
|
+
self._save()
|
|
819
|
+
|
|
820
|
+
# Update GLOBAL 5-hour window (shared across all projects)
|
|
821
|
+
window_status = self._global_window.record_usage(duration_seconds)
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
"cycle_cost": round(cost, 2),
|
|
825
|
+
"total_cost": round(self._usage["total_cost_usd"], 2),
|
|
826
|
+
"total_cycles": self._usage["total_cycles"],
|
|
827
|
+
"cycle_duration": round(duration_seconds, 1),
|
|
828
|
+
"total_duration": round(self._usage["total_duration_seconds"], 1),
|
|
829
|
+
"window_usage_pct": window_status["usage_pct"],
|
|
830
|
+
"window_duration": window_status["used_seconds"],
|
|
831
|
+
"window_hours": window_status["used_hours"],
|
|
832
|
+
"window_remaining_hours": window_status["remaining_hours"],
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
def get_summary(self) -> dict:
|
|
836
|
+
"""Get usage summary with GLOBAL window status."""
|
|
837
|
+
total_hours = self._usage["total_duration_seconds"] / 3600
|
|
838
|
+
|
|
839
|
+
# Get global window status
|
|
840
|
+
window_status = self._global_window.get_status()
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
"total_cycles": self._usage["total_cycles"],
|
|
844
|
+
"total_cost_usd": round(self._usage["total_cost_usd"], 2),
|
|
845
|
+
"total_hours": round(total_hours, 2),
|
|
846
|
+
"window_hours": window_status["used_hours"],
|
|
847
|
+
"window_usage_pct": window_status["usage_pct"],
|
|
848
|
+
"window_remaining_hours": window_status["remaining_hours"],
|
|
849
|
+
"is_limit_reached": window_status["is_limit_reached"],
|
|
850
|
+
"by_model": self._usage["by_model"],
|
|
851
|
+
"by_agent": self._usage["by_agent"],
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
def get_recent_sessions(self, limit: int = 10) -> list[dict]:
|
|
855
|
+
"""Get recent session logs."""
|
|
856
|
+
return self._usage["sessions"][-limit:]
|
|
857
|
+
|
|
858
|
+
def get_window_status(self) -> dict:
|
|
859
|
+
"""Get current GLOBAL 5-hour window status."""
|
|
860
|
+
return self._global_window.get_status()
|
|
861
|
+
|
|
862
|
+
def mark_limit_reached(self) -> None:
|
|
863
|
+
"""Mark that the 5-hour limit was reached (detected from error)."""
|
|
864
|
+
self._global_window.mark_limit_reached()
|