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:
parent
a0576560ed
commit
08be8e5ef7
4 changed files with 178 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue