Merge pull request #55859 from NousResearch/bb/journey-edit-delete

feat(journey): edit and delete learned skills/memories
This commit is contained in:
brooklyn! 2026-06-30 15:14:07 -05:00 committed by GitHub
commit d153918f14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 868 additions and 7 deletions

206
agent/learning_mutations.py Normal file
View file

@ -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

View file

@ -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 ?? ''}?`}
/>
</>
)
}

View file

@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures'
import { loadStarmapGraph } from '@/store/starmap'
import type { StarmapGraph } from '@/types/hermes'
import { computePalette, memoryInkFor, resolveRgb, rgba } from './color'
@ -15,6 +16,7 @@ import { ShareControls } from './share-controls'
import { buildSimulation } from './simulation'
import { formatDate } from './text'
import { buildTimeAxis, dateAtReveal, type TimeAxis } from './time-axis'
import { NodeContextMenu, type NodeMenuTarget } from './node-context-menu'
import { Timeline } from './timeline'
import type { FadeBuckets, MemoryCard, Palette, Ring, RingLabelRect, SimLink, SimNode, Viewport } from './types'
@ -153,6 +155,7 @@ export function StarMap({
}>({ id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: { k: 1, x: 0, y: 0 } })
const [selectedId, setSelectedId] = useState<null | string>(null)
const [menuTarget, setMenuTarget] = useState<NodeMenuTarget | null>(null)
const [size, setSize] = useState({ h: 0, w: 0 })
// Increments on every theme repaint (shared hook) so the legend swatch and the
// canvas palette re-resolve against the freshly-painted CSS custom properties.
@ -867,6 +870,25 @@ export function StarMap({
endDrag()
}
const onContextMenu = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault()
const { x, y } = localXY(e)
const node = pickNode(x, y)
if (!node) {
return setMenuTarget(null)
}
setSelectedId(node.id)
setMenuTarget({
id: node.id,
kind: node.kind === 'memory' ? 'memory' : 'skill',
label: node.label,
x: e.clientX,
y: e.clientY
})
}
const onWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect()
@ -899,12 +921,23 @@ export function StarMap({
onDoubleClick={resetView}
onMouseDown={onMouseDown}
onMouseLeave={onMouseLeave}
onContextMenu={onContextMenu}
onMouseMove={onMouseMove}
onMouseUp={endDrag}
onWheel={onWheel}
ref={canvasRef}
/>
<NodeContextMenu
onChanged={() => {
setMenuTarget(null)
setSelectedId(null)
void loadStarmapGraph(true)
}}
onClose={() => setMenuTarget(null)}
target={menuTarget}
/>
{/* Timeline scrubber centered along the top, clear of the close button.
z-20 lifts it above the titlebar's app-region drag layer (z-10) so the
scrubber receives pointer events instead of dragging the window. */}

View file

@ -500,6 +500,38 @@ export function getStarmapGraph(): Promise<StarmapGraph> {
})
}
export interface LearningNodeDetail {
content: string
kind: 'memory' | 'skill'
label: string
ok: boolean
}
export function getLearningNode(id: string): Promise<LearningNodeDetail> {
return window.hermesDesktop.api<LearningNodeDetail>({
...profileScoped(),
path: `/api/learning/node?id=${encodeURIComponent(id)}`
})
}
export function deleteLearningNode(id: string): Promise<{ message: string; ok: boolean }> {
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
...profileScoped(),
path: '/api/learning/node',
method: 'DELETE',
body: { id }
})
}
export function editLearningNode(id: string, content: string): Promise<{ message: string; ok: boolean }> {
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
...profileScoped(),
path: '/api/learning/node',
method: 'PUT',
body: { content, id }
})
}
export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...profileScoped(),

View file

@ -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 <id>|edit <id>]",
subcommands=("list", "delete", "edit")),
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
aliases=("q",), args_hint="<prompt>"),
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",

View file

@ -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:<source>:<index>; 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:<source>:<index>; see `journey list`).")
p_edit.set_defaults(func=_cmd_edit)
def cmd_journey(args: argparse.Namespace) -> int:
return _cmd_show(args)

View file

@ -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)

View file

@ -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 idfile 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"]

View file

@ -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", "")

View file

@ -2,12 +2,23 @@ import { Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text, useInput, useStdo
import { useEffect, useRef, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { openInEditor } from '../lib/editor.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { deriveStarmapPalette, fadeHex, fadeInk, type StarmapPalette } from '../lib/starmapPalette.js'
import type { Theme } from '../theme.js'
import { OverlayScrollbar } from './overlayScrollbar.js'
interface MutationResult {
message: string
ok: boolean
}
interface NodeDetail extends MutationResult {
content?: string
kind?: string
}
// A run is [text, styleKey, alpha?, hexOverride?] from learning_graph_render.py.
type Run = [string, string, number?, (string | null)?]
@ -22,6 +33,7 @@ interface BucketNode {
body?: string
fullLabel?: string
glyph: string
id: string
label: string
meta: string
style: string
@ -132,9 +144,14 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
const [cursor, setCursor] = useState(0)
const [mode, setMode] = useState<'item' | 'timeline'>('timeline')
const [tick, setTick] = useState(0)
const [reloadKey, setReloadKey] = useState(0)
const [confirmDelete, setConfirmDelete] = useState(false)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState('')
const itemScroll = useRef<null | ScrollBoxHandle>(null)
// The renderer is size-aware, so refetch when the terminal resizes.
// The renderer is size-aware, so refetch when the terminal resizes (or after a
// mutation bumps reloadKey).
useEffect(() => {
let alive = true
setData(null)
@ -155,13 +172,67 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
return () => {
alive = false
}
}, [gw, cols, chartRows])
}, [gw, cols, chartRows, reloadKey])
const tree = buildTree(data?.buckets ?? [])
const activeRow = tree[Math.min(cursor, Math.max(0, tree.length - 1))]
const activeNode = activeRow?.kind === 'node' ? activeRow.node : undefined
const activeBucket = activeRow && activeRow.kind !== 'gap' ? activeRow.bucket : undefined
const doDelete = () => {
const node = activeNode
if (!node) {
return
}
setBusy(true)
gw.request<MutationResult>('learning.delete', { id: node.id })
.then(res => {
setNotice(res.message)
if (res.ok) {
setMode('timeline')
setReloadKey(k => k + 1)
}
})
.catch((e: unknown) => setNotice(rpcErrorMessage(e)))
.finally(() => {
setBusy(false)
setConfirmDelete(false)
})
}
const doEdit = async () => {
const node = activeNode
if (!node) {
return
}
setBusy(true)
try {
const detail = await gw.request<NodeDetail>('learning.detail', { id: node.id })
if (!detail.ok || detail.content == null) {
return setNotice(detail.message || 'cannot edit')
}
const edited = await openInEditor(detail.content, detail.kind === 'skill' ? '.md' : '.txt')
if (edited == null || edited.trim() === detail.content.trim()) {
return setNotice('no changes')
}
const res = await gw.request<MutationResult>('learning.edit', { content: edited, id: node.id })
setNotice(res.message)
if (res.ok) {
setReloadKey(k => k + 1)
}
} catch (e) {
setNotice(rpcErrorMessage(e))
} finally {
setBusy(false)
}
}
useEffect(() => {
if (mode === 'item') {
itemScroll.current?.scrollTo(0)
@ -196,12 +267,38 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
}
useInput((ch, key) => {
if (busy) {
return
}
// Pending delete confirmation swallows the next key (y confirms, else cancel).
if (confirmDelete) {
if (ch === 'y' || ch === 'Y') {
return doDelete()
}
return setConfirmDelete(false)
}
const back = key.escape || key.leftArrow || ch === 'h'
if (ch === 'q') {
return onClose()
}
// Edit / delete work in both modes whenever a node is selected.
if (activeNode && ch === 'd' && !key.ctrl) {
setNotice('')
return setConfirmDelete(true)
}
if (activeNode && ch === 'e') {
setNotice('')
return void doEdit()
}
if (mode === 'item') {
if (back) {
return setMode('timeline')
@ -332,7 +429,8 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
</Box>
<Footer>
<Hint t={t}>/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/ back · q close</Hint>
<StatusLines confirm={confirmDelete} label={activeNode.fullLabel || activeNode.label} notice={notice} t={t} />
<Hint t={t}>/jk scroll · PgUp/PgDn page · e edit · d delete · Esc/ back · q close</Hint>
</Footer>
</Box>
)
@ -394,9 +492,16 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
</Box>
<Footer>
{data.summary.length ? <Hint t={t}>{data.summary.join(' · ')}</Hint> : null}
<StatusLines
confirm={confirmDelete}
label={activeNode ? activeNode.fullLabel || activeNode.label : ''}
notice={notice}
t={t}
/>
{!confirmDelete && !notice && data.summary.length ? <Hint t={t}>{data.summary.join(' · ')}</Hint> : null}
<Hint t={t}>
/jk move{activeNode?.body ? ' · Enter/→ open' : ''} · g/G top/bottom · q close
/jk move{activeNode?.body ? ' · Enter/→ open' : ''}
{activeNode ? ' · e edit · d delete' : ''} · g/G top/bottom · q close
</Hint>
</Footer>
</Box>
@ -453,6 +558,18 @@ function Shell({ children, t }: { children: React.ReactNode; t: Theme }) {
)
}
function StatusLines({ confirm, label, notice, t }: { confirm: boolean; label: string; notice: string; t: Theme }) {
if (confirm) {
return <Text color={t.color.error}>delete {label}? y/N</Text>
}
if (notice) {
return <Text color={t.color.accent}>{notice}</Text>
}
return null
}
function Footer({ children }: { children: React.ReactNode }) {
return (
<Box flexDirection="column" marginTop={1}>

View file

@ -1,6 +1,10 @@
import { accessSync, constants } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { accessSync, constants, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { withInkSuspended } from '@hermes/ink'
/**
* Editor fallback chain when neither $VISUAL nor $EDITOR is set. Mirrors
* prompt_toolkit's `Buffer.open_in_editor()` picker so the classic CLI and
@ -45,3 +49,22 @@ export const resolveEditor = (
return [found ?? 'vi']
}
/** Suspend Ink, open ``initial`` in $EDITOR, return the edited text (null if aborted). */
export async function openInEditor(initial: string, suffix = '.txt'): Promise<null | string> {
const dir = mkdtempSync(join(tmpdir(), 'hermes-edit-'))
const file = join(dir, `edit${suffix}`)
writeFileSync(file, initial)
const [cmd, ...args] = resolveEditor()
let status: null | number = null
await withInkSuspended(async () => {
status = spawnSync(cmd!, [...args, file], { stdio: 'inherit' }).status
})
try {
return status === 0 ? readFileSync(file, 'utf8') : null
} finally {
rmSync(dir, { force: true, recursive: true })
}
}