feat: add persistent memory system + SQLite session store
Two-part implementation: Part A - Curated Bounded Memory: - New memory tool (tools/memory_tool.py) with MEMORY.md + USER.md stores - Character-limited (2200/1375 chars), § delimited entries - Frozen snapshot injected into system prompt at session start - Model manages pruning via replace/remove with substring matching - Usage indicator shown in system prompt header Part B - SQLite Session Store: - New hermes_state.py with SessionDB class, FTS5 full-text search - Gateway session.py rewritten to dual-write SQLite + legacy JSONL - Compression-triggered session splitting with parent_session_id chains - New session_search tool with Gemini Flash summarization of matched sessions - CLI session lifecycle (create on launch, close on exit) Also: - System prompt now cached per session, only rebuilt on compression (fixes prefix cache invalidation from date/time changes every turn) - Config version bumped to 3, hermes doctor checks for new artifacts - Disabled in batch_runner and RL environments
This commit is contained in:
parent
655303f2f1
commit
440c244cac
19 changed files with 2397 additions and 327 deletions
|
|
@ -64,6 +64,7 @@ def ensure_hermes_home():
|
|||
(home / "cron").mkdir(parents=True, exist_ok=True)
|
||||
(home / "sessions").mkdir(parents=True, exist_ok=True)
|
||||
(home / "logs").mkdir(parents=True, exist_ok=True)
|
||||
(home / "memories").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -128,11 +129,19 @@ DEFAULT_CONFIG = {
|
|||
"max_ms": 2500,
|
||||
},
|
||||
|
||||
# Persistent memory -- bounded curated memory injected into system prompt
|
||||
"memory": {
|
||||
"memory_enabled": True,
|
||||
"user_profile_enabled": True,
|
||||
"memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
|
||||
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
|
||||
},
|
||||
|
||||
# Permanently allowed dangerous command patterns (added via "always" approval)
|
||||
"command_allowlist": [],
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 2,
|
||||
"_config_version": 3,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -196,6 +196,43 @@ def run_doctor(args):
|
|||
else:
|
||||
check_warn("logs/ not found", "(will be created on first use)")
|
||||
|
||||
# Check memory directory
|
||||
memories_dir = hermes_home / "memories"
|
||||
if memories_dir.exists():
|
||||
check_ok("~/.hermes/memories/ directory exists")
|
||||
memory_file = memories_dir / "MEMORY.md"
|
||||
user_file = memories_dir / "USER.md"
|
||||
if memory_file.exists():
|
||||
size = len(memory_file.read_text(encoding="utf-8").strip())
|
||||
check_ok(f"MEMORY.md exists ({size} chars)")
|
||||
else:
|
||||
check_info("MEMORY.md not created yet (will be created when the agent first writes a memory)")
|
||||
if user_file.exists():
|
||||
size = len(user_file.read_text(encoding="utf-8").strip())
|
||||
check_ok(f"USER.md exists ({size} chars)")
|
||||
else:
|
||||
check_info("USER.md not created yet (will be created when the agent first writes a memory)")
|
||||
else:
|
||||
check_warn("~/.hermes/memories/ not found", "(will be created on first use)")
|
||||
if should_fix:
|
||||
memories_dir.mkdir(parents=True, exist_ok=True)
|
||||
check_ok("Created ~/.hermes/memories/")
|
||||
|
||||
# Check SQLite session store
|
||||
state_db_path = hermes_home / "state.db"
|
||||
if state_db_path.exists():
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(str(state_db_path))
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM sessions")
|
||||
count = cursor.fetchone()[0]
|
||||
conn.close()
|
||||
check_ok(f"~/.hermes/state.db exists ({count} sessions)")
|
||||
except Exception as e:
|
||||
check_warn(f"~/.hermes/state.db exists but has issues: {e}")
|
||||
else:
|
||||
check_info("~/.hermes/state.db not created yet (will be created on first session)")
|
||||
|
||||
# =========================================================================
|
||||
# Check: External tools
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -533,6 +533,121 @@ For more help on a command:
|
|||
|
||||
skills_parser.set_defaults(func=cmd_skills)
|
||||
|
||||
# =========================================================================
|
||||
# sessions command
|
||||
# =========================================================================
|
||||
sessions_parser = subparsers.add_parser(
|
||||
"sessions",
|
||||
help="Manage session history (list, export, prune, delete)",
|
||||
description="View and manage the SQLite session store"
|
||||
)
|
||||
sessions_subparsers = sessions_parser.add_subparsers(dest="sessions_action")
|
||||
|
||||
sessions_list = sessions_subparsers.add_parser("list", help="List recent sessions")
|
||||
sessions_list.add_argument("--source", help="Filter by source (cli, telegram, discord, etc.)")
|
||||
sessions_list.add_argument("--limit", type=int, default=20, help="Max sessions to show")
|
||||
|
||||
sessions_export = sessions_subparsers.add_parser("export", help="Export sessions to a JSONL file")
|
||||
sessions_export.add_argument("output", help="Output JSONL file path")
|
||||
sessions_export.add_argument("--source", help="Filter by source")
|
||||
sessions_export.add_argument("--session-id", help="Export a specific session")
|
||||
|
||||
sessions_delete = sessions_subparsers.add_parser("delete", help="Delete a specific session")
|
||||
sessions_delete.add_argument("session_id", help="Session ID to delete")
|
||||
sessions_delete.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
||||
|
||||
sessions_prune = sessions_subparsers.add_parser("prune", help="Delete old sessions")
|
||||
sessions_prune.add_argument("--older-than", type=int, default=90, help="Delete sessions older than N days (default: 90)")
|
||||
sessions_prune.add_argument("--source", help="Only prune sessions from this source")
|
||||
sessions_prune.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
||||
|
||||
sessions_stats = sessions_subparsers.add_parser("stats", help="Show session store statistics")
|
||||
|
||||
def cmd_sessions(args):
|
||||
import json as _json
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
except Exception as e:
|
||||
print(f"Error: Could not open session database: {e}")
|
||||
return
|
||||
|
||||
action = args.sessions_action
|
||||
|
||||
if action == "list":
|
||||
sessions = db.search_sessions(source=args.source, limit=args.limit)
|
||||
if not sessions:
|
||||
print("No sessions found.")
|
||||
return
|
||||
print(f"{'ID':<30} {'Source':<12} {'Model':<30} {'Messages':>8} {'Started'}")
|
||||
print("─" * 100)
|
||||
from datetime import datetime
|
||||
for s in sessions:
|
||||
started = datetime.fromtimestamp(s["started_at"]).strftime("%Y-%m-%d %H:%M") if s["started_at"] else "?"
|
||||
model = (s.get("model") or "?")[:28]
|
||||
ended = " (ended)" if s.get("ended_at") else ""
|
||||
print(f"{s['id']:<30} {s['source']:<12} {model:<30} {s['message_count']:>8} {started}{ended}")
|
||||
|
||||
elif action == "export":
|
||||
if args.session_id:
|
||||
data = db.export_session(args.session_id)
|
||||
if not data:
|
||||
print(f"Session '{args.session_id}' not found.")
|
||||
return
|
||||
with open(args.output, "w") as f:
|
||||
f.write(_json.dumps(data, ensure_ascii=False) + "\n")
|
||||
print(f"Exported 1 session to {args.output}")
|
||||
else:
|
||||
sessions = db.export_all(source=args.source)
|
||||
with open(args.output, "w") as f:
|
||||
for s in sessions:
|
||||
f.write(_json.dumps(s, ensure_ascii=False) + "\n")
|
||||
print(f"Exported {len(sessions)} sessions to {args.output}")
|
||||
|
||||
elif action == "delete":
|
||||
if not args.yes:
|
||||
confirm = input(f"Delete session '{args.session_id}' and all its messages? [y/N] ")
|
||||
if confirm.lower() not in ("y", "yes"):
|
||||
print("Cancelled.")
|
||||
return
|
||||
if db.delete_session(args.session_id):
|
||||
print(f"Deleted session '{args.session_id}'.")
|
||||
else:
|
||||
print(f"Session '{args.session_id}' not found.")
|
||||
|
||||
elif action == "prune":
|
||||
days = args.older_than
|
||||
source_msg = f" from '{args.source}'" if args.source else ""
|
||||
if not args.yes:
|
||||
confirm = input(f"Delete all ended sessions older than {days} days{source_msg}? [y/N] ")
|
||||
if confirm.lower() not in ("y", "yes"):
|
||||
print("Cancelled.")
|
||||
return
|
||||
count = db.prune_sessions(older_than_days=days, source=args.source)
|
||||
print(f"Pruned {count} session(s).")
|
||||
|
||||
elif action == "stats":
|
||||
total = db.session_count()
|
||||
msgs = db.message_count()
|
||||
print(f"Total sessions: {total}")
|
||||
print(f"Total messages: {msgs}")
|
||||
for src in ["cli", "telegram", "discord", "whatsapp", "slack"]:
|
||||
c = db.session_count(source=src)
|
||||
if c > 0:
|
||||
print(f" {src}: {c} sessions")
|
||||
import os
|
||||
db_path = db.db_path
|
||||
if db_path.exists():
|
||||
size_mb = os.path.getsize(db_path) / (1024 * 1024)
|
||||
print(f"Database size: {size_mb:.1f} MB")
|
||||
|
||||
else:
|
||||
sessions_parser.print_help()
|
||||
|
||||
db.close()
|
||||
|
||||
sessions_parser.set_defaults(func=cmd_sessions)
|
||||
|
||||
# =========================================================================
|
||||
# version command
|
||||
# =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue