diff --git a/scripts/pi1-master/weekly-maintenance.sh b/scripts/pi1-master/weekly-maintenance.sh index 6bbce8a..ee874e8 100755 --- a/scripts/pi1-master/weekly-maintenance.sh +++ b/scripts/pi1-master/weekly-maintenance.sh @@ -558,6 +558,19 @@ if ((${#WARNINGS[@]} > 0)); then fi append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')" +# 5b. Il peer deve poter coprire: niente reboot se è giù o in manutenzione +# (31/10: Pi-1 riavviato alle 04:50 con Pi-2 già giù → rete senza nessun nodo). +REBOOT_GUARD="/home/daniely/rete/scripts/maintenance-reboot-guard.sh" +if [[ "$REBOOT_ON_SUCCESS" == true && -x "$REBOOT_GUARD" ]]; then + if guard_msg="$("$REBOOT_GUARD" check 2>&1)"; then + log "Guardia reboot: ${guard_msg}" + else + REBOOT_ON_SUCCESS=false + WARNINGS+=("Reboot saltato: ${guard_msg}") + log "⚠ Reboot saltato: ${guard_msg}" + fi +fi + build_telegram_summary # 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf) @@ -566,6 +579,9 @@ if [[ "$REBOOT_ON_SUCCESS" == true ]]; then append_report "" append_report "=== Reboot ===" append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti" + # Annuncio al peer: prende il VIP ma non pivota le app, e se il nodo non + # torna entro il TTL il suo watchdog fa il failover vero. + [[ -x "$REBOOT_GUARD" ]] && "$REBOOT_GUARD" announce "$REBOOT_DELAY_MIN" || true /usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || { ERRORS+=("shutdown -r (reboot programmato)") log "✗ Impossibile programmare il reboot" diff --git a/scripts/pi2-backup/weekly-maintenance.sh b/scripts/pi2-backup/weekly-maintenance.sh index fa249ec..fce4900 100755 --- a/scripts/pi2-backup/weekly-maintenance.sh +++ b/scripts/pi2-backup/weekly-maintenance.sh @@ -558,6 +558,19 @@ if ((${#WARNINGS[@]} > 0)); then fi append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')" +# 5b. Il peer deve poter coprire: niente reboot se è giù o in manutenzione +# (31/10: Pi-1 riavviato alle 04:50 con Pi-2 già giù → rete senza nessun nodo). +REBOOT_GUARD="/home/daniely/rete/scripts/maintenance-reboot-guard.sh" +if [[ "$REBOOT_ON_SUCCESS" == true && -x "$REBOOT_GUARD" ]]; then + if guard_msg="$("$REBOOT_GUARD" check 2>&1)"; then + log "Guardia reboot: ${guard_msg}" + else + REBOOT_ON_SUCCESS=false + WARNINGS+=("Reboot saltato: ${guard_msg}") + log "⚠ Reboot saltato: ${guard_msg}" + fi +fi + build_telegram_summary # 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf) @@ -566,6 +579,9 @@ if [[ "$REBOOT_ON_SUCCESS" == true ]]; then append_report "" append_report "=== Reboot ===" append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti" + # Annuncio al peer: prende il VIP ma non pivota le app, e se il nodo non + # torna entro il TTL il suo watchdog fa il failover vero. + [[ -x "$REBOOT_GUARD" ]] && "$REBOOT_GUARD" announce "$REBOOT_DELAY_MIN" || true /usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || { ERRORS+=("shutdown -r (reboot programmato)") log "✗ Impossibile programmare il reboot" diff --git a/services/loogle-mcp/data.pre-failback/drill-c0.txt b/services/loogle-mcp/data.pre-failback/drill-c0.txt new file mode 100644 index 0000000..58c7079 --- /dev/null +++ b/services/loogle-mcp/data.pre-failback/drill-c0.txt @@ -0,0 +1 @@ +drill-20261031-205709 C0 diff --git a/services/loogle-mcp/data.pre-failback/drill-c1.txt b/services/loogle-mcp/data.pre-failback/drill-c1.txt new file mode 100644 index 0000000..073d957 --- /dev/null +++ b/services/loogle-mcp/data.pre-failback/drill-c1.txt @@ -0,0 +1 @@ +drill-20261031-205709 C1 diff --git a/services/loogle-mcp/data.pre-failback/loogle_mcp.db b/services/loogle-mcp/data.pre-failback/loogle_mcp.db new file mode 100644 index 0000000..563299e Binary files /dev/null and b/services/loogle-mcp/data.pre-failback/loogle_mcp.db differ diff --git a/services/loogle-mcp/data.pre-failback/loogle_mcp.db-shm b/services/loogle-mcp/data.pre-failback/loogle_mcp.db-shm new file mode 100644 index 0000000..a05d796 Binary files /dev/null and b/services/loogle-mcp/data.pre-failback/loogle_mcp.db-shm differ diff --git a/services/loogle-mcp/data.pre-failback/loogle_mcp.db-wal b/services/loogle-mcp/data.pre-failback/loogle_mcp.db-wal new file mode 100644 index 0000000..06e16e1 Binary files /dev/null and b/services/loogle-mcp/data.pre-failback/loogle_mcp.db-wal differ diff --git a/services/loogle-mcp/data.pre-failback/mcp.db b/services/loogle-mcp/data.pre-failback/mcp.db new file mode 100644 index 0000000..e69de29 diff --git a/services/loogle-mcp/data.pre-failback/vector_fallback.db b/services/loogle-mcp/data.pre-failback/vector_fallback.db new file mode 100644 index 0000000..0fa8735 Binary files /dev/null and b/services/loogle-mcp/data.pre-failback/vector_fallback.db differ diff --git a/services/loogle-mcp/scripts/export_daily_agent_digest.py b/services/loogle-mcp/scripts/export_daily_agent_digest.py index cede7c8..01cb3bb 100644 --- a/services/loogle-mcp/scripts/export_daily_agent_digest.py +++ b/services/loogle-mcp/scripts/export_daily_agent_digest.py @@ -4,6 +4,7 @@ Fonti (massimo contesto utile, non dump grezzo): - transcript agenti Cursor (query utente + conclusioni assistente) + - transcript Claude Code di Pi1 (locali) e Pi2 (copiati via rsync in ~/.cache/claude-transcripts/) - 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 @@ -52,6 +53,12 @@ DEFAULT_GIT_REPOS = [ Path.home() / "docker/loogle-mcp", ] DEFAULT_PLANS_DIR = Path.home() / ".cursor/plans" +# Claude Code: //.jsonl. Pi2 viene copiato qui prima della lettura. +CLAUDE_LOCAL_ROOT = Path.home() / ".claude/projects" +CLAUDE_REMOTES = [("pi2", "daniely@192.168.128.81")] +CLAUDE_CACHE = Path.home() / ".cache/claude-transcripts" +SYSTEM_TAG_RE = re.compile(r"<(system-reminder|command-[a-z-]+|local-command-[a-z-]+)[^>]*>.*?", re.DOTALL) +PASTED_TAG_RE = re.compile(r"]*>") def _load_dotenv() -> None: @@ -253,6 +260,119 @@ def collect_transcripts(day: date, roots: list[Path], max_chats: int = 25) -> li return chats +def sync_claude_remotes(remotes: list[tuple[str, str]], cache: Path) -> list[tuple[str, Path]]: + """Copia i transcript Claude Code dei nodi remoti in cache; un nodo giù non blocca il digest.""" + roots: list[tuple[str, Path]] = [] + for name, host in remotes: + dest = cache / name + dest.mkdir(parents=True, exist_ok=True) + cmd = [ + "rsync", "-a", "--timeout=30", "--prune-empty-dirs", + "-e", "ssh -o BatchMode=yes -o ConnectTimeout=6", + "--include=*/", "--exclude=subagents/", "--include=*.jsonl", "--exclude=*", + f"{host}:.claude/projects/", f"{dest}/", + ] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + if r.returncode != 0: + print(f"# claude {name}: rsync rc={r.returncode} {r.stderr.strip()[:200]} (uso la copia precedente)", + file=sys.stderr) + except (OSError, subprocess.TimeoutExpired) as e: + print(f"# claude {name}: rsync non riuscito: {e} (uso la copia precedente)", file=sys.stderr) + roots.append((name, dest)) + return roots + + +def _claude_text(content: Any) -> list[str]: + 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" and part.get("text"): + out.append(part["text"]) + return out + + +def _clean_claude_user(text: str) -> str: + text = SYSTEM_TAG_RE.sub(" ", text) + text = PASTED_TAG_RE.sub(" ", text) + return text.strip() + + +def collect_claude_transcripts(day: date, roots: list[tuple[str, Path]], max_chats: int = 25) -> list[dict]: + """Sessioni Claude Code con almeno un messaggio umano nel giorno (ora locale).""" + start, end = _day_bounds(day) + files: list[tuple[str, Path]] = [] + for host, root in roots: + if root.is_dir(): + files.extend((host, p) for p in root.glob("*/*.jsonl")) + chats: list[dict] = [] + for host, path in sorted(files, key=lambda hp: hp[1].stat().st_mtime, reverse=True): + if datetime.fromtimestamp(path.stat().st_mtime) < start: + continue + title = "" + queries: list[str] = [] + tails: list[str] = [] + paths_touched: Counter[str] = Counter() + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + kind = obj.get("type") + if kind == "custom-title": + title = obj.get("customTitle") or title + continue + if kind not in ("user", "assistant") or obj.get("isSidechain") or obj.get("isMeta"): + continue + try: + when = datetime.fromisoformat(obj["timestamp"].replace("Z", "+00:00")).astimezone().replace(tzinfo=None) + except (KeyError, ValueError): + continue + if not (start <= when < end): + continue + msg = obj.get("message") or {} + content = msg.get("content") if isinstance(msg, dict) else None + if kind == "user": + if obj.get("turnOrigin") not in (None, "human"): + continue + for t in _claude_text(content): + q = _truncate(_clean_claude_user(t), 280) + if q and not _is_noise_query(q): + queries.append(q) + else: + for t in _claude_text(content): + if len(t) > 80: + tails.append(_truncate(t, 320)) + if isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "tool_use": + inp = part.get("input") or {} + for key in ("file_path", "path"): + if isinstance(inp.get(key), str): + paths_touched[inp[key]] += 1 + except OSError: + continue + if not queries: + continue + seen: set[str] = set() + uniq = [q for q in queries if not (q in seen or seen.add(q))] + chats.append({ + "id": path.stem, + "source": f"claude-{host}", + "title": title, + "queries": uniq[:8], + "conclusions": 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} @@ -474,15 +594,15 @@ def build_markdown( "", ] - lines.append("### Chat / agenti Cursor") + lines.append("### Chat / agenti (Cursor + Claude Code)") 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]: + title = chat.get("title") or (chat["queries"][0] if chat["queries"] else chat["id"]) + lines.append(f"{i}. **{_truncate(title, 120)}** `[{chat.get('source', 'cursor')} {chat['id'][:8]}]`") + for q in chat["queries"][(0 if chat.get("title") else 1):4]: lines.append(f" - Q: {_truncate(q, 160)}") for c in chat["conclusions"][-2:]: lines.append(f" - → {_truncate(c, 200)}") @@ -585,6 +705,7 @@ def main() -> int: 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-claude", action="store_true", help="Non leggere i transcript Claude Code") parser.add_argument( "--no-plans", action="store_true", @@ -609,6 +730,9 @@ def main() -> int: ) chats = collect_transcripts(day, DEFAULT_TRANSCRIPT_ROOTS, max_chats=args.max_chats) + if not args.no_claude: + claude_roots = [("pi1", CLAUDE_LOCAL_ROOT)] + sync_claude_remotes(CLAUDE_REMOTES, CLAUDE_CACHE) + chats += collect_claude_transcripts(day, claude_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( diff --git a/services/loogle-mcp/scripts/export_daily_agent_digest.py.bak-311026 b/services/loogle-mcp/scripts/export_daily_agent_digest.py.bak-311026 new file mode 100644 index 0000000..cede7c8 --- /dev/null +++ b/services/loogle-mcp/scripts/export_daily_agent_digest.py.bak-311026 @@ -0,0 +1,721 @@ +#!/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())