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.
@@ -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()