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())