#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Esporta un blocco giornaliero ricco su progetto MCP (default: homelab-loogle). Fonti (massimo contesto utile, non dump grezzo): - transcript agenti Cursor (query utente + conclusioni assistente) - audit_log tool MCP del giorno - commit git recenti in repo tipici (rete, loogle-mcp) - piani Cursor `~/.cursor/plans/*.plan.md` → artifacts/plans/ + indice in context.md Idempotente: marker — con --force sostituisce il blocco del giorno. Esempi: python3 scripts/export_daily_agent_digest.py python3 scripts/export_daily_agent_digest.py --date 2026-09-03 --force python3 scripts/export_daily_agent_digest.py --dry-run """ from __future__ import annotations import argparse import json import os import re import shutil import sqlite3 import subprocess import sys from collections import Counter from datetime import date, datetime, timedelta from pathlib import Path from typing import Any, Optional ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) MARKER_RE = re.compile(r"") USER_QUERY_RE = re.compile( r"(.*?)\s*\s*(.*?)\s*", re.DOTALL | re.IGNORECASE, ) TS_PREFIX_RE = re.compile(r"^\[?\d{4}-\d{2}-\d{2}") FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL) DEFAULT_TRANSCRIPT_ROOTS = [ Path.home() / ".cursor/projects/home-daniely/agent-transcripts", Path.home() / ".cursor/projects/home-daniely-docker-loogle-mcp/agent-transcripts", ] DEFAULT_GIT_REPOS = [ Path.home() / "rete", Path.home() / "docker/loogle-mcp", ] DEFAULT_PLANS_DIR = Path.home() / ".cursor/plans" def _load_dotenv() -> None: env_path = ROOT / ".env" if not env_path.is_file(): return for line in env_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") key = key.strip() if key and key not in os.environ: os.environ[key] = value.strip().strip("'").strip('"') def _configure_paths() -> None: if Path("/.dockerenv").is_file() or (Path("/data").is_dir() and os.access("/data", os.W_OK)): os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db") os.environ.setdefault("MCP_CONTEXT_ROOT", "/data/context") os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db") return os.environ.setdefault("MCP_DB", str(ROOT / "data" / "loogle_mcp.db")) os.environ.setdefault("MCP_CONTEXT_ROOT", "/mnt/ha-apps/mcp/context") os.environ.setdefault("MCP_VECTOR_FALLBACK", str(ROOT / "data" / "vector_fallback.db")) def _parse_day(s: Optional[str]) -> date: if not s: return date.today() return date.fromisoformat(s) def _day_bounds(day: date) -> tuple[datetime, datetime]: start = datetime.combine(day, datetime.min.time()) end = start + timedelta(days=1) return start, end def _is_noise_query(q: str) -> bool: q = (q or "").strip() if len(q) < 12: return True low = q.lower() if q.startswith("<") or q.startswith("[REDACTED]"): return True noise_prefixes = ( "you are ", "you have access", "start multitasking", "briefly inform the user", "perform any necessary follow-up", "the following task has finished", "", "", ) return any(low.startswith(p) or p in low[:80] for p in noise_prefixes) def _truncate(text: str, limit: int) -> str: text = re.sub(r"\s+", " ", (text or "").strip()) if len(text) <= limit: return text return text[: limit - 1].rstrip() + "…" def _extract_text_blocks(message: Any) -> list[str]: if not isinstance(message, dict): return [] content = message.get("content") if isinstance(content, str): return [content] out: list[str] = [] if isinstance(content, list): for part in content: if isinstance(part, dict) and part.get("type") == "text": t = part.get("text") or "" if t and t != "[REDACTED]": out.append(t) return out def _parse_event_time(raw: str, file_mtime: float) -> Optional[datetime]: raw = (raw or "").strip() cleaned = re.sub(r"\s*\([^)]*\)\s*$", "", raw).strip() for fmt in ( "%A, %b %d, %Y, %I:%M %p", "%A, %B %d, %Y, %I:%M %p", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S", ): try: return datetime.strptime(cleaned, fmt) except ValueError: continue return datetime.fromtimestamp(file_mtime) def collect_transcripts(day: date, roots: list[Path], max_chats: int = 25) -> list[dict]: start, end = _day_bounds(day) chats: list[dict] = [] files: list[Path] = [] for root in roots: if not root.is_dir(): continue for path in root.rglob("*.jsonl"): if "subagents" in path.parts: continue files.append(path) for path in sorted(files, key=lambda p: p.stat().st_mtime, reverse=True): mtime = path.stat().st_mtime # quick skip: file entirely older than day-1 or newer handled by content if datetime.fromtimestamp(mtime) < start - timedelta(days=2): continue user_queries: list[str] = [] assistant_tails: list[str] = [] paths_touched: Counter[str] = Counter() day_hit = False try: with open(path, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: continue role = obj.get("role") for text in _extract_text_blocks(obj.get("message") or {}): if role == "user": for ts_raw, query in USER_QUERY_RE.findall(text): when = _parse_event_time(ts_raw, mtime) if when and start <= when < end: day_hit = True q = _truncate(query, 280) if not _is_noise_query(q): user_queries.append(q) # bare user text without wrapper — solo se breve messaggio umano if ( "" not in text and start.timestamp() <= mtime < end.timestamp() and len(text) > 40 and not _is_noise_query(text) and "<" not in text[:20] ): day_hit = True user_queries.append(_truncate(text, 280)) elif role == "assistant": if start.timestamp() <= mtime < end.timestamp() or day_hit: if ( len(text) > 80 and not text.startswith("[REDACTED]") and not text.startswith("<") and "tool_use" not in text[:40] ): # preferisci paragrafi conclusivi (markdown grassetto / verdetto) assistant_tails.append(_truncate(text, 320)) # tool paths msg = obj.get("message") or {} content = msg.get("content") if isinstance(msg, dict) else None if isinstance(content, list): for part in content: if not isinstance(part, dict) or part.get("type") != "tool_use": continue inp = part.get("input") or {} for key in ("path", "target_notebook", "file_path"): if key in inp and isinstance(inp[key], str): paths_touched[inp[key]] += 1 except OSError: continue if not day_hit and not user_queries: # include if file modified that day and has substance if not (start.timestamp() <= mtime < end.timestamp()): continue if not assistant_tails: continue # dedupe queries seen = set() uniq_q = [] for q in user_queries: if q not in seen: seen.add(q) uniq_q.append(q) chats.append( { "id": path.parent.name if path.parent.name != "agent-transcripts" else path.stem, "queries": uniq_q[:8], "conclusions": assistant_tails[-3:], "paths": [p for p, _ in paths_touched.most_common(8)], } ) if len(chats) >= max_chats: break return chats def collect_audit(day: date, db_path: Path) -> dict: if not db_path.is_file(): return {"tools": [], "total": 0} start = day.isoformat() end = (day + timedelta(days=1)).isoformat() conn = sqlite3.connect(str(db_path)) conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT tool_name, COUNT(*) AS n FROM audit_log" " WHERE created_at >= ? AND created_at < ? AND username=?" " GROUP BY tool_name ORDER BY n DESC", (start, end, "daniele"), ).fetchall() samples = conn.execute( "SELECT created_at, tool_name, detail FROM audit_log" " WHERE created_at >= ? AND created_at < ? AND username=?" " ORDER BY id DESC LIMIT 15", (start, end, "daniele"), ).fetchall() conn.close() return { "tools": [(r["tool_name"], r["n"]) for r in rows], "total": sum(r["n"] for r in rows), "samples": [ { "at": r["created_at"], "tool": r["tool_name"], "detail": _truncate(r["detail"] or "", 120), } for r in samples ], } def collect_git(day: date, repos: list[Path], limit: int = 12) -> list[str]: since = day.isoformat() until = (day + timedelta(days=1)).isoformat() lines: list[str] = [] for repo in repos: if not (repo / ".git").exists(): continue try: out = subprocess.check_output( [ "git", "-C", str(repo), "log", f"--since={since}", f"--until={until}", "--pretty=format:%h %s", f"-n{limit}", ], stderr=subprocess.DEVNULL, text=True, timeout=15, ).strip() except (subprocess.CalledProcessError, subprocess.TimeoutExpired): continue if out: for line in out.splitlines(): lines.append(f"{repo.name}: {line}") return lines[:limit] def _parse_plan_file(path: Path) -> dict: text = path.read_text(encoding="utf-8", errors="replace") name = path.stem overview = "" todos_total = 0 todos_done = 0 m = FRONTMATTER_RE.match(text) if m: fm = m.group(1) nm = re.search(r"^name:\s*(.+)$", fm, re.M) if nm: name = nm.group(1).strip().strip("\"'") ov = re.search(r"^overview:\s*(.+)$", fm, re.M) if ov: overview = ov.group(1).strip().strip("\"'") # overview può essere su più righe YAML quoted — fallback grezzo if overview.startswith("|") or not overview: ov2 = re.search(r"^overview:\s*[>|]?\s*\n((?:[ \t]+.+\n)+)", fm, re.M) if ov2: overview = " ".join(line.strip() for line in ov2.group(1).splitlines()) statuses = re.findall(r"^\s+status:\s*(\w+)", fm, re.M) todos_total = len(statuses) todos_done = sum(1 for s in statuses if s == "completed") mtime = datetime.fromtimestamp(path.stat().st_mtime) return { "file": path.name, "name": name, "overview": _truncate(overview, 220), "todos_total": todos_total, "todos_done": todos_done, "mtime": mtime, "text": text, "path": path, } def collect_and_sync_plans( day: date, plans_dir: Path, dest_dir: Path, *, dry_run: bool = False, ) -> dict: """Copia tutti i .plan.md in artifacts/plans/; ritorna catalogo + aggiornati nel giorno.""" start, end = _day_bounds(day) plans: list[dict] = [] if plans_dir.is_dir(): for path in sorted(plans_dir.glob("*.plan.md")): try: plans.append(_parse_plan_file(path)) except OSError: continue copied = 0 updated_today: list[dict] = [] if not dry_run: dest_dir.mkdir(parents=True, exist_ok=True) for plan in plans: target = dest_dir / plan["file"] shutil.copy2(plan["path"], target) copied += 1 if start <= plan["mtime"] < end: updated_today.append(plan) # INDEX.md per lettura umana / futuri tool idx_lines = [ "# Piani Cursor (sync)", "", f"_Aggiornato {datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')} " f"da `~/.cursor/plans` → `artifacts/plans/`._", "", ] for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True): prog = ( f"{plan['todos_done']}/{plan['todos_total']}" if plan["todos_total"] else "?" ) idx_lines.append( f"- **{plan['name']}** (`{plan['file']}`, todos {prog}, " f"mtime {plan['mtime'].date().isoformat()})" ) if plan["overview"]: idx_lines.append(f" - {plan['overview']}") idx_lines.append("") (dest_dir / "INDEX.md").write_text("\n".join(idx_lines), encoding="utf-8") else: updated_today = [p for p in plans if start <= p["mtime"] < end] return { "plans": plans, "copied": copied, "updated_today": updated_today, "dest": str(dest_dir), } def upsert_plans_index_section(context_md: str, plans: list[dict]) -> str: """Sezione stabile in context.md (non nel digest giornaliero) con indice piani.""" begin = "" end = "" lines = [ begin, "## Piani Cursor (indice sync)", "", "_File completi in `artifacts/plans/`. Qui solo indice per `get_project_context`._", "", ] if not plans: lines.append("- Nessun piano in `~/.cursor/plans`.") else: for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True): prog = ( f"{plan['todos_done']}/{plan['todos_total']} done" if plan["todos_total"] else "n/d" ) lines.append( f"- **{plan['name']}** — {prog} — `{plan['file']}` " f"({plan['mtime'].date().isoformat()})" ) if plan["overview"]: lines.append(f" - {plan['overview']}") lines.extend(["", end, ""]) block = "\n".join(lines) if begin in context_md and end in context_md: pattern = re.compile( re.escape(begin) + r".*?" + re.escape(end), re.DOTALL, ) return pattern.sub(block.strip(), context_md) # inserisci prima dei daily digests se presenti dig = "" if dig in context_md: head, tail = context_md.split(dig, 1) return head.rstrip() + "\n\n" + block + "\n" + dig + tail return context_md.rstrip() + "\n\n" + block def build_markdown( day: date, chats: list[dict], audit: dict, git_lines: list[str], project_id: str, plans_updated: Optional[list[dict]] = None, ) -> str: lines: list[str] = [ f"", f"## Digest agenti {day.isoformat()} — `{project_id}`", "", f"_Generato automaticamente da `export_daily_agent_digest.py` " f"({datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')})._", "", ] lines.append("### Chat / agenti Cursor") if not chats: lines.append("- Nessun transcript rilevante per questo giorno.") else: lines.append(f"- Sessioni considerate: **{len(chats)}**") for i, chat in enumerate(chats, 1): title = chat["queries"][0] if chat["queries"] else chat["id"] lines.append(f"{i}. **{_truncate(title, 120)}** `[{chat['id'][:8]}]`") for q in chat["queries"][1:4]: lines.append(f" - Q: {_truncate(q, 160)}") for c in chat["conclusions"][-2:]: lines.append(f" - → {_truncate(c, 200)}") if chat["paths"]: short_paths = ", ".join(_truncate(p, 60) for p in chat["paths"][:5]) lines.append(f" - File: `{short_paths}`") lines.append("") lines.append("### Tool MCP usati") if not audit.get("total"): lines.append("- Nessuna chiamata tool in audit_log.") else: lines.append(f"- Totale chiamate: **{audit['total']}**") top = ", ".join(f"`{name}`×{n}" for name, n in audit["tools"][:10]) lines.append(f"- Top: {top}") for s in (audit.get("samples") or [])[:8]: lines.append(f" - {s['at']} `{s['tool']}` {s['detail']}") lines.append("") lines.append("### Commit git (homelab)") if not git_lines: lines.append("- Nessun commit nel giorno.") else: for g in git_lines: lines.append(f"- `{g}`") lines.append("") lines.append("### Piani Cursor aggiornati oggi") plans_updated = plans_updated or [] if not plans_updated: lines.append("- Nessun `.plan.md` modificato in questa data.") else: for plan in plans_updated: prog = ( f"{plan['todos_done']}/{plan['todos_total']}" if plan["todos_total"] else "?" ) lines.append( f"- **{plan['name']}** (`artifacts/plans/{plan['file']}`, todos {prog})" ) if plan["overview"]: lines.append(f" - {plan['overview']}") lines.append("") lines.append("### Per Claude / prossimi agenti") lines.append( "- Usa questo blocco come memoria del giorno; per dettagli codice preferisci " "`search_gitea_knowledge` / `get_file` sui path citati." ) lines.append( "- Piani completi: sezione **Piani Cursor** in context.md + file in `artifacts/plans/`." ) lines.append( "- Non ripetere setup già conclusi; riparti da decisioni e next step qui sopra." ) lines.append("") return "\n".join(lines) def upsert_daily_block(context_md: str, day: date, block: str) -> str: """Rimuove eventuale blocco del giorno e inserisce il nuovo in cima alla sezione digest.""" pattern = re.compile( rf".*?" rf"(?=||\Z)", re.DOTALL, ) context_md = pattern.sub("", context_md) begin = "" end = "" if begin not in context_md: context_md = context_md.rstrip() + f"\n\n{begin}\n\n{end}\n" # Assicura END if end not in context_md: context_md = context_md.rstrip() + f"\n\n{end}\n" head, rest = context_md.split(begin, 1) # rest inizia dopo BEGIN; togli END temporaneamente dalla porzione digest if end in rest: mid, tail = rest.split(end, 1) else: mid, tail = rest, "" mid = mid.strip() new_mid = block.strip() + ("\n\n" + mid if mid else "") return head.rstrip() + f"\n\n{begin}\n\n{new_mid}\n\n{end}" + tail def has_daily_block(context_md: str, day: date) -> bool: return f"" in context_md def main() -> int: parser = argparse.ArgumentParser(description="Digest giornaliero agenti → MCP context") parser.add_argument("--date", help="YYYY-MM-DD (default: oggi)") parser.add_argument("--user", default="daniele") parser.add_argument("--project", default="homelab-loogle") parser.add_argument("--force", action="store_true", help="Sostituisci blocco del giorno se già presente") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--no-index", action="store_true", help="Non aggiornare Qdrant ctx_*") parser.add_argument("--max-chats", type=int, default=20) parser.add_argument( "--no-plans", action="store_true", help="Non sincronizzare ~/.cursor/plans", ) args = parser.parse_args() _load_dotenv() _configure_paths() day = _parse_day(args.date) proj = Path(os.environ["MCP_CONTEXT_ROOT"]) / args.user / "projects" / args.project plans_dest = proj / "artifacts" / "plans" plans_info: dict = {"plans": [], "copied": 0, "updated_today": [], "dest": str(plans_dest)} if not args.no_plans: plans_info = collect_and_sync_plans( day, DEFAULT_PLANS_DIR, plans_dest, dry_run=args.dry_run, ) chats = collect_transcripts(day, DEFAULT_TRANSCRIPT_ROOTS, max_chats=args.max_chats) audit = collect_audit(day, Path(os.environ["MCP_DB"])) git_lines = collect_git(day, DEFAULT_GIT_REPOS) block = build_markdown( day, chats, audit, git_lines, args.project, plans_updated=plans_info.get("updated_today") or [], ) if args.dry_run: print(block) if plans_info.get("plans"): print("\n# plans index preview", file=sys.stderr) for p in plans_info["plans"][:5]: print(f"# - {p['name']} ({p['file']})", file=sys.stderr) print( f"\n# dry-run chats={len(chats)} audit={audit.get('total', 0)} " f"git={len(git_lines)} plans={len(plans_info.get('plans') or [])} " f"plans_today={len(plans_info.get('updated_today') or [])} chars={len(block)}", file=sys.stderr, ) return 0 from app.context import store as context_store from app.db import init_db from app.knowledge import indexer init_db() data = context_store.get_project_context(args.user, args.project, session_limit=1, include_gitea=False) existing = data.get("context_md") or "" # I piani si sincronizzano sempre; il digest può essere skippato se già presente existing = upsert_plans_index_section(existing, plans_info.get("plans") or []) digest_skipped = False if has_daily_block(existing, day) and not args.force: digest_skipped = True new_md = existing print(f"SKIP digest: {day.isoformat()} già presente (usa --force); plans sync ok") else: new_md = upsert_daily_block(existing, day, block) ctx_path = proj / "context.md" meta_path = proj / "meta.json" sessions = proj / "sessions" sessions.mkdir(parents=True, exist_ok=True) ctx_path.write_text(new_md, encoding="utf-8") meta = json.loads(meta_path.read_text(encoding="utf-8")) meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds") meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") snap = None if not digest_skipped: snap = sessions / f"{day.isoformat()}-daily.md" snap.write_text(block, encoding="utf-8") if not args.no_index: try: if not digest_skipped: indexer.index_context_snippet( args.user, args.project, block, f"Digest {day.isoformat()} — {meta.get('title', args.project)}", snippet_id=f"daily-{day.isoformat()}", ) # indicizza ogni piano (testo ridotto: name+overview+body troncato) for plan in plans_info.get("plans") or []: body = plan["text"] if len(body) > 12000: body = body[:12000] + "\n…[troncato]" indexer.index_context_snippet( args.user, args.project, f"# Piano Cursor: {plan['name']}\n\n{plan['overview']}\n\n{body}", f"Plan: {plan['name']}", snippet_id=f"plan-{plan['file']}", ) print("indexed: ok") except Exception as exc: print(f"indexed: skip ({exc})") print( json.dumps( { "ok": True, "day": day.isoformat(), "project": args.project, "digest_skipped": digest_skipped, "chats": len(chats), "audit_calls": audit.get("total", 0), "git_commits": len(git_lines), "plans_synced": plans_info.get("copied", 0), "plans_updated_today": len(plans_info.get("updated_today") or []), "chars": len(block), "context_chars": len(new_md), "session": str(snap) if snap else None, "plans_dest": plans_info.get("dest"), }, ensure_ascii=False, indent=2, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())