Digest summarizes unread emails via Claude CLI; forward sends raw content (split to 4096 chars). Wired as /email digest and /email forward slash commands, plus instant per-guild sync on ready. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
782 lines
25 KiB
Python
782 lines
25 KiB
Python
"""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 <to> <subject> :: <body>."""
|
|
raw = " ".join(args)
|
|
if "::" not in raw:
|
|
return "Format: /email send <to> <subject> :: <body>"
|
|
header, body = raw.split("::", 1)
|
|
header_parts = header.strip().split(None, 1)
|
|
if len(header_parts) < 2:
|
|
return "Format: /email send <to> <subject> :: <body>"
|
|
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>"
|
|
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>"
|
|
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>"
|
|
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 <HH:MM> <text> or /remind <YYYY-MM-DD> <HH:MM> <text>"
|
|
|
|
# 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 <YYYY-MM-DD> <HH:MM> <text>"
|
|
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 <to> <subject> :: <body> — 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 <text> — Quick note in daily file
|
|
/jurnal <text> — Journal entry in daily file
|
|
|
|
Search:
|
|
/search <query> — Semantic search over memory/
|
|
/kb [category] — Recent KB notes
|
|
|
|
Reminders:
|
|
/remind <HH:MM> <text> — Reminder today
|
|
/remind <YYYY-MM-DD> <HH:MM> <text> — 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})"
|