Backup automatico script del 2026-09-04 07:00
This commit is contained in:
1 parent
795a15a7b6
commit
aab8502e27
10 files changed
+1001
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=...
|
||||
@@ -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=...
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 `<!-- 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
|
||||
|
||||
- `reindex_document` — forza re-indicizzazione di un doc Paperless
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user