Add API usage tracking and dynamic task reloading

Features:
- Usage tracking system (usage_tracker.py)
  - Tracks input/output tokens per API call
  - Calculates costs with support for cache pricing
  - Stores data in usage_data.json (gitignored)
  - Integrated into llm_interface.py

- Dynamic task scheduler reloading
  - Auto-detects YAML changes every 60s
  - No restart needed for new tasks
  - reload_tasks() method for manual refresh

- Example cost tracking scheduled task
  - Daily API usage report
  - Budget tracking ($5/month target)
  - Disabled by default in scheduled_tasks.yaml

Improvements:
- Fixed tool_use/tool_result pair splitting bug (CRITICAL)
- Added thread safety to agent.chat()
- Fixed N+1 query problem in hybrid search
- Optimized database batch queries
- Added conversation history pruning (50 messages max)

Updated .gitignore:
- Exclude user profiles (memory_workspace/users/*.md)
- Exclude usage data (usage_data.json)
- Exclude vector index (vectors.usearch)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 23:38:44 -07:00
parent ab3a5afd59
commit 8afff96bb5
16 changed files with 1096 additions and 244 deletions

View File

@@ -79,6 +79,9 @@ class TaskScheduler:
Callable[[ScheduledTask, str], None]
] = None
# Track file modification time for auto-reload
self._last_mtime: Optional[float] = None
self._load_tasks()
def _load_tasks(self) -> None:
@@ -87,9 +90,13 @@ class TaskScheduler:
self._create_default_config()
return
# Track file modification time
self._last_mtime = self.config_file.stat().st_mtime
with open(self.config_file) as f:
config = yaml.safe_load(f) or {}
self.tasks.clear() # Clear existing tasks before reload
for task_config in config.get("tasks", []):
task = ScheduledTask(
name=task_config["name"],
@@ -187,17 +194,45 @@ class TaskScheduler:
hour, minute = map(int, parts[2].split(":"))
days_ahead = target_day - now.weekday()
if days_ahead <= 0:
if days_ahead < 0:
days_ahead += 7
next_run = now + timedelta(days=days_ahead)
next_run = next_run.replace(
hour=hour, minute=minute, second=0, microsecond=0
)
# If same day but time already passed, advance to next week
if next_run <= now:
next_run += timedelta(days=7)
return next_run
raise ValueError(f"Unknown schedule format: {schedule}")
def reload_tasks(self) -> bool:
"""Reload tasks from config file if it has changed.
Returns:
True if tasks were reloaded, False if no changes detected.
"""
if not self.config_file.exists():
return False
current_mtime = self.config_file.stat().st_mtime
if self._last_mtime is not None and current_mtime == self._last_mtime:
return False
print(f"[Scheduler] Config file changed, reloading tasks...")
self._load_tasks()
if self.running:
print("[Scheduler] Updated task schedule:")
for task in self.tasks:
if task.enabled and task.next_run:
formatted = task.next_run.strftime("%Y-%m-%d %H:%M")
print(f" - {task.name}: next run at {formatted}")
return True
def add_adapter(self, platform: str, adapter: Any) -> None:
"""Register an adapter for sending task outputs."""
self.adapters[platform] = adapter
@@ -233,6 +268,9 @@ class TaskScheduler:
"""Main scheduler loop (runs in background thread)."""
while self.running:
try:
# Auto-reload tasks if config file changed
self.reload_tasks()
now = datetime.now()
for task in self.tasks:
@@ -269,7 +307,11 @@ class TaskScheduler:
threading.Event().wait(_SCHEDULER_POLL_INTERVAL)
def _execute_task(self, task: ScheduledTask) -> None:
"""Execute a single task using the Agent."""
"""Execute a single task using the Agent.
Note: agent.chat() is thread-safe (uses internal lock), so this
can safely run from the scheduler's background thread.
"""
try:
print(f"[Scheduler] Running: {task.name}")
@@ -282,7 +324,19 @@ class TaskScheduler:
print(f" Response: {response[:100]}...")
if task.send_to_platform and task.send_to_channel:
asyncio.run(self._send_to_platform(task, response))
# Use the running event loop if available, otherwise create one.
# asyncio.run() fails if an event loop is already running
# (which it is when the bot is active).
try:
loop = asyncio.get_running_loop()
# Schedule on the existing loop from this background thread
future = asyncio.run_coroutine_threadsafe(
self._send_to_platform(task, response), loop
)
future.result(timeout=30) # Wait up to 30s
except RuntimeError:
# No running loop (e.g., standalone test mode)
asyncio.run(self._send_to_platform(task, response))
if self.on_task_complete:
self.on_task_complete(task, response)