Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions agent/learning_mutations.py
Original file line number Diff line number Diff line change
@@ -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:<source>:<index>`` 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>:<index>`` → (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
159 changes: 159 additions & 0 deletions apps/desktop/src/app/starmap/node-context-menu.tsx
Original file line number Diff line number Diff line change
@@ -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<EditState | null>(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 | string>(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 ? (
<>
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={e => e.preventDefault()} />
<div
className="fixed z-50 min-w-36 overflow-hidden rounded-md border border-border bg-popover py-1 text-sm shadow-md"
style={{ left: target.x, top: target.y }}
>
<div className="truncate px-3 py-1 text-xs text-muted-foreground">{target.label}</div>
<button
className="block w-full px-3 py-1 text-left hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
disabled={loading}
onClick={() => void openEdit()}
type="button"
>
Edit {noun}…
</button>
<button
className="block w-full px-3 py-1 text-left text-destructive hover:bg-destructive/10"
onClick={() => {
setDeleting({ id: target.id, label: target.label })
onClose()
}}
type="button"
>
Delete {noun}
</button>
</div>
</>
) : null}

<Dialog onOpenChange={value => !value && !saving && setEditing(null)} open={Boolean(editing)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit {editing?.label}</DialogTitle>
</DialogHeader>
<Textarea
className="h-80 font-mono text-xs"
onChange={e => setEditing(prev => (prev ? { ...prev, content: e.target.value } : prev))}
value={editing?.content ?? ''}
/>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<DialogFooter>
<Button disabled={saving} onClick={() => setEditing(null)} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={saving} onClick={() => void save()}>
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

<ConfirmDialog
confirmLabel="Delete"
description={
noun === 'skill'
? 'The skill is archived and can be restored with `hermes curator restore`.'
: 'This memory is removed permanently.'
}
destructive
onClose={() => setDeleting(null)}
onConfirm={async () => {
if (!deleting) {
return
}

const res = await deleteLearningNode(deleting.id)
if (!res.ok) {
throw new Error(res.message)
}
onChanged()
}}
open={Boolean(deleting)}
title={`Delete ${deleting?.label ?? ''}?`}
/>
</>
)
}
Loading
Loading