feat: RSO observation system, child safety, Discord adapter, Telegram watchdog, email attachments

Core agent improvements:
- RSO (Relevance Scoring & Observation) system: interaction_logger, memory_scorer, signal_detector
- Memory access logging (memory_access_log table) for relevance scoring; high-signal turn detection
- Rich conversation storage for notable turns; compact_conversation truncates long user messages
- Task-type classifier (query/action/analysis/creative) for observation tagging
- Nested sub-agent visibility: deep delegations now register against the main agent's manager

Child safety (Gabriel profile):
- child_safety.py: filtering, audit logging, prompt constants for restricted sessions
- .kiro/specs/child-safety-profile: requirements, design, tasks specs
- GABRIEL_BOT_PROPOSAL.md: initial proposal doc
- Reduced context window (10 msgs) and tutor-mode identity for restricted users

Telegram adapter:
- Polling watchdog: auto-restarts updater if polling drops unexpectedly
- get_me() with exponential-backoff retry on NetworkError at startup
- Correct stop() ordering: signal watchdog before cancelling tasks

Email / Gmail:
- send_email: supports file attachments (attachments list param)
- get_email: surfaces attachment metadata in response

Scheduled tasks / weather:
- Remove OpenWeatherMap API calls from morning-weather task; use wttr.in exclusively
- New scheduled tasks and scheduler state persistence

Discord:
- adapters/discord/__init__.py scaffold
- discord-plugin: MCP plugin for Claude Code Discord integration (server.ts, skills, config)

Infrastructure:
- n8n workflow exports (garvis_webhook, content_pipeline variants)
- memory_workspace: context, homelab-repo-updates, weekly observation summaries, error logs
- UCS C240 migration plan doc
- requirements.txt: new deps
- .claude/settings.json, fix_hooks.py: hook/permission tuning
This commit is contained in:
2026-04-23 07:54:01 -06:00
parent 1232490c3b
commit 916f86725d
70 changed files with 10945 additions and 187 deletions

View File

@@ -12,6 +12,7 @@ Example use cases:
"""
import asyncio
import json
import threading
import traceback
from dataclasses import dataclass
@@ -82,6 +83,9 @@ class TaskScheduler:
# Track file modification time for auto-reload
self._last_mtime: Optional[float] = None
# Persistent state: survives restarts so we can recover missed tasks
self._state_file = self.config_file.parent / "scheduler_state.json"
self._load_tasks()
def _load_tasks(self) -> None:
@@ -111,6 +115,7 @@ class TaskScheduler:
self.tasks.append(task)
print(f"[Scheduler] Loaded {len(self.tasks)} task(s)")
self._load_state()
def _create_default_config(self) -> None:
"""Create default scheduled tasks config."""
@@ -233,6 +238,101 @@ class TaskScheduler:
return True
def _load_state(self) -> None:
"""Restore last_run times from disk so missed-task recovery works after restart."""
if not self._state_file.exists():
return
try:
state = json.loads(self._state_file.read_text(encoding="utf-8"))
for task in self.tasks:
if task.name in state:
task.last_run = datetime.fromisoformat(state[task.name])
except Exception as e:
print(f"[Scheduler] Could not load state: {e}")
def _save_state(self) -> None:
"""Persist last_run times to disk."""
try:
state = {
t.name: t.last_run.isoformat()
for t in self.tasks
if t.last_run
}
self._state_file.write_text(
json.dumps(state, indent=2), encoding="utf-8"
)
except Exception as e:
print(f"[Scheduler] Could not save state: {e}")
def _last_scheduled_time(self, schedule: str) -> Optional[datetime]:
"""Return the most recent past occurrence of this schedule."""
now = datetime.now()
parts = schedule.lower().split()
if parts[0] == "daily" and len(parts) >= 2:
hour, minute = map(int, parts[1].split(":"))
t = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if t > now:
t -= timedelta(days=1)
return t
if parts[0] == "weekly" and len(parts) >= 3:
target_day = _DAY_NAMES.index(parts[1])
hour, minute = map(int, parts[2].split(":"))
days_back = (now.weekday() - target_day) % 7
t = (now - timedelta(days=days_back)).replace(
hour=hour, minute=minute, second=0, microsecond=0
)
if t > now:
t -= timedelta(days=7)
return t
return None
def _check_missed_tasks(self) -> None:
"""On startup, immediately run any task that was missed while the bot was down.
Recovery windows:
weekly → 24 hours (bot could be down overnight on the scheduled day)
daily → 2 hours (short window; don't replay stale morning briefings)
hourly → skipped (not worth recovering)
"""
now = datetime.now()
for task in self.tasks:
if not task.enabled:
continue
schedule_type = task.schedule.lower().split()[0]
if schedule_type == "hourly":
continue
recovery_window = (
timedelta(hours=24) if schedule_type == "weekly"
else timedelta(hours=2)
)
last_scheduled = self._last_scheduled_time(task.schedule)
if last_scheduled is None:
continue
# Was the scheduled time within the recovery window?
if now - last_scheduled > recovery_window:
continue
# Did the task already run since the scheduled time?
if task.last_run and task.last_run >= last_scheduled:
continue
print(
f"[Scheduler] Recovering missed task: {task.name} "
f"(was due {last_scheduled.strftime('%Y-%m-%d %H:%M')})"
)
threading.Thread(
target=self._execute_task,
args=(task,),
daemon=True,
).start()
def add_adapter(self, platform: str, adapter: Any) -> None:
"""Register an adapter for sending task outputs."""
self.adapters[platform] = adapter
@@ -249,6 +349,9 @@ class TaskScheduler:
)
self.thread.start()
# Recover any tasks missed while the bot was down before continuing
self._check_missed_tasks()
print(f"[Scheduler] Started with {len(self.tasks)} task(s)")
for task in self.tasks:
if task.enabled and task.next_run:
@@ -323,6 +426,9 @@ class TaskScheduler:
print(f"[Scheduler] Task completed: {task.name}")
print(f" Response: {response[:100]}...")
# Persist last_run so missed-task recovery works after restarts
self._save_state()
if task.send_to_platform and task.send_to_channel:
# Use the running event loop if available, otherwise create one.
# asyncio.run() fails if an event loop is already running