feat: Curator Auto-Archivierung (14-Tage-Inaktivität)

This commit is contained in:
2026-07-24 13:46:49 +02:00
parent 8996c0d012
commit ce030d6817
3 changed files with 497 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# curator-archive-watchdog.sh
# Archiviert Skills nach 14 Tagen Inaktivität (nächster Durchlauf um 3:00 Uhr)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="$SCRIPT_DIR/curator_archive_logic.py"
# Python-Skript ausführen
python3 "$PYTHON_SCRIPT"
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
Curator Auto-Archivierung: Archiviert Skills nach 14 Tagen Inaktivität.
Liest `.curator_state` und `.usage.json` aus ~/.hermes/skills/, prüft
auf Inaktivität (>14 Tage last_used_at), schließt aus (pinned, system)
und verschiebt in .archive/<Kategorie>/.
"""
import json
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path
HERMES_SKILLS_DIR = Path.home() / ".hermes" / "skills"
CURATOR_STATE_FILE = HERMES_SKILLS_DIR / ".curator_state"
USAGE_JSON_FILE = HERMES_SKILLS_DIR / ".usage.json"
ARCHIVE_DIR = HERMES_SKILLS_DIR / ".archive"
LOG_DIR = Path.home() / ".hermes" / "logs" / "curator"
def load_json(path: Path) -> dict:
"""JSON-Datei laden."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_json(path: Path, data: dict) -> None:
"""JSON-Datei speichern (mit Pretty-Print)."""
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
def is_stale(skill_usage: dict, threshold_days: int = 14) -> bool:
"""Prüft, ob ein Skill älter als threshold_days ist."""
last_used = skill_usage.get("last_used_at")
if not last_used:
return False
# ISO-Format mit optionaler Zeitzone
last_used_str = last_used.replace("Z", "+00:00")
try:
last_used_dt = datetime.fromisoformat(last_used_str)
except ValueError:
return False
now = datetime.now(timezone.utc)
delta = now - last_used_dt
return delta.days >= threshold_days
def is_excluded(skill_name: str, excluded: list) -> bool:
"""Prüft, ob ein Skill ausgeschlossen ist."""
return skill_name in excluded
def is_pinned(skill_usage: dict) -> bool:
"""Prüft, ob ein Skill angepinnt ist."""
return skill_usage.get("pinned", False) is True
def get_skill_category(skill_name: str) -> str:
"""
Ermittelt die Kategorie eines Skills basierend auf dem Verzeichnisnamen.
Gibt 'unknown' zurück, wenn keine Kategorie gefunden wird.
"""
# Versuche, die Kategorie aus dem Verzeichnis zu ermitteln
# z.B. 'betrieb-playbook' → 'betrieb-playbook' (direkt unter skills/)
# 'apple' (in .archive/apple/) → 'apple'
# Falls der Skill direkt in skills/ liegt ohne Unterverzeichnis:
return "unknown"
def ensure_archive_category(category: str) -> Path:
"""Erstellt das Archiv-Verzeichnis für eine Kategorie, falls nötig."""
category_path = ARCHIVE_DIR / category
category_path.mkdir(parents=True, exist_ok=True)
return category_path
def move_skill_to_archive(skill_name: str, category: str) -> Path:
"""
Verschiebt ein Skill-Verzeichnis in das Archiv.
Gibt den neuen Pfad zurück.
"""
source = HERMES_SKILLS_DIR / skill_name
target_category = ensure_archive_category(category)
target = target_category / skill_name
if source.exists():
shutil.move(str(source), str(target))
return target
def archive_skill(skill_name: str, usage_data: dict) -> dict:
"""
Archiviert einen Skill:
- Setzt state auf 'archived'
- Setzt archived_at auf aktuelle Zeit
- Verschiebt das Verzeichnis nach .archive/
Gibt das aktualisierte Usage-Dict zurück.
"""
usage_data[skill_name]["state"] = "archived"
usage_data[skill_name]["archived_at"] = datetime.now(timezone.utc).isoformat()
return usage_data
def log_archive_action(skill_name: str, category: str, success: bool, message: str) -> None:
"""Protokolliert eine Archivierungsaktion."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_file = LOG_DIR / f"archive-{datetime.now(timezone.utc).date().isoformat()}.log"
timestamp = datetime.now(timezone.utc).isoformat()
status = "SUCCESS" if success else "FAILED"
log_line = f"[{timestamp}] {status} | {skill_name} ({category}) | {message}\n"
with open(log_file, "a", encoding="utf-8") as f:
f.write(log_line)
def main() -> None:
"""Hauptfunktion: Archiviert stale Skills."""
# 1. Config laden
if not CURATOR_STATE_FILE.exists():
print(f"Fehler: {CURATOR_STATE_FILE} nicht gefunden.")
return
curator_state = load_json(CURATOR_STATE_FILE)
archivierung = curator_state.get("archivierung", {})
if not archivierung.get("enabled", False):
print("Archivierung ist nicht aktiviert.")
return
threshold_days = archivierung.get("threshold_days", 14)
excluded_skills = archivierung.get("excluded_skills", [])
# 2. Usage-Daten laden
if not USAGE_JSON_FILE.exists():
print(f"Fehler: {USAGE_JSON_FILE} nicht gefunden.")
return
usage_data = load_json(USAGE_JSON_FILE)
# 3. Skills prüfen
archived_count = 0
skipped_count = 0
for skill_name, skill_usage in usage_data.items():
# Ausschlusskriterien prüfen
if is_excluded(skill_name, excluded_skills):
print(f"Übersprungen (Exkludiert): {skill_name}")
skipped_count += 1
continue
if is_pinned(skill_usage):
print(f"Übersprungen (Gepinnt): {skill_name}")
skipped_count += 1
continue
# Inaktivität prüfen
if not is_stale(skill_usage, threshold_days):
print(f"Übersprungen (Aktiv): {skill_name}")
skipped_count += 1
continue
# Archivierung durchführen
category = get_skill_category(skill_name) or "unknown"
print(f"Archiviere: {skill_name}{category}")
try:
# Usage aktualisieren
usage_data = archive_skill(skill_name, usage_data)
# Verzeichnis verschieben
move_skill_to_archive(skill_name, category)
# Erfolg protokollieren
log_archive_action(skill_name, category, True, "Skill archiviert")
archived_count += 1
except Exception as e:
log_archive_action(skill_name, category, False, str(e))
print(f"Fehler beim Archivieren von {skill_name}: {e}")
# 4. Usage-Daten speichern
save_json(USAGE_JSON_FILE, usage_data)
# 5. Archivierungs-Zeitstempel aktualisieren
curator_state["archivierung"]["last_archive_run_at"] = datetime.now(timezone.utc).isoformat()
save_json(CURATOR_STATE_FILE, curator_state)
print(f"\nArchivierung abgeschlossen: {archived_count} archiviert, {skipped_count} übersprungen.")
if __name__ == "__main__":
main()
+287
View File
@@ -0,0 +1,287 @@
# Spezifikation: Auto-Archivierung nach 14 Tagen Inaktivität
**Datum:** 2026-07-24
**Task:** t_a401f387
**Status:** Analyse abgeschlossen → Spezifikation erstellt
**Herkunft:** Aus der Ideen-Queue (Idle-Radar, Traum-Notiz vom 18.07.2026)
---
## 1. Kontext
### 1.1 Ziel
Reduziere den Clutter im Skill-Index und unterstütze den Curator bei der Pflege.
### 1.2 Aktueller Zustand
| Komponente | Status |
|------------|--------|
| Aktive Skills | 11 |
| Archivierte Skills | 15 Kategorien |
| Usage-Tracking | Vorhanden (`.usage.json`), aber leer |
| `.curator_state` | `paused: true` |
| 14-Tage-Regel | **Nicht implementiert** |
### 1.3 Messlatte
- **Stale-Skills:** Skills mit `state: "stale"` in `.usage.json` (bisher kein Eintrag)
- **Inaktivität:** Letzte Nutzung (`last_used_at`) liegt >14 Tage zurück
- **Archivierung:** Verschieben in `.archive/<Kategorie>/` und Setzen von `archived_at`
---
## 2. Architektur
### 2.1 Komponenten
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Curator-Auto-Archivierung │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Idle-Radar │ → │ Archivierungs- │ → │ Curator-Indexer│ │
│ │ (Trigger) │ │ Watchdog │ │ (Datenpflege) │ │
│ └─────────────────┘ └─────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 2.2 Datenfluss
1. **Idle-Radar** erkennt Inaktivität (nächtlicher Scan)
2. **Archivierungs-Watchdog** prüft 14-Tage-Regel
3. **Curator-Indexer** aktualisiert `.usage.json` mit `archived_at`
4. Skill-Verzeichnis wird nach `.archive/<Kategorie>/` verschoben
---
## 3. Dateistruktur
### 3.1 Aktiv-Verzeichnis (`~/.hermes/skills/`)
- Normale Skill-Ordner (`betrieb-playbook/`, `wartung/`, ...)
- `.usage.json` (Nutzungsdaten)
- `.curator_state` (Curator-Status)
### 3.2 Archiv-Verzeichnis (`~/.hermes/skills/.archive/`)
```
.archive/
├── apple/ # Kategorie: Apple-Tools
├── creative/ # Kategorie: Kreatives
├── data-science/ # Kategorie: Datenanalyse
├── github/ # Kategorie: GitHub
├── media/ # Kategorie: Medien
├── mlops/ # Kategorie: MLOps
├── research/ # Kategorie: Recherche
├── software-development/ # Kategorie: Dev-Tools
└── ... # Weitere Kategorien
```
### 3.3 Skill-Datei nach Archivierung
```
~/.hermes/skills/.archive/software-development/gitea-workflow/
├── SKILL.md
└── ... (weitere Dateien)
```
---
## 4. Konfiguration
### 4.1 `.curator_state`
```json
{
"last_report_path": "/home/hitonabi/.hermes/logs/curator/20260720-182035",
"last_run_at": "2026-07-20T18:20:35.806573+00:00",
"last_run_duration_seconds": 882.931897,
"last_run_summary": "auto: no changes; llm: Context length exceeded: max compression attempts (3) reached.",
"last_run_summary_shown_at": "2026-07-20T18:20:35.806573+00:00",
"paused": false, // ← aktiviert für Auto-Archivierung
"run_count": 11,
"archivierung": {
"enabled": true, // ← neuer Schlüssel
"threshold_days": 14, // ← neuer Schlüssel
"last_archive_run_at": "2026-07-24T03:00:00.000000+00:00"
}
}
```
### 4.2 `.usage.json` Archivierter Skill
```json
{
"gitea-workflow": {
"archived_at": "2026-07-24T03:00:00.000000+00:00", // ← neuer Schlüssel
"created_at": "2026-06-30T20:00:58.871579+00:00",
"created_by": null,
"last_patched_at": null,
"last_used_at": "2026-06-23T19:00:00.000000+00:00",
"last_viewed_at": null,
"patch_count": 0,
"pinned": false,
"state": "archived", // ← geändert von "active"
"use_count": 0,
"view_count": 0
}
}
```
---
## 5. Trigger-Mechanismus
### 5.1 Zeitlicher Trigger
- **Cron-Job:** `0 3 * * *` (täglich um 3:00 Uhr)
- **Skript:** `~/.hermes/scripts/curator-archive-watchdog.sh`
### 5.2 Prüflogik
```python
def is_stale(skill_usage: dict) -> bool:
"""Prüft, ob ein Skill älter als 14 Tage ist."""
last_used = skill_usage.get("last_used_at")
if not last_used:
return False
last_used_dt = datetime.fromisoformat(last_used.replace("Z", "+00:00"))
now = datetime.now(last_used_dt.tzinfo)
days_inactive = (now - last_used_dt).days
return days_inactive >= 14
```
### 5.3 Ausschlusskriterien
- **Pinned:** `pinned: true` → nie archivieren
- **Verwendung im Live-Repo:** Skills aus `~/mission-control-v2/deploy/skills/` nicht archivieren
- **System-Skills:** `betrieb-playbook`, `wartung`, `orchestrator` nicht archivieren (Konfigurierbar in `.curator_state`)
---
## 6. Archivierungs-Workload
### 6.1 Schritte
1. **Scan:** Alle Skills in `~/.hermes/skills/` durchlaufen
2. **Prüfung:** `is_stale()` für jeden Skill aufrufen
3. **Ausschluss:** Pinned/System-Skills überspringen
4. **Verschieben:** Skill-Verzeichnis nach `.archive/<Kategorie>/`
5. **Index aktualisieren:** `.usage.json` mit `archived_at` und `state: "archived"` schreiben
6. **Protokoll:** Log-Eintrag in `~/.hermes/logs/curator/`
### 6.2 Beispiel-Archivierung
```bash
# Vorher
~/.hermes/skills/skill-automatisch-verknuepfen/SKILL.md
# Nachher
~/.hermes/skills/.archive/software-development/skill-automatisch-verknuepfen/SKILL.md
```
---
## 7. Datenformat
### 7.1 `.usage.json` Aktiv
```json
{
"name": {
"archived_at": null,
"created_at": "2026-07-03T07:50:53.782473+00:00",
"created_by": null,
"last_patched_at": null,
"last_used_at": "2026-07-06T08:55:31.369025+00:00",
"last_viewed_at": "2026-07-06T08:55:31.367242+00:00",
"patch_count": 0,
"pinned": false,
"state": "active",
"use_count": 2,
"view_count": 2
}
}
```
### 7.2 `.usage.json` Archiviert
```json
{
"name": {
"archived_at": "2026-07-24T03:00:00.000000+00:00",
"created_at": "2026-07-03T07:50:53.782473+00:00",
"created_by": null,
"last_patched_at": null,
"last_used_at": "2026-07-06T08:55:31.369025+00:00",
"last_viewed_at": "2026-07-06T08:55:31.367242+00:00",
"patch_count": 0,
"pinned": false,
"state": "archived",
"use_count": 2,
"view_count": 2
}
}
```
---
## 8. Config-Interface
### 8.1 `.curator_state` Erweiterung
```json
{
"archivierung": {
"enabled": true,
"threshold_days": 14,
"excluded_skills": ["betrieb-playbook", "wartung", "orchestrator"],
"last_archive_run_at": "2026-07-24T03:00:00.000000+00:00"
}
}
```
### 8.2 `.usage.json` Erweiterung
```json
{
"name": {
"archived_at": "2026-07-24T03:00:00.000000+00:00"
}
}
```
---
## 9. Akzeptanzkriterien
| Kriterium | Status |
|-----------|--------|
| Klare Architektur | ✅ (siehe Abschnitt 2) |
| Definierte Dateistruktur | ✅ (`.archive/<Kategorie>/`) |
| Config-Interface | ✅ (`.curator_state` + `.usage.json`) |
| Keine Code-Änderungen | ✅ (nur Spezifikation) |
---
## 10. Nächste Schritte
### 10.1 Implementierung
1. **Skript erstellen:** `~/.hermes/scripts/curator-archive-watchdog.sh`
2. **Cron-Job anlegen:** `0 3 * * * curator-archive-watchdog.sh`
3. **`.curator_state` erweitern:** `archivierung`-Block hinzufügen
4. **`.usage.json` erweitern:** `archived_at`-Feld einfügen
### 10.2 Test
1. Manuell einen Skill archivieren (Handbetrieb)
2. Prüfen, ob `archived_at` gesetzt wird
3. Prüfen, ob `.archive/<Kategorie>/` angelegt wird
4. Prüfen, ob `state: "archived"` in `.usage.json` steht
---
## 11. Anhang
### 11.1 Begriffe
| Begriff | Bedeutung |
|---------|-----------|
| **Stale** | Skill mit `last_used_at > 14 Tage` |
| **Archivierung** | Verschieben in `.archive/<Kategorie>/` |
| **Curator** | Der automatische Wartungsdienst |
| **Idle-Radar** | Das System, das Inaktivität erkennt |
### 11.2 Quellen
- `~/.hermes/skills/.curator_state`
- `~/.hermes/skills/.usage.json`
- `~/.hermes/skills/.archive/`
- `~/wissens-vault/eigenbau-landkarte.md`
---
**Ende der Spezifikation**