From f721d8742300231df355dda254e0ee4b35ae8628 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 9 Apr 2026 20:59:33 -0500 Subject: [PATCH] Add Skills browser, Cron jobs, SOUL.md editor - Skills: categorized list with search, detail view with markdown - Cron: job list grouped by status, manual trigger button - Soul: full SOUL.md editor with save/revert - Pulse: quick link cards to Skills, Cron, Soul - All routes proxied server-side (no CORS) --- app/api/skills/[...name]/route.ts | 15 ++++ app/api/skills/route.ts | 13 +++ app/cron/jobs/[id]/run/route.ts | 17 ++++ app/cron/jobs/route.ts | 16 ++++ app/cron/page.tsx | 144 ++++++++++++++++++++++++++++++ app/page.tsx | 20 +++++ app/skills/[name]/page.tsx | 65 ++++++++++++++ app/skills/page.tsx | 104 +++++++++++++++++++++ app/soul/api/route.ts | 23 +++++ app/soul/page.tsx | 84 +++++++++++++++++ 10 files changed, 501 insertions(+) create mode 100644 app/api/skills/[...name]/route.ts create mode 100644 app/api/skills/route.ts create mode 100644 app/cron/jobs/[id]/run/route.ts create mode 100644 app/cron/jobs/route.ts create mode 100644 app/cron/page.tsx create mode 100644 app/skills/[name]/page.tsx create mode 100644 app/skills/page.tsx create mode 100644 app/soul/api/route.ts create mode 100644 app/soul/page.tsx diff --git a/app/api/skills/[...name]/route.ts b/app/api/skills/[...name]/route.ts new file mode 100644 index 0000000..be6ad18 --- /dev/null +++ b/app/api/skills/[...name]/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; + +const BACKEND = process.env.HERMES_BACKEND_URL || "http://127.0.0.1:8643"; + +export async function GET(req: NextRequest, { params }: { params: Promise<{ name: string[] }> }) { + const { name } = await params; + const target = name.join("/"); + const qs = req.nextUrl.search; + const res = await fetch(BACKEND + "/api/skills/" + target + qs); + const data = await res.text(); + return new NextResponse(data, { + status: res.status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts new file mode 100644 index 0000000..336ff70 --- /dev/null +++ b/app/api/skills/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from "next/server"; + +const BACKEND = process.env.HERMES_BACKEND_URL || "http://127.0.0.1:8643"; + +export async function GET(req: NextRequest) { + const qs = req.nextUrl.search; + const res = await fetch(BACKEND + "/api/skills" + qs); + const data = await res.text(); + return new NextResponse(data, { + status: res.status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/app/cron/jobs/[id]/run/route.ts b/app/cron/jobs/[id]/run/route.ts new file mode 100644 index 0000000..afdfa2c --- /dev/null +++ b/app/cron/jobs/[id]/run/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; + +const GATEWAY = process.env.HERMES_GATEWAY_URL || "http://127.0.0.1:8642"; + +export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + try { + const res = await fetch(GATEWAY + "/api/jobs/" + id + "/run", { method: "POST" }); + const data = await res.text(); + return new NextResponse(data, { + status: res.status, + headers: { "content-type": "application/json" }, + }); + } catch { + return NextResponse.json({ error: "gateway unreachable" }, { status: 502 }); + } +} diff --git a/app/cron/jobs/route.ts b/app/cron/jobs/route.ts new file mode 100644 index 0000000..77d9ea3 --- /dev/null +++ b/app/cron/jobs/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; + +const GATEWAY = process.env.HERMES_GATEWAY_URL || "http://127.0.0.1:8642"; + +export async function GET() { + try { + const res = await fetch(GATEWAY + "/api/jobs"); + const data = await res.text(); + return new NextResponse(data, { + status: res.status, + headers: { "content-type": "application/json" }, + }); + } catch { + return NextResponse.json({ jobs: [] }, { status: 502 }); + } +} diff --git a/app/cron/page.tsx b/app/cron/page.tsx new file mode 100644 index 0000000..3b2b0bf --- /dev/null +++ b/app/cron/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Timer, Play, Pause, RefreshCw, ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { timeAgo } from "@/lib/utils"; + +interface CronJob { + id: string; + name?: string; + prompt: string; + schedule: string; + status: string; + deliver: string; + skill?: string; + skills?: string[]; + model?: string; + repeat?: number; + run_count?: number; + last_run?: string; + next_run?: string; + created_at?: string; +} + +export default function CronPage() { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + + const load = () => { + fetch("/cron/jobs") + .then((r) => r.json()) + .then((d) => setJobs(d.jobs || [])) + .catch(() => {}) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); + + const trigger = async (id: string) => { + await fetch(`/cron/jobs/${id}/run`, { method: "POST" }); + load(); + }; + + const active = jobs.filter((j) => j.status === "active" || j.status === "enabled"); + const paused = jobs.filter((j) => j.status === "paused"); + const other = jobs.filter((j) => !["active", "enabled", "paused"].includes(j.status)); + + return ( +
+
+
+ +

Cron Jobs

+
+ +
+ + {loading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+ ))} +
+ ) : jobs.length === 0 ? ( +
+ No cron jobs configured +
+ ) : ( +
+ {active.length > 0 && ( +
+ )} + {paused.length > 0 && ( +
+ )} + {other.length > 0 && ( +
+ )} +
+ )} +
+ ); +} + +function Section({ label, jobs, onTrigger }: { label: string; jobs: CronJob[]; onTrigger: (id: string) => void }) { + return ( +
+

+ {label} ({jobs.length}) +

+
+ {jobs.map((j) => ( + onTrigger(j.id)} /> + ))} +
+
+ ); +} + +function CronCard({ job, onTrigger }: { job: CronJob; onTrigger: () => void }) { + const isActive = job.status === "active" || job.status === "enabled"; + return ( +
+
+
+
+ {job.name || job.prompt.slice(0, 60)} +
+
{job.schedule}
+
+
+
+ +
+
+
+ → {job.deliver} + {job.skills && job.skills.length > 0 && ( + {job.skills.join(", ")} + )} + {job.last_run && last: {timeAgo(new Date(job.last_run).getTime() / 1000)}} +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index e4e2231..717f75f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,18 @@ "use client"; +import Link from "next/link"; +import { Puzzle, Timer, Sparkles } from "lucide-react"; import { StatusCard } from "@/components/StatusCard"; import { SessionCard } from "@/components/SessionCard"; import { MemorySnapshotCard } from "@/components/MemorySnapshotCard"; import { useSessions } from "@/lib/hooks"; +const QUICK_LINKS = [ + { href: "/skills", label: "Skills", icon: Puzzle, color: "#a78bfa" }, + { href: "/cron", label: "Cron", icon: Timer, color: "#f59e0b" }, + { href: "/soul", label: "Soul", icon: Sparkles, color: "#ec4899" }, +] as const; + export default function PulsePage() { const { data: sessionsData, isLoading } = useSessions(8); @@ -17,6 +25,18 @@ export default function PulsePage() { + {/* Quick links */} +
+ {QUICK_LINKS.map(({ href, label, icon: Icon, color }) => ( + +
+ + {label} +
+ + ))} +
+

Recent Sessions diff --git a/app/skills/[name]/page.tsx b/app/skills/[name]/page.tsx new file mode 100644 index 0000000..9963a62 --- /dev/null +++ b/app/skills/[name]/page.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { use } from "react"; +import { ArrowLeft, FileText, Folder } from "lucide-react"; +import Link from "next/link"; +import { useSkill } from "@/lib/hooks"; + +export default function SkillDetailPage({ params }: { params: Promise<{ name: string }> }) { + const { name } = use(params); + const { data, isLoading } = useSkill(name); + + return ( +
+
+ + + +

{name}

+
+ + {isLoading ? ( +
+
+
+
+
+ ) : data ? ( + <> + {/* Linked files */} + {data.linked_files && Object.keys(data.linked_files).length > 0 && ( +
+
+ Linked Files +
+
+ {Object.entries(data.linked_files).map(([dir, files]) => + (files as string[]).map((f) => ( +
+ + {dir}/{f} +
+ )) + )} +
+
+ )} + + {/* Skill content */} +
+
+              {data.content}
+            
+
+ + ) : ( +
+ Skill not found +
+ )} +
+ ); +} diff --git a/app/skills/page.tsx b/app/skills/page.tsx new file mode 100644 index 0000000..adb7b4e --- /dev/null +++ b/app/skills/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Puzzle, ChevronRight, Search, X } from "lucide-react"; +import { useSkills } from "@/lib/hooks"; +import type { SkillSummary } from "@/lib/types"; + +export default function SkillsPage() { + const { data, isLoading } = useSkills(); + const [search, setSearch] = useState(""); + + const skills = data?.skills || []; + const categories = [...new Set(skills.map((s) => s.category || "uncategorized"))].sort(); + + const filtered = search + ? skills.filter( + (s) => + s.name.toLowerCase().includes(search.toLowerCase()) || + s.description.toLowerCase().includes(search.toLowerCase()) + ) + : skills; + + const grouped = categories + .map((cat) => ({ + category: cat, + skills: filtered.filter((s) => (s.category || "uncategorized") === cat), + })) + .filter((g) => g.skills.length > 0); + + return ( +
+
+ +

Skills

+ {skills.length} total +
+ +
+ + setSearch(e.target.value)} + placeholder="Filter skills..." + className="w-full pl-9 pr-9 py-2.5 rounded-xl text-sm bg-white/[0.04] border border-white/[0.08] text-zinc-200 placeholder-zinc-600 outline-none focus:border-white/[0.15] transition-colors" + /> + {search && ( + + )} +
+ + {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+ ))} +
+ ) : ( +
+ {grouped.map(({ category, skills }) => ( +
+

+ {category} +

+
+ {skills.map((s) => ( + + ))} +
+
+ ))} + {grouped.length === 0 && ( +
+ No skills match “{search}” +
+ )} +
+ )} +
+ ); +} + +function SkillRow({ skill }: { skill: SkillSummary }) { + return ( + +
+
+
{skill.name}
+
{skill.description}
+
+ +
+ + ); +} diff --git a/app/soul/api/route.ts b/app/soul/api/route.ts new file mode 100644 index 0000000..af88583 --- /dev/null +++ b/app/soul/api/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "fs"; + +const SOUL_PATH = process.env.HERMES_SOUL_PATH || "/home/hermes/.hermes/SOUL.md"; + +export async function GET() { + try { + const content = await fs.readFile(SOUL_PATH, "utf-8"); + return NextResponse.json({ content }); + } catch { + return NextResponse.json({ content: "" }); + } +} + +export async function PUT(req: NextRequest) { + try { + const { content } = await req.json(); + await fs.writeFile(SOUL_PATH, content, "utf-8"); + return NextResponse.json({ ok: true }); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } +} diff --git a/app/soul/page.tsx b/app/soul/page.tsx new file mode 100644 index 0000000..abe65ee --- /dev/null +++ b/app/soul/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Sparkles, Save, RotateCcw } from "lucide-react"; + +export default function SoulPage() { + const [content, setContent] = useState(""); + const [original, setOriginal] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + useEffect(() => { + fetch("/soul/api") + .then((r) => r.json()) + .then((d) => { setContent(d.content || ""); setOriginal(d.content || ""); }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const dirty = content !== original; + + const save = async () => { + setSaving(true); + await fetch("/soul/api", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + setOriginal(content); + setSaving(false); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + return ( +
+
+
+ +

SOUL.md

+
+
+ {dirty && ( + + )} + +
+
+ +

+ Persona file loaded fresh each message. Edit to change how Hermes communicates. +

+ + {loading ? ( +
+
+
+
+
+ ) : ( +