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:
sharbelxyz
2026-07-26 09:33:24 +02:00
co-authored by Claude Opus 4.8
commit b463027468
153 changed files with 37795 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# Hermes Bridge
Two-way sync between **Hermy HQ** (the deployed website) and **Hermes** (your local agent on the Mac mini), using the shared Postgres as a message bus. Nothing is exposed to the internet — the bridge only needs outbound access to Postgres and the local `hermes` CLI.
```
website ──insert AgentRequest──▶ Postgres ◀──poll & run── bridge ──▶ hermes CLI
website ◀──read HermesTask/──── Postgres ◀──mirror─────── bridge ◀── hermes CLI
AgentEvent/DataStore
```
## What it does
- **Pull (Hermes → website):** mirrors the kanban board into `HermesTask`, cron list + health into `DataStore`, and writes activity to `AgentEvent`.
- **Push (website → Hermes):** runs `AgentRequest` rows that are `queued` (safe) or `approved` (you approved a side-effecting one) via the `hermes` CLI, then writes results back. It never runs `awaiting_approval` rows.
## Setup (on the Mac mini)
1. Copy this folder to the mini (or `git pull` the repo there).
2. Install the one dependency:
```sh
cd hermes-bridge && npm install
```
3. Make sure `hermes` is on PATH: `which hermes` should resolve (e.g. `~/.local/bin/hermes`).
4. Try it once, pointing at your DB:
```sh
DATABASE_URL='postgres://…' HERMES_BOARD=default node bridge.mjs
```
You should see `hermes-bridge up …`, and a "Bridge connected" event appear in the website's activity feed.
5. Run it forever with launchd:
```sh
# edit the placeholders in ai.hermyhq.bridge.plist first (path, DATABASE_URL, PATH)
cp ai.hermyhq.bridge.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/ai.hermyhq.bridge.plist
```
Logs: `/tmp/hermes-bridge.out.log`, `/tmp/hermes-bridge.err.log`.
## Config (env)
| var | default | meaning |
|---|---|---|
| `DATABASE_URL` | — (required) | same Postgres the website uses |
| `HERMES_BOARD` | `default` | kanban board slug to mirror |
| `HERMES_BIN` | `hermes` | path to the CLI if not on PATH |
| `BRIDGE_POLL_MS` | `5000` | how often to check for new requests |
| `BRIDGE_MIRROR_MS` | `30000` | how often to mirror kanban/cron/health |
| `BRIDGE_RUN_TIMEOUT_MS` | `240000` | max time for one agent run |
## Notes / assumptions
- CLI arg shapes (`hermes kanban create <title>`, `hermes cron create <schedule> <prompt>`) are best-effort for Hermes v0.17.x — if your build differs, tweak `runRequest()` in `bridge.mjs`.
- The bridge writes to Postgres with plain SQL, so it doesn't need Prisma.
- Safe by design: side-effecting work waits for your approval in the website's Approval Inbox before the bridge will touch it.
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
launchd service for the Hermy HQ ↔ Hermes bridge.
Edit the CAPITALIZED placeholders, then:
cp ai.hermyhq.bridge.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/ai.hermyhq.bridge.plist
Logs: /tmp/hermes-bridge.out.log /tmp/hermes-bridge.err.log
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.hermyhq.bridge</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/env</string>
<string>node</string>
<string>/ABSOLUTE/PATH/TO/hermes-bridge/bridge.mjs</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>DATABASE_URL</key>
<string>PASTE_YOUR_DATABASE_URL_HERE</string>
<key>HERMES_BOARD</key>
<string>default</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/Users/YOU/.local/bin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/hermes-bridge.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/hermes-bridge.err.log</string>
</dict>
</plist>
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env node
/**
* Hermy HQ ↔ Hermes bridge.
*
* Runs on the Mac mini where Hermes lives. Talks to the shared Postgres
* (the same DATABASE_URL the website uses) — nothing is exposed to the
* internet. Two jobs:
*
* PULL (Hermes → website): mirror the kanban board into HermesTask,
* cron list + health into DataStore, and emit activity events.
* PUSH (website → Hermes): pick up AgentRequest rows that are `queued`
* (safe) or `approved` (human-approved side-effecting), run them
* through the `hermes` CLI, and write results back.
*
* Requires: the `hermes` binary on PATH, and env DATABASE_URL.
* Optional env: HERMES_BOARD (default "default"), BRIDGE_POLL_MS (5000),
* BRIDGE_MIRROR_MS (30000), HERMES_BIN (default "hermes").
*/
import pg from "pg";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
const execFileP = promisify(execFile);
const HERMES = process.env.HERMES_BIN || "hermes";
const BOARD = process.env.HERMES_BOARD || "default";
const POLL_MS = Number(process.env.BRIDGE_POLL_MS || 5000);
const MIRROR_MS = Number(process.env.BRIDGE_MIRROR_MS || 30000);
const RUN_TIMEOUT_MS = Number(process.env.BRIDGE_RUN_TIMEOUT_MS || 240000);
const WIKI_DIR = process.env.HERMES_WIKI || path.join(os.homedir(), ".hermes", "wiki");
const BRIEF_HOUR = Number(process.env.BRIEF_HOUR || 8); // local hour to auto-generate the daily brief
const BRIEF_PROMPT =
"You are the operator's chief of staff. Produce today's brief. Read your memory wiki open-loops " +
"(~/.hermes/wiki), the kanban board, and recent activity. Output ONLY valid JSON (no prose, no code fences) " +
'in exactly this shape: {"greeting":"one warm line","summary":"2-3 sentences on where things stand",' +
'"sections":[{"label":"Needs your decision","items":["..."]},{"label":"Top priorities","items":["..."]},' +
'{"label":"Recently shipped","items":["..."]},{"label":"Next actions","items":["..."]}]}. ' +
"Keep every item short, concrete, and specific. Omit a section if it has nothing.";
let lastBriefDate = null;
const DB_URL = process.env.DATABASE_URL || "";
if (!DB_URL) { console.error("DATABASE_URL is required (use the direct postgres:// URL, not a prisma:// Accelerate URL)"); process.exit(1); }
if (DB_URL.startsWith("prisma://") || DB_URL.startsWith("prisma+")) {
console.error("DATABASE_URL is a Prisma Accelerate URL; the bridge needs a DIRECT postgres:// connection string (e.g. POSTGRES_URL).");
process.exit(1);
}
// Cloud Postgres (Prisma Postgres/Neon/Supabase/RDS) needs SSL; localhost doesn't.
const isLocal = /@(localhost|127\.0\.0\.1)/.test(DB_URL);
const pool = new pg.Pool({ connectionString: DB_URL, max: 4, ssl: isLocal ? undefined : { rejectUnauthorized: false } });
const log = (...a) => console.log(new Date().toISOString(), ...a);
const q = (text, params) => pool.query(text, params);
async function hermes(args, { timeout = 30000 } = {}) {
const { stdout } = await execFileP(HERMES, args, { timeout, maxBuffer: 8 * 1024 * 1024 });
return stdout;
}
async function emit(kind, title, { detail = null, agent = "hermes", level = "info", meta = null } = {}) {
await q(
`INSERT INTO "AgentEvent" (id, kind, title, detail, agent, level, meta, "createdAt")
VALUES ($1,$2,$3,$4,$5,$6,$7, now())`,
[randomUUID(), kind, title.slice(0, 200), detail, agent, level, meta ? JSON.stringify(meta) : null]
);
}
async function setStore(key, data) {
await q(
`INSERT INTO "DataStore" (key, data, "updatedAt") VALUES ($1,$2, now())
ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, "updatedAt" = now()`,
[key, JSON.stringify(data)]
);
}
/* ─────────────── PULL: mirror Hermes → Postgres ─────────────── */
async function mirrorKanban() {
let tasks = [];
try {
// NB: this Hermes CLI wants --board BEFORE the subcommand.
const out = await hermes(["kanban", "--board", BOARD, "list", "--json"], { timeout: 15000 });
const parsed = JSON.parse(out || "[]");
tasks = Array.isArray(parsed) ? parsed : parsed.tasks || [];
} catch (e) { log("kanban list failed:", e.message.split("\n")[0]); return; }
const seen = new Set();
for (const t of tasks) {
const id = String(t.id ?? t.task_id ?? "");
if (!id) continue;
seen.add(id);
await q(
`INSERT INTO "HermesTask" (id, board, title, assignee, status, priority, result, "updatedAt", "syncedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7, now(), now())
ON CONFLICT (id) DO UPDATE SET
title=EXCLUDED.title, assignee=EXCLUDED.assignee, status=EXCLUDED.status,
priority=EXCLUDED.priority, result=EXCLUDED.result, "syncedAt"=now()`,
[id, BOARD, String(t.title ?? "untitled").slice(0, 300), t.assignee ?? null,
String(t.status ?? "todo"), t.priority != null ? Number(t.priority) : null,
t.result ? String(t.result).slice(0, 2000) : null]
);
}
// prune tasks that vanished from the board
if (seen.size) {
await q(`DELETE FROM "HermesTask" WHERE board=$1 AND id <> ALL($2::text[])`, [BOARD, [...seen]]);
} else {
await q(`DELETE FROM "HermesTask" WHERE board=$1`, [BOARD]);
}
}
async function mirrorCrons() {
try {
const out = await hermes(["cron", "list", "--all"], { timeout: 15000 });
const lines = out.split("\n").map((l) => l.trimEnd()).filter(Boolean);
await setStore("hermes-crons", { jobs: lines, raw: out.slice(0, 8000), syncedAt: new Date().toISOString() });
} catch (e) { log("cron list failed:", e.message.split("\n")[0]); }
}
async function mirrorCost() {
for (const args of [["insights", "--days", "7"], ["insights"]]) {
try {
const out = await hermes(args, { timeout: 15000 });
await setStore("hermes-cost", { summary: out.slice(0, 4000), syncedAt: new Date().toISOString() });
return;
} catch { /* try next arg shape */ }
}
}
async function mirrorHealth() {
let online = false, gateway = "unknown", detail = "";
try {
const out = await hermes(["status"], { timeout: 12000 });
detail = out.slice(0, 4000);
online = /online|running|connected/i.test(out);
gateway = /gateway[^\n]*(running|online)/i.test(out) ? "running" : "stopped";
} catch (e) { detail = e.message.split("\n")[0]; }
await setStore("hermes-health", { online, gateway, detail, lastSeen: new Date().toISOString() });
}
/* ─────────────── Memory Wiki (warm tier: git-tracked markdown) ─────────────── */
function parseEntry(md) {
const m = md.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
const fm = {}; let body = md;
if (m) {
body = m[2];
for (const line of m[1].split("\n")) {
const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/);
if (!kv) continue;
const v = kv[2].trim();
if (v.startsWith("[") && v.endsWith("]")) fm[kv[1]] = v.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
else fm[kv[1]] = v === "null" || v === "" ? null : v;
}
}
return { fm, body: body.trim() };
}
function walkMd(dir, out = []) {
let items = [];
try { items = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
for (const it of items) {
const full = path.join(dir, it.name);
if (it.isDirectory()) { if (it.name !== ".git") walkMd(full, out); }
else if (it.name.endsWith(".md") && it.name !== "INDEX.md") out.push(full);
}
return out;
}
async function mirrorWiki() {
if (!fs.existsSync(WIKI_DIR)) return;
const seen = new Set();
for (const file of walkMd(WIKI_DIR)) {
const rel = path.relative(WIKI_DIR, file);
const id = rel.replace(/\.md$/, "");
seen.add(id);
let raw = ""; try { raw = fs.readFileSync(file, "utf8"); } catch { continue; }
const { fm, body } = parseEntry(raw);
await q(
`INSERT INTO "HermesMemory" (id, path, type, title, status, confidence, provenance, tags, links, body, "validFrom", "validTo", "updatedAt", "syncedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now(), now())
ON CONFLICT (id) DO UPDATE SET path=EXCLUDED.path, type=EXCLUDED.type, title=EXCLUDED.title,
status=EXCLUDED.status, confidence=EXCLUDED.confidence, provenance=EXCLUDED.provenance,
tags=EXCLUDED.tags, links=EXCLUDED.links, body=EXCLUDED.body,
"validFrom"=EXCLUDED."validFrom", "validTo"=EXCLUDED."validTo", "syncedAt"=now()`,
[id, rel, fm.type || "fact", fm.title || id, fm.status || "active", fm.confidence || null,
fm.provenance || null, Array.isArray(fm.tags) ? fm.tags : [], Array.isArray(fm.links) ? fm.links : [],
body, fm.valid_from || null, fm.valid_to || null]
);
}
if (seen.size) await q(`DELETE FROM "HermesMemory" WHERE id <> ALL($1::text[])`, [[...seen]]);
else await q(`DELETE FROM "HermesMemory"`);
}
function writeWikiEntry(e) {
const rel = e.path || `${e.type || "note"}s/${e.id}.md`;
const full = path.join(WIKI_DIR, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
const now = new Date().toISOString().slice(0, 10);
const lines = [
"---", `id: ${e.id}`, `type: ${e.type || "note"}`, `title: ${e.title}`,
`status: ${e.status || "active"}`,
e.confidence ? `confidence: ${e.confidence}` : null,
`provenance: ${e.provenance || "dashboard"}`,
`tags: [${(e.tags || []).join(", ")}]`, `links: [${(e.links || []).join(", ")}]`,
`updated: ${now}`, "---", "", e.body || "", "",
].filter((l) => l !== null);
fs.writeFileSync(full, lines.join("\n"), "utf8");
return rel;
}
async function gitCommitWiki(msg) {
try {
if (!fs.existsSync(path.join(WIKI_DIR, ".git"))) await execFileP("git", ["-C", WIKI_DIR, "init"]).catch(() => {});
await execFileP("git", ["-C", WIKI_DIR, "add", "-A"]).catch(() => {});
await execFileP("git", ["-C", WIKI_DIR, "commit", "-m", msg]).catch(() => {});
} catch { /* ignore */ }
}
/* ─────────────── Chief-of-staff daily brief ─────────────── */
async function generateBriefing() {
const raw = (await hermes(["-z", BRIEF_PROMPT], { timeout: RUN_TIMEOUT_MS })).trim();
let brief;
try {
const jsonStr = raw.replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
const m = jsonStr.match(/\{[\s\S]*\}/);
brief = JSON.parse(m ? m[0] : jsonStr);
} catch { brief = { summary: raw.slice(0, 1500), sections: [] }; }
brief.generatedAt = new Date().toISOString();
await setStore("hermes-briefing", brief);
await emit("status", "Daily brief generated", { level: "up" });
}
async function maybeDailyBrief() {
const now = new Date();
const today = now.toISOString().slice(0, 10);
if (now.getHours() >= BRIEF_HOUR && lastBriefDate !== today) {
lastBriefDate = today;
try { await generateBriefing(); } catch (e) { log("daily brief err", e.message); }
}
}
/* ─────────────── PUSH: run website requests via Hermes ─────────────── */
async function runRequest(r) {
await q(`UPDATE "AgentRequest" SET status='running', "startedAt"=now(), "updatedAt"=now() WHERE id=$1`, [r.id]);
await emit("run", `Started: ${r.title}`, { level: "info", meta: { requestId: r.id, kind: r.kind } });
try {
let result = "";
if (r.kind === "oneshot" || r.kind === "chat") {
result = (await hermes(["-z", r.prompt || r.title], { timeout: RUN_TIMEOUT_MS })).trim();
} else if (r.kind === "kanban") {
result = (await hermes(["kanban", "--board", BOARD, "create", "--json", r.title], { timeout: 20000 })).trim();
} else if (r.kind.startsWith("cron.")) {
const op = r.kind.split(".")[1];
const a = JSON.parse(r.prompt || "{}");
const argv =
op === "create" ? ["cron", "create", a.schedule, a.prompt || a.name].filter(Boolean)
: op === "run" ? ["cron", "run", a.id || a.name]
: op === "pause" ? ["cron", "pause", a.id || a.name]
: op === "resume" ? ["cron", "resume", a.id || a.name]
: op === "remove" ? ["cron", "remove", a.id || a.name]
: op === "edit" ? ["cron", "edit", a.id || a.name]
: null;
if (!argv) throw new Error(`unknown cron op ${op}`);
result = (await hermes(argv, { timeout: 20000 })).trim();
await mirrorCrons();
} else if (r.kind === "memory.write") {
const e = JSON.parse(r.prompt || "{}");
const rel = writeWikiEntry(e);
await gitCommitWiki(`wiki: update ${rel} (via dashboard)`);
await mirrorWiki();
result = `wrote ${rel}`;
} else if (r.kind === "briefing.generate") {
await generateBriefing();
lastBriefDate = new Date().toISOString().slice(0, 10);
result = "brief updated";
} else {
throw new Error(`unknown kind ${r.kind}`);
}
await q(`UPDATE "AgentRequest" SET status='done', result=$2, "finishedAt"=now(), "updatedAt"=now() WHERE id=$1`,
[r.id, result.slice(0, 8000)]);
await emit("run", `Done: ${r.title}`, { level: "up", detail: result.slice(0, 400), meta: { requestId: r.id } });
} catch (e) {
const msg = (e.stderr || e.message || "error").toString().split("\n")[0].slice(0, 600);
await q(`UPDATE "AgentRequest" SET status='failed', error=$2, "finishedAt"=now(), "updatedAt"=now() WHERE id=$1`, [r.id, msg]);
await emit("run", `Failed: ${r.title}`, { level: "down", detail: msg, meta: { requestId: r.id } });
log("request failed:", r.id, msg);
}
}
async function processQueue() {
const { rows } = await q(
`SELECT * FROM "AgentRequest" WHERE status IN ('queued','approved') ORDER BY "createdAt" ASC LIMIT 3`
);
for (const r of rows) await runRequest(r);
}
/* ─────────────── loops ─────────────── */
async function mirrorTick() {
try { await mirrorKanban(); } catch (e) { log("mirrorKanban err", e.message); }
try { await mirrorCrons(); } catch (e) { log("mirrorCrons err", e.message); }
try { await mirrorHealth(); } catch (e) { log("mirrorHealth err", e.message); }
try { await mirrorWiki(); } catch (e) { log("mirrorWiki err", e.message); }
try { await mirrorCost(); } catch (e) { log("mirrorCost err", e.message); }
try { await maybeDailyBrief(); } catch (e) { log("maybeDailyBrief err", e.message); }
}
async function main() {
log(`hermes-bridge up · board=${BOARD} · poll=${POLL_MS}ms · mirror=${MIRROR_MS}ms`);
await emit("status", "Bridge connected", { level: "up" });
await mirrorTick();
setInterval(() => mirrorTick().catch((e) => log("mirror loop", e.message)), MIRROR_MS);
// queue loop
const tick = async () => { try { await processQueue(); } catch (e) { log("queue loop", e.message); } finally { setTimeout(tick, POLL_MS); } };
tick();
}
main().catch((e) => { console.error("fatal", e); process.exit(1); });
+10
View File
@@ -0,0 +1,10 @@
{
"name": "hermes-bridge",
"private": true,
"type": "module",
"version": "1.0.0",
"description": "Two-way bridge between Hermy HQ (website) and Hermes (local agent) via shared Postgres.",
"bin": { "hermes-bridge": "./bridge.mjs" },
"scripts": { "start": "node bridge.mjs" },
"dependencies": { "pg": "^8.13.1" }
}
+57
View File
@@ -0,0 +1,57 @@
---
name: wiki
description: >
Long-term memory wiki. Use this whenever you learn, decide, or are corrected on
something durable that is too big or too detailed for MEMORY.md. Read from and
write to ~/.hermes/wiki as your warm, git-tracked, never-forget memory.
version: 1.0.0
---
# Memory Wiki
`MEMORY.md` and `USER.md` are your tiny hot cache (~1,300 tokens total, always in
context). The **wiki** at `~/.hermes/wiki/` is your warm, unlimited, git-tracked
long-term memory. It is browsed and edited by the operator in Hermy HQ, so keep it clean.
## When to write to the wiki (not MEMORY.md)
- After any complex task (5+ tool calls): append a line to `log/YYYY-MM.md` and, if you
learned something reusable, create/update a `lessons/` entry.
- When the operator makes a **decision** → a `decisions/` entry with the rationale + date.
- When you learn a durable fact about a **project, person, or the business** that is
bigger than a one-liner → a `projects/`, `people/`, or `facts/` entry.
- When corrected ("no, do it this way") → update the relevant entry; **don't delete the
old value — mark it `status: superseded`** and add the new fact. Preserve history.
- Keep MEMORY.md for only ~10-20 always-true, high-frequency facts, plus a pointer:
`Full long-term memory at ~/.hermes/wiki — grep/read it before answering project questions.`
## Entry format (one markdown file per entry, YAML frontmatter + body)
```
---
id: proj-viralpen
type: project # fact | preference | decision | event | project | contact | lesson | metric | note
title: ViralPen.ai SaaS
status: active # active | superseded | archived
confidence: high # high | medium | low
provenance: user-stated # user-stated | observed | web | session:<id>
tags: [saas, billing]
links: [decision-pricing-99]
updated: 2026-07-23
---
Multi-tenant article studio at ~/viralpen. $99/mo. Twitter OAuth.
## Open loops
- [ ] Migrate billing to usage-based (see decision-pricing-99)
```
Files live under type folders: `projects/`, `people/`, `decisions/`, `lessons/`,
`facts/`, `log/`. Keep `INDEX.md` updated (one line per entry: `id · title · type · updated`).
## Retrieval (before answering)
1. `INDEX.md` is injected hot — scan it for the right entry id.
2. `search_files ~/.hermes/wiki "<term>"` or read the specific file with `read_file`.
3. If still unsure, `session_search` the conversation history (free, unlimited).
Only pull the 1-2 entries you actually need — don't load the whole wiki.
## Hygiene
- After writing, `git -C ~/.hermes/wiki add -A && git commit -m "wiki: <what changed>"`.
- Never destroy history — supersede, don't overwrite.
- The nightly `wiki-consolidate` cron merges duplicates, demotes stale entries, and
rebuilds INDEX.md — keep entries small and single-purpose so it can.