fix(desktop): scope memory graph cache by profile

Ensure the Memory Graph cannot show stale data after switching profiles, and tighten the graph backend's profile-safe timestamp handling.
This commit is contained in:
Brooklyn Nicholson 2026-06-30 03:44:41 -05:00
parent 3e7ed0c53b
commit babbefb164
5 changed files with 613 additions and 19 deletions

View file

@ -16,12 +16,14 @@ Run as a module to print edge-density stats against real data:
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from hermes_constants import get_hermes_home
@dataclass
class SkillNode:
@ -77,7 +79,7 @@ def _load_usage() -> dict[str, dict[str, Any]]:
return load_usage()
except Exception:
path = Path(os.path.expanduser("~/.hermes/skills/.usage.json"))
path = get_hermes_home() / "skills" / ".usage.json"
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
@ -91,11 +93,27 @@ def _to_int_ts(value: Any) -> Optional[int]:
if isinstance(value, (int, float)):
return int(value)
s = str(value).strip()
return int(float(s)) if s else None
if not s:
return None
try:
return int(float(s))
except ValueError:
parsed = datetime.fromisoformat(s.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return int(parsed.timestamp())
except Exception:
return None
def _usage_timestamp(rec: dict[str, Any]) -> Optional[int]:
for key in ("last_activity_at", "last_used_at", "last_viewed_at", "last_patched_at", "created_at"):
ts = _to_int_ts(rec.get(key))
if ts is not None:
return ts
return None
def build_skill_nodes(skill_roots: list[tuple[str, Path]]) -> dict[str, SkillNode]:
usage = _load_usage()
nodes: dict[str, SkillNode] = {}
@ -111,7 +129,7 @@ def build_skill_nodes(skill_roots: list[tuple[str, Path]]) -> dict[str, SkillNod
if not name or name in nodes:
continue
rec = usage.get(name, {})
last_activity = _to_int_ts(rec.get("last_activity_at"))
last_activity = _usage_timestamp(rec)
file_ts = _to_int_ts(skill_md.stat().st_mtime)
nodes[name] = SkillNode(
name=name,
@ -134,7 +152,8 @@ def build_edges(nodes: dict[str, SkillNode]) -> list[tuple[str, str]]:
for node in nodes.values():
for target in node.related:
if target in nodes and target != node.name:
key = tuple(sorted((node.name, target)))
a, b = sorted((node.name, target))
key = (a, b)
if key not in seen:
seen.add(key)
edges.append(key)
@ -169,13 +188,7 @@ def _memory_cards() -> list[dict[str, Any]]:
``MEMORY.md`` / ``USER.md`` are prose split on bare ``§`` separators; each
chunk becomes one card. Every chunk is surfaced the graph shows everything.
"""
try:
from hermes_constants import get_hermes_home
base = get_hermes_home() / "memories"
except Exception:
base = Path(os.path.expanduser("~/.hermes/memories"))
base = get_hermes_home() / "memories"
cards: list[dict[str, Any]] = []
for fname, source in (("MEMORY.md", "memory"), ("USER.md", "profile")):
path = base / fname
@ -203,7 +216,7 @@ def _tokenize(text: str) -> set[str]:
return {t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) >= 3}
def _memory_skill_edges(memory_cards: list[dict[str, str]], skills: list[SkillNode]) -> list[tuple[str, str]]:
def _memory_skill_edges(memory_cards: list[dict[str, Any]], skills: list[SkillNode]) -> list[tuple[str, str]]:
edges: list[tuple[str, str]] = []
skill_meta = [(s, _tokenize(s.name), s.name.lower()) for s in skills]
for idx, card in enumerate(memory_cards):
@ -226,12 +239,7 @@ def _memory_skill_edges(memory_cards: list[dict[str, str]], skills: list[SkillNo
def _skill_roots() -> list[tuple[str, Path]]:
repo = Path(__file__).resolve().parent.parent
try:
from hermes_constants import get_hermes_home
home_skills = get_hermes_home() / "skills"
except Exception:
home_skills = Path(os.path.expanduser("~/.hermes/skills"))
home_skills = get_hermes_home() / "skills"
return [("base", repo / "skills"), ("profile", home_skills)]