Commit ae1a70a0 authored by Lisa (AI Assistant)'s avatar Lisa (AI Assistant)

feat: add hermes three-tier mempalace memory plugin

parents
Pipeline #320 canceled with stages
# DOCUMENTATION
## Overview
`hermes-three-tier-memory` is a Hermes plugin that adds a layered memory architecture without modifying Hermes core.
### Storage layers
- **Tier 1**: active working memory in `~/.hermes/memories/MEMORY.md`
- **Tier 2**: rolling daily summaries in `~/.hermes/memories/daily/`
- **Tier 3**: MemPalace long-term storage accessed over MCP
## Design goals
- Keep memory behavior outside Hermes core
- Preserve backward compatibility with legacy MemPalace content
- Improve retrieval relevance using taxonomy and wing-aware search
- Support safe evolution of taxonomy over time
## Taxonomy model
### Wings
Default wings:
- `identity`
- `governance`
- `systems`
- `work`
- `history`
- `relationships`
Additional wings can be created dynamically or manually.
### Rooms
Each wing contains rooms represented in `taxonomy.json`.
Rooms are used for:
- classification
- organization
- search targeting
- lifecycle maintenance
## Tool behavior
### `status`
Returns current plugin state, daily file status, retention, and taxonomy summary.
### `search`
Searches daily memory first, then falls back to MemPalace if confidence is weak or empty.
### `search_long_term`
Direct MemPalace search.
### `maintain`
Compacts current memory, writes daily summary, promotes summary lines to MemPalace, and writes a diary entry.
### `taxonomy`
Shows full or filtered taxonomy.
### Lifecycle actions
- `create_wing`
- `create_room`
- `update_wing`
- `update_room`
- `move_room`
- `delete_room`
- `delete_wing`
Delete actions optionally purge live MemPalace drawers when `dry_run=false`.
### `list_memories`
Lists drawers from MemPalace, optionally filtered by wing/room.
### `migrate_legacy`
Reclassifies legacy-seeded content into current taxonomy. Supports dry-run and write mode.
### `validate_wing_hints`
Compares targeted wing search vs broad search and reports:
- top similarity
- query alignment
- source file and drawer identity for top result
- deltas between broad and best targeted runs
### `introspect_mempalace`
Checks live availability of MemPalace support functions.
## Classification strategy
Classification is lightweight and pragmatic, not overengineered.
It uses:
- room/wing token overlap
- phrase bonuses
- Jaccard token similarity
- fallback routing to `work/general`
Diagnostics are returned during migration/promotion paths.
## Verification strategy
Recommended checks:
1. `python3 -m py_compile __init__.py scripts/*.py`
2. taxonomy create/update/move/delete lifecycle tests
3. drawer listing before/after destructive actions
4. validation harness on representative queries
5. maintenance run with summary promotion enabled
## Safe delete verification workflow
Suggested workflow for destructive verification:
1. create temporary wing
2. create temporary room
3. add one or more drawers there
4. list drawers to capture IDs
5. delete room with purge enabled
6. verify drawers are gone
7. delete temporary wing
8. verify wing removal in taxonomy
## Operational notes
- This plugin assumes MemPalace MCP is connected in Hermes.
- It does not patch Hermes core.
- Taxonomy is persisted in `taxonomy.json` and can evolve independently.
- Legacy compatibility is maintained through `memories` wing handling.
## Licensing
GPLv3
Copyleft Stefy Lanza <stefy@nexlab.net>
This diff is collapsed.
# hermes-three-tier-memory
Three-tier memory plugin for Hermes Agent backed by rolling local memory files and MemPalace long-term storage.
## What it does
This plugin adds an update-safe memory layer without patching Hermes core:
1. **Tier 1 — Active memory**
- `~/.hermes/memories/MEMORY.md`
2. **Tier 2 — Rolling daily memory**
- `~/.hermes/memories/daily/YYYY-MM-DD.md`
- 7-day retention by default
3. **Tier 3 — Long-term memory**
- MemPalace via MCP
- wing/room taxonomy
- promotion, search, migration, listing, move, update, delete
## Features
- Smart search across recent and long-term memory
- Multi-wing taxonomy with rooms
- Legacy migration support
- Daily maintenance / compaction
- Drawer listing and MemPalace introspection
- Wing-hint validation harness
- Taxonomy lifecycle operations
- Safe delete verification workflow
## Tool actions
- `status`
- `list`
- `compact`
- `maintain`
- `search`
- `search_long_term`
- `taxonomy`
- `create_wing`
- `create_room`
- `update_wing`
- `update_room`
- `move_room`
- `delete_room`
- `delete_wing`
- `list_memories`
- `migrate_legacy`
- `validate_wing_hints`
- `introspect_mempalace`
## Requirements
- Hermes Agent plugin support
- MemPalace MCP server configured and reachable
- Python 3
## Files
- `__init__.py` — plugin entrypoint and tool implementation
- `taxonomy.json` — persisted taxonomy
- `scripts/compact_memory.py` — compaction/search helper
- `scripts/run_maintenance.py` — maintenance runner
- `scripts/organize_memory.py` — memory organization helper
- `STATUS.md` — development ledger and verification status
- `DOCUMENTATION.md` — detailed operational documentation
## Installation
Clone or install the plugin into Hermes plugin space, then restart Hermes or start a fresh session so the tool is registered.
## License
GPLv3. See `LICENSE`.
## Copyright
Copyleft Stefy Lanza <stefy@nexlab.net>
This diff is collapsed.
This diff is collapsed.
name: hermes-three-tier-memory
version: 1.0.0
description: "Three-tier Hermes memory outside core source: active MEMORY.md, rolling 7-day daily memory, and nightly compaction tooling."
author: Lisa (Hermes AI)
kind: standalone
platforms:
- linux
provides_tools:
- three_tier_memory
hooks:
- pre_tool_call
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from collections import Counter
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
ENTRY_DELIMITER = "\n§\n"
DEFAULT_RETENTION_DAYS = 7
MAX_DAILY_LINE_LEN = 220
STOPWORDS = {
"a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "how", "i", "in", "is", "it",
"of", "on", "or", "that", "the", "this", "to", "was", "we", "what", "when", "where", "which",
"who", "why", "with", "you", "your",
}
def utc_today_key() -> str:
return datetime.now(timezone.utc).date().isoformat()
def load_entries(path: Path) -> list[str]:
if not path.exists():
return []
text = path.read_text(encoding="utf-8").strip()
if not text:
return []
return [chunk.strip() for chunk in text.split(ENTRY_DELIMITER) if chunk.strip()]
def write_entries(path: Path, entries: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
body = ENTRY_DELIMITER.join(entry.strip() for entry in entries if entry.strip())
if body:
body += "\n"
path.write_text(body, encoding="utf-8")
def compress_entry(entry: str) -> str:
line = " ".join(part.strip() for part in entry.splitlines() if part.strip())
line = re.sub(r"\s+", " ", line).strip()
if len(line) > MAX_DAILY_LINE_LEN:
line = line[: MAX_DAILY_LINE_LEN - 3].rstrip() + "..."
return line
def dedupe_keep_order(items: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for item in items:
key = item.strip()
if not key or key in seen:
continue
seen.add(key)
out.append(key)
return out
def prune_daily_dir(daily_dir: Path, retention_days: int) -> list[str]:
keep = {
(datetime.now(timezone.utc).date() - timedelta(days=delta)).isoformat()
for delta in range(max(1, retention_days))
}
removed: list[str] = []
daily_dir.mkdir(parents=True, exist_ok=True)
for path in daily_dir.glob("*.md"):
if path.stem not in keep:
path.unlink(missing_ok=True)
removed.append(path.stem)
return sorted(removed)
def tokenize(text: str) -> list[str]:
return [token for token in re.findall(r"[a-z0-9_./:-]+", text.lower()) if token]
def keywords(text: str) -> list[str]:
return [token for token in tokenize(text) if token not in STOPWORDS and len(token) > 1]
def summarize_entries(entries: list[str], max_points: int = 12) -> list[str]:
compressed = [compress_entry(entry) for entry in entries if compress_entry(entry)]
if not compressed:
return []
counts = Counter()
for entry in compressed:
counts.update(set(keywords(entry)))
def score(entry: str) -> tuple[int, int]:
toks = set(keywords(entry))
return (sum(counts[token] for token in toks), len(entry))
ranked = sorted(compressed, key=score, reverse=True)
return dedupe_keep_order(ranked)[:max_points]
def score_entry(query: str, entry: str, day: str) -> dict[str, Any]:
q = query.strip().lower()
entry_text = entry.strip()
entry_lower = entry_text.lower()
q_tokens = keywords(query)
e_tokens = set(keywords(entry_text))
overlap = [token for token in q_tokens if token in e_tokens]
overlap_ratio = (len(set(overlap)) / len(set(q_tokens))) if q_tokens else 0.0
exact_phrase = q in entry_lower if q else False
prefix_hit = any(token.startswith(q) for token in e_tokens) if q and len(q) > 2 else False
score = 0.0
if exact_phrase:
score += 1.0
score += overlap_ratio * 0.9
if prefix_hit:
score += 0.15
if query.strip() == entry_text:
score += 0.5
confidence = "low"
if score >= 1.45:
confidence = "high"
elif score >= 0.75:
confidence = "medium"
return {
"day": day,
"entry": entry_text,
"score": round(score, 4),
"confidence": confidence,
"exact_phrase": exact_phrase,
"matched_terms": sorted(set(overlap)),
"matched_term_count": len(set(overlap)),
}
def compact(memory_file: Path, daily_dir: Path, day: str | None, retention_days: int) -> dict[str, Any]:
day_key = day or utc_today_key()
active_entries = load_entries(memory_file)
compressed = dedupe_keep_order([compress_entry(entry) for entry in active_entries if compress_entry(entry)])
daily_file = daily_dir / f"{day_key}.md"
existing = load_entries(daily_file)
merged = dedupe_keep_order(existing + compressed)
write_entries(daily_file, merged)
removed = prune_daily_dir(daily_dir, retention_days)
summary = summarize_entries(merged)
return {
"success": True,
"action": "compact",
"memory_file": str(memory_file),
"daily_file": str(daily_file),
"day": day_key,
"active_entry_count": len(active_entries),
"written_entry_count": len(merged),
"summary": summary,
"removed_days": removed,
}
def search_daily(daily_dir: Path, query: str, retention_days: int, limit: int) -> dict[str, Any]:
q = query.strip().lower()
if not q:
return {"success": False, "error": "query cannot be empty"}
prune_daily_dir(daily_dir, retention_days)
matches: list[dict[str, Any]] = []
for path in sorted(daily_dir.glob("*.md"), reverse=True):
for entry in load_entries(path):
scored = score_entry(query, entry, path.stem)
if scored["score"] > 0:
matches.append(scored)
matches.sort(key=lambda item: (item["score"], item["day"]), reverse=True)
trimmed = matches[:limit]
best_score = trimmed[0]["score"] if trimmed else 0.0
confidence = "low"
if best_score >= 1.45:
confidence = "high"
elif best_score >= 0.75:
confidence = "medium"
summary = summarize_entries([item["entry"] for item in trimmed], max_points=min(6, limit))
return {
"success": True,
"action": "search",
"query": query,
"matches": trimmed,
"match_count": len(trimmed),
"best_score": round(best_score, 4),
"confidence": confidence,
"summary": summary,
}
def list_daily(daily_dir: Path, retention_days: int) -> dict[str, Any]:
prune_daily_dir(daily_dir, retention_days)
daily: dict[str, list[str]] = {}
for path in sorted(daily_dir.glob("*.md"), reverse=True):
daily[path.stem] = load_entries(path)
return {"success": True, "action": "list", "daily": daily}
def main() -> int:
parser = argparse.ArgumentParser(description="Compact/search Hermes active memory into rolling daily memory files")
parser.add_argument("action", choices=["compact", "search", "list"])
parser.add_argument("--memory-file", default=str(Path.home() / ".hermes/memories/MEMORY.md"))
parser.add_argument("--daily-dir", default=str(Path.home() / ".hermes/memories/daily"))
parser.add_argument("--day", default=None)
parser.add_argument("--retention-days", type=int, default=DEFAULT_RETENTION_DAYS)
parser.add_argument("--query", default="")
parser.add_argument("--limit", type=int, default=8)
args = parser.parse_args()
memory_file = Path(args.memory_file).expanduser()
daily_dir = Path(args.daily_dir).expanduser()
if args.action == "compact":
result = compact(memory_file, daily_dir, args.day, args.retention_days)
elif args.action == "search":
result = search_daily(daily_dir, args.query, args.retention_days, args.limit)
else:
result = list_daily(daily_dir, args.retention_days)
print(json.dumps(result, ensure_ascii=False))
return 0 if result.get("success") else 1
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, '/home/lisa/.hermes/hermes-agent')
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import __init__ as plugin # noqa: E402
from compact_memory import load_entries, summarize_entries # noqa: E402
from tools.mcp_tool import discover_mcp_tools # noqa: E402
MEMORY_FILE = Path.home() / '.hermes' / 'memories' / 'MEMORY.md'
DEFAULT_WING = 'memories'
DEFAULT_GENERAL_ROOM = 'general'
SUMMARY_ROOM = 'daily-summaries'
class DummyCtx:
pass
def slugify(text: str) -> str:
text = text.strip().lower()
text = re.sub(r'[`*_#\[\](){}:;,.!?"\'\\/]+', ' ', text)
text = re.sub(r'\s+', '-', text)
text = re.sub(r'[^a-z0-9-]+', '', text)
return text.strip('-') or DEFAULT_GENERAL_ROOM
def classify_entry(entry: str) -> tuple[str, str, int]:
lower = entry.lower()
importance = 1
if any(token in lower for token in ['preference', 'prefers', 'workflow preference', 'likes', 'dislikes', 'expects', 'strongly dislike']):
return DEFAULT_WING, 'preferences', 5
if any(token in lower for token in ['always', 'never', 'must', 'critical', 'require', 'rejected', 'explicitly rejected']):
return DEFAULT_WING, 'policies', 5
if any(token in lower for token in ['config', 'path', 'installed', 'reachable', 'machine', 'gateway', 'plugin', 'mcp', 'command', 'repo']):
return DEFAULT_WING, 'infrastructure', 4
if any(token in lower for token in ['architecture', 'capability', 'design', 'protocol', 'integration']):
return DEFAULT_WING, 'architecture', 4
return DEFAULT_WING, DEFAULT_GENERAL_ROOM, importance
def main() -> int:
plugin._STATE.ctx = DummyCtx()
discover_mcp_tools()
raw_entries = load_entries(MEMORY_FILE)
organized = []
promoted = []
seen_payloads = set()
for entry in raw_entries:
compact = plugin._run_script('compact', '--memory-file', str(MEMORY_FILE), '--daily-dir', str(Path.home() / '.hermes' / 'memories' / 'daily'), '--retention-days', '7')
day = compact.get('day', '') if isinstance(compact, dict) else ''
break
else:
day = ''
for entry in raw_entries:
compressed = ' '.join(entry.split())
if not compressed:
continue
wing, room, importance = classify_entry(compressed)
room = slugify(room)
if room == SUMMARY_ROOM:
room = DEFAULT_GENERAL_ROOM
payload = f"[{day or 'undated'}] [importance:{importance}] {compressed}"
if payload in seen_payloads:
continue
seen_payloads.add(payload)
result = plugin._promote_daily_summary_to_mempalace(day or 'undated', [payload], f'{day or "undated"}.md')
if isinstance(result, dict) and result.get('success'):
organized.append({'wing': wing, 'room': room, 'importance': importance, 'entry': compressed})
promoted.append(result)
summary_lines = summarize_entries(raw_entries, max_points=10)
summary = plugin._promote_daily_summary_to_mempalace(day or 'undated', summary_lines, f'{day or "undated"}.md')
print(json.dumps({
'success': True,
'day': day,
'organized_entries': organized,
'promoted_count': len(promoted),
'summary_promotion': summary,
}, ensure_ascii=False))
return 0
if __name__ == '__main__':
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, '/home/lisa/.hermes/hermes-agent')
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import __init__ as plugin # noqa: E402
class DummyCtx:
pass
plugin._STATE.ctx = DummyCtx()
try:
from tools.mcp_tool import discover_mcp_tools # type: ignore
except Exception:
discover_mcp_tools = None
if discover_mcp_tools is not None:
try:
discover_mcp_tools()
except Exception:
pass
result = json.loads(plugin.three_tier_memory_tool(action="maintain", promote_to_long_term=True))
print(json.dumps(result, ensure_ascii=False))
{
"version": 1,
"legacy": {
"enabled": true,
"wing": "memories",
"history_room": "daily-summaries"
},
"defaults": {
"history_wing": "history",
"summary_room": "daily-summaries",
"diary_room": "diary",
"fallback_wing": "work",
"fallback_room": "general"
},
"wings": {
"identity": {
"description": "Stable facts about user identity, preferences, and habits.",
"aliases": [
"preferences",
"profile",
"identity",
"habits"
],
"rooms": {
"preferences": [
"preference",
"prefers",
"likes",
"dislikes",
"workflow preference",
"expects",
"style",
"habit"
],
"profile": [
"name",
"timezone",
"role",
"identity",
"profile"
],
"habits": [
"habit",
"routine",
"usually",
"normally",
"often"
]
}
},
"governance": {
"description": "Policies, constraints, security requirements, and durable decisions.",
"aliases": [
"policy",
"policies",
"security",
"constraints",
"governance"
],
"rooms": {
"policies": [
"always",
"never",
"must",
"critical",
"required",
"explicitly rejected",
"policy"
],
"constraints": [
"constraint",
"cannot",
"must not",
"do not",
"without"
],
"security": [
"security",
"secret",
"credential",
"token",
"privacy",
"approval"
],
"decisions": [
"decision",
"chosen",
"prefer that over",
"target architecture"
]
}
},
"systems": {
"description": "Infrastructure, architecture, platforms, and technical integrations.",
"aliases": [
"systems",
"infrastructure",
"architecture",
"platforms",
"integrations"
],
"rooms": {
"infrastructure": [
"config",
"path",
"installed",
"reachable",
"machine",
"node",
"gateway",
"plugin",
"repo",
"package"
],
"architecture": [
"architecture",
"capability",
"design",
"protocol",
"integration",
"taxonomy",
"topology"
],
"integrations": [
"mcp",
"api",
"integration",
"provider",
"adapter"
],
"platforms": [
"telegram",
"discord",
"browser",
"chrome",
"linux",
"windows"
]
}
},
"work": {
"description": "Projects, operations, debugging, and delivery-oriented memory.",
"aliases": [
"work",
"projects",
"operations",
"debugging"
],
"rooms": {
"projects": [
"project",
"feature",
"roadmap",
"milestone"
],
"operations": [
"workflow",
"process",
"procedure",
"deploy",
"release",
"maintenance",
"operational"
],
"debugging": [
"bug",
"error",
"traceback",
"fix",
"debug",
"issue"
],
"deliveries": [
"deliver",
"release",
"bundle",
"shipped",
"published"
],
"general": [
"work",
"task",
"implementation"
],
"appointments": [
"appointments",
"appointment",
"doctor",
"clinic",
"medical-visits",
"hospital",
"clinic-visits"
]
}
},
"history": {
"description": "Summaries and chronological traces.",
"aliases": [
"history",
"daily",
"summary",
"diary"
],
"rooms": {
"daily": [
"daily"
],
"daily-summaries": [
"summary",
"daily summary"
],
"diary": [
"diary",
"journal",
"history"
]
}
},
"relationships": {
"description": "Family, marriage, partners, and important people relationships.",
"aliases": [
"relationships",
"family",
"marriage",
"partner",
"people"
],
"rooms": {
"family": [
"family",
"parent",
"child",
"sibling",
"relative"
],
"marriage": [
"marriage",
"married",
"spouse",
"wife",
"husband"
],
"partners": [
"partner",
"girlfriend",
"boyfriend",
"significant other"
],
"important-people": [
"friend",
"person",
"people",
"relationship"
]
}
},
"care": {
"description": "Care responsibilities and support logistics",
"aliases": [
"care",
"healthcare",
"carework",
"caregiving"
],
"rooms": {}
}
},
"dynamic_wings": {
"enabled": true,
"keywords": {
"marriage": {
"wing": "relationships",
"room": "marriage"
},
"family": {
"wing": "relationships",
"room": "family"
}
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment