Hermy HQ: self-hostable mission-control template for your Hermes agent
A Next.js 16 + Prisma/Postgres dashboard that pairs with a local Hermes agent over a Postgres message bus: dispatch work, approve side-effecting actions, browse the agent's memory, and watch it run. Ships with an agent-onboarding prompt (ONBOARDING.md) so your Hermes can install it for you step by step. All secrets are env-configured; nothing sensitive is bundled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
export const maxDuration = 120;
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
|
||||
async function callLLM(apiKey: string, apiBase: string, payload: object): Promise<string> {
|
||||
const res = await fetch(`${apiBase}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`LLM API error ${res.status}: ${err.slice(0, 200)}`);
|
||||
}
|
||||
return res.json().then(d => JSON.stringify(d));
|
||||
}
|
||||
|
||||
async function braveSearch(query: string, braveKey: string, count = 5): Promise<{ title: string; url: string; description: string }[]> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`,
|
||||
{ headers: { "Accept": "application/json", "X-Subscription-Token": braveKey } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.web?.results || []).map((r: any) => ({ title: r.title, url: r.url, description: r.description }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { topic, hook: existingHook, type: contentType } = await req.json();
|
||||
if (!topic) return NextResponse.json({ error: "topic required" }, { status: 400 });
|
||||
|
||||
// Use XAI (Grok) — already configured on Vercel. Falls back to OpenAI if set.
|
||||
const apiKey = process.env.XAI_API_KEY || process.env.OPENAI_API_KEY || "";
|
||||
const apiBase = process.env.XAI_API_KEY ? "https://api.x.ai/v1" : "https://api.openai.com/v1";
|
||||
const model = process.env.XAI_API_KEY ? "grok-4-1-fast-non-reasoning" : "gpt-4o-mini";
|
||||
const braveKey = process.env.BRAVE_API_KEY || "";
|
||||
if (!apiKey) return NextResponse.json({ error: "No API key" }, { status: 500 });
|
||||
|
||||
// Step 1: Web search to gather real context
|
||||
let sourceContext = "";
|
||||
const sourceUrls: string[] = [];
|
||||
|
||||
if (braveKey) {
|
||||
// Generate search queries
|
||||
const queryResult = await callLLM(apiKey, apiBase, {
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: "Extract 3 specific search queries to research this video topic thoroughly. Return JSON: {\"queries\":[\"query1\",\"query2\",\"query3\"]}" },
|
||||
{ role: "user", content: `Long-form video topic: ${topic}` }
|
||||
],
|
||||
temperature: 0.3,
|
||||
response_format: { type: "json_object" }
|
||||
});
|
||||
|
||||
let queries: string[] = [];
|
||||
try {
|
||||
const qd = JSON.parse(queryResult);
|
||||
queries = JSON.parse(qd.choices[0].message.content).queries || [];
|
||||
} catch { /* ok */ }
|
||||
|
||||
for (const query of queries.slice(0, 3)) {
|
||||
const results = await braveSearch(query, braveKey, 3);
|
||||
if (results.length > 0) {
|
||||
sourceContext += `\n\n## SEARCH: "${query}"\n${results.map(r => {
|
||||
sourceUrls.push(r.url);
|
||||
return `- ${r.title} (${r.url})\n ${r.description}`;
|
||||
}).join("\n")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Load voice analysis
|
||||
let voiceGuide = "";
|
||||
try {
|
||||
voiceGuide = fs.readFileSync("./data/longform-voice-analysis.md", "utf-8");
|
||||
} catch { /* ok */ }
|
||||
|
||||
// Step 2b: Load feedback history (what the user likes/hates)
|
||||
let feedbackContext = "";
|
||||
try {
|
||||
const drafts = JSON.parse(fs.readFileSync("./data/drafts.json", "utf-8"));
|
||||
const rejected = drafts.filter((d: any) => d.feedback?.rating === "down" && d.feedback?.reason);
|
||||
const approved = drafts.filter((d: any) => d.status === "approved" || d.feedback?.rating === "up");
|
||||
|
||||
if (rejected.length > 0) {
|
||||
feedbackContext += "\n## THINGS THE USER HATES (from rejected drafts):\n";
|
||||
for (const r of rejected.slice(0, 15)) {
|
||||
feedbackContext += `- "${r.title}" REJECTED because: "${r.feedback.reason}"\n`;
|
||||
}
|
||||
}
|
||||
if (approved.length > 0) {
|
||||
feedbackContext += "\n## THINGS THE USER LIKES (approved drafts):\n";
|
||||
for (const a of approved.slice(0, 10)) {
|
||||
feedbackContext += `- "${a.title}" ✅\n`;
|
||||
}
|
||||
}
|
||||
} catch { /* ok */ }
|
||||
|
||||
// Step 2c: Load YouTube performance data
|
||||
let performanceContext = "";
|
||||
try {
|
||||
const transcripts = JSON.parse(fs.readFileSync("./data/youtube-transcripts.json", "utf-8"));
|
||||
const sorted = transcripts.sort((a: any, b: any) => (b.views || 0) - (a.views || 0));
|
||||
performanceContext = "\n## TOP PERFORMING YOUTUBE VIDEOS:\n";
|
||||
for (const v of sorted.slice(0, 5)) {
|
||||
performanceContext += `- "${v.title}" — ${v.views} views\n`;
|
||||
}
|
||||
performanceContext += "\nThe AI trading/experiment format massively outperforms other content.\n";
|
||||
} catch { /* ok */ }
|
||||
|
||||
// Step 2d: Load rejected longform scripts for learning
|
||||
let longformFeedback = "";
|
||||
try {
|
||||
const lfScripts = JSON.parse(fs.readFileSync("./data/longform-scripts.json", "utf-8"));
|
||||
const rejectedLf = lfScripts.filter((s: any) => s.status === "rejected" && s.rejectedReason);
|
||||
if (rejectedLf.length > 0) {
|
||||
longformFeedback = "\n## REJECTED LONGFORM SCRIPTS:\n";
|
||||
for (const r of rejectedLf) {
|
||||
longformFeedback += `- "${r.title}" REJECTED: "${r.rejectedReason}"\n`;
|
||||
}
|
||||
}
|
||||
} catch { /* ok */ }
|
||||
|
||||
// Step 3: Generate long-form script
|
||||
const systemPrompt = `You are writing a long-form YouTube video script for the user, a founder and content creator.
|
||||
|
||||
## THE USER'S LONG-FORM VOICE (30-Transcript Analysis)
|
||||
|
||||
**Narrative Strategy and Performance Optimization:**
|
||||
|
||||
1. **Content Architecture (Statistically Most Engaging):**
|
||||
- Personal experiment/challenge format
|
||||
- Clear, quantifiable goal
|
||||
- Day-by-day progression
|
||||
- Unexpected insights
|
||||
- Data-driven conclusions
|
||||
- Vulnerability in sharing results
|
||||
|
||||
2. **Voice Characteristics:**
|
||||
- "Curious explorer" — NOT a lecturer
|
||||
- Learning WITH the audience, not AT them
|
||||
- Comfortable admitting initial ignorance
|
||||
- Technical depth without academic language
|
||||
|
||||
3. **Opening Hook Hierarchy (By Performance):**
|
||||
- "I was doing X when I noticed Y"
|
||||
- "Everyone says Z. So I decided to test it."
|
||||
- "I kept seeing something and realized I didn't understand it."
|
||||
|
||||
4. **Linguistic Performance Markers:**
|
||||
- Primary transition: "So,"
|
||||
- Secondary transitions:
|
||||
* "And here's the thing"
|
||||
* "Which made me realize something"
|
||||
* "See,"
|
||||
- Conversational fillers: "like," "I mean," "honestly"
|
||||
|
||||
5. **Humor Guidelines:**
|
||||
- Dry, self-deprecating
|
||||
- Observational comedy
|
||||
- Highlight absurdity without forcing jokes
|
||||
- Meta-commentary on process
|
||||
|
||||
6. **Content Structure:**
|
||||
1. Personal discovery moment
|
||||
2. Clear investigation goal
|
||||
3. Systematic exploration
|
||||
4. Unexpected insights
|
||||
5. Broader implications
|
||||
6. Soft, organic conclusion
|
||||
|
||||
7. **Language Restrictions:**
|
||||
- No em dashes
|
||||
- Avoid academic vocabulary
|
||||
- No exaggerated YouTuber language
|
||||
- Minimal CTAs
|
||||
- Prioritize genuine curiosity
|
||||
|
||||
8. **High-Performance Topic Types:**
|
||||
- Technology disruption
|
||||
- Personal experiments
|
||||
- Demystifying complex topics
|
||||
- Unexpected perspectives on trends
|
||||
|
||||
9. **Sentence Mechanics:**
|
||||
- Mix technical explanation + conversational reaction
|
||||
- Short, punchy sentences
|
||||
- Rarely academic
|
||||
- Strategic emphatic words: "literally," "impressive"
|
||||
|
||||
10. **Fundamental Principle:**
|
||||
Sound like a smart, curious friend figuring something out — NOT an expert lecturing.
|
||||
|
||||
## Performance Correlation Insights
|
||||
- Experiment/challenge videos generate 3-5x more engagement
|
||||
- Personal vulnerability increases viewer retention
|
||||
- Unexpected topic approaches drive curiosity
|
||||
- Technical topics made accessible through storytelling
|
||||
|
||||
**Absolute Rule:** Authenticity trumps polish. Always.
|
||||
|
||||
${voiceGuide ? `## FULL VOICE ANALYSIS REFERENCE\n${voiceGuide.slice(0, 3000)}` : ""}
|
||||
${feedbackContext}
|
||||
${performanceContext}
|
||||
${longformFeedback}
|
||||
|
||||
${existingHook ? `## EXISTING HOOK (keep or improve):\n"${existingHook}"\n` : ""}
|
||||
|
||||
## SOURCE MATERIAL (verified):
|
||||
${sourceContext || "No sources — be cautious with claims, hedge everything."}
|
||||
|
||||
## SCRIPT FORMAT
|
||||
Write a FULL script in markdown with:
|
||||
- Clear section headers (## HOOK, ## PART 1: ..., etc.)
|
||||
- Stage directions in *[brackets]* for B-roll/screen recordings
|
||||
- ALL dialogue written as SPOKEN WORD, exactly how the user would say it on camera
|
||||
- Target: 8-15 minutes when read aloud (~1500-2500 words)
|
||||
- Use their actual patterns: "So, I decided to look into it just to understand it." / "And here's where it gets interesting."
|
||||
|
||||
## OUTPUT FORMAT — valid JSON:
|
||||
{
|
||||
"title": "Compelling YouTube title",
|
||||
"hook": "The first 2-3 sentences that stop the scroll — personal, narrative, curiosity-driven",
|
||||
"outline": "Section1 → Section2 → Section3 → ...",
|
||||
"targetLength": "X-Y min",
|
||||
"fullScript": "Full markdown script with all sections, stage directions, and dialogue",
|
||||
"factCheck": {
|
||||
"status": "✅",
|
||||
"verified": ["list of verified claims"],
|
||||
"issues": ["any issues found"]
|
||||
}
|
||||
}`;
|
||||
|
||||
const genResult = await callLLM(apiKey, apiBase, {
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `Write a full long-form video script about: ${topic}\n\nMake it feel real and honest. Use facts from the source material. Write the COMPLETE script, not just an outline.` }
|
||||
],
|
||||
temperature: 0.85,
|
||||
response_format: { type: "json_object" }
|
||||
});
|
||||
|
||||
const genData = JSON.parse(genResult);
|
||||
const script = JSON.parse(genData.choices[0].message.content);
|
||||
|
||||
return NextResponse.json({
|
||||
id: `lf-${Date.now()}`,
|
||||
title: script.title,
|
||||
hook: script.hook,
|
||||
outline: script.outline,
|
||||
targetLength: script.targetLength || "8-12 min",
|
||||
fullScript: script.fullScript,
|
||||
platforms: ["youtube", "twitter"],
|
||||
factCheck: script.factCheck || { status: "✅", verified: [], issues: [] },
|
||||
status: "draft",
|
||||
type: contentType || "article",
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
notes: "",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user