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

@@ -171,6 +171,21 @@ class MemorySystem:
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)"
)
# RSO Phase 2: memory access log for relevance scoring
self.db.execute("""
CREATE TABLE IF NOT EXISTS memory_access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
chunk_id TEXT,
query_preview TEXT,
accessed_at INTEGER NOT NULL
)
""")
self.db.execute(
"CREATE INDEX IF NOT EXISTS idx_mal_path_time "
"ON memory_access_log(path, accessed_at)"
)
# Migration: Add vector_id column if it doesn't exist
try:
self.db.execute("ALTER TABLE chunks ADD COLUMN vector_id INTEGER")
@@ -373,6 +388,32 @@ class MemorySystem:
embeddings = list(self.embedding_model.embed([text]))
return embeddings[0]
def _log_access(self, results: List[Dict], query: str) -> None:
"""Log accessed memory paths for relevance scoring (fire-and-forget)."""
if not results:
return
query_preview = query[:60] if query else ""
now = int(time.time() * 1000)
db_path = str(self.db_path)
def _write():
try:
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.executemany(
"INSERT INTO memory_access_log (path, chunk_id, query_preview, accessed_at) "
"VALUES (?, ?, ?, ?)",
[
(r.get("path", ""), r.get("id"), query_preview, now)
for r in results
],
)
conn.commit()
conn.close()
except Exception as e:
print(f"[MemoryScorer] Access log write failed: {e}")
threading.Thread(target=_write, daemon=True).start()
def search(self, query: str, max_results: int = 5) -> List[Dict]:
"""Search memory using full-text search."""
# Sanitize query to prevent FTS5 injection
@@ -396,7 +437,9 @@ class MemorySystem:
(safe_query, max_results),
).fetchall()
return [dict(row) for row in results]
result_dicts = [dict(row) for row in results]
self._log_access(result_dicts, query)
return result_dicts
def search_hybrid(self, query: str, max_results: int = 5) -> List[Dict]:
"""
@@ -540,7 +583,9 @@ class MemorySystem:
reverse=True
)
return sorted_results[:max_results]
top_results = sorted_results[:max_results]
self._log_access(top_results, query)
return top_results
def compact_conversation(self, user_message: str, assistant_response: str, tools_used: list = None) -> str:
"""Create a compact summary of a conversation for memory storage.
@@ -558,6 +603,12 @@ class MemorySystem:
file_paths = re.findall(r'[a-zA-Z]:[\\\/][\w\\\/\-\.]+\.\w+|[\w\/\-\.]+\.(?:py|md|yaml|yml|json|txt|js|ts)', assistant_response)
file_paths = list(set(file_paths))[:5] # Limit to 5 unique paths
# Truncate long user messages (multi-line prompts, instructions, etc.)
if len(user_message) > 120:
user_summary = user_message[:120].rsplit(' ', 1)[0] + '...'
else:
user_summary = user_message
# Truncate long responses
if len(assistant_response) > 300:
# Try to get first complete sentence or paragraph
@@ -570,7 +621,7 @@ class MemorySystem:
summary = assistant_response
# Build compact entry
compact = f"**User**: {user_message}\n**Action**: {summary}"
compact = f"**User**: {user_summary}\n**Action**: {summary}"
if tools_used:
compact += f"\n**Tools**: {', '.join(tools_used)}"
@@ -580,6 +631,54 @@ class MemorySystem:
return compact
@staticmethod
def is_high_signal(message: str) -> bool:
"""Return True if the message contains information worth richer storage."""
import re
msg = message.lower()
# Explicit memory triggers
if any(t in msg for t in ['remember', 'note that', "don't forget", 'from now on',
'going forward', 'make note', 'keep in mind']):
return True
# New information / hardware / infra
if any(t in msg for t in ['i have a new', "i've got", 'just got', 'setting up',
'new server', 'new vm', 'new machine', 'replacing',
'migrating', 'upgraded']):
return True
# IP addresses
if re.search(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', message):
return True
# Credentials / secrets
if any(t in msg for t in ['password', 'credential', 'api key', 'token', 'secret']):
return True
return False
def rich_conversation(self, user_message: str, assistant_response: str, tools_used: list = None) -> str:
"""Create a rich memory entry for high-signal turns — full user message, longer response."""
import re
file_paths = re.findall(
r'[a-zA-Z]:[\\\/][\w\\\/\-\.]+\.\w+|[\w\/\-\.]+\.(?:py|md|yaml|yml|json|txt|js|ts)',
assistant_response
)
file_paths = list(set(file_paths))[:5]
response_summary = assistant_response[:500] + '...' if len(assistant_response) > 500 else assistant_response
timestamp = datetime.now().strftime('%H:%M')
entry = f"## Notable [{timestamp}]\n**User**: {user_message}\n**Action**: {response_summary}"
if tools_used:
entry += f"\n**Tools**: {', '.join(tools_used)}"
if file_paths:
entry += f"\n**Files**: {', '.join(file_paths[:3])}"
return entry
def write_memory(self, content: str, daily: bool = True) -> None:
"""Write to memory file. Thread-safe via _write_lock."""
with self._write_lock:
@@ -648,6 +747,13 @@ class MemorySystem:
return soul_file.read_text(encoding="utf-8")
return ""
def get_context(self) -> str:
"""Get context.md content (always-loaded operational facts)."""
context_file = self.workspace_dir / "context.md"
if context_file.exists():
return context_file.read_text(encoding="utf-8")
return ""
def get_user(self, username: str) -> str:
"""Get user-specific content."""
# Validate username to prevent path traversal