nullabot 1.0.1__tar.gz → 1.0.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nullabot
3
- Version: 1.0.1
3
+ Version: 1.0.3
4
4
  Summary: 24/7 AI agents that think, design, and code - controlled via Telegram
5
5
  Project-URL: Homepage, https://github.com/ebokoo/nullabot
6
6
  Project-URL: Repository, https://github.com/ebokoo/nullabot
@@ -300,12 +300,17 @@ IMPORTANT:
300
300
  async def _run_claude_async(
301
301
  self,
302
302
  prompt: str,
303
- ) -> tuple[str, bool]:
304
- """Run Claude Code CLI asynchronously."""
303
+ ) -> tuple[str, bool, int, int]:
304
+ """
305
+ Run Claude Code CLI asynchronously.
306
+
307
+ Returns:
308
+ tuple: (response_text, success, input_tokens, output_tokens)
309
+ """
305
310
  cmd = [
306
311
  "claude",
307
312
  "-p",
308
- "--output-format", "text",
313
+ "--output-format", "json",
309
314
  "--model", self.model,
310
315
  "--max-turns", "50",
311
316
  "--dangerously-skip-permissions",
@@ -332,15 +337,51 @@ IMPORTANT:
332
337
  output = stdout.decode().strip()
333
338
  if process.returncode != 0:
334
339
  error = stderr.decode() or "Unknown error"
335
- return f"Error: {error}", False
340
+ return f"Error: {error}", False, 0, 0
341
+
342
+ # Parse JSON response to extract tokens
343
+ try:
344
+ data = json.loads(output)
336
345
 
337
- return output, True
346
+ # Get result text - try multiple fields
347
+ result_text = data.get("result") or data.get("content") or ""
348
+
349
+ # If no result text but we have the raw output, use a summary
350
+ if not result_text and data.get("subtype") == "error_max_turns":
351
+ result_text = "Reached max turns limit. Work saved."
352
+ elif not result_text:
353
+ result_text = output # Fallback to raw output
354
+
355
+ # Extract token usage from nested usage object
356
+ usage = data.get("usage", {})
357
+
358
+ # Input includes regular + cache tokens
359
+ input_tokens = (
360
+ usage.get("input_tokens", 0) +
361
+ usage.get("cache_read_input_tokens", 0) +
362
+ usage.get("cache_creation_input_tokens", 0)
363
+ )
364
+ output_tokens = usage.get("output_tokens", 0)
365
+
366
+ # Also check modelUsage for more detailed breakdown
367
+ model_usage = data.get("modelUsage", {})
368
+ if model_usage and not input_tokens:
369
+ for model, mu in model_usage.items():
370
+ input_tokens += mu.get("inputTokens", 0)
371
+ input_tokens += mu.get("cacheReadInputTokens", 0)
372
+ input_tokens += mu.get("cacheCreationInputTokens", 0)
373
+ output_tokens += mu.get("outputTokens", 0)
374
+
375
+ return result_text, True, input_tokens, output_tokens
376
+ except json.JSONDecodeError:
377
+ # Fallback if not valid JSON
378
+ return output, True, 0, 0
338
379
 
339
380
  except asyncio.TimeoutError:
340
381
  timeout_min = self.timeout // 60
341
- return f"Error: Claude timed out after {timeout_min} minutes", False
382
+ return f"Error: Claude timed out after {timeout_min} minutes", False, 0, 0
342
383
  except Exception as e:
343
- return f"Error: {str(e)}", False
384
+ return f"Error: {str(e)}", False, 0, 0
344
385
 
345
386
  def _get_previous_work_hint(self) -> str:
346
387
  """Get hint about where to find previous agents' work."""
@@ -500,16 +541,16 @@ IMPORTANT:
500
541
  )
501
542
 
502
543
  # Run Claude
503
- response, success = await self._run_claude_async(current_prompt)
544
+ response, success, input_tokens, output_tokens = await self._run_claude_async(current_prompt)
504
545
 
505
546
  cycle_duration = (datetime.now() - cycle_start).total_seconds()
506
547
 
507
- # Track usage
548
+ # Track usage with actual token counts
508
549
  usage_info = self.usage.record_cycle(
509
550
  model=self.model,
510
551
  agent_type=self.agent_type,
511
- input_tokens=len(current_prompt) // 4, # Rough estimate
512
- output_tokens=len(response) // 4 if success else 0,
552
+ input_tokens=input_tokens,
553
+ output_tokens=output_tokens,
513
554
  duration_seconds=cycle_duration,
514
555
  )
515
556
 
@@ -636,14 +677,25 @@ IMPORTANT:
636
677
  state["total_time_seconds"] = (datetime.now() - start_time).total_seconds()
637
678
  self._save_state(state)
638
679
 
639
- # Send cycle complete notification with window usage
680
+ # Send cycle complete notification with window usage and tokens
640
681
  window_pct = usage_info.get('window_usage_pct', 0)
641
682
  window_bar = self._make_progress_bar(window_pct)
683
+ cycle_tokens = usage_info.get('cycle_tokens', 0)
684
+ total_tokens = usage_info.get('total_tokens', 0)
685
+
686
+ # Format token counts (K for thousands, M for millions)
687
+ def fmt_tokens(n):
688
+ if n >= 1_000_000:
689
+ return f"{n/1_000_000:.1f}M"
690
+ elif n >= 1_000:
691
+ return f"{n/1_000:.1f}K"
692
+ return str(n)
642
693
 
643
694
  await self._notify(
644
695
  "cycle_complete",
645
696
  f"✅ *Cycle {cycle}* complete ({cycle_duration:.0f}s)\n\n"
646
697
  f"📁 *Files:* {file_count}\n"
698
+ f"🔤 *Tokens:* {fmt_tokens(cycle_tokens)} (Total: {fmt_tokens(total_tokens)})\n"
647
699
  f"⏱ *5hr window:* {window_bar} {window_pct:.0f}%\n"
648
700
  f"💰 *Est. cost:* ${usage_info['cycle_cost']:.2f} (Total: ${usage_info['total_cost']:.2f})\n"
649
701
  f"📊 {status_line}\n\n"
@@ -741,6 +793,17 @@ Remember to include STATUS and SUMMARY lines at the end."""
741
793
  window_pct = usage_summary.get('window_usage_pct', 0)
742
794
  window_hours = usage_summary.get('window_hours', 0)
743
795
  window_bar = self._make_progress_bar(window_pct)
796
+ total_tokens = usage_summary.get('total_tokens', 0)
797
+ input_tokens = usage_summary.get('total_input_tokens', 0)
798
+ output_tokens = usage_summary.get('total_output_tokens', 0)
799
+
800
+ # Format token counts
801
+ def fmt_tokens(n):
802
+ if n >= 1_000_000:
803
+ return f"{n/1_000_000:.1f}M"
804
+ elif n >= 1_000:
805
+ return f"{n/1_000:.1f}K"
806
+ return str(n)
744
807
 
745
808
  await self._notify(
746
809
  "complete",
@@ -749,6 +812,7 @@ Remember to include STATUS and SUMMARY lines at the end."""
749
812
  f"🔄 *Cycles:* {cycle}\n"
750
813
  f"⏱ *Duration:* {time_str}\n"
751
814
  f"📂 *Files created:* {file_count}\n"
815
+ f"🔤 *Tokens:* {fmt_tokens(total_tokens)} ({fmt_tokens(input_tokens)} in / {fmt_tokens(output_tokens)} out)\n"
752
816
  f"⏱ *5hr window:* {window_bar} {window_pct:.0f}% ({window_hours:.1f}h used)\n"
753
817
  f"💰 *Est. cost:* ${usage_summary['total_cost_usd']:.2f}\n\n"
754
818
  f"*Files:*\n" + "\n".join(f"• `{f}`" for f in file_list[:10]),
@@ -1097,6 +1097,14 @@ class TelegramBot:
1097
1097
 
1098
1098
  base_dir = self.base_projects_dir.parent
1099
1099
 
1100
+ # Format token counts
1101
+ def fmt_tokens(n):
1102
+ if n >= 1_000_000:
1103
+ return f"{n/1_000_000:.1f}M"
1104
+ elif n >= 1_000:
1105
+ return f"{n/1_000:.1f}K"
1106
+ return str(n)
1107
+
1100
1108
  lines = [
1101
1109
  "📈 *Nullabot Usage*\n",
1102
1110
  ]
@@ -1105,6 +1113,7 @@ class TelegramBot:
1105
1113
  total_cost = 0
1106
1114
  total_cycles = 0
1107
1115
  total_hours = 0
1116
+ total_tokens = 0
1108
1117
 
1109
1118
  for item in self.base_projects_dir.iterdir():
1110
1119
  if item.is_dir() and (item / ".nullabot").exists():
@@ -1112,16 +1121,18 @@ class TelegramBot:
1112
1121
  tracker = UsageTracker(item, base_dir)
1113
1122
  summary = tracker.get_summary()
1114
1123
  if summary["total_cycles"] > 0:
1124
+ tokens = summary.get("total_tokens", 0)
1115
1125
  lines.append(f"📁 *{item.name}*")
1116
- lines.append(f" {summary['total_cycles']} cycles · {summary['total_hours']:.1f}hrs · ${summary['total_cost_usd']:.2f}")
1126
+ lines.append(f" {summary['total_cycles']} cycles · {fmt_tokens(tokens)} tokens · ${summary['total_cost_usd']:.2f}")
1117
1127
  total_cost += summary["total_cost_usd"]
1118
1128
  total_cycles += summary["total_cycles"]
1119
1129
  total_hours += summary["total_hours"]
1130
+ total_tokens += tokens
1120
1131
  except:
1121
1132
  pass
1122
1133
 
1123
1134
  if total_cycles > 0:
1124
- lines.append(f"\n💰 *Total:* {total_cycles} cycles · {total_hours:.1f}hrs · ${total_cost:.2f}")
1135
+ lines.append(f"\n💰 *Total:* {total_cycles} cycles · {fmt_tokens(total_tokens)} tokens · ${total_cost:.2f}")
1125
1136
  else:
1126
1137
  lines.append("_No usage yet_")
1127
1138
 
@@ -1595,6 +1606,14 @@ class TelegramBot:
1595
1606
 
1596
1607
  base_dir = self.base_projects_dir.parent
1597
1608
 
1609
+ # Format token counts
1610
+ def fmt_tokens(n):
1611
+ if n >= 1_000_000:
1612
+ return f"{n/1_000_000:.1f}M"
1613
+ elif n >= 1_000:
1614
+ return f"{n/1_000:.1f}K"
1615
+ return str(n)
1616
+
1598
1617
  lines = [
1599
1618
  "📈 *Nullabot Usage*\n",
1600
1619
  ]
@@ -1603,6 +1622,7 @@ class TelegramBot:
1603
1622
  total_cost = 0
1604
1623
  total_cycles = 0
1605
1624
  total_hours = 0
1625
+ total_tokens = 0
1606
1626
 
1607
1627
  for item in self.base_projects_dir.iterdir():
1608
1628
  if item.is_dir() and (item / ".nullabot").exists():
@@ -1610,16 +1630,18 @@ class TelegramBot:
1610
1630
  tracker = UsageTracker(item, base_dir)
1611
1631
  summary = tracker.get_summary()
1612
1632
  if summary["total_cycles"] > 0:
1633
+ tokens = summary.get("total_tokens", 0)
1613
1634
  lines.append(f"📁 *{item.name}*")
1614
- lines.append(f" {summary['total_cycles']} cycles · {summary['total_hours']:.1f}hrs · ${summary['total_cost_usd']:.2f}")
1635
+ lines.append(f" {summary['total_cycles']} cycles · {fmt_tokens(tokens)} tokens · ${summary['total_cost_usd']:.2f}")
1615
1636
  total_cost += summary["total_cost_usd"]
1616
1637
  total_cycles += summary["total_cycles"]
1617
1638
  total_hours += summary["total_hours"]
1639
+ total_tokens += tokens
1618
1640
  except:
1619
1641
  pass
1620
1642
 
1621
1643
  if total_cycles > 0:
1622
- lines.append(f"\n💰 *Total:* {total_cycles} cycles · {total_hours:.1f}hrs · ${total_cost:.2f}")
1644
+ lines.append(f"\n💰 *Total:* {total_cycles} cycles · {fmt_tokens(total_tokens)} tokens · ${total_cost:.2f}")
1623
1645
  else:
1624
1646
  lines.append("_No usage yet_")
1625
1647
 
@@ -776,33 +776,50 @@ class UsageTracker:
776
776
  self,
777
777
  model: str,
778
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
779
+ input_tokens: int = 0,
780
+ output_tokens: int = 0,
781
781
  duration_seconds: float = 0,
782
782
  ) -> dict:
783
- """Record a cycle's usage based on actual duration."""
783
+ """Record a cycle's usage based on actual duration and tokens."""
784
784
  # Calculate cost based on time (more accurate for subscriptions)
785
785
  hourly_rate = self.COST_PER_HOUR.get(model, self.COST_PER_HOUR["opus"])
786
786
  cost = (duration_seconds / 3600) * hourly_rate
787
787
 
788
+ # Initialize token tracking if not present
789
+ if "total_input_tokens" not in self._usage:
790
+ self._usage["total_input_tokens"] = 0
791
+ self._usage["total_output_tokens"] = 0
792
+
788
793
  # Update project totals
789
794
  self._usage["total_cycles"] += 1
790
795
  self._usage["total_duration_seconds"] += duration_seconds
791
796
  self._usage["total_cost_usd"] += cost
797
+ self._usage["total_input_tokens"] += input_tokens
798
+ self._usage["total_output_tokens"] += output_tokens
792
799
 
793
800
  # By model
794
801
  if model not in self._usage["by_model"]:
795
- self._usage["by_model"][model] = {"cycles": 0, "duration": 0.0, "cost": 0.0}
802
+ self._usage["by_model"][model] = {
803
+ "cycles": 0, "duration": 0.0, "cost": 0.0,
804
+ "input_tokens": 0, "output_tokens": 0
805
+ }
796
806
  self._usage["by_model"][model]["cycles"] += 1
797
807
  self._usage["by_model"][model]["duration"] += duration_seconds
798
808
  self._usage["by_model"][model]["cost"] += cost
809
+ self._usage["by_model"][model]["input_tokens"] += input_tokens
810
+ self._usage["by_model"][model]["output_tokens"] += output_tokens
799
811
 
800
812
  # By agent
801
813
  if agent_type not in self._usage["by_agent"]:
802
- self._usage["by_agent"][agent_type] = {"cycles": 0, "duration": 0.0, "cost": 0.0}
814
+ self._usage["by_agent"][agent_type] = {
815
+ "cycles": 0, "duration": 0.0, "cost": 0.0,
816
+ "input_tokens": 0, "output_tokens": 0
817
+ }
803
818
  self._usage["by_agent"][agent_type]["cycles"] += 1
804
819
  self._usage["by_agent"][agent_type]["duration"] += duration_seconds
805
820
  self._usage["by_agent"][agent_type]["cost"] += cost
821
+ self._usage["by_agent"][agent_type]["input_tokens"] += input_tokens
822
+ self._usage["by_agent"][agent_type]["output_tokens"] += output_tokens
806
823
 
807
824
  # Session log
808
825
  self._usage["sessions"].append({
@@ -811,6 +828,8 @@ class UsageTracker:
811
828
  "agent": agent_type,
812
829
  "duration": round(duration_seconds, 1),
813
830
  "cost": round(cost, 4),
831
+ "input_tokens": input_tokens,
832
+ "output_tokens": output_tokens,
814
833
  })
815
834
  # Keep last 500 sessions
816
835
  self._usage["sessions"] = self._usage["sessions"][-500:]
@@ -820,12 +839,20 @@ class UsageTracker:
820
839
  # Update GLOBAL 5-hour window (shared across all projects)
821
840
  window_status = self._global_window.record_usage(duration_seconds)
822
841
 
842
+ total_tokens = self._usage["total_input_tokens"] + self._usage["total_output_tokens"]
843
+
823
844
  return {
824
845
  "cycle_cost": round(cost, 2),
825
846
  "total_cost": round(self._usage["total_cost_usd"], 2),
826
847
  "total_cycles": self._usage["total_cycles"],
827
848
  "cycle_duration": round(duration_seconds, 1),
828
849
  "total_duration": round(self._usage["total_duration_seconds"], 1),
850
+ "cycle_input_tokens": input_tokens,
851
+ "cycle_output_tokens": output_tokens,
852
+ "cycle_tokens": input_tokens + output_tokens,
853
+ "total_input_tokens": self._usage["total_input_tokens"],
854
+ "total_output_tokens": self._usage["total_output_tokens"],
855
+ "total_tokens": total_tokens,
829
856
  "window_usage_pct": window_status["usage_pct"],
830
857
  "window_duration": window_status["used_seconds"],
831
858
  "window_hours": window_status["used_hours"],
@@ -839,10 +866,17 @@ class UsageTracker:
839
866
  # Get global window status
840
867
  window_status = self._global_window.get_status()
841
868
 
869
+ # Token totals (with backwards compatibility)
870
+ total_input = self._usage.get("total_input_tokens", 0)
871
+ total_output = self._usage.get("total_output_tokens", 0)
872
+
842
873
  return {
843
874
  "total_cycles": self._usage["total_cycles"],
844
875
  "total_cost_usd": round(self._usage["total_cost_usd"], 2),
845
876
  "total_hours": round(total_hours, 2),
877
+ "total_input_tokens": total_input,
878
+ "total_output_tokens": total_output,
879
+ "total_tokens": total_input + total_output,
846
880
  "window_hours": window_status["used_hours"],
847
881
  "window_usage_pct": window_status["usage_pct"],
848
882
  "window_remaining_hours": window_status["remaining_hours"],
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nullabot"
3
- version = "1.0.1"
3
+ version = "1.0.3"
4
4
  description = "24/7 AI agents that think, design, and code - controlled via Telegram"
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes