Files
roadpad/offline.py

304 lines
10 KiB
Python

"""
Lucidia Offline Mode - Sovereign AI caching.
When backends are unavailable, Lucidia can still help using:
1. Cached responses from previous conversations
2. Fuzzy matching to find similar queries
3. Local command execution for known patterns
"""
import os
import json
import hashlib
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, field
# ═══════════════════════════════════════════════════════════════════════════════
# RESPONSE CACHE
# ═══════════════════════════════════════════════════════════════════════════════
CACHE_DIR = Path.home() / ".lucidia" / "cache"
@dataclass
class CachedResponse:
"""A cached query-response pair."""
query: str
query_hash: str
response: str
backend: str
created: str
hits: int = 0
category: str = "chat"
class ResponseCache:
"""
Persistent cache for Lucidia responses.
Enables offline operation with smart retrieval.
"""
def __init__(self, max_entries: int = 1000):
self.cache_dir = CACHE_DIR
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.cache_file = self.cache_dir / "responses.json"
self.max_entries = max_entries
self.entries: Dict[str, CachedResponse] = {}
self._load()
def _load(self):
"""Load cache from disk."""
if self.cache_file.exists():
try:
with open(self.cache_file) as f:
data = json.load(f)
for key, entry in data.items():
self.entries[key] = CachedResponse(**entry)
except:
self.entries = {}
def _save(self):
"""Save cache to disk."""
data = {
key: {
"query": e.query,
"query_hash": e.query_hash,
"response": e.response,
"backend": e.backend,
"created": e.created,
"hits": e.hits,
"category": e.category
}
for key, e in self.entries.items()
}
with open(self.cache_file, "w") as f:
json.dump(data, f, indent=2)
def _hash(self, query: str) -> str:
"""Hash a query for lookup."""
# Normalize: lowercase, strip whitespace, remove punctuation
normalized = query.lower().strip()
return hashlib.sha256(normalized.encode()).hexdigest()[:16]
def _normalize(self, query: str) -> str:
"""Normalize query for comparison."""
return " ".join(query.lower().split())
def cache(self, query: str, response: str, backend: str, category: str = "chat") -> None:
"""Cache a response."""
query_hash = self._hash(query)
self.entries[query_hash] = CachedResponse(
query=query,
query_hash=query_hash,
response=response,
backend=backend,
created=datetime.now().isoformat(),
hits=0,
category=category
)
# Prune if over limit (remove oldest, least-hit entries)
if len(self.entries) > self.max_entries:
sorted_entries = sorted(
self.entries.items(),
key=lambda x: (x[1].hits, x[1].created)
)
for key, _ in sorted_entries[:len(self.entries) - self.max_entries]:
del self.entries[key]
self._save()
def get(self, query: str) -> Optional[CachedResponse]:
"""Get exact match from cache."""
query_hash = self._hash(query)
if query_hash in self.entries:
entry = self.entries[query_hash]
entry.hits += 1
self._save()
return entry
return None
def search(self, query: str, threshold: float = 0.6) -> List[CachedResponse]:
"""
Fuzzy search for similar queries.
Returns matches sorted by similarity.
"""
results = []
query_words = set(self._normalize(query).split())
for entry in self.entries.values():
entry_words = set(self._normalize(entry.query).split())
# Jaccard similarity
if query_words and entry_words:
intersection = len(query_words & entry_words)
union = len(query_words | entry_words)
similarity = intersection / union
if similarity >= threshold:
results.append((similarity, entry))
# Sort by similarity descending
results.sort(key=lambda x: x[0], reverse=True)
return [entry for _, entry in results[:5]]
def get_stats(self) -> Dict:
"""Get cache statistics."""
if not self.entries:
return {"entries": 0, "total_hits": 0}
return {
"entries": len(self.entries),
"total_hits": sum(e.hits for e in self.entries.values()),
"categories": list(set(e.category for e in self.entries.values())),
"backends": list(set(e.backend for e in self.entries.values())),
}
# ═══════════════════════════════════════════════════════════════════════════════
# LOCAL COMMAND PATTERNS
# ═══════════════════════════════════════════════════════════════════════════════
# Patterns that Lucidia can handle locally without backends
LOCAL_PATTERNS = {
# File operations
r"list files": "ls -la",
r"show files": "ls -la",
r"what files": "ls -la",
r"current directory": "pwd",
r"where am i": "pwd",
# Git
r"git status": "git status",
r"git log": "git log --oneline -10",
r"git branch": "git branch -a",
r"show branches": "git branch -a",
# System
r"disk space": "df -h",
r"memory usage": "free -h 2>/dev/null || vm_stat",
r"uptime": "uptime",
r"date": "date",
r"time": "date",
# Network
r"ip address": "hostname -I 2>/dev/null || ipconfig getifaddr en0",
r"network interfaces": "ifconfig 2>/dev/null || ip addr",
}
def match_local_pattern(query: str) -> Optional[str]:
"""
Check if query matches a local command pattern.
Returns the command to execute, or None.
"""
import re
query_lower = query.lower()
for pattern, command in LOCAL_PATTERNS.items():
if re.search(pattern, query_lower):
return command
return None
def execute_local(command: str) -> Tuple[bool, str]:
"""Execute a local command safely."""
import subprocess
# Safety check - only allow read-only commands
dangerous = ["rm", "mv", "cp", "chmod", "chown", "sudo", ">", ">>", "|"]
if any(d in command for d in dangerous):
return False, "blocked: potentially destructive command"
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=10
)
output = result.stdout or result.stderr
return True, output.strip()
except subprocess.TimeoutExpired:
return False, "command timed out"
except Exception as e:
return False, str(e)
# ═══════════════════════════════════════════════════════════════════════════════
# OFFLINE MODE HANDLER
# ═══════════════════════════════════════════════════════════════════════════════
class OfflineHandler:
"""
Handles queries when backends are unavailable.
Combines cached responses with local command execution.
"""
def __init__(self):
self.cache = ResponseCache()
self.offline_responses = 0
self.local_commands = 0
def can_handle(self, query: str) -> bool:
"""Check if we can handle this query offline."""
# Check cache
if self.cache.get(query):
return True
# Check local patterns
if match_local_pattern(query):
return True
# Check fuzzy cache matches
if self.cache.search(query, threshold=0.7):
return True
return False
def handle(self, query: str) -> Tuple[bool, str, str]:
"""
Handle a query offline.
Returns (success, response, source).
"""
# 1. Check exact cache match
cached = self.cache.get(query)
if cached:
self.offline_responses += 1
return True, cached.response, f"cache ({cached.backend})"
# 2. Check local command pattern
command = match_local_pattern(query)
if command:
success, output = execute_local(command)
if success:
self.local_commands += 1
return True, f"```\n{output}\n```", "local command"
# 3. Check fuzzy cache matches
similar = self.cache.search(query, threshold=0.6)
if similar:
best = similar[0]
self.offline_responses += 1
response = f"(Similar to: \"{best.query[:50]}...\")\n\n{best.response}"
return True, response, f"fuzzy cache ({best.backend})"
return False, "No cached response available", "none"
def cache_response(self, query: str, response: str, backend: str, category: str = "chat"):
"""Store a response for future offline use."""
self.cache.cache(query, response, backend, category)
def get_stats(self) -> Dict:
"""Get offline mode statistics."""
return {
"cache": self.cache.get_stats(),
"offline_responses": self.offline_responses,
"local_commands": self.local_commands,
}