feat(heartbeat): save emails to KB + fix memory symlink access

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 12:10:53 +00:00
parent 08c330a371
commit 19e253ec43
5 changed files with 72 additions and 61 deletions

View File

@@ -26,10 +26,10 @@
}, },
"heartbeat": { "heartbeat": {
"enabled": true, "enabled": true,
"interval_minutes": 30, "interval_minutes": 120,
"channel": "echo-core", "channel": "echo-core",
"model": "haiku", "model": "haiku",
"quiet_hours": [23, 8], "quiet_hours": [23, 7],
"checks": { "checks": {
"email": true, "email": true,
"calendar": true, "calendar": true,

View File

@@ -3,4 +3,12 @@
## Reguli ## Reguli
- **Noapte (23:00-08:00):** Doar HEARTBEAT_OK, nu deranja - **Noapte (23:00-08:00):** Doar HEARTBEAT_OK, nu deranja
- **Nu spama:** Dacă nu e nimic, HEARTBEAT_OK - **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 `<!-- Echo: completează cu rezumat -->` î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

View File

@@ -144,22 +144,43 @@ def _is_quiet_hour(hour: int, quiet_hours: tuple[int, int]) -> bool:
def _check_email(state: dict) -> str | None: def _check_email(state: dict) -> str | None:
"""Check for new emails via tools/email_check.py. Parses JSON output.""" """Save unread whitelisted emails as notes via tools/email_process.py.
script = TOOLS_DIR / "email_check.py"
if not script.exists(): 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 return None
try: try:
result = subprocess.run( result = subprocess.run(
["python3", str(script)], ["python3", str(check_script)],
capture_output=True, text=True, timeout=30, capture_output=True, text=True, timeout=30,
cwd=str(PROJECT_ROOT) cwd=str(PROJECT_ROOT)
) )
if result.returncode != 0: if result.returncode != 0:
return None return None
output = result.stdout.strip() data = json.loads(result.stdout.strip())
if not output:
return None
data = json.loads(output)
if not data.get("ok"): if not data.get("ok"):
return None return None
count = data.get("unread_count", 0) count = data.get("unread_count", 0)
@@ -167,14 +188,7 @@ def _check_email(state: dict) -> str | None:
return None return None
emails = data.get("emails", []) emails = data.get("emails", [])
subjects = [e.get("subject", "?") for e in emails[:5]] subjects = [e.get("subject", "?") for e in emails[:5]]
subject_list = ", ".join(subjects) return f"Email: {count} necitite ({', '.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
except Exception as e: except Exception as e:
log.warning("Email check failed: %s", e) log.warning("Email check failed: %s", e)
return None return None
@@ -421,10 +435,13 @@ def _run_claude_extra(hb_config: dict, python_results: list[str],
) )
prompt = "\n\n".join(context_parts) 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 = [ cmd = [
CLAUDE_BIN, "-p", prompt, CLAUDE_BIN, "-p", prompt,
"--model", model, "--model", model,
"--output-format", "json", "--output-format", "json",
"--add-dir", str(memory_real),
] ]
try: try:

View File

@@ -77,7 +77,7 @@ def check_inbox(unread_only=True, limit=10):
emails = [] emails = []
for eid in reversed(email_ids): # Newest first 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": if status != "OK":
continue continue

View File

@@ -11,7 +11,6 @@ Usage:
import imaplib import imaplib
import email import email
import os
import sys import sys
import re import re
import json import json
@@ -19,34 +18,13 @@ from email.header import decode_header
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
# Try keyring first, fall back to .env sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.path.insert(0, str(Path(__file__).parent.parent)) from src.credential_store import get_secret
try:
from src.credential_store import get_secret
except ImportError:
get_secret = lambda name: None
def _get(keyring_name, env_name, default=''): IMAP_SERVER = get_secret("email_server") or "mail.romfast.ro"
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_PORT = 993 IMAP_PORT = 993
IMAP_USER = _get('email_user', 'EMAIL_USER', 'echo@romfast.ro') IMAP_USER = get_secret("email_user") or "echo@romfast.ro"
IMAP_PASS = _get('email_password', 'EMAIL_PASSWORD') IMAP_PASS = get_secret("email_password") or ""
# Whitelist - only process emails from these addresses # Whitelist - only process emails from these addresses
WHITELIST = [ WHITELIST = [
@@ -54,7 +32,8 @@ WHITELIST = [
'marius.mutu@romfast.ro', '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: def slugify(text: str, max_len: int = 50) -> str:
"""Convert text to URL-friendly slug""" """Convert text to URL-friendly slug"""
@@ -121,7 +100,10 @@ def list_emails(show_all=False):
emails = [] emails = []
for eid in email_ids: 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]) msg = email.message_from_bytes(data[0][1])
from_addr = decode_mime_header(msg['From']) from_addr = decode_mime_header(msg['From'])
@@ -227,25 +209,29 @@ def save_unread_emails():
return results return results
if __name__ == "__main__": if __name__ == "__main__":
if '--save' in sys.argv: as_json = "--json" in sys.argv
if "--save" in sys.argv:
results = save_unread_emails() results = save_unread_emails()
for r in results: if as_json:
if r['ok']: print(json.dumps(results, ensure_ascii=False, indent=2))
print(f"✅ Salvat: {r['file']}") else:
else: if not results:
print(f"❌ Eroare: {r['error']}") print("Niciun email nou de la adrese whitelisted.")
if not results: for r in results:
print("Niciun email nou de la adrese whitelisted.") if r["ok"]:
print(f"✅ Salvat: {r['file']}")
else:
print(f"❌ Eroare: {r['error']}")
else: else:
show_all = '--all' in sys.argv show_all = "--all" in sys.argv
emails = list_emails(show_all=show_all) emails = list_emails(show_all=show_all)
if not emails: if not emails:
print("Inbox gol." if show_all else "Niciun email necitit.") print("Inbox gol." if show_all else "Niciun email necitit.")
else: else:
for em in emails: for em in emails:
wl = "" if em['whitelisted'] else "⚠️" wl = "" if em["whitelisted"] else "⚠️"
print(f"{wl} [{em['id']}] {em['subject']}") print(f"{wl} [{em['id']}] {em['subject']}")
print(f" De la: {em['from']}") print(f" De la: {em['from']}")
print(f" Data: {em['date']}") print(f" Data: {em['date']}")
print() print()