mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
Merge pull request #55859 from NousResearch/bb/journey-edit-delete
feat(journey): edit and delete learned skills/memories
This commit is contained in:
commit
d153918f14
11 changed files with 868 additions and 7 deletions
206
agent/learning_mutations.py
Normal file
206
agent/learning_mutations.py
Normal 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
|
||||
159
apps/desktop/src/app/starmap/node-context-menu.tsx
Normal file
159
apps/desktop/src/app/starmap/node-context-menu.tsx
Normal 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 ?? ''}?`}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
114
tests/agent/test_learning_mutations.py
Normal file
114
tests/agent/test_learning_mutations.py
Normal 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 id→file 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"]
|
||||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue