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:
Teknium 2026-06-23 13:51:28 -07:00 committed by GitHub
parent aaa2e2cb88
commit e32ebc6aa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 404 additions and 1 deletions

View file

@ -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) => {

View file

@ -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>
);