Files
hermes-agent/agent/learning_mutations.py
kshitijk4poor 27c3347faf refactor(memory): resolve a journey fingerprint by text alone
The occurrence hint in _resolve_fingerprint (_local_index_hint, "the
occurrence the user clicked") came from the contributor's dedupe=False
mutate, which this salvage deliberately dropped: MemoryStore._mutate
collapses byte-identical entries and _apply goes back to the first match
with entries.index(text), so the hint could never pick an occurrence. Its
only live effect was a spurious "stale" refusal for a duplicated card
after an earlier entry was removed, although the text was still there.

Match by fingerprint -> first entry with that text, delete
_local_index_hint (and with it the second copy of the profile-index
formula), and read _memory_cards() only on the legacy index-id path, so
a fingerprinted id no longer re-reads and hashes both files for a hint.
Legacy ids still resolve by position.

The render lookup goes back to a single memory_node_id key: every
render_frames caller builds the graph fresh, and build_learning_graph
always sets the fingerprint. The desktop star-map keeps its dual key
(imported graphs). The memory_fingerprint docstring said a memory add
prepends; it appends; what shifts a card is an earlier entry removed.
2026-09-24 18:01:46 +05:30

208 lines
10 KiB
Python

"""User-initiated edit/delete for journey nodes (learned skills + memories).
Node ids (from ``agent.learning_graph``): skills → the skill name; memories →
``memory:<source>:<index>:<fingerprint>`` (``source`` = ``memory`` for MEMORY.md /
``profile`` for USER.md; ``index`` = position in the combined card list, MEMORY.md
first; ``fingerprint`` = digest of the card's text, so the entry the user clicked is
still nameable once the list has shifted). Ids from an older graph carry no
fingerprint and resolve by position alone.
Shared by CLI ``hermes journey``, the TUI ``/journey`` overlay and the desktop.
Deleting a skill *archives* it (``hermes curator restore`` recovers it);
deleting a memory rewrites its file under the memory tool's lock.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Callable
_MEMORY_FILES = {"memory": "MEMORY.md", "profile": "USER.md"}
_STORE_TARGETS = {"memory": "memory", "profile": "user"} # journey source -> MemoryStore target
def parse_node_kind(node_id: str) -> str:
return "memory" if node_id.startswith("memory:") else "skill"
def _parse_memory_id(node_id: str) -> tuple[str, int, str]:
"""``memory:<source>:<index>[:<fingerprint>]`` → (source, global_index, fingerprint).
The fingerprint is empty for an id minted before the graph carried one."""
parts = node_id.split(":")
try:
if len(parts) not in (3, 4) or parts[0] != "memory" or parts[1] not in _MEMORY_FILES:
raise ValueError
return parts[1], int(parts[2]), parts[3] if len(parts) == 4 else ""
except ValueError as exc:
raise ValueError(f"bad memory node id: {node_id!r}") from exc
def _resolve_fingerprint(chunks: list[str], fingerprint: str) -> int | None:
"""Index of the first entry in *chunks* whose text carries *fingerprint*, or None when gone.
The text names the entry, so a list that shifted under the user (an earlier entry removed
between the graph being drawn and the edit being submitted) still resolves to the card they
clicked. Identical entries are one entry to the memory store (it collapses byte-identical
copies on every mutation), so the first match is the entry.
"""
from agent.learning_graph import memory_fingerprint
return next((i for i, chunk in enumerate(chunks) if memory_fingerprint(chunk) == fingerprint), None)
def _locate_memory(node_id: str) -> tuple[Path, list[str], int]:
"""Resolve a memory node id to (file, all §-delimited entries, local index).
Entries come from ``MemoryStore._read_file`` — the memory tool's own parser. A
fingerprinted id resolves by the entry's text; a legacy id by position (a profile
card's local index is its global index minus the MEMORY.md card count). Read-only
view: mutations resolve the id again INSIDE ``_mutate_memory``'s lock."""
from hermes_constants import get_hermes_home
from tools.memory_tool import MemoryStore
source, gidx, fingerprint = _parse_memory_id(node_id)
path = get_hermes_home() / "memories" / _MEMORY_FILES[source]
if not path.exists():
raise ValueError(f"{path.name} not found")
chunks = MemoryStore._read_file(path)
if fingerprint:
local = _resolve_fingerprint(chunks, fingerprint)
if local is None:
raise ValueError("memory node id is stale — refresh the graph")
return path, chunks, local
from agent.learning_graph import _memory_cards
cards = _memory_cards()
if not 0 <= gidx < len(cards):
raise IndexError(f"memory index {gidx} out of range")
if cards[gidx].get("source") != source:
raise ValueError("memory node id is stale — refresh the graph")
local = gidx if source == "memory" else gidx - sum(1 for c in cards if c.get("source") == "memory")
if not 0 <= local < len(chunks):
raise ValueError("memory node id is stale — refresh the graph")
return path, chunks, local
def _mutate_memory(node_id: str, replacement: str | None) -> dict[str, Any]:
"""Replace (or, with ``replacement=None``, remove) the entry *node_id* names, through
``MemoryStore._mutate`` — the memory tool's cross-process lock, re-read under lock and
drift guard (``.bak`` snapshot + refusal when the file wouldn't round-trip). The file is
shared with the live agent, so a read-modify-write from an unlocked snapshot silently
dropped whatever the agent stored in between and reformatted hand-edited files
(#119668). The id is resolved to its entry text INSIDE the lock and matched by exact
text against the store's re-read entries; a target gone under the lock is refused."""
from tools.memory_tool import load_on_disk_store
source, _, _ = _parse_memory_id(node_id)
name = _MEMORY_FILES[source]
message = f"deleted memory from {name}" if replacement is None else f"updated memory in {name}"
def _apply(entries, limit):
from tools.memory_tool import ENTRY_DELIMITER
_, chunks, local = _locate_memory(node_id)
text = chunks[local].strip()
if text not in entries:
return {"success": False, "error": "memory node id is stale — refresh the graph"}
idx = entries.index(text)
new_entries = entries[:idx] + ([] if replacement is None else [replacement]) + entries[idx + 1:]
# Same cap the memory tool enforces on replace (never on remove: deleting is how a file
# already over its total gets back under it). An over-limit entry reads as external drift
# to every later mutation, so the tool's own remove/replace refuse until hand-fixed.
if replacement is not None and (total := len(ENTRY_DELIMITER.join(new_entries))) > limit:
return {"success": False,
"error": f"Replacement would put memory at {total:,}/{limit:,} chars. Shorten the new content."}
return new_entries, message
result = load_on_disk_store()._mutate(_STORE_TARGETS[source], _apply)
if not result.get("success"):
return {"ok": False, "message": result.get("error", f"{name} write failed")}
return {"ok": True, "message": message}
def _clear_skill_cache() -> None:
try:
from agent.prompt_builder import clear_skills_system_prompt_cache
clear_skills_system_prompt_cache(clear_snapshot=True)
except Exception:
pass
def _dispatch(node_id: str, memory_fn: Callable, skill_fn: Callable, *args) -> dict[str, Any]:
try:
return (memory_fn if parse_node_kind(node_id) == "memory" else skill_fn)(node_id, *args)
except (ValueError, IndexError) as exc:
return {"ok": False, "message": str(exc)}
# ── Inspect (edit prefill) ──────────────────────────────────────────────────
def node_detail(node_id: str) -> dict[str, Any]:
"""Current content for an edit prefill. ``content`` is the full SKILL.md
(skills) or the raw memory chunk (memories)."""
return _dispatch(node_id, _memory_detail, _skill_detail)
def _memory_detail(node_id: str) -> dict[str, Any]:
_, chunks, local = _locate_memory(node_id)
body = chunks[local].strip()
return {"ok": True, "kind": "memory", "id": node_id, "label": body.splitlines()[0][:80], "content": body}
def _skill_detail(node_id: str) -> dict[str, Any]:
from tools.skill_manager_tool import _find_skill
found = _find_skill(node_id)
if not found:
return {"ok": False, "message": f"skill '{node_id}' not found"}
skill_md = Path(found["path"]) / "SKILL.md"
if not skill_md.exists():
return {"ok": False, "message": f"SKILL.md missing for '{node_id}'"}
return {"ok": True, "kind": "skill", "id": node_id, "label": node_id, "content": skill_md.read_text(encoding="utf-8")}
# ── Delete ──────────────────────────────────────────────────────────────────
def delete_node(node_id: str) -> dict[str, Any]:
return _dispatch(node_id, _delete_memory, _delete_skill)
def _delete_skill(name: str) -> dict[str, Any]:
from tools import skill_usage
# Pin must be respected by autonomous maintenance. The curator already skips pinned skills from every
# auto-transition; the background review fork is the same kind of autonomous, no-user-present actor, so
# it must not write to a pinned skill either (issue #25839). This is stricter than the foreground
# ``_pinned_guard`` (which only blocks deletion) precisely because there is no user in the loop to
# consent to an edit here.
if skill_usage.get_record(name).get("pinned"):
return {"ok": False, "message": f"'{name}' is pinned — unpin it first (hermes curator unpin {name})"}
ok, message = skill_usage.archive_skill(name)
if ok:
_clear_skill_cache()
return {"ok": ok, "message": f"archived '{name}' — restore with: hermes curator restore {name}" if ok else message}
def _delete_memory(node_id: str) -> dict[str, Any]:
return _mutate_memory(node_id, None)
# ── Edit ────────────────────────────────────────────────────────────────────
def edit_node(node_id: str, content: str) -> dict[str, Any]:
return _dispatch(node_id, _edit_memory, _edit_skill, content)
def _edit_skill(name: str, content: str) -> dict[str, Any]:
from tools.skill_manager_tool import _edit_skill as _do_edit
result = _do_edit(name, content)
if result.get("success"):
_clear_skill_cache()
return {"ok": True, "message": f"updated '{name}'"}
return {"ok": False, "message": result.get("error", "edit failed")}
def _edit_memory(node_id: str, content: str) -> dict[str, Any]:
_parse_memory_id(node_id) # id errors win over the empty-body message
body = content.strip()
if not body:
return {"ok": False, "message": "empty memory — use delete to remove it"}
return _mutate_memory(node_id, body)