Backup automatico script del 2026-11-01 07:00
This commit is contained in:
1 parent
8ad51325ce
commit
48c0d0c21e
11 files changed
+883
-4
No files matched your search
@@ -558,6 +558,19 @@ if ((${#WARNINGS[@]} > 0)); then
|
|||||||
fi
|
fi
|
||||||
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
|
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
|
||||||
|
# 5b. Il peer deve poter coprire: niente reboot se è giù o in manutenzione
|
||||||
|
# (31/10: Pi-1 riavviato alle 04:50 con Pi-2 già giù → rete senza nessun nodo).
|
||||||
|
REBOOT_GUARD="/home/daniely/rete/scripts/maintenance-reboot-guard.sh"
|
||||||
|
if [[ "$REBOOT_ON_SUCCESS" == true && -x "$REBOOT_GUARD" ]]; then
|
||||||
|
if guard_msg="$("$REBOOT_GUARD" check 2>&1)"; then
|
||||||
|
log "Guardia reboot: ${guard_msg}"
|
||||||
|
else
|
||||||
|
REBOOT_ON_SUCCESS=false
|
||||||
|
WARNINGS+=("Reboot saltato: ${guard_msg}")
|
||||||
|
log "⚠ Reboot saltato: ${guard_msg}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
build_telegram_summary
|
build_telegram_summary
|
||||||
|
|
||||||
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
|
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
|
||||||
@@ -566,6 +579,9 @@ if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
|||||||
append_report ""
|
append_report ""
|
||||||
append_report "=== Reboot ==="
|
append_report "=== Reboot ==="
|
||||||
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
||||||
|
# Annuncio al peer: prende il VIP ma non pivota le app, e se il nodo non
|
||||||
|
# torna entro il TTL il suo watchdog fa il failover vero.
|
||||||
|
[[ -x "$REBOOT_GUARD" ]] && "$REBOOT_GUARD" announce "$REBOOT_DELAY_MIN" || true
|
||||||
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
|
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
|
||||||
ERRORS+=("shutdown -r (reboot programmato)")
|
ERRORS+=("shutdown -r (reboot programmato)")
|
||||||
log "✗ Impossibile programmare il reboot"
|
log "✗ Impossibile programmare il reboot"
|
||||||
|
|||||||
@@ -558,6 +558,19 @@ if ((${#WARNINGS[@]} > 0)); then
|
|||||||
fi
|
fi
|
||||||
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
|
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
|
||||||
|
# 5b. Il peer deve poter coprire: niente reboot se è giù o in manutenzione
|
||||||
|
# (31/10: Pi-1 riavviato alle 04:50 con Pi-2 già giù → rete senza nessun nodo).
|
||||||
|
REBOOT_GUARD="/home/daniely/rete/scripts/maintenance-reboot-guard.sh"
|
||||||
|
if [[ "$REBOOT_ON_SUCCESS" == true && -x "$REBOOT_GUARD" ]]; then
|
||||||
|
if guard_msg="$("$REBOOT_GUARD" check 2>&1)"; then
|
||||||
|
log "Guardia reboot: ${guard_msg}"
|
||||||
|
else
|
||||||
|
REBOOT_ON_SUCCESS=false
|
||||||
|
WARNINGS+=("Reboot saltato: ${guard_msg}")
|
||||||
|
log "⚠ Reboot saltato: ${guard_msg}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
build_telegram_summary
|
build_telegram_summary
|
||||||
|
|
||||||
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
|
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
|
||||||
@@ -566,6 +579,9 @@ if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
|||||||
append_report ""
|
append_report ""
|
||||||
append_report "=== Reboot ==="
|
append_report "=== Reboot ==="
|
||||||
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
||||||
|
# Annuncio al peer: prende il VIP ma non pivota le app, e se il nodo non
|
||||||
|
# torna entro il TTL il suo watchdog fa il failover vero.
|
||||||
|
[[ -x "$REBOOT_GUARD" ]] && "$REBOOT_GUARD" announce "$REBOOT_DELAY_MIN" || true
|
||||||
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
|
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
|
||||||
ERRORS+=("shutdown -r (reboot programmato)")
|
ERRORS+=("shutdown -r (reboot programmato)")
|
||||||
log "✗ Impossibile programmare il reboot"
|
log "✗ Impossibile programmare il reboot"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
drill-20261031-205709 C0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
drill-20261031-205709 C1
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
Fonti (massimo contesto utile, non dump grezzo):
|
Fonti (massimo contesto utile, non dump grezzo):
|
||||||
- transcript agenti Cursor (query utente + conclusioni assistente)
|
- transcript agenti Cursor (query utente + conclusioni assistente)
|
||||||
|
- transcript Claude Code di Pi1 (locali) e Pi2 (copiati via rsync in ~/.cache/claude-transcripts/)
|
||||||
- audit_log tool MCP del giorno
|
- audit_log tool MCP del giorno
|
||||||
- commit git recenti in repo tipici (rete, loogle-mcp)
|
- commit git recenti in repo tipici (rete, loogle-mcp)
|
||||||
- piani Cursor `~/.cursor/plans/*.plan.md` → artifacts/plans/ + indice in context.md
|
- piani Cursor `~/.cursor/plans/*.plan.md` → artifacts/plans/ + indice in context.md
|
||||||
@@ -52,6 +53,12 @@ DEFAULT_GIT_REPOS = [
|
|||||||
Path.home() / "docker/loogle-mcp",
|
Path.home() / "docker/loogle-mcp",
|
||||||
]
|
]
|
||||||
DEFAULT_PLANS_DIR = Path.home() / ".cursor/plans"
|
DEFAULT_PLANS_DIR = Path.home() / ".cursor/plans"
|
||||||
|
# Claude Code: <root>/<progetto>/<sessione>.jsonl. Pi2 viene copiato qui prima della lettura.
|
||||||
|
CLAUDE_LOCAL_ROOT = Path.home() / ".claude/projects"
|
||||||
|
CLAUDE_REMOTES = [("pi2", "daniely@192.168.128.81")]
|
||||||
|
CLAUDE_CACHE = Path.home() / ".cache/claude-transcripts"
|
||||||
|
SYSTEM_TAG_RE = re.compile(r"<(system-reminder|command-[a-z-]+|local-command-[a-z-]+)[^>]*>.*?</\1>", re.DOTALL)
|
||||||
|
PASTED_TAG_RE = re.compile(r"</?pasted_content[^>]*>")
|
||||||
|
|
||||||
|
|
||||||
def _load_dotenv() -> None:
|
def _load_dotenv() -> None:
|
||||||
@@ -253,6 +260,119 @@ def collect_transcripts(day: date, roots: list[Path], max_chats: int = 25) -> li
|
|||||||
return chats
|
return chats
|
||||||
|
|
||||||
|
|
||||||
|
def sync_claude_remotes(remotes: list[tuple[str, str]], cache: Path) -> list[tuple[str, Path]]:
|
||||||
|
"""Copia i transcript Claude Code dei nodi remoti in cache; un nodo giù non blocca il digest."""
|
||||||
|
roots: list[tuple[str, Path]] = []
|
||||||
|
for name, host in remotes:
|
||||||
|
dest = cache / name
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = [
|
||||||
|
"rsync", "-a", "--timeout=30", "--prune-empty-dirs",
|
||||||
|
"-e", "ssh -o BatchMode=yes -o ConnectTimeout=6",
|
||||||
|
"--include=*/", "--exclude=subagents/", "--include=*.jsonl", "--exclude=*",
|
||||||
|
f"{host}:.claude/projects/", f"{dest}/",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(f"# claude {name}: rsync rc={r.returncode} {r.stderr.strip()[:200]} (uso la copia precedente)",
|
||||||
|
file=sys.stderr)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as e:
|
||||||
|
print(f"# claude {name}: rsync non riuscito: {e} (uso la copia precedente)", file=sys.stderr)
|
||||||
|
roots.append((name, dest))
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_text(content: Any) -> list[str]:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return [content]
|
||||||
|
out: list[str] = []
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text" and part.get("text"):
|
||||||
|
out.append(part["text"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_claude_user(text: str) -> str:
|
||||||
|
text = SYSTEM_TAG_RE.sub(" ", text)
|
||||||
|
text = PASTED_TAG_RE.sub(" ", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def collect_claude_transcripts(day: date, roots: list[tuple[str, Path]], max_chats: int = 25) -> list[dict]:
|
||||||
|
"""Sessioni Claude Code con almeno un messaggio umano nel giorno (ora locale)."""
|
||||||
|
start, end = _day_bounds(day)
|
||||||
|
files: list[tuple[str, Path]] = []
|
||||||
|
for host, root in roots:
|
||||||
|
if root.is_dir():
|
||||||
|
files.extend((host, p) for p in root.glob("*/*.jsonl"))
|
||||||
|
chats: list[dict] = []
|
||||||
|
for host, path in sorted(files, key=lambda hp: hp[1].stat().st_mtime, reverse=True):
|
||||||
|
if datetime.fromtimestamp(path.stat().st_mtime) < start:
|
||||||
|
continue
|
||||||
|
title = ""
|
||||||
|
queries: list[str] = []
|
||||||
|
tails: list[str] = []
|
||||||
|
paths_touched: Counter[str] = Counter()
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
kind = obj.get("type")
|
||||||
|
if kind == "custom-title":
|
||||||
|
title = obj.get("customTitle") or title
|
||||||
|
continue
|
||||||
|
if kind not in ("user", "assistant") or obj.get("isSidechain") or obj.get("isMeta"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
when = datetime.fromisoformat(obj["timestamp"].replace("Z", "+00:00")).astimezone().replace(tzinfo=None)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
if not (start <= when < end):
|
||||||
|
continue
|
||||||
|
msg = obj.get("message") or {}
|
||||||
|
content = msg.get("content") if isinstance(msg, dict) else None
|
||||||
|
if kind == "user":
|
||||||
|
if obj.get("turnOrigin") not in (None, "human"):
|
||||||
|
continue
|
||||||
|
for t in _claude_text(content):
|
||||||
|
q = _truncate(_clean_claude_user(t), 280)
|
||||||
|
if q and not _is_noise_query(q):
|
||||||
|
queries.append(q)
|
||||||
|
else:
|
||||||
|
for t in _claude_text(content):
|
||||||
|
if len(t) > 80:
|
||||||
|
tails.append(_truncate(t, 320))
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "tool_use":
|
||||||
|
inp = part.get("input") or {}
|
||||||
|
for key in ("file_path", "path"):
|
||||||
|
if isinstance(inp.get(key), str):
|
||||||
|
paths_touched[inp[key]] += 1
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not queries:
|
||||||
|
continue
|
||||||
|
seen: set[str] = set()
|
||||||
|
uniq = [q for q in queries if not (q in seen or seen.add(q))]
|
||||||
|
chats.append({
|
||||||
|
"id": path.stem,
|
||||||
|
"source": f"claude-{host}",
|
||||||
|
"title": title,
|
||||||
|
"queries": uniq[:8],
|
||||||
|
"conclusions": tails[-3:],
|
||||||
|
"paths": [p for p, _ in paths_touched.most_common(8)],
|
||||||
|
})
|
||||||
|
if len(chats) >= max_chats:
|
||||||
|
break
|
||||||
|
return chats
|
||||||
|
|
||||||
|
|
||||||
def collect_audit(day: date, db_path: Path) -> dict:
|
def collect_audit(day: date, db_path: Path) -> dict:
|
||||||
if not db_path.is_file():
|
if not db_path.is_file():
|
||||||
return {"tools": [], "total": 0}
|
return {"tools": [], "total": 0}
|
||||||
@@ -474,15 +594,15 @@ def build_markdown(
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
lines.append("### Chat / agenti Cursor")
|
lines.append("### Chat / agenti (Cursor + Claude Code)")
|
||||||
if not chats:
|
if not chats:
|
||||||
lines.append("- Nessun transcript rilevante per questo giorno.")
|
lines.append("- Nessun transcript rilevante per questo giorno.")
|
||||||
else:
|
else:
|
||||||
lines.append(f"- Sessioni considerate: **{len(chats)}**")
|
lines.append(f"- Sessioni considerate: **{len(chats)}**")
|
||||||
for i, chat in enumerate(chats, 1):
|
for i, chat in enumerate(chats, 1):
|
||||||
title = chat["queries"][0] if chat["queries"] else chat["id"]
|
title = chat.get("title") or (chat["queries"][0] if chat["queries"] else chat["id"])
|
||||||
lines.append(f"{i}. **{_truncate(title, 120)}** `[{chat['id'][:8]}]`")
|
lines.append(f"{i}. **{_truncate(title, 120)}** `[{chat.get('source', 'cursor')} {chat['id'][:8]}]`")
|
||||||
for q in chat["queries"][1:4]:
|
for q in chat["queries"][(0 if chat.get("title") else 1):4]:
|
||||||
lines.append(f" - Q: {_truncate(q, 160)}")
|
lines.append(f" - Q: {_truncate(q, 160)}")
|
||||||
for c in chat["conclusions"][-2:]:
|
for c in chat["conclusions"][-2:]:
|
||||||
lines.append(f" - → {_truncate(c, 200)}")
|
lines.append(f" - → {_truncate(c, 200)}")
|
||||||
@@ -585,6 +705,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--dry-run", action="store_true")
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
parser.add_argument("--no-index", action="store_true", help="Non aggiornare Qdrant ctx_*")
|
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("--max-chats", type=int, default=20)
|
||||||
|
parser.add_argument("--no-claude", action="store_true", help="Non leggere i transcript Claude Code")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-plans",
|
"--no-plans",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -609,6 +730,9 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
chats = collect_transcripts(day, DEFAULT_TRANSCRIPT_ROOTS, max_chats=args.max_chats)
|
chats = collect_transcripts(day, DEFAULT_TRANSCRIPT_ROOTS, max_chats=args.max_chats)
|
||||||
|
if not args.no_claude:
|
||||||
|
claude_roots = [("pi1", CLAUDE_LOCAL_ROOT)] + sync_claude_remotes(CLAUDE_REMOTES, CLAUDE_CACHE)
|
||||||
|
chats += collect_claude_transcripts(day, claude_roots, max_chats=args.max_chats)
|
||||||
audit = collect_audit(day, Path(os.environ["MCP_DB"]))
|
audit = collect_audit(day, Path(os.environ["MCP_DB"]))
|
||||||
git_lines = collect_git(day, DEFAULT_GIT_REPOS)
|
git_lines = collect_git(day, DEFAULT_GIT_REPOS)
|
||||||
block = build_markdown(
|
block = build_markdown(
|
||||||
|
|||||||
@@ -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())
|
||||||