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:
112
memory_system.py
112
memory_system.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user