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

@@ -0,0 +1,5 @@
"""Discord adapter package."""
from .adapter import DiscordAdapter
__all__ = ["DiscordAdapter"]

View File

@@ -218,14 +218,19 @@ class AdapterRuntime:
except Exception as e:
print(f"[Runtime] Failed to send progress update: {e}")
# Get response from agent (synchronous call in thread)
response = await asyncio.to_thread(
self.agent.chat,
user_message=processed_message.text,
username=username,
progress_callback=progress_callback,
inbound_message=processed_message,
)
# Check if a preprocessor signaled a block (e.g., child safety filter)
_block_reply = processed_message.metadata.get("_cs_blocked")
if _block_reply:
response = _block_reply
else:
# Get response from agent (synchronous call in thread)
response = await asyncio.to_thread(
self.agent.chat,
user_message=processed_message.text,
username=username,
progress_callback=progress_callback,
inbound_message=processed_message,
)
# Apply postprocessors
for postprocessor in self._postprocessors:

View File

@@ -112,6 +112,13 @@ class SlackAdapter(BaseAdapter):
self.is_running = False
print("[Slack] Disconnected")
def _is_user_allowed(self, user_id: str) -> bool:
"""Return False if an allow-list is configured and this user is not on it."""
allowed = self.config.settings.get("allowed_users", [])
if not allowed:
return True # open if unconfigured — preserves existing behaviour
return str(user_id) in [str(u) for u in allowed]
def _register_handlers(self) -> None:
"""Register Slack event handlers."""
@@ -121,8 +128,19 @@ class SlackAdapter(BaseAdapter):
if event.get("subtype") in ["bot_message", "message_changed"]:
return
# Suppress Slack system notifications (channel privacy changes, etc.)
raw_text = event.get("text", "")
_SUPPRESSED_PATTERNS = [
"made this channel *private*",
"has joined the channel",
]
if any(p in raw_text for p in _SUPPRESSED_PATTERNS):
return
user_id = event.get("user")
text = event.get("text", "")
if not self._is_user_allowed(user_id):
return
text = raw_text
channel = event.get("channel")
thread_ts = event.get("thread_ts")
ts = event.get("ts")
@@ -184,6 +202,8 @@ class SlackAdapter(BaseAdapter):
async def handle_app_mentions(event, say):
"""Handle @mentions of the bot."""
user_id = event.get("user")
if not self._is_user_allowed(user_id):
return
text = self._strip_mention(event.get("text", ""))
channel = event.get("channel")
thread_ts = event.get("thread_ts")

View File

@@ -5,10 +5,11 @@ Uses python-telegram-bot library for async Telegram Bot API integration.
"""
import asyncio
import logging
from typing import Any, Dict, List, Optional
from telegram import Bot, Update
from telegram.error import TelegramError
from telegram.error import NetworkError, TelegramError
from telegram.ext import (
Application,
CommandHandler,
@@ -39,11 +40,15 @@ class TelegramAdapter(BaseAdapter):
- parse_mode: "HTML" or "Markdown" (default: "Markdown")
"""
_RECONNECT_DELAYS = [5, 15, 30, 60, 120] # seconds between startup retries
def __init__(self, config: AdapterConfig) -> None:
super().__init__(config)
self.application: Optional[Application] = None
self.bot: Optional[Bot] = None
self._polling_task: Optional[asyncio.Task] = None
self._watchdog_task: Optional[asyncio.Task] = None
self._logger = logging.getLogger(__name__)
@property
def platform_name(self) -> str:
@@ -70,7 +75,7 @@ class TelegramAdapter(BaseAdapter):
return bool(bot_token and len(bot_token) > 20)
async def start(self) -> None:
"""Start the Telegram bot."""
"""Start the Telegram bot with retry on network failures."""
if not self.validate_config():
raise ValueError(
"Invalid Telegram configuration. Need bot_token"
@@ -89,36 +94,84 @@ class TelegramAdapter(BaseAdapter):
await self.application.initialize()
await self.application.start()
# Run polling in a background task instead of blocking
self._polling_task = asyncio.create_task(
self.application.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True,
)
# start_polling() starts internal Updater machinery and returns quickly.
# Use updater.running (not task state) to detect if polling is alive.
await self.application.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True,
)
self.is_running = True
me = await self.bot.get_me()
print(
f"[Telegram] Bot started: @{me.username} ({me.first_name})"
)
# Verify connectivity with retries — get_me() fails on DNS errors at boot.
me = await self._get_me_with_retry()
if me:
print(f"[Telegram] Bot started: @{me.username} ({me.first_name})")
else:
print("[Telegram] Bot started (could not verify identity — network may be degraded)")
# Watchdog restarts polling if the task dies unexpectedly.
self._watchdog_task = asyncio.create_task(self._polling_watchdog())
async def _get_me_with_retry(self):
"""Call get_me() with exponential backoff. Returns None if all attempts fail."""
for attempt, delay in enumerate(self._RECONNECT_DELAYS, 1):
try:
return await self.bot.get_me()
except NetworkError as e:
if attempt <= len(self._RECONNECT_DELAYS):
print(f"[Telegram] Network error on startup (attempt {attempt}): {e} — retrying in {delay}s")
await asyncio.sleep(delay)
# Last attempt with no retry after
try:
return await self.bot.get_me()
except NetworkError as e:
print(f"[Telegram] Could not reach Telegram API after {len(self._RECONNECT_DELAYS)+1} attempts: {e}")
return None
async def _polling_watchdog(self) -> None:
"""Monitor the Updater and restart polling if it stops unexpectedly."""
# Give polling a moment to fully initialise before we start watching.
await asyncio.sleep(15)
while self.is_running:
await asyncio.sleep(30)
if not self.is_running:
break
if not self.application or self.application.updater.running:
continue # All good
self._logger.warning("[Telegram] Updater is no longer running — attempting restart")
print("[Telegram] Polling dropped — restarting...")
for attempt, delay in enumerate(self._RECONNECT_DELAYS, 1):
await asyncio.sleep(delay)
if not self.is_running:
return
try:
await self.application.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=False,
)
print(f"[Telegram] Polling restarted (attempt {attempt})")
break
except Exception as e:
print(f"[Telegram] Restart attempt {attempt} failed: {e}")
async def stop(self) -> None:
"""Stop the Telegram bot."""
self.is_running = False # Signal watchdog to exit before cancelling tasks
if self._watchdog_task and not self._watchdog_task.done():
self._watchdog_task.cancel()
try:
await self._watchdog_task
except (asyncio.CancelledError, Exception):
pass
if self.application:
print("[Telegram] Stopping bot...")
await self.application.updater.stop()
await self.application.stop()
await self.application.shutdown()
self.is_running = False
if self._polling_task and not self._polling_task.done():
self._polling_task.cancel()
try:
await self._polling_task
except asyncio.CancelledError:
pass
print("[Telegram] Bot stopped")