From 19e253ec43b805cd3b6c78f9e28ff654a15f6494 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Wed, 25 Feb 2026 12:10:53 +0000 Subject: [PATCH] feat(heartbeat): save emails to KB + fix memory symlink access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - heartbeat saves unread whitelisted emails via email_process --save --json - fix: add --add-dir so Claude CLI subprocess can access memory/ symlink - email_check/process: use BODY.PEEK[] to avoid marking emails as read - email_process: simplify credential loading via credential_store only - config: heartbeat interval 30→120min, quiet hours end 08→07 Co-Authored-By: Claude Sonnet 4.6 --- config.json | 4 +-- personality/HEARTBEAT.md | 10 +++++- src/heartbeat.py | 49 +++++++++++++++++++---------- tools/email_check.py | 2 +- tools/email_process.py | 68 ++++++++++++++++------------------------ 5 files changed, 72 insertions(+), 61 deletions(-) diff --git a/config.json b/config.json index 04d4ac4..1d6e032 100644 --- a/config.json +++ b/config.json @@ -26,10 +26,10 @@ }, "heartbeat": { "enabled": true, - "interval_minutes": 30, + "interval_minutes": 120, "channel": "echo-core", "model": "haiku", - "quiet_hours": [23, 8], + "quiet_hours": [23, 7], "checks": { "email": true, "calendar": true, diff --git a/personality/HEARTBEAT.md b/personality/HEARTBEAT.md index ec7681f..8fa240e 100644 --- a/personality/HEARTBEAT.md +++ b/personality/HEARTBEAT.md @@ -3,4 +3,12 @@ ## Reguli - **Noapte (23:00-08:00):** Doar HEARTBEAT_OK, nu deranja -- **Nu spama:** Dacă nu e nimic, HEARTBEAT_OK \ No newline at end of file +- **Nu spama:** Dacă nu e nimic, HEARTBEAT_OK + +## Flux emailuri + +Dacă check-urile Python raportează emailuri salvate (ex: "Email: 2 salvate (subiect1, subiect2)"): +1. Citește fișierele din `memory/kb/emails/` care au `` în TL;DR +2. Completează **TL;DR** cu rezumat concis (3-5 rânduri) +3. Completează **Insights** cu idei acționabile și tag-uri (@work @health @growth etc) +4. Salvează fișierul actualizat \ No newline at end of file diff --git a/src/heartbeat.py b/src/heartbeat.py index f341aee..2e5ed46 100644 --- a/src/heartbeat.py +++ b/src/heartbeat.py @@ -144,22 +144,43 @@ def _is_quiet_hour(hour: int, quiet_hours: tuple[int, int]) -> bool: def _check_email(state: dict) -> str | None: - """Check for new emails via tools/email_check.py. Parses JSON output.""" - script = TOOLS_DIR / "email_check.py" - if not script.exists(): + """Save unread whitelisted emails as notes via tools/email_process.py. + + Uses --save --json to process and mark as read only after saving. + Falls back to email_check.py for reporting if no emails to save. + """ + process_script = TOOLS_DIR / "email_process.py" + check_script = TOOLS_DIR / "email_check.py" + + # First: save unread emails as notes + if process_script.exists(): + try: + result = subprocess.run( + ["python3", str(process_script), "--save", "--json"], + capture_output=True, text=True, timeout=60, + cwd=str(PROJECT_ROOT) + ) + if result.returncode == 0 and result.stdout.strip(): + saved = json.loads(result.stdout.strip()) + ok_saves = [r for r in saved if r.get("ok")] + if ok_saves: + subjects = [r.get("subject", "?") for r in ok_saves[:5]] + return f"Email: {len(ok_saves)} salvate ({', '.join(subjects)})" + except Exception as e: + log.warning("Email process failed: %s", e) + + # Fallback: just report unread count (without marking as read) + if not check_script.exists(): return None try: result = subprocess.run( - ["python3", str(script)], + ["python3", str(check_script)], capture_output=True, text=True, timeout=30, cwd=str(PROJECT_ROOT) ) if result.returncode != 0: return None - output = result.stdout.strip() - if not output: - return None - data = json.loads(output) + data = json.loads(result.stdout.strip()) if not data.get("ok"): return None count = data.get("unread_count", 0) @@ -167,14 +188,7 @@ def _check_email(state: dict) -> str | None: return None emails = data.get("emails", []) subjects = [e.get("subject", "?") for e in emails[:5]] - subject_list = ", ".join(subjects) - return f"Email: {count} necitite ({subject_list})" - except json.JSONDecodeError: - # Fallback: treat as plain text - output = result.stdout.strip() - if output and output != "0": - return f"Email: {output}" - return None + return f"Email: {count} necitite ({', '.join(subjects)})" except Exception as e: log.warning("Email check failed: %s", e) return None @@ -421,10 +435,13 @@ def _run_claude_extra(hb_config: dict, python_results: list[str], ) prompt = "\n\n".join(context_parts) + # Resolve symlink for memory/ so Claude CLI can access it from outside project root + memory_real = (PROJECT_ROOT / "memory").resolve() cmd = [ CLAUDE_BIN, "-p", prompt, "--model", model, "--output-format", "json", + "--add-dir", str(memory_real), ] try: diff --git a/tools/email_check.py b/tools/email_check.py index ff0c564..9ba30d5 100644 --- a/tools/email_check.py +++ b/tools/email_check.py @@ -77,7 +77,7 @@ def check_inbox(unread_only=True, limit=10): emails = [] for eid in reversed(email_ids): # Newest first - status, msg_data = mail.fetch(eid, "(RFC822)") + status, msg_data = mail.fetch(eid, "(BODY.PEEK[])") if status != "OK": continue diff --git a/tools/email_process.py b/tools/email_process.py index 6b9625f..46a2e13 100755 --- a/tools/email_process.py +++ b/tools/email_process.py @@ -11,7 +11,6 @@ Usage: import imaplib import email -import os import sys import re import json @@ -19,34 +18,13 @@ from email.header import decode_header from datetime import datetime from pathlib import Path -# Try keyring first, fall back to .env -sys.path.insert(0, str(Path(__file__).parent.parent)) -try: - from src.credential_store import get_secret -except ImportError: - get_secret = lambda name: None +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from src.credential_store import get_secret -def _get(keyring_name, env_name, default=''): - val = get_secret(keyring_name) - if val: - return val - return os.environ.get(env_name, default) - -# Load .env as fallback -env_path = Path(__file__).parent.parent / '.env' -if env_path.exists(): - with open(env_path) as f: - for line in f: - line = line.strip() - if line and not line.startswith('#') and '=' in line: - key, value = line.split('=', 1) - os.environ.setdefault(key, value) - -# Config -IMAP_SERVER = _get('email_server', 'EMAIL_SERVER', 'mail.romfast.ro') +IMAP_SERVER = get_secret("email_server") or "mail.romfast.ro" IMAP_PORT = 993 -IMAP_USER = _get('email_user', 'EMAIL_USER', 'echo@romfast.ro') -IMAP_PASS = _get('email_password', 'EMAIL_PASSWORD') +IMAP_USER = get_secret("email_user") or "echo@romfast.ro" +IMAP_PASS = get_secret("email_password") or "" # Whitelist - only process emails from these addresses WHITELIST = [ @@ -54,7 +32,8 @@ WHITELIST = [ 'marius.mutu@romfast.ro', ] -KB_PATH = Path(__file__).parent.parent / 'memory' / 'kb' / 'emails' +PROJECT_ROOT = Path(__file__).resolve().parent.parent +KB_PATH = PROJECT_ROOT / "memory" / "kb" / "emails" def slugify(text: str, max_len: int = 50) -> str: """Convert text to URL-friendly slug""" @@ -121,7 +100,10 @@ def list_emails(show_all=False): emails = [] for eid in email_ids: - status, data = mail.fetch(eid, '(RFC822)') + # BODY.PEEK does not mark as read + status, data = mail.fetch(eid, "(BODY.PEEK[])") + if status != "OK": + continue msg = email.message_from_bytes(data[0][1]) from_addr = decode_mime_header(msg['From']) @@ -227,25 +209,29 @@ def save_unread_emails(): return results if __name__ == "__main__": - if '--save' in sys.argv: + as_json = "--json" in sys.argv + + if "--save" in sys.argv: results = save_unread_emails() - for r in results: - if r['ok']: - print(f"✅ Salvat: {r['file']}") - else: - print(f"❌ Eroare: {r['error']}") - if not results: - print("Niciun email nou de la adrese whitelisted.") + if as_json: + print(json.dumps(results, ensure_ascii=False, indent=2)) + else: + if not results: + print("Niciun email nou de la adrese whitelisted.") + for r in results: + if r["ok"]: + print(f"✅ Salvat: {r['file']}") + else: + print(f"❌ Eroare: {r['error']}") else: - show_all = '--all' in sys.argv + show_all = "--all" in sys.argv emails = list_emails(show_all=show_all) - if not emails: print("Inbox gol." if show_all else "Niciun email necitit.") else: for em in emails: - wl = "✅" if em['whitelisted'] else "⚠️" + wl = "✅" if em["whitelisted"] else "⚠️" print(f"{wl} [{em['id']}] {em['subject']}") print(f" De la: {em['from']}") - print(f" Data: {em['date']}") + print(f" Data: {em['date']}") print()