mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-29 11:42:04 +00:00
feat(skills): /learn — distill a reusable skill from anything you describe (#51506)
Open-ended skill learning across every surface. /learn <free text> takes a description of any source — a directory, a URL, the workflow you just walked the agent through, or pasted notes — and the live agent gathers it with the tools it already has (read_file/search_files, web_extract, the conversation, the pasted text), then authors a SKILL.md via skill_manage following the house authoring standards (<=60-char description, the standard section order, Hermes-tool framing, no invented commands). No engine, no model-tool footprint, works on any terminal backend (local, Docker, remote): /learn builds a standards-guided prompt and hands it to the agent as a normal turn. - agent/learn_prompt.py: shared standards-guided prompt builder - /learn registry entry (both surfaces) + CLI handler (inject onto input queue) + gateway handler (rewrite turn, fall through, /blueprint pattern) - tui_gateway command.dispatch returns a send directive -> TUI + dashboard chat - dashboard Skills page 'Learn a skill' panel (dir + URL + open-ended text) composes a /learn request and runs it in chat - docs (slash-commands ref + skills feature page), 11 targeted tests Inspired by OpenAI Codex's Record & Replay and the /learn concept from #47234 (dir-distillation engine); reworked to be open-ended and engine-free per review.
This commit is contained in:
parent
aaa2e2cb88
commit
e32ebc6aa2
11 changed files with 404 additions and 1 deletions
|
|
@ -671,6 +671,25 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
// follow up with the authoritative measurement — at worst Ink
|
||||
// reflows once after the PTY boots, which is imperceptible.
|
||||
ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||
// One-shot: a ?learn=<text> param (set by the Skills page "Learn a
|
||||
// skill" panel) is typed into the composer as a /learn command once the
|
||||
// PTY is up. /learn resolves via command.dispatch → a normal agent turn,
|
||||
// so this reuses the existing composer path — no special PTY protocol.
|
||||
const learnSeed = searchParams.get("learn");
|
||||
if (learnSeed) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("learn");
|
||||
setSearchParams(next, { replace: true });
|
||||
const cmd = `/learn ${learnSeed}`.trim();
|
||||
// Delay so Ink's composer has mounted and grabbed focus before input.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
wsRef.current?.send(cmd + "\r");
|
||||
} catch {
|
||||
/* PTY not ready / closed — user can retype */
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useLayoutEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Package,
|
||||
Search,
|
||||
|
|
@ -212,6 +213,37 @@ export default function SkillsPage() {
|
|||
setEditorSkill(null);
|
||||
setEditorOpen(true);
|
||||
}, []);
|
||||
// ── "Learn a skill" panel ──────────────────────────────────────────────
|
||||
// Open-ended: dir + URL + free-text inputs are composed into a single-line
|
||||
// /learn command and handed to the chat. /learn resolves to a normal agent
|
||||
// turn (command.dispatch → send), so the live agent gathers the sources
|
||||
// with its own tools and authors the skill via skill_manage. No backend
|
||||
// distill endpoint — one code path with the CLI/TUI/gateway /learn.
|
||||
const navigate = useNavigate();
|
||||
const [learnOpen, setLearnOpen] = useState(false);
|
||||
const [learnDir, setLearnDir] = useState("");
|
||||
const [learnUrl, setLearnUrl] = useState("");
|
||||
const [learnText, setLearnText] = useState("");
|
||||
const openLearn = useCallback(() => {
|
||||
setLearnDir("");
|
||||
setLearnUrl("");
|
||||
setLearnText("");
|
||||
setLearnOpen(true);
|
||||
}, []);
|
||||
const submitLearn = useCallback(() => {
|
||||
const segs: string[] = [];
|
||||
const dir = learnDir.trim();
|
||||
const url = learnUrl.trim();
|
||||
const text = learnText.trim();
|
||||
if (dir) segs.push(`local source: ${dir}`);
|
||||
if (url) segs.push(`URL: ${url}`);
|
||||
if (text) segs.push(text);
|
||||
// Flatten to a single line — the chat composer submits on the first Enter.
|
||||
const composed = segs.join("; ").replace(/\s*\n\s*/g, " ").trim();
|
||||
if (!composed) return;
|
||||
setLearnOpen(false);
|
||||
navigate(`/chat?learn=${encodeURIComponent(composed)}`);
|
||||
}, [learnDir, learnUrl, learnText, navigate]);
|
||||
const openEditEditor = useCallback((skillName: string) => {
|
||||
setEditorSkill(skillName);
|
||||
setEditorOpen(true);
|
||||
|
|
@ -492,6 +524,14 @@ export default function SkillsPage() {
|
|||
.replace("{count}", String(activeSkills.length))
|
||||
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={openLearn}
|
||||
prefix={<Sparkles />}
|
||||
>
|
||||
Learn a skill
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
|
|
@ -630,6 +670,64 @@ export default function SkillsPage() {
|
|||
onClose={() => setEditorOpen(false)}
|
||||
onSaved={handleEditorSaved}
|
||||
/>
|
||||
<Dialog open={learnOpen} onOpenChange={setLearnOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Learn a skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Point Hermes at anything and it will distill a reusable skill —
|
||||
following the house authoring standards. Fill in any combination
|
||||
below; the agent gathers the sources and writes the skill in chat.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Local file or directory
|
||||
</label>
|
||||
<Input
|
||||
placeholder="~/projects/some-sdk (read with read_file / search_files)"
|
||||
value={learnDir}
|
||||
onChange={(e) => setLearnDir(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
URL
|
||||
</label>
|
||||
<Input
|
||||
placeholder="https://docs.example.com/api (fetched with web_extract)"
|
||||
value={learnUrl}
|
||||
onChange={(e) => setLearnUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Anything else — describe the workflow, paste notes, or say
|
||||
"what we just did"
|
||||
</label>
|
||||
<textarea
|
||||
className="min-h-[90px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="e.g. how I file an expense report: open the portal, …"
|
||||
value={learnText}
|
||||
onChange={(e) => setLearnText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button ghost onClick={() => setLearnOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submitLearn}
|
||||
prefix={<Sparkles />}
|
||||
disabled={!learnDir.trim() && !learnUrl.trim() && !learnText.trim()}
|
||||
>
|
||||
Learn it
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PluginSlot name="skills:bottom" />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue