diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 3cdbc6faa..4e46a0636 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 c576e4526..11d9bb4fd 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 a76cf42fc..e01f925b3 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 f7bb8f6f3..2c057155b 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", "")