Compare commits
2 Commits
08c330a371
...
006123a63b
| Author | SHA1 | Date | |
|---|---|---|---|
| 006123a63b | |||
| 19e253ec43 |
@@ -25,16 +25,16 @@
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval_minutes": 30,
|
||||
"enabled": false,
|
||||
"interval_minutes": 120,
|
||||
"channel": "echo-core",
|
||||
"model": "haiku",
|
||||
"quiet_hours": [23, 8],
|
||||
"quiet_hours": [23, 7],
|
||||
"checks": {
|
||||
"email": true,
|
||||
"calendar": true,
|
||||
"kb_index": true,
|
||||
"git": true
|
||||
"git": false
|
||||
},
|
||||
"cooldowns": {
|
||||
"email": 1800,
|
||||
|
||||
@@ -3,4 +3,12 @@
|
||||
## Reguli
|
||||
|
||||
- **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
|
||||
@@ -53,6 +53,9 @@ def run_heartbeat(config: dict | None = None) -> str:
|
||||
hour = datetime.now().hour # local hour
|
||||
is_quiet = _is_quiet_hour(hour, quiet_hours)
|
||||
|
||||
if is_quiet:
|
||||
return "HEARTBEAT_OK"
|
||||
|
||||
state = _load_state()
|
||||
checks = state.setdefault("checks", {})
|
||||
results = []
|
||||
@@ -65,8 +68,8 @@ def run_heartbeat(config: dict | None = None) -> str:
|
||||
results.append(email_result)
|
||||
checks["email"] = now.isoformat()
|
||||
|
||||
# Check 2: Calendar — daily summary + next-event reminder (no quiet hours bypass)
|
||||
if check_flags.get("calendar") and not is_quiet and _should_run("calendar", checks, now, cooldowns):
|
||||
# Check 2: Calendar — daily summary + next-event reminder
|
||||
if check_flags.get("calendar") and _should_run("calendar", checks, now, cooldowns):
|
||||
cal_result = _check_calendar_smart(state, quiet_hours)
|
||||
if cal_result:
|
||||
results.append(cal_result)
|
||||
@@ -94,23 +97,14 @@ def run_heartbeat(config: dict | None = None) -> str:
|
||||
checks["embeddings"] = now.isoformat()
|
||||
|
||||
# Claude CLI: run if HEARTBEAT.md has extra instructions
|
||||
if not is_quiet:
|
||||
claude_result = _run_claude_extra(
|
||||
hb_config, critical + results, is_quiet
|
||||
)
|
||||
if claude_result:
|
||||
results.append(claude_result)
|
||||
claude_result = _run_claude_extra(hb_config, critical + results)
|
||||
if claude_result:
|
||||
results.append(claude_result)
|
||||
|
||||
# Update state
|
||||
state["last_run"] = now.isoformat()
|
||||
_save_state(state)
|
||||
|
||||
# Critical items always get through (even quiet hours)
|
||||
if is_quiet:
|
||||
if critical:
|
||||
return " | ".join(critical)
|
||||
return "HEARTBEAT_OK"
|
||||
|
||||
all_results = critical + results
|
||||
if not all_results:
|
||||
return "HEARTBEAT_OK"
|
||||
@@ -144,22 +138,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 +182,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
|
||||
@@ -394,8 +402,7 @@ def _get_extra_instructions() -> str | None:
|
||||
return "\n".join(meaningful).strip()
|
||||
|
||||
|
||||
def _run_claude_extra(hb_config: dict, python_results: list[str],
|
||||
is_quiet: bool) -> str | None:
|
||||
def _run_claude_extra(hb_config: dict, python_results: list[str]) -> str | None:
|
||||
"""Run Claude CLI if HEARTBEAT.md has extra instructions."""
|
||||
from src.claude_session import CLAUDE_BIN, _safe_env
|
||||
|
||||
@@ -421,10 +428,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:
|
||||
@@ -440,7 +450,7 @@ def _run_claude_extra(hb_config: dict, python_results: list[str],
|
||||
return None
|
||||
data = json.loads(proc.stdout)
|
||||
result = data.get("result", "").strip()
|
||||
if not result or result == "HEARTBEAT_OK":
|
||||
if not result or "HEARTBEAT_OK" in result:
|
||||
return None
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
|
||||
@@ -94,7 +94,7 @@ def main():
|
||||
try:
|
||||
result = await asyncio.to_thread(run_heartbeat, config)
|
||||
logger.info("Heartbeat: %s", result)
|
||||
if result != "HEARTBEAT_OK":
|
||||
if result and "HEARTBEAT_OK" not in result:
|
||||
await _send_to_channel(hb_channel, result)
|
||||
except Exception as exc:
|
||||
logger.error("Heartbeat failed: %s", exc)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user