Files
hermes-agent-mission-control/hermes-bridge/bridge.mjs
T

312 lines
15 KiB
JavaScript
Raw Normal View History

#!/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); });