From a0576560eda6968ff9590aaef0a31978be79553f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 15:07:19 -0500 Subject: [PATCH 1/3] feat(journey): shared backend for editing and deleting learned nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map journey node ids back to SKILL.md or §-delimited memory chunks and perform user-initiated edits/deletes. Skill deletes archive (curator- restorable); memory deletes rewrite MEMORY.md/USER.md in place. --- agent/learning_mutations.py | 206 +++++++++++++++++++++++++ tests/agent/test_learning_mutations.py | 114 ++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 agent/learning_mutations.py create mode 100644 tests/agent/test_learning_mutations.py diff --git a/agent/learning_mutations.py b/agent/learning_mutations.py new file mode 100644 index 00000000000..7566fa2166e --- /dev/null +++ b/agent/learning_mutations.py @@ -0,0 +1,206 @@ +"""User-initiated edit/delete for journey nodes (learned skills + memories). + +The journey graph (``agent.learning_graph``) gives every node a stable id: + +- **skills** → the skill name (e.g. ``"debugging-hermes-desktop"``) +- **memories** → ``memory::`` where ``source`` is ``memory`` + (``MEMORY.md``) or ``profile`` (``USER.md``) and ``index`` is the node's + position in the combined card list (``MEMORY.md`` cards first, then + ``USER.md``). + +This module maps a node id back to its on-disk home and performs the mutation, +shared by the CLI (``hermes journey delete|edit``), the TUI ``/journey`` overlay +(gateway RPCs), and the desktop GUI (REST). Deleting a skill *archives* it +(recoverable via ``hermes curator restore``); deleting a memory rewrites its +file. Pure stdlib + existing skill/memory helpers. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +_MEMORY_DELIM = "\n§\n" +_MEMORY_FILES = {"memory": "MEMORY.md", "profile": "USER.md"} + + +def parse_node_kind(node_id: str) -> str: + return "memory" if node_id.startswith("memory:") else "skill" + + +def _memories_dir() -> Path: + from hermes_constants import get_hermes_home + + return get_hermes_home() / "memories" + + +def _parse_memory_id(node_id: str) -> tuple[str, int]: + """``memory::`` → (source, global_index).""" + parts = node_id.split(":", 2) + if len(parts) != 3 or parts[0] != "memory" or parts[1] not in _MEMORY_FILES: + raise ValueError(f"bad memory node id: {node_id!r}") + try: + return parts[1], int(parts[2]) + except ValueError as exc: + raise ValueError(f"bad memory node id: {node_id!r}") from exc + + +def _memory_local_index(source: str, global_index: int) -> int: + """Global card index → position within the source's own file. + + ``_memory_cards`` emits all ``MEMORY.md`` cards before ``USER.md`` cards, so + a profile card's local index is its global index minus the memory count. + """ + from agent.learning_graph import _memory_cards + + cards = _memory_cards() + if not 0 <= global_index < len(cards): + raise IndexError(f"memory index {global_index} out of range") + if cards[global_index].get("source") != source: + raise ValueError("memory node id is stale — refresh the graph") + if source == "memory": + return global_index + return global_index - sum(1 for c in cards if c.get("source") == "memory") + + +def _read_chunks(path: Path) -> list[str]: + """Raw ``§``-delimited chunks, preserving formatting; empties dropped to + match ``_memory_cards`` indexing.""" + text = path.read_text(encoding="utf-8") + + return [c for c in text.split(_MEMORY_DELIM) if c.strip()] + + +def _locate_memory(source: str, gidx: int) -> tuple[Path, list[str], int]: + """Resolve a memory card to its file, all chunks, and local index.""" + path = _memories_dir() / _MEMORY_FILES[source] + if not path.exists(): + raise ValueError(f"{path.name} not found") + chunks = _read_chunks(path) + local = _memory_local_index(source, gidx) + if not 0 <= local < len(chunks): + raise ValueError("memory node id is stale — refresh the graph") + return path, chunks, local + + +# ── 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).""" + try: + return _node_detail(node_id) + except (ValueError, IndexError) as exc: + return {"ok": False, "message": str(exc)} + + +def _node_detail(node_id: str) -> dict[str, Any]: + if parse_node_kind(node_id) == "memory": + source, gidx = _parse_memory_id(node_id) + _, chunks, local = _locate_memory(source, gidx) + body = chunks[local].strip() + + return {"ok": True, "kind": "memory", "id": node_id, "label": body.splitlines()[0][:80], "content": body} + + 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]: + try: + return _delete_memory(node_id) if parse_node_kind(node_id) == "memory" else _delete_skill(node_id) + except (ValueError, IndexError) as exc: + return {"ok": False, "message": str(exc)} + + +def _delete_skill(name: str) -> dict[str, Any]: + from tools import skill_usage + + 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]: + source, gidx = _parse_memory_id(node_id) + path, chunks, local = _locate_memory(source, gidx) + + del chunks[local] + _write_chunks(path, chunks) + + return {"ok": True, "message": f"deleted memory from {path.name}"} + + +# ── Edit ──────────────────────────────────────────────────────────────────── + + +def edit_node(node_id: str, content: str) -> dict[str, Any]: + try: + return _edit_memory(node_id, content) if parse_node_kind(node_id) == "memory" else _edit_skill(node_id, content) + except (ValueError, IndexError) as exc: + return {"ok": False, "message": str(exc)} + + +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]: + source, gidx = _parse_memory_id(node_id) + body = content.strip() + if not body: + return {"ok": False, "message": "empty memory — use delete to remove it"} + path, chunks, local = _locate_memory(source, gidx) + + chunks[local] = body + _write_chunks(path, chunks) + + return {"ok": True, "message": f"updated memory in {path.name}"} + + +# ── Helpers ───────────────────────────────────────────────────────────────── + + +def _write_chunks(path: Path, chunks: list[str]) -> None: + body = _MEMORY_DELIM.join(c.strip() for c in chunks) + path.write_text(f"{body}\n" if body else "", encoding="utf-8") + + +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 diff --git a/tests/agent/test_learning_mutations.py b/tests/agent/test_learning_mutations.py new file mode 100644 index 00000000000..79afa4d1379 --- /dev/null +++ b/tests/agent/test_learning_mutations.py @@ -0,0 +1,114 @@ +"""Behavior contracts for journey node edit/delete (agent.learning_mutations). + +Exercises the real on-disk resolution (skills dir + MEMORY.md/USER.md chunking) +against a temp HERMES_HOME, never mocks — the id→file mapping is the whole point. +""" + +from __future__ import annotations + +import pytest + +from agent import learning_mutations as lm +from hermes_constants import get_hermes_home + +_SKILL = """--- +name: my-skill +description: A test skill. +--- + +# My Skill + +Body. +""" + + +@pytest.fixture +def home(): + base = get_hermes_home() + (base / "memories").mkdir(parents=True, exist_ok=True) + (base / "memories" / "MEMORY.md").write_text("alpha note\nline two\n§\nbeta note", encoding="utf-8") + (base / "memories" / "USER.md").write_text("user profile note", encoding="utf-8") + skill = base / "skills" / "my-skill" + skill.mkdir(parents=True, exist_ok=True) + (skill / "SKILL.md").write_text(_SKILL, encoding="utf-8") + return base + + +def test_parse_node_kind(): + assert lm.parse_node_kind("memory:memory:0") == "memory" + assert lm.parse_node_kind("memory:profile:3") == "memory" + assert lm.parse_node_kind("debugging-hermes") == "skill" + + +def test_memory_global_index_maps_across_files(home): + # MEMORY.md → indices 0,1; USER.md → index 2 (global, memory cards first). + assert lm.node_detail("memory:memory:0")["content"].startswith("alpha note") + assert lm.node_detail("memory:memory:1")["content"] == "beta note" + assert lm.node_detail("memory:profile:2")["content"] == "user profile note" + + +def test_memory_label_is_first_line(home): + assert lm.node_detail("memory:memory:0")["label"] == "alpha note" + + +def test_delete_memory_rewrites_file(home): + assert lm.delete_node("memory:memory:0")["ok"] + remaining = (home / "memories" / "MEMORY.md").read_text(encoding="utf-8") + assert "alpha note" not in remaining + assert "beta note" in remaining + + +def test_edit_memory_replaces_chunk(home): + assert lm.edit_node("memory:profile:2", "rewritten profile")["ok"] + assert (home / "memories" / "USER.md").read_text(encoding="utf-8").strip() == "rewritten profile" + + +def test_edit_memory_empty_is_rejected(home): + res = lm.edit_node("memory:memory:1", " ") + assert not res["ok"] + assert "delete" in res["message"] + + +def test_stale_memory_index_errors(home): + res = lm.node_detail("memory:memory:9") + assert not res["ok"] + + +def test_bad_memory_id_returns_error(home): + res = lm.delete_node("memory:bogus:0") + assert not res["ok"] + + +def test_skill_detail_returns_skill_md(home): + d = lm.node_detail("my-skill") + assert d["ok"] and d["kind"] == "skill" + assert "name: my-skill" in d["content"] + + +def test_delete_skill_archives_recoverably(home): + res = lm.delete_node("my-skill") + assert res["ok"] + assert not (home / "skills" / "my-skill").exists() + assert (home / "skills" / ".archive" / "my-skill" / "SKILL.md").exists() + + +def test_delete_pinned_skill_refused(home): + from tools import skill_usage + + skill_usage.set_pinned("my-skill", True) + res = lm.delete_node("my-skill") + assert not res["ok"] + assert "pinned" in res["message"] + assert (home / "skills" / "my-skill").exists() + + +def test_edit_skill_rewrites_and_validates(home): + bad = lm.edit_node("my-skill", "no frontmatter here") + assert not bad["ok"] + good = lm.edit_node("my-skill", _SKILL.replace("A test skill.", "Updated desc.")) + assert good["ok"] + assert "Updated desc." in (home / "skills" / "my-skill" / "SKILL.md").read_text(encoding="utf-8") + + +def test_missing_skill_detail(home): + assert not lm.node_detail("nonexistent-skill")["ok"] From 08be8e5ef7a07e1bce9910b379b6a5fe48f40763 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 15:07:22 -0500 Subject: [PATCH 2/3] feat(journey): wire list/delete/edit through CLI, RPC, and REST Expose learning_mutations via hermes journey subcommands, TUI gateway learning.detail|delete|edit, and /api/learning/node for the desktop app. --- hermes_cli/commands.py | 4 +- hermes_cli/journey.py | 95 ++++++++++++++++++++++++++++++++++++++++ hermes_cli/web_server.py | 47 ++++++++++++++++++++ tui_gateway/server.py | 33 ++++++++++++++ 4 files changed, 178 insertions(+), 1 deletion(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 3cdbc6faa17..4e46a06362f 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -104,7 +104,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("agents", "Show active agents and running tasks", "Session", aliases=("tasks",)), CommandDef("journey", "Open the learning journey timeline", - "Session", aliases=("learning", "memory-graph"), cli_only=True), + "Session", aliases=("learning", "memory-graph"), cli_only=True, + args_hint="[list|delete |edit ]", + subcommands=("list", "delete", "edit")), CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session", aliases=("q",), args_hint=""), CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session", diff --git a/hermes_cli/journey.py b/hermes_cli/journey.py index c576e45263c..11d9bb4fd66 100644 --- a/hermes_cli/journey.py +++ b/hermes_cli/journey.py @@ -222,6 +222,86 @@ def _clamp(v: float, lo: float, hi: float) -> float: return lo if v < lo else hi if v > hi else v +# ── list / delete / edit ───────────────────────────────────────────────────── + + +def _cmd_list(args: argparse.Namespace) -> int: + from rich.console import Console + + from agent.learning_graph_render import format_date + + console = Console(no_color=bool(getattr(args, "no_color", False))) + nodes = sorted(_build_payload().get("nodes", []), key=lambda n: n.get("timestamp") or 0) + if not nodes: + console.print("[grey62]No learning yet.[/grey62]") + return 0 + for node in nodes: + glyph = "◆" if node.get("kind") == "memory" else "●" + date = format_date(node.get("timestamp")) + console.print(f"[grey54]{node['id']}[/grey54] {glyph} {node.get('label', '')} [grey54]{date}[/grey54]") + return 0 + + +def _cmd_delete(args: argparse.Namespace) -> int: + from agent.learning_mutations import delete_node, node_detail + + detail = node_detail(args.node) + if not detail.get("ok"): + print(f" {detail.get('message', 'not found')}") + return 1 + if not getattr(args, "yes", False): + try: + if input(f" Delete {detail['label']!r}? [y/N] ").strip().lower() not in ("y", "yes"): + print(" aborted") + return 1 + except (EOFError, KeyboardInterrupt): + print("\n aborted") + return 1 + res = delete_node(args.node) + print(f" {res['message']}") + return 0 if res.get("ok") else 1 + + +def _cmd_edit(args: argparse.Namespace) -> int: + from agent.learning_mutations import edit_node, node_detail + + detail = node_detail(args.node) + if not detail.get("ok"): + print(f" {detail.get('message', 'not found')}") + return 1 + suffix = ".md" if detail["kind"] == "skill" else ".txt" + edited = _open_in_editor(detail["content"], suffix=suffix) + if edited is None or edited.strip() == detail["content"].strip(): + print(" no changes") + return 0 + res = edit_node(args.node, edited) + print(f" {res['message']}") + return 0 if res.get("ok") else 1 + + +def _open_in_editor(initial: str, *, suffix: str) -> Optional[str]: + import os + import subprocess + import tempfile + + editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi" + with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False, encoding="utf-8") as fh: + fh.write(initial) + path = fh.name + try: + subprocess.call([*editor.split(), path]) + with open(path, encoding="utf-8") as fh: + return fh.read() + except OSError as exc: + print(f" editor failed: {exc}") + return None + finally: + try: + os.unlink(path) + except OSError: + pass + + def register_cli(parent: argparse.ArgumentParser) -> None: parent.add_argument( "--reveal", @@ -238,6 +318,21 @@ def register_cli(parent: argparse.ArgumentParser) -> None: parent.add_argument("--json", action="store_true", help="Print the raw graph payload as JSON and exit.") parent.set_defaults(func=_cmd_show) + sub = parent.add_subparsers(dest="journey_action") + + p_list = sub.add_parser("list", help="List node ids (for delete/edit).") + p_list.add_argument("--no-color", action="store_true") + p_list.set_defaults(func=_cmd_list) + + p_del = sub.add_parser("delete", help="Delete a learned skill (archived) or memory by node id.") + p_del.add_argument("node", help="Node id (skill name or memory::; see `journey list`).") + p_del.add_argument("-y", "--yes", action="store_true", help="Skip the confirmation prompt.") + p_del.set_defaults(func=_cmd_delete) + + p_edit = sub.add_parser("edit", help="Edit a learned skill or memory by node id in $EDITOR.") + p_edit.add_argument("node", help="Node id (skill name or memory::; see `journey list`).") + p_edit.set_defaults(func=_cmd_edit) + def cmd_journey(args: argparse.Namespace) -> int: return _cmd_show(args) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a76cf42fc2e..e01f925b332 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2469,6 +2469,53 @@ async def get_learning_graph(profile: Optional[str] = None): raise HTTPException(status_code=500, detail="Failed to build learning graph") +class LearningNodeRef(BaseModel): + id: str + profile: Optional[str] = None + + +class LearningNodeEdit(BaseModel): + id: str + content: str + profile: Optional[str] = None + + +@app.get("/api/learning/node") +async def get_learning_node(id: str, profile: Optional[str] = None): + """Current content of a journey node (skill SKILL.md or memory chunk), for an edit prefill.""" + from agent.learning_mutations import node_detail + + with _profile_scope(profile): + res = node_detail(id) + if not res.get("ok"): + raise HTTPException(status_code=404, detail=res.get("message", "not found")) + return res + + +@app.delete("/api/learning/node") +async def delete_learning_node(body: LearningNodeRef): + """Delete a journey node — skills are archived (restorable), memories removed.""" + from agent.learning_mutations import delete_node + + with _profile_scope(body.profile): + res = delete_node(body.id) + if not res.get("ok"): + raise HTTPException(status_code=400, detail=res.get("message", "delete failed")) + return res + + +@app.put("/api/learning/node") +async def update_learning_node(body: LearningNodeEdit): + """Rewrite a journey node's content (SKILL.md or memory chunk).""" + from agent.learning_mutations import edit_node + + with _profile_scope(body.profile): + res = edit_node(body.id, body.content) + if not res.get("ok"): + raise HTTPException(status_code=400, detail=res.get("message", "edit failed")) + return res + + def _safe_call(mod, fn_name: str, default): try: fn = getattr(mod, fn_name, None) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f7bb8f6f310..2c057155b58 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -13495,6 +13495,39 @@ def _(rid, params: dict) -> dict: return _err(rid, 5000, f"learning.frames failed: {exc}") +@method("learning.detail") +def _(rid, params: dict) -> dict: + """Current content of a journey node, for an edit prefill.""" + try: + from agent.learning_mutations import node_detail + + return _ok(rid, node_detail(str(params.get("id", "")))) + except Exception as exc: # noqa: BLE001 + return _err(rid, 5000, f"learning.detail failed: {exc}") + + +@method("learning.delete") +def _(rid, params: dict) -> dict: + """Delete a journey node — skills are archived (restorable), memories removed.""" + try: + from agent.learning_mutations import delete_node + + return _ok(rid, delete_node(str(params.get("id", "")))) + except Exception as exc: # noqa: BLE001 + return _err(rid, 5000, f"learning.delete failed: {exc}") + + +@method("learning.edit") +def _(rid, params: dict) -> dict: + """Rewrite a journey node's content (SKILL.md or memory chunk).""" + try: + from agent.learning_mutations import edit_node + + return _ok(rid, edit_node(str(params.get("id", "")), str(params.get("content", "")))) + except Exception as exc: # noqa: BLE001 + return _err(rid, 5000, f"learning.edit failed: {exc}") + + @method("skills.manage") def _(rid, params: dict) -> dict: action, query = params.get("action", "list"), params.get("query", "") From bb67dad07a8c1540032e36f8ed10d76d2b186062 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 15:07:24 -0500 Subject: [PATCH 3/3] feat(journey): edit/delete in TUI overlay and desktop star map TUI /journey gets d/e with confirm + $EDITOR; desktop gets a right-click context menu with inline edit modal. Both refresh the graph after mutation. Extract openInEditor into the shared TUI editor helper. --- .../src/app/starmap/node-context-menu.tsx | 159 ++++++++++++++++++ apps/desktop/src/app/starmap/star-map.tsx | 33 ++++ apps/desktop/src/hermes.ts | 32 ++++ ui-tui/src/components/journey.tsx | 127 +++++++++++++- ui-tui/src/lib/editor.ts | 25 ++- 5 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/app/starmap/node-context-menu.tsx diff --git a/apps/desktop/src/app/starmap/node-context-menu.tsx b/apps/desktop/src/app/starmap/node-context-menu.tsx new file mode 100644 index 00000000000..dc7a1bece6c --- /dev/null +++ b/apps/desktop/src/app/starmap/node-context-menu.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Textarea } from '@/components/ui/textarea' +import { deleteLearningNode, editLearningNode, getLearningNode } from '@/hermes' + +export interface NodeMenuTarget { + id: string + kind: 'memory' | 'skill' + label: string + x: number + y: number +} + +interface NodeContextMenuProps { + onChanged: () => void + onClose: () => void + target: NodeMenuTarget | null +} + +interface EditState { + content: string + id: string + label: string +} + +/** Right-click actions for a star-map node: edit (modal) or delete (confirm). */ +export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuProps) { + const [editing, setEditing] = useState(null) + const [deleting, setDeleting] = useState<{ id: string; label: string } | null>(null) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const noun = target?.kind === 'memory' ? 'memory' : 'skill' + + const openEdit = async () => { + if (!target) { + return + } + + setLoading(true) + setError(null) + try { + const detail = await getLearningNode(target.id) + setEditing({ content: detail.content, id: target.id, label: target.label }) + onClose() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + } + + const save = async () => { + if (!editing) { + return + } + + setSaving(true) + setError(null) + try { + const res = await editLearningNode(editing.id, editing.content) + if (!res.ok) { + throw new Error(res.message) + } + setEditing(null) + onChanged() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSaving(false) + } + } + + const menuOpen = target && !editing && !deleting + + return ( + <> + {menuOpen ? ( + <> +
e.preventDefault()} /> +
+
{target.label}
+ + +
+ + ) : null} + + !value && !saving && setEditing(null)} open={Boolean(editing)}> + + + Edit {editing?.label} + +