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.
This commit is contained in:
Brooklyn Nicholson 2026-06-30 15:07:22 -05:00
parent a0576560ed
commit 08be8e5ef7
4 changed files with 178 additions and 1 deletions

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

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