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/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} + +