From 223e90222fef32073610e58054661696da6e7441 Mon Sep 17 00:00:00 2001 From: git_alhan Date: Fri, 12 Jun 2026 20:29:54 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20session=20cleaner=20=E2=80=94=20delete?= =?UTF-8?q?=20short=20sessions=20+=20generate=20missing=20titles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 35 +++++++- hermes_cleaner.py | 210 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 hermes_cleaner.py diff --git a/README.md b/README.md index 78b4f25..84157f2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ -# hermes-session-cleaner +# Hermes Session Cleaner -Hermes Agent session cleanup: delete short conversations, generate missing titles \ No newline at end of file +Hermes Agent session temizlik aracı: kısa konuşmaları sil, başlıksız session'lara title üret. + +## Kullanım + +```bash +# Neler olacağını gör (dry-run) +python3 hermes_cleaner.py --dry-run + +# Gerçek temizlik +python3 hermes_cleaner.py + +# Sadece 3 mesajdan az olanları sil +python3 hermes_cleaner.py --min-messages 3 + +# Sadece silme yap, title üretme +python3 hermes_cleaner.py --no-title + +# Sadece title üret, silme +python3 hermes_cleaner.py --no-delete --max-titles 50 +``` + +## Nasıl Çalışır + +1. Tüm session'ları tarar +2. `< N` mesajlı session'ları siler (default: 5) +3. `>= N` mesajlı ama başlıksız session'lara title üretir (hermes3:latest ile) + +## Gereksinimler + +- Hermes Agent kurulu olmalı +- Ollama'da `hermes3:latest` modeli (env ile değiştirilebilir) +- `HERMES_CLEANER_MODEL` ve `HERMES_CLEANER_ENDPOINT` env override'ları diff --git a/hermes_cleaner.py b/hermes_cleaner.py new file mode 100644 index 0000000..3aac9e7 --- /dev/null +++ b/hermes_cleaner.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Hermes Session Cleaner — delete short sessions, generate missing titles. + +Usage: + hermes_cleaner.py [--dry-run] [--min-messages N] [--no-delete] [--no-title] + +Scans the Hermes session DB and: +1. Deletes sessions with fewer than N messages (default: 5) +2. Generates titles for sessions that don't have one (using local Ollama) + +Safety: --dry-run shows what would happen without making changes. +""" + +import argparse +import json +import os +import re +import sys +from datetime import datetime +from pathlib import Path +from urllib.request import Request, urlopen +from urllib.error import URLError + +HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) +sys.path.insert(0, str(HERMES_HOME / "hermes-agent")) +from hermes_state import SessionDB # noqa: E402 + + +# ── Markdown cleanup ──────────────────────────────────────────────────────── + +def strip_markdown(text: str) -> str: + """Strip markdown formatting.""" + text = re.sub(r"```.*?```", "", text, flags=re.DOTALL) + text = re.sub(r"`([^`]+)`", r"\1", text) + text = re.sub(r"\*\*\*([^*]+)\*\*\*", r"\1", text) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"\*([^*]+)\*", r"\1", text) + text = re.sub(r"__([^_]+)__", r"\1", text) + text = re.sub(r"_([^_]+)_", r"\1", text) + text = re.sub(r"^>\s*", "", text, flags=re.MULTILINE) + return text.strip() + + +# ── Title generation ──────────────────────────────────────────────────────── + +def build_title_prompt(msgs: list[dict]) -> str: + """Build prompt from conversation messages.""" + conversation = [] + for m in msgs: + content = (m.get("content") or "").strip() + if len(content) > 400: + content = content[:400] + "..." + if content: + conversation.append(f"[{m['role']}] {content}") + + transcript = "\n\n".join(conversation) + + return ( + "You are a title generator. Your ONLY job is to output a plain text title.\n\n" + "Generate a short, descriptive title (3-7 words, max 50 characters) for the " + "conversation below. Capture the MAIN topic.\n\n" + "FORMAT RULES:\n" + "- Output ONLY the title text. Nothing else.\n" + "- Plain text only — NO markdown, NO quotes, NO formatting.\n" + "- NO punctuation at the end. NO 'Title:' prefix.\n\n" + f"CONVERSATION:\n{transcript}\n\n" + "TITLE:" + ) + + +def call_llm(prompt: str, endpoint: str, model: str) -> str | None: + """Call OpenAI-compatible API for title generation.""" + body = json.dumps({ + "model": model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 80, + "temperature": 0.1, + }).encode("utf-8") + + try: + req = Request(endpoint, data=body, + headers={"Content-Type": "application/json"}) + with urlopen(req, timeout=60) as resp: + data = json.loads(resp.read()) + msg = data["choices"][0]["message"] + raw = (msg.get("content") + or msg.get("reasoning_content") + or msg.get("reasoning") + or "").strip() + + title = strip_markdown(raw) + title = title.strip("\"'«»„”") + title = re.sub(r"^(Title|Başlık|TITLE)[:\s-]*", "", + title, flags=re.IGNORECASE) + if len(title) > 50: + title = title[:47] + "..." + return title if title else None + except Exception as e: + print(f" LLM error: {e}", file=sys.stderr) + return None + + +def generate_title_for_session(db: SessionDB, session_id: str, + endpoint: str, model: str) -> str | None: + """Generate and set a title for a session.""" + msgs = db.get_messages_as_conversation(session_id) + user_msgs = [m for m in msgs if m.get("role") in ("user", "assistant")] + if len(user_msgs) < 2: + return None + + prompt = build_title_prompt(user_msgs) + title = call_llm(prompt, endpoint, model) + if title: + db.set_session_title(session_id, title) + return title + + +# ── Main ──────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Hermes session cleaner — delete short sessions + missing titles" + ) + parser.add_argument("--dry-run", action="store_true", + help="Show what would happen, don't change anything") + parser.add_argument("--min-messages", type=int, default=5, + help="Delete sessions with fewer than N messages (default: 5)") + parser.add_argument("--no-delete", action="store_true", + help="Skip deletion, only generate titles") + parser.add_argument("--no-title", action="store_true", + help="Skip title generation, only delete") + parser.add_argument("--max-titles", type=int, default=20, + help="Max titles to generate in one run (default: 20)") + args = parser.parse_args() + + db_path = HERMES_HOME / "state.db" + if not db_path.exists(): + print(f"Session DB not found: {db_path}", file=sys.stderr) + sys.exit(1) + + db = SessionDB(db_path) + endpoint = os.environ.get( + "HERMES_CLEANER_ENDPOINT", + "http://100.83.239.61:11434/v1/chat/completions" + ) + model = os.environ.get("HERMES_CLEANER_MODEL", "hermes3:latest") + + # ── Scan ──────────────────────────────────────────────────────────── + sessions = db.list_sessions_rich(limit=500) + + to_delete = [] + to_title = [] + + for s in sessions: + sid = s["id"] + msg_count = int(s.get("message_count", 0)) + title = s.get("title") or "" + source = s.get("source", "?") + + if msg_count < args.min_messages: + to_delete.append((sid, msg_count, title, source)) + elif not title: + to_title.append((sid, msg_count, source)) + + print(f"Sessions scanned : {len(sessions)}") + print(f"To delete (<{args.min_messages} msgs) : {len(to_delete)}") + print(f"To title (>= {args.min_messages} msgs, no title): {len(to_title)}") + + # ── Delete short sessions ─────────────────────────────────────────── + if not args.no_delete and to_delete: + print(f"\n--- Deleting {len(to_delete)} short sessions ---") + for sid, cnt, title, src in to_delete: + title_info = f' "{title}"' if title else "" + if args.dry_run: + print(f" [DRY RUN] Would delete: {sid[:30]} msgs={cnt} src={src}{title_info}") + else: + try: + db.delete_session(sid) + print(f" Deleted: {sid[:30]} msgs={cnt} src={src}{title_info}") + except Exception as e: + print(f" FAILED: {sid[:30]} — {e}", file=sys.stderr) + elif args.no_delete: + print("\n(delete skipped via --no-delete)") + + # ── Generate missing titles ───────────────────────────────────────── + if not args.no_title and to_title: + limit = min(len(to_title), args.max_titles) + print(f"\n--- Generating titles ({limit} of {len(to_title)}) ---") + generated = 0 + for sid, cnt, src in to_title[:limit]: + if args.dry_run: + print(f" [DRY RUN] Would title: {sid[:30]} msgs={cnt} src={src}") + continue + print(f" {sid[:30]} msgs={cnt} src={src} ...", end=" ", flush=True) + title = generate_title_for_session(db, sid, endpoint, model) + if title: + print(f'→ "{title}"') + generated += 1 + else: + print("FAILED") + print(f"\nTitles generated: {generated}/{limit}") + elif args.no_title: + print("\n(title generation skipped via --no-title)") + + if args.dry_run: + print("\n[DRY RUN] No changes made.") + + +if __name__ == "__main__": + main()