"""Echo Core fast commands — no-LLM instant responses. Dispatch table with handlers for quick operations: git, email, calendar, notes, search, reminders, and diagnostics. """ import json import logging import os import shutil import subprocess import threading from datetime import datetime, date, timedelta from pathlib import Path from typing import Callable log = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).resolve().parent.parent TOOLS_DIR = PROJECT_ROOT / "tools" MEMORY_DIR = PROJECT_ROOT / "memory" LOGS_DIR = PROJECT_ROOT / "logs" # --------------------------------------------------------------------------- # Git # --------------------------------------------------------------------------- def cmd_commit(args: list[str]) -> str: """git add -A + commit with auto or custom message.""" status = _git("status", "--porcelain") if not status.strip(): return "Nothing to commit." files = [l for l in status.strip().split("\n") if l.strip()] file_count = len(files) if args: message = " ".join(args) else: message = _auto_commit_message(files) _git("add", "-A") result = _git("commit", "-m", message) # Extract short hash short_hash = _git("rev-parse", "--short", "HEAD").strip() return f"Committed: {short_hash} — {message} ({file_count} files)" def cmd_push(args: list[str]) -> str: """git push with safety checks.""" # Check for uncommitted changes status = _git("status", "--porcelain") if status.strip(): uncommitted = len([l for l in status.strip().split("\n") if l.strip()]) return f"Warning: {uncommitted} uncommitted changes. Commit first." result = _git("push") if "Everything up-to-date" in result or "up-to-date" in result.lower(): return "Already up to date." branch = _git("branch", "--show-current").strip() return f"Pushed to origin/{branch}." def cmd_pull(args: list[str]) -> str: """git pull --rebase.""" result = _git("pull", "--rebase") if "Already up to date" in result: return "Already up to date." if "Fast-forward" in result or "rewinding" in result.lower(): # Count commits pulled lines = [l for l in result.split("\n") if l.strip()] return f"Pulled updates ({len(lines)} lines of output)." return result.strip()[:500] # --------------------------------------------------------------------------- # Test & Dev # --------------------------------------------------------------------------- def cmd_test(args: list[str]) -> str: """Run pytest tests.""" cmd = ["python3", "-m", "pytest", "tests/", "-q", "--tb=short"] if args: cmd.extend(["-k", " ".join(args)]) try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT), ) output = proc.stdout.strip() if proc.stderr and proc.returncode != 0: output += "\n" + proc.stderr.strip() # Return last few lines (summary) lines = output.split("\n") summary = "\n".join(lines[-10:]) if len(lines) > 10 else output return summary except subprocess.TimeoutExpired: return "Tests timed out (120s limit)." # --------------------------------------------------------------------------- # Email # --------------------------------------------------------------------------- def cmd_email(args: list[str]) -> str: """Dispatch: /email, /email send ..., /email save, /email digest.""" if not args: return _email_check() sub = args[0].lower() if sub == "send": return _email_send(args[1:]) if sub == "save": return _email_save() if sub == "digest": return _email_digest() if sub == "forward": return _email_forward() return f"Unknown email sub-command: {sub}. Use: /email, /email send, /email save, /email digest, /email forward" def _email_check() -> str: """Check unread emails.""" script = TOOLS_DIR / "email_check.py" if not script.exists(): return "email_check.py not found." try: proc = subprocess.run( ["python3", str(script)], capture_output=True, text=True, timeout=30, cwd=str(PROJECT_ROOT), ) if proc.returncode != 0: return f"Email check failed: {proc.stderr.strip()[:200]}" data = json.loads(proc.stdout) if not data.get("ok"): return f"Email error: {data.get('error', 'unknown')}" count = data.get("unread_count", 0) if count == 0: return "Inbox curat." emails = data.get("emails", []) parts = [f"{count} necitite:\n"] for e in emails[:5]: sender = e.get("from", "?") subject = e.get("subject", "?") body = e.get("body_preview", "").strip() parts.append(f"**{subject}**\nDe la: {sender}") if body: parts.append(body[:500]) parts.append("") return "\n".join(parts).strip() except json.JSONDecodeError: return proc.stdout.strip()[:300] if proc.stdout else "Email check: no output." except Exception as e: return f"Email check error: {e}" def _email_send(args: list[str]) -> str: """Send email: /email send :: .""" raw = " ".join(args) if "::" not in raw: return "Format: /email send :: " header, body = raw.split("::", 1) header_parts = header.strip().split(None, 1) if len(header_parts) < 2: return "Format: /email send :: " to_addr = header_parts[0] subject = header_parts[1] body = body.strip() script = TOOLS_DIR / "email_send.py" if not script.exists(): return "email_send.py not found." try: proc = subprocess.run( ["python3", str(script), to_addr, subject, body], capture_output=True, text=True, timeout=30, cwd=str(PROJECT_ROOT), ) if proc.returncode != 0: return f"Email send failed: {proc.stderr.strip()[:200]}" data = json.loads(proc.stdout) if data.get("ok"): return f"Email trimis catre {to_addr}." return f"Email error: {data.get('error', 'unknown')}" except Exception as e: return f"Email send error: {e}" def _email_digest() -> str: """Run email digest: save unread emails, generate summaries, send on WhatsApp.""" script = TOOLS_DIR / "email_digest.py" if not script.exists(): return "email_digest.py not found." import sys as _sys def _run(): try: result = subprocess.run( [_sys.executable, str(script)], timeout=120, cwd=str(PROJECT_ROOT), capture_output=True, text=True, ) if result.returncode != 0: log.error("Email digest failed: %s", result.stderr.strip()[:300]) else: log.info("Email digest: %s", result.stdout.strip()[:300]) except Exception as e: log.error("Email digest error: %s", e) threading.Thread(target=_run, daemon=True).start() return "Procesez emailurile... rezumatele vin pe WhatsApp." def _email_forward() -> str: """Forward unread emails directly to WhatsApp without summarizing.""" script = TOOLS_DIR / "email_forward.py" if not script.exists(): return "email_forward.py not found." import sys as _sys def _run(): try: result = subprocess.run( [_sys.executable, str(script)], timeout=60, cwd=str(PROJECT_ROOT), capture_output=True, text=True, ) if result.returncode != 0: log.error("Email forward failed: %s", result.stderr.strip()[:300]) else: log.info("Email forward: %s", result.stdout.strip()[:300]) except Exception as e: log.error("Email forward error: %s", e) threading.Thread(target=_run, daemon=True).start() return "Forwardez emailurile pe WhatsApp..." def _email_save() -> str: """Save unread emails as KB notes.""" script = TOOLS_DIR / "email_process.py" if not script.exists(): return "email_process.py not found." try: proc = subprocess.run( ["python3", str(script), "--save"], capture_output=True, text=True, timeout=60, cwd=str(PROJECT_ROOT), ) output = proc.stdout.strip() if not output or "Niciun email" in output: return "Nimic de salvat." # Count saved lines and index new files saved = output.count("Salvat") if saved: # Index newly saved email files kb_emails = MEMORY_DIR / "kb" / "emails" if kb_emails.exists(): for md in sorted(kb_emails.glob("*.md"))[-saved:]: _index_file_async(md) return f"Salvate {saved} emailuri in KB." return output[:300] except Exception as e: return f"Email save error: {e}" # --------------------------------------------------------------------------- # Calendar # --------------------------------------------------------------------------- def cmd_calendar(args: list[str]) -> str: """Dispatch: /calendar, /calendar week, /calendar busy.""" script = TOOLS_DIR / "calendar_check.py" if not script.exists(): return "calendar_check.py not found." if not args: return _calendar_today(script) sub = args[0].lower() if sub == "week": return _calendar_week(script) if sub == "busy": return _calendar_busy(script) return f"Unknown calendar sub-command: {sub}. Use: /calendar, /calendar week, /calendar busy" def _calendar_today(script: Path) -> str: """Today + tomorrow events.""" try: proc = subprocess.run( ["python3", str(script), "today"], capture_output=True, text=True, timeout=30, cwd=str(PROJECT_ROOT), ) if proc.returncode != 0: return f"Calendar error: {proc.stderr.strip()[:200]}" data = json.loads(proc.stdout) parts = [] today_events = data.get("today", []) tomorrow_events = data.get("tomorrow", []) if today_events: parts.append("Program azi:") for ev in today_events: parts.append(f" {ev.get('time', '?')} — {ev.get('summary', '?')}") else: parts.append("Azi: nimic programat.") if tomorrow_events: parts.append("Maine:") for ev in tomorrow_events: parts.append(f" {ev.get('time', '?')} — {ev.get('summary', '?')}") return "\n".join(parts) except Exception as e: return f"Calendar error: {e}" def _calendar_week(script: Path) -> str: """Week events.""" try: proc = subprocess.run( ["python3", str(script), "week"], capture_output=True, text=True, timeout=30, cwd=str(PROJECT_ROOT), ) if proc.returncode != 0: return f"Calendar error: {proc.stderr.strip()[:200]}" data = json.loads(proc.stdout) events = data.get("events", []) if not events: return "Saptamana libera." week_range = f"{data.get('week_start', '?')} — {data.get('week_end', '?')}" parts = [f"Saptamana {week_range}:"] current_date = None for ev in events: ev_date = ev.get("date", "?") if ev_date != current_date: current_date = ev_date parts.append(f"\n {ev_date}:") parts.append(f" {ev.get('time', '?')} — {ev.get('summary', '?')}") return "\n".join(parts) except Exception as e: return f"Calendar error: {e}" def _calendar_busy(script: Path) -> str: """Am I busy right now?""" try: proc = subprocess.run( ["python3", str(script), "busy"], capture_output=True, text=True, timeout=30, cwd=str(PROJECT_ROOT), ) if proc.returncode != 0: return f"Calendar error: {proc.stderr.strip()[:200]}" data = json.loads(proc.stdout) if data.get("busy"): event = data.get("event", "?") ends = data.get("ends", "?") return f"Ocupat: {event} (pana la {ends})" return "Liber." except Exception as e: return f"Calendar error: {e}" # --------------------------------------------------------------------------- # Notes & Journal # --------------------------------------------------------------------------- def cmd_note(args: list[str]) -> str: """Append a quick note to today's daily file.""" if not args: return "Usage: /note " text = " ".join(args) today = date.today().isoformat() now = datetime.now().strftime("%H:%M") filepath = MEMORY_DIR / f"{today}.md" if not filepath.exists(): filepath.write_text(f"# {today}\n\n", encoding="utf-8") with open(filepath, "a", encoding="utf-8") as f: f.write(f"- [{now}] {text}\n") _index_file_async(filepath) return "Notat." def cmd_jurnal(args: list[str]) -> str: """Append a journal entry under ## Jurnal section in today's file.""" if not args: return "Usage: /jurnal " text = " ".join(args) today = date.today().isoformat() now = datetime.now().strftime("%H:%M") filepath = MEMORY_DIR / f"{today}.md" if not filepath.exists(): filepath.write_text(f"# {today}\n\n## Jurnal\n\n", encoding="utf-8") content = filepath.read_text(encoding="utf-8") else: content = filepath.read_text(encoding="utf-8") if "## Jurnal" not in content: with open(filepath, "a", encoding="utf-8") as f: f.write("\n## Jurnal\n\n") content = filepath.read_text(encoding="utf-8") # Append under ## Jurnal entry = f"**{now}** — {text}\n" # Find position after ## Jurnal and any existing entries jurnal_idx = content.index("## Jurnal") # Find next section or end of file rest = content[jurnal_idx + len("## Jurnal"):] next_section = rest.find("\n## ") if next_section == -1: # Append at end with open(filepath, "a", encoding="utf-8") as f: f.write(f"{entry}\n") else: # Insert before next section insert_pos = jurnal_idx + len("## Jurnal") + next_section new_content = content[:insert_pos] + f"\n{entry}" + content[insert_pos:] filepath.write_text(new_content, encoding="utf-8") _index_file_async(filepath) return "Jurnal actualizat." # --------------------------------------------------------------------------- # Search & KB # --------------------------------------------------------------------------- def cmd_search(args: list[str]) -> str: """Semantic search over memory/ files.""" if not args: return "Usage: /search " query = " ".join(args) try: from src.memory_search import search results = search(query, top_k=5) if not results: return "Niciun rezultat." parts = [] for r in results: score = f"{r['score']:.2f}" snippet = r["chunk"][:120].replace("\n", " ") parts.append(f" [{score}] {r['file']} — {snippet}") return "Rezultate:\n" + "\n".join(parts) except ConnectionError as e: return f"Search error (Ollama): {e}" except Exception as e: return f"Search error: {e}" def cmd_kb(args: list[str]) -> str: """List recent KB notes, optionally filtered by category.""" index_file = MEMORY_DIR / "kb" / "index.json" if not index_file.exists(): return "KB index not found. Run reindex." try: data = json.loads(index_file.read_text(encoding="utf-8")) notes = data.get("notes", []) if not notes: return "KB gol." category = args[0].lower() if args else None if category: notes = [n for n in notes if n.get("category", "").lower() == category] if not notes: return f"Nicio nota in categoria '{category}'." # Sort by date desc, take top 10 notes.sort(key=lambda n: n.get("date", ""), reverse=True) notes = notes[:10] parts = [] for n in notes: title = n.get("title", "?") d = n.get("date", "?") cat = n.get("category", "") parts.append(f" {d} [{cat}] {title}") return "Ultimele note:\n" + "\n".join(parts) except Exception as e: return f"KB error: {e}" # --------------------------------------------------------------------------- # Reminders # --------------------------------------------------------------------------- def cmd_remind(args: list[str]) -> str: """Create a Google Calendar reminder event. /remind HH:MM text — today at HH:MM /remind YYYY-MM-DD HH:MM text — specific date """ if len(args) < 2: return "Usage: /remind or /remind " # Try to detect date vs time in first arg first = args[0] if "-" in first and len(first) == 10: # YYYY-MM-DD format if len(args) < 3: return "Usage: /remind " day = first time_str = args[1] text = " ".join(args[2:]) else: # HH:MM — today day = date.today().isoformat() time_str = first text = " ".join(args[1:]) # Validate time try: hour, minute = time_str.split(":") int(hour) int(minute) except (ValueError, AttributeError): return f"Invalid time format: {time_str}. Use HH:MM." start_iso = f"{day}T{time_str}:00" try: # Import create_event from calendar_check.py import sys sys.path.insert(0, str(TOOLS_DIR)) from calendar_check import create_event result = create_event(text, start_iso, duration_minutes=30) time_display = time_str return f"Reminder setat: {time_display} — {text}" except Exception as e: return f"Remind error: {e}" finally: # Clean up sys.path if str(TOOLS_DIR) in sys.path: sys.path.remove(str(TOOLS_DIR)) # --------------------------------------------------------------------------- # Ops & Diagnostics # --------------------------------------------------------------------------- def cmd_logs(args: list[str]) -> str: """Show last N lines from echo-core.log.""" n = 20 if args: try: n = int(args[0]) except ValueError: return "Usage: /logs [N] (default 20)" log_file = LOGS_DIR / "echo-core.log" if not log_file.exists(): return "No log file found." try: lines = log_file.read_text(encoding="utf-8").split("\n") tail = lines[-n:] if len(lines) > n else lines return "\n".join(tail).strip() or "Log empty." except Exception as e: return f"Log read error: {e}" def cmd_doctor(args: list[str]) -> str: """Quick diagnostic checks.""" checks = [] # Claude CLI claude_found = shutil.which("claude") is not None checks.append(("Claude CLI", claude_found)) # Keyring secrets count try: from src.credential_store import get_secret import keyring keyring.get_password("echo-core", "_registry") checks.append(("Keyring", True)) except Exception: checks.append(("Keyring", False)) # Config valid config_file = PROJECT_ROOT / "config.json" try: json.loads(config_file.read_text(encoding="utf-8")) checks.append(("config.json", True)) except Exception: checks.append(("config.json", False)) # Ollama reachable (read URL from config) try: import httpx ollama_url = "http://10.0.20.161:11434" try: cfg_data = json.loads(config_file.read_text(encoding="utf-8")) ollama_url = cfg_data.get("ollama", {}).get("url", ollama_url).rstrip("/") except Exception: pass resp = httpx.get(f"{ollama_url}/api/version", timeout=5.0) checks.append(("Ollama", resp.status_code == 200)) except Exception: checks.append(("Ollama", False)) # Disk space try: stat = os.statvfs(str(PROJECT_ROOT)) free_gb = (stat.f_bavail * stat.f_frsize) / (1024 ** 3) checks.append((f"Disk ({free_gb:.1f}GB free)", free_gb >= 1.0)) except OSError: checks.append(("Disk", False)) # Format output lines = [] for name, ok in checks: icon = "OK" if ok else "FAIL" lines.append(f" [{icon}] {name}") passed = sum(1 for _, ok in checks if ok) total = len(checks) header = f"Doctor: {passed}/{total} checks passed" return header + "\n" + "\n".join(lines) def cmd_heartbeat(args: list[str]) -> str: """Force a heartbeat run and return the result.""" try: from src.heartbeat import run_heartbeat from src.config import Config config = Config() result = run_heartbeat(config.data) return f"Heartbeat: {result}" except Exception as e: return f"Heartbeat error: {e}" def cmd_help(args: list[str]) -> str: """List all available fast commands.""" return """Comenzi disponibile: Git: /commit [msg] — Commit all changes (auto-message or custom) /push — Push to remote (with safety checks) /pull — Pull with rebase Dev: /test [pattern] — Run tests (optional -k filter) Email: /email — Check unread emails /email send :: — Send email /email save — Save unread emails to KB /email digest — Rezumate emailuri necitite → WhatsApp /email forward — Forward emailuri necitite direct → WhatsApp (fără rezumat) Calendar: /calendar — Today + tomorrow events /calendar week — This week's schedule /calendar busy — Am I in a meeting now? Notes: /note — Quick note in daily file /jurnal — Journal entry in daily file Search: /search — Semantic search over memory/ /kb [category] — Recent KB notes Reminders: /remind — Reminder today /remind — Reminder on date Ops: /logs [N] — Last N log lines (default 20) /doctor — System diagnostics /heartbeat — Force heartbeat check /help — This message Session: /clear — Clear Claude session /status — Session info /model [name] — Show/change model""" # --------------------------------------------------------------------------- # Dispatch # --------------------------------------------------------------------------- COMMANDS: dict[str, Callable] = { "commit": cmd_commit, "push": cmd_push, "pull": cmd_pull, "test": cmd_test, "email": cmd_email, "calendar": cmd_calendar, "note": cmd_note, "jurnal": cmd_jurnal, "search": cmd_search, "kb": cmd_kb, "remind": cmd_remind, "logs": cmd_logs, "doctor": cmd_doctor, "heartbeat": cmd_heartbeat, "help": cmd_help, } def dispatch(name: str, args: list[str]) -> str | None: """Dispatch a fast command by name. Returns response or None if unknown.""" handler = COMMANDS.get(name) if handler is None: return None try: return handler(args) except Exception as e: log.exception("Fast command /%s failed", name) return f"Error in /{name}: {e}" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _index_file_async(filepath: Path) -> None: """Index a file for semantic search in a background thread.""" def _do_index(): try: from src.memory_search import index_file index_file(filepath) except Exception as e: log.debug("Background index of %s failed: %s", filepath.name, e) threading.Thread(target=_do_index, daemon=True).start() def _git(*args: str) -> str: """Run a git command and return stdout.""" proc = subprocess.run( ["git", *args], capture_output=True, text=True, timeout=30, cwd=str(PROJECT_ROOT), ) if proc.returncode != 0 and proc.stderr: return proc.stderr.strip() return proc.stdout.strip() def _auto_commit_message(files: list[str]) -> str: """Generate a commit message from changed files list (porcelain output).""" areas = set() added = modified = deleted = 0 for line in files: if not line.strip(): continue status_code = line[:2].strip() filename = line[3:] if "/" in filename: areas.add(filename.split("/")[0]) else: areas.add("root") if "A" in status_code or "?" in status_code: added += 1 elif "D" in status_code: deleted += 1 else: modified += 1 parts = [] if added: parts.append(f"+{added}") if modified: parts.append(f"~{modified}") if deleted: parts.append(f"-{deleted}") change_summary = " ".join(parts) if parts else "changes" area_list = ", ".join(sorted(areas)[:3]) if len(areas) > 3: area_list += f" +{len(areas) - 3} more" return f"Update {area_list} ({change_summary})"