diff --git a/configs/keepalived_pi1.conf b/configs/keepalived_pi1.conf index ba2d997..9982904 100644 --- a/configs/keepalived_pi1.conf +++ b/configs/keepalived_pi1.conf @@ -17,8 +17,12 @@ vrrp_instance VI_1 { interface eth0 virtual_router_id 51 priority 101 - advert_int 1 + advert_int 2 preempt_delay 30 + unicast_src_ip 192.168.128.80 + unicast_peer { + 192.168.128.81 + } authentication { auth_type PASS auth_pass @Dedelove1 diff --git a/configs/keepalived_pi2.conf b/configs/keepalived_pi2.conf index 3407c53..3379e0c 100644 --- a/configs/keepalived_pi2.conf +++ b/configs/keepalived_pi2.conf @@ -17,8 +17,12 @@ vrrp_instance VI_1 { interface eth0 virtual_router_id 51 priority 100 - advert_int 1 + advert_int 2 preempt_delay 30 + unicast_src_ip 192.168.128.81 + unicast_peer { + 192.168.128.80 + } authentication { auth_type PASS auth_pass @Dedelove1 diff --git a/configs/weekly-maintenance.pi1.conf b/configs/weekly-maintenance.pi1.conf index 8174ac0..3cd65a0 100644 --- a/configs/weekly-maintenance.pi1.conf +++ b/configs/weekly-maintenance.pi1.conf @@ -5,3 +5,5 @@ # Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2) # Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script: # turni-app:live-latest +# Dopo Watchtower: pull registry da rete/compose/failover anche senza container. +# Override: FAILOVER_COMPOSE_DIR=... diff --git a/configs/weekly-maintenance.pi2.conf b/configs/weekly-maintenance.pi2.conf index 8406446..1488e50 100644 --- a/configs/weekly-maintenance.pi2.conf +++ b/configs/weekly-maintenance.pi2.conf @@ -6,3 +6,5 @@ # Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30) # Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script: # irrigazione, turni-app:beta/alpha, meteo-alert, loogle-casa, ewelink_smart_home +# Dopo Watchtower: pull registry da rete/compose/failover (Paperless, VW, Stalwart, …) +# anche senza container. Override: FAILOVER_COMPOSE_DIR=... diff --git a/scripts/pi1-master/weekly-maintenance.sh b/scripts/pi1-master/weekly-maintenance.sh index 7eab67e..6bbce8a 100755 --- a/scripts/pi1-master/weekly-maintenance.sh +++ b/scripts/pi1-master/weekly-maintenance.sh @@ -20,6 +20,8 @@ PIHOLE_BIN="/usr/local/bin/pihole" REBOOT_DELAY_MIN=2 WATCHTOWER_IMAGE="nickfedor/watchtower:latest" WATCHTOWER_TIMEOUT=900 +# Compose HA usati al pivot: Watchtower aggiorna solo i container esistenti. +FAILOVER_COMPOSE_DIR="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}" HOST_LABEL="$(hostname -s)" REBOOT_ON_SUCCESS=true @@ -218,6 +220,99 @@ run_watchtower() { fi } +# Espande ${VAR:-default} (usato nei compose Turni). Altri ${VAR} → scarta. +expand_compose_image() { + local raw="$1" + while [[ "$raw" =~ \$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\} ]]; do + raw="${raw/${BASH_REMATCH[0]}/${BASH_REMATCH[2]}}" + done + [[ "$raw" == *'$'* ]] && return 1 + printf '%s' "$raw" +} + +# Build locali / no registry: non fare docker pull (fallirebbe o scaricherebbe un omonimo Hub). +failover_image_is_local_build() { + local image="$1" + local name="${image%%:*}" + image_is_ignored "$image" && return 0 + case "$name" in + nodus-backend|nodus-frontend|loogle-casa|loogle-mcp|irrigazione|ewelink_smart_home|turni-app) + return 0 + ;; + esac + return 1 +} + +collect_failover_registry_images() { + local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}" + local f line img expanded + [[ -d "$dir" ]] || return 1 + while IFS= read -r f; do + [[ -f "$f" ]] || continue + while IFS= read -r line; do + [[ -z "$line" ]] && continue + img=$(expand_compose_image "$line") || continue + failover_image_is_local_build "$img" && continue + printf '%s\n' "$img" + done < <(sed -n 's/^[[:space:]]*image:[[:space:]]*//p' "$f" | sed 's/["'\'']//g') + done < <(find "$dir" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort) +} + +pull_failover_standby_images() { + command -v docker >/dev/null 2>&1 || return 0 + + local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}" + log "▶ Pull immagini failover (registry, anche senza container)" + append_report "" + append_report "=== Pull immagini failover (standby) ===" + append_report "Compose: $dir" + + if [[ ! -d "$dir" ]]; then + WARNINGS+=("Directory compose failover assente: $dir") + append_report "Directory assente" + return 0 + fi + + local -a images=() + local img + while IFS= read -r img; do + [[ -z "$img" ]] && continue + images+=("$img") + done < <(collect_failover_registry_images | sort -u) + + if ((${#images[@]} == 0)); then + WARNINGS+=("Nessuna immagine registry nei compose failover") + append_report "Lista vuota" + return 0 + fi + + local pulled=0 failed=0 skipped=0 + local before after + for img in "${images[@]}"; do + before=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true) + if docker pull "$img" >> "$REPORT_FILE" 2>&1; then + after=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true) + if [[ -n "$before" && "$before" == "$after" ]]; then + skipped=$((skipped + 1)) + log " = $img (già aggiornata)" + append_report "unchanged: $img" + else + pulled=$((pulled + 1)) + log " ↑ $img" + append_report "updated: $img" + fi + else + failed=$((failed + 1)) + WARNINGS+=("docker pull fallito: $img") + log " ✗ $img" + fi + done + + NOTES+=("Failover images: ${#images[@]} registry, $pulled aggiornate, $skipped già ok, $failed errori") + append_report "Riepilogo: ${#images[@]} immagini, aggiornate=$pulled, invariate=$skipped, errori=$failed" + log "✓ Pull failover: aggiornate=$pulled invariate=$skipped errori=$failed" +} + audit_apt() { local holds holds=$(apt-mark showhold 2>/dev/null || true) @@ -442,6 +537,8 @@ audit_eeprom # 4. Aggiornamento container Docker (Watchtower run-once; il daemon è MONITOR_ONLY) run_watchtower +# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container +pull_failover_standby_images # 5. Audit residui audit_docker diff --git a/scripts/pi2-backup/weekly-maintenance.sh b/scripts/pi2-backup/weekly-maintenance.sh index 1acfddd..fa249ec 100755 --- a/scripts/pi2-backup/weekly-maintenance.sh +++ b/scripts/pi2-backup/weekly-maintenance.sh @@ -20,6 +20,8 @@ PIHOLE_BIN="/usr/local/bin/pihole" REBOOT_DELAY_MIN=2 WATCHTOWER_IMAGE="nickfedor/watchtower:latest" WATCHTOWER_TIMEOUT=900 +# Compose HA usati al pivot: Watchtower aggiorna solo i container esistenti. +FAILOVER_COMPOSE_DIR="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}" HOST_LABEL="$(hostname -s)" REBOOT_ON_SUCCESS=true @@ -218,6 +220,99 @@ run_watchtower() { fi } +# Espande ${VAR:-default} (usato nei compose Turni). Altri ${VAR} → scarta. +expand_compose_image() { + local raw="$1" + while [[ "$raw" =~ \$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\} ]]; do + raw="${raw/${BASH_REMATCH[0]}/${BASH_REMATCH[2]}}" + done + [[ "$raw" == *'$'* ]] && return 1 + printf '%s' "$raw" +} + +# Build locali / no registry: non fare docker pull (fallirebbe o scaricherebbe un omonimo Hub). +failover_image_is_local_build() { + local image="$1" + local name="${image%%:*}" + image_is_ignored "$image" && return 0 + case "$name" in + nodus-backend|nodus-frontend|loogle-casa|loogle-mcp|irrigazione|ewelink_smart_home|turni-app) + return 0 + ;; + esac + return 1 +} + +collect_failover_registry_images() { + local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}" + local f line img expanded + [[ -d "$dir" ]] || return 1 + while IFS= read -r f; do + [[ -f "$f" ]] || continue + while IFS= read -r line; do + [[ -z "$line" ]] && continue + img=$(expand_compose_image "$line") || continue + failover_image_is_local_build "$img" && continue + printf '%s\n' "$img" + done < <(sed -n 's/^[[:space:]]*image:[[:space:]]*//p' "$f" | sed 's/["'\'']//g') + done < <(find "$dir" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort) +} + +pull_failover_standby_images() { + command -v docker >/dev/null 2>&1 || return 0 + + local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}" + log "▶ Pull immagini failover (registry, anche senza container)" + append_report "" + append_report "=== Pull immagini failover (standby) ===" + append_report "Compose: $dir" + + if [[ ! -d "$dir" ]]; then + WARNINGS+=("Directory compose failover assente: $dir") + append_report "Directory assente" + return 0 + fi + + local -a images=() + local img + while IFS= read -r img; do + [[ -z "$img" ]] && continue + images+=("$img") + done < <(collect_failover_registry_images | sort -u) + + if ((${#images[@]} == 0)); then + WARNINGS+=("Nessuna immagine registry nei compose failover") + append_report "Lista vuota" + return 0 + fi + + local pulled=0 failed=0 skipped=0 + local before after + for img in "${images[@]}"; do + before=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true) + if docker pull "$img" >> "$REPORT_FILE" 2>&1; then + after=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true) + if [[ -n "$before" && "$before" == "$after" ]]; then + skipped=$((skipped + 1)) + log " = $img (già aggiornata)" + append_report "unchanged: $img" + else + pulled=$((pulled + 1)) + log " ↑ $img" + append_report "updated: $img" + fi + else + failed=$((failed + 1)) + WARNINGS+=("docker pull fallito: $img") + log " ✗ $img" + fi + done + + NOTES+=("Failover images: ${#images[@]} registry, $pulled aggiornate, $skipped già ok, $failed errori") + append_report "Riepilogo: ${#images[@]} immagini, aggiornate=$pulled, invariate=$skipped, errori=$failed" + log "✓ Pull failover: aggiornate=$pulled invariate=$skipped errori=$failed" +} + audit_apt() { local holds holds=$(apt-mark showhold 2>/dev/null || true) @@ -442,6 +537,8 @@ audit_eeprom # 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato) run_watchtower +# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container +pull_failover_standby_images # 5. Audit residui audit_docker diff --git a/services/loogle-mcp/app/knowledge/indexer.py b/services/loogle-mcp/app/knowledge/indexer.py index 3b051d2..3ec5056 100644 --- a/services/loogle-mcp/app/knowledge/indexer.py +++ b/services/loogle-mcp/app/knowledge/indexer.py @@ -89,14 +89,23 @@ def index_all(max_pages: int = 20) -> dict: return {"indexed": indexed, "errors": errors, "users": users} -def index_context_snippet(username: str, project_id: str, text: str, title: str) -> None: +def index_context_snippet( + username: str, + project_id: str, + text: str, + title: str, + snippet_id: Optional[str] = None, +) -> None: chunks = _chunk_text(text) if not chunks: return vectors = embeddings.embed_texts(chunks) collection = qdrant_store.ctx_collection(username) doc_key = f"ctx-{username}-{project_id}" - qdrant_store.delete_by_doc(collection, hash(doc_key) % (2**31)) + if snippet_id: + doc_key = f"{doc_key}-{snippet_id}" + doc_id = hash(doc_key) % (2**31) + qdrant_store.delete_by_doc(collection, doc_id) ids = [] payloads = [] for i, chunk in enumerate(chunks): @@ -104,13 +113,15 @@ def index_context_snippet(username: str, project_id: str, text: str, title: str) ids.append(point_id) payloads.append( { - "doc_id": hash(doc_key) % (2**31), + "doc_id": doc_id, "project_id": project_id, "chunk_index": i, "title": title, "text": chunk, "owner": username, "visibility": "personal", + "source": "context", + "snippet_id": snippet_id or "latest", } ) qdrant_store.upsert_chunks(collection, ids, vectors, payloads) diff --git a/services/loogle-mcp/docs/GUIDA-ADMIN.md b/services/loogle-mcp/docs/GUIDA-ADMIN.md index acbca09..91b6c39 100644 --- a/services/loogle-mcp/docs/GUIDA-ADMIN.md +++ b/services/loogle-mcp/docs/GUIDA-ADMIN.md @@ -67,6 +67,27 @@ Stesso URL MCP: `https://mcp.loogle.it/mcp` 4. **search_knowledge** — recuperi doc da Paperless quando serve 5. **search_context** — ritrovi discussioni passate semanticamente +### Digest giornaliero agenti → `homelab-loogle` + +Ogni sera (cron `22:45` su Pi-1) lo script `scripts/export_daily_agent_digest.py` aggiorna un blocco `` sul progetto **homelab-loogle**, con: + +- query e conclusioni dagli agent transcript Cursor +- tool MCP usati (audit_log) +- commit git del giorno (`rete`, `loogle-mcp`) +- sync `~/.cursor/plans/*.plan.md` → `artifacts/plans/` + indice in `context.md` (solo i piani toccati quel giorno compaiono nel digest) + +Claude (e Cursor) lo leggono con `get_project_context` su `homelab-loogle`. + +```bash +cd /home/daniely/docker/loogle-mcp +python3 scripts/export_daily_agent_digest.py --dry-run +python3 scripts/export_daily_agent_digest.py --force --no-index +# opzionale: indicizza il blocco in Qdrant ctx_* (più lento, Ollama) +python3 scripts/export_daily_agent_digest.py --force +``` + +Log: `data/daily-digest.log`. Idempotente; `--force` sovrascrive il giorno. + ### Tool solo admin - `reindex_document` — forza re-indicizzazione di un doc Paperless diff --git a/services/loogle-mcp/docs/GUIDA-UTENTI.md b/services/loogle-mcp/docs/GUIDA-UTENTI.md index 96dd0ab..c4a4b63 100644 --- a/services/loogle-mcp/docs/GUIDA-UTENTI.md +++ b/services/loogle-mcp/docs/GUIDA-UTENTI.md @@ -73,8 +73,45 @@ Nelle conversazioni, abilita i tool del connector **Loogle MCP** quando vuoi usa Claude potrà usare gli stessi tool (progetti, ricerca documenti, salvataggio contesto). +### Far usare MCP ogni giorno (Desktop + mobile) + +Claude **non** salva il contesto da solo: va istruito una volta e poi richiamato con abitudini brevi. + +**1. Istruzioni personalizzate (Desktop e App — stesso account)** +Settings → **Profile** / **Custom instructions** (o *What should Claude know about you?*) e incolla: + +``` +Hai il connector Loogle MCP (mcp.loogle.it). Per lavori su casa, documenti, progetti o codice famiglia: +- All’inizio: list_projects → get_project_context sul progetto rilevante. +- Per documenti/bollette/manuali: search_knowledge (non inventare). +- Per codice/runbook Gitea: search_gitea_knowledge o get_file. +- A fine chat utile: save_context (append) con decisioni e next step. +Se non esiste un progetto adatto, create_project prima di salvare. +``` + +**2. Abilita sempre i tool del connector** +In ogni chat nuova, assicurati che **Loogle MCP** sia attivo nei tool/connectors (su Desktop a volte va riacceso per conversazione). + +**3. Frasi-ancora (funzionano anche da mobile)** +Usa all’inizio o alla fine, senza ricordare i nomi tool: + +- *«Controlla prima su Loogle MCP il mio progetto e i documenti rilevanti.»* +- *«A fine risposta salva su MCP un riepilogo nel progetto giusto.»* +- *«Cerca in Paperless via MCP la bolletta / il manuale …»* +- *«Come sta l’irrigazione / i turni oggi? Usa MCP.»* + +**4. Un progetto = un tema** +Es. «Rinnovo bagno», «Homelab», «Studio». Più il `context.md` è pieno di decisioni reali, più Claude lo riuserà da solo. + +**5. Mobile** +Stesso account = stesse custom instructions. MCP remoto dipende dal supporto app Claude; se i tool non compaiono, usa Desktop/web per i salvataggi e da mobile chiedi almeno *«ricorda di aggiornare MCP quando torno al computer»* oppure ripeti la frase-ancora quando i connector sono disponibili. + +**6. Verifica** +Dashboard https://mcp.loogle.it/dashboard → ultime azioni: devono comparire `get_project_context` / `save_context` / `search_knowledge` dopo le chat. + --- + ## Collegare Cursor IDE 1. Crea o modifica `~/.cursor/mcp.json` (globale) oppure `.cursor/mcp.json` nel progetto: diff --git a/services/loogle-mcp/scripts/export_daily_agent_digest.py b/services/loogle-mcp/scripts/export_daily_agent_digest.py new file mode 100644 index 0000000..cede7c8 --- /dev/null +++ b/services/loogle-mcp/scripts/export_daily_agent_digest.py @@ -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())