Backup automatico script del 2026-09-04 07:00

This commit is contained in:
daniele committed 2026-09-04 07:00:02 +02:00
1 parent 795a15a7b6
commit aab8502e27
10 files changed
+1001 -5

No files matched your search

+5 -1
View File
@@ -17,8 +17,12 @@ vrrp_instance VI_1 {
interface eth0 interface eth0
virtual_router_id 51 virtual_router_id 51
priority 101 priority 101
advert_int 1 advert_int 2
preempt_delay 30 preempt_delay 30
unicast_src_ip 192.168.128.80
unicast_peer {
192.168.128.81
}
authentication { authentication {
auth_type PASS auth_type PASS
auth_pass @Dedelove1 auth_pass @Dedelove1
+5 -1
View File
@@ -17,8 +17,12 @@ vrrp_instance VI_1 {
interface eth0 interface eth0
virtual_router_id 51 virtual_router_id 51
priority 100 priority 100
advert_int 1 advert_int 2
preempt_delay 30 preempt_delay 30
unicast_src_ip 192.168.128.81
unicast_peer {
192.168.128.80
}
authentication { authentication {
auth_type PASS auth_type PASS
auth_pass @Dedelove1 auth_pass @Dedelove1
+2
View File
@@ -5,3 +5,5 @@
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2) # Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script: # Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
# turni-app:live-latest # turni-app:live-latest
# Dopo Watchtower: pull registry da rete/compose/failover anche senza container.
# Override: FAILOVER_COMPOSE_DIR=...
+2
View File
@@ -6,3 +6,5 @@
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30) # 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: # Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
# irrigazione, turni-app:beta/alpha, meteo-alert, loogle-casa, ewelink_smart_home # 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=...
+97
View File
@@ -20,6 +20,8 @@ PIHOLE_BIN="/usr/local/bin/pihole"
REBOOT_DELAY_MIN=2 REBOOT_DELAY_MIN=2
WATCHTOWER_IMAGE="nickfedor/watchtower:latest" WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
WATCHTOWER_TIMEOUT=900 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)" HOST_LABEL="$(hostname -s)"
REBOOT_ON_SUCCESS=true REBOOT_ON_SUCCESS=true
@@ -218,6 +220,99 @@ run_watchtower() {
fi 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() { audit_apt() {
local holds local holds
holds=$(apt-mark showhold 2>/dev/null || true) 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) # 4. Aggiornamento container Docker (Watchtower run-once; il daemon è MONITOR_ONLY)
run_watchtower run_watchtower
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
pull_failover_standby_images
# 5. Audit residui # 5. Audit residui
audit_docker audit_docker
+97
View File
@@ -20,6 +20,8 @@ PIHOLE_BIN="/usr/local/bin/pihole"
REBOOT_DELAY_MIN=2 REBOOT_DELAY_MIN=2
WATCHTOWER_IMAGE="nickfedor/watchtower:latest" WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
WATCHTOWER_TIMEOUT=900 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)" HOST_LABEL="$(hostname -s)"
REBOOT_ON_SUCCESS=true REBOOT_ON_SUCCESS=true
@@ -218,6 +220,99 @@ run_watchtower() {
fi 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() { audit_apt() {
local holds local holds
holds=$(apt-mark showhold 2>/dev/null || true) 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) # 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
run_watchtower run_watchtower
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
pull_failover_standby_images
# 5. Audit residui # 5. Audit residui
audit_docker audit_docker
+14 -3
View File
@@ -89,14 +89,23 @@ def index_all(max_pages: int = 20) -> dict:
return {"indexed": indexed, "errors": errors, "users": users} 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) chunks = _chunk_text(text)
if not chunks: if not chunks:
return return
vectors = embeddings.embed_texts(chunks) vectors = embeddings.embed_texts(chunks)
collection = qdrant_store.ctx_collection(username) collection = qdrant_store.ctx_collection(username)
doc_key = f"ctx-{username}-{project_id}" 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 = [] ids = []
payloads = [] payloads = []
for i, chunk in enumerate(chunks): 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) ids.append(point_id)
payloads.append( payloads.append(
{ {
"doc_id": hash(doc_key) % (2**31), "doc_id": doc_id,
"project_id": project_id, "project_id": project_id,
"chunk_index": i, "chunk_index": i,
"title": title, "title": title,
"text": chunk, "text": chunk,
"owner": username, "owner": username,
"visibility": "personal", "visibility": "personal",
"source": "context",
"snippet_id": snippet_id or "latest",
} }
) )
qdrant_store.upsert_chunks(collection, ids, vectors, payloads) qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
+21
View File
@@ -67,6 +67,27 @@ Stesso URL MCP: `https://mcp.loogle.it/mcp`
4. **search_knowledge** — recuperi doc da Paperless quando serve 4. **search_knowledge** — recuperi doc da Paperless quando serve
5. **search_context** — ritrovi discussioni passate semanticamente 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 `<!-- daily-digest:YYYY-MM-DD -->` 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 ### Tool solo admin
- `reindex_document` — forza re-indicizzazione di un doc Paperless - `reindex_document` — forza re-indicizzazione di un doc Paperless
+37
View File
@@ -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). 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:
- Allinizio: 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 allinizio 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 lirrigazione / 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 ## Collegare Cursor IDE
1. Crea o modifica `~/.cursor/mcp.json` (globale) oppure `.cursor/mcp.json` nel progetto: 1. Crea o modifica `~/.cursor/mcp.json` (globale) oppure `.cursor/mcp.json` nel progetto:
@@ -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 <!-- daily-digest:YYYY-MM-DD --> — 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"<!--\s*daily-digest:(\d{4}-\d{2}-\d{2})\s*-->")
USER_QUERY_RE = re.compile(
r"<timestamp>(.*?)</timestamp>\s*<user_query>\s*(.*?)\s*</user_query>",
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",
"<mcp_",
"<dynamic_tools>",
"<agent_transcripts>",
)
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 (
"<user_query>" 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 = "<!-- BEGIN CURSOR PLANS -->"
end = "<!-- END CURSOR PLANS -->"
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 = "<!-- BEGIN DAILY DIGESTS -->"
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"<!-- daily-digest:{day.isoformat()} -->",
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"<!--\s*daily-digest:{day.isoformat()}\s*-->.*?"
rf"(?=<!--\s*daily-digest:\d{{4}}-\d{{2}}-\d{{2}}\s*-->|<!--\s*END DAILY DIGESTS\s*-->|\Z)",
re.DOTALL,
)
context_md = pattern.sub("", context_md)
begin = "<!-- BEGIN DAILY DIGESTS -->"
end = "<!-- END DAILY DIGESTS -->"
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"<!-- daily-digest:{day.isoformat()} -->" 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())