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
+126
View File
@@ -0,0 +1,126 @@
# ============================================================================
# Hermy HQ — environment template
# ----------------------------------------------------------------------------
# Copy this file to `.env` and fill in your own values:
# cp .env.example .env
#
# NEVER commit your real .env. The values below are FAKE placeholders — replace
# every one of them. When you deploy, set the same vars in your host (Vercel →
# Project → Settings → Environment Variables).
# ============================================================================
# ─── REQUIRED · Core ─────────────────────────────────────────────
# The dashboard will not run without these.
# Postgres connection string. This same database is the message bus shared
# with your Hermes bridge. Get one from Neon, Prisma Postgres, Supabase, or
# Vercel Postgres. Keep sslmode=require for hosted databases.
DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require"
# Same Postgres URL again (some tooling reads POSTGRES_URL). Usually identical
# to DATABASE_URL.
POSTGRES_URL="postgresql://user:pass@host:5432/db?sslmode=require"
# Public URL of the site. Use http://localhost:3000 locally, and your real
# domain in production (e.g. https://your-app.vercel.app).
NEXTAUTH_URL="http://localhost:3000"
# Secret used to sign NextAuth sessions. Generate one with:
# openssl rand -base64 32
NEXTAUTH_SECRET="your-generated-secret"
# Google OAuth credentials for login. Create an OAuth 2.0 Client (type: Web)
# at https://console.cloud.google.com/apis/credentials and add the redirect
# URI: <NEXTAUTH_URL>/api/auth/callback/google
GOOGLE_CLIENT_ID="xxxxxxxxxxxx.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
# Comma-separated allowlist of emails permitted to sign in. Anyone else is
# rejected even with a valid Google account.
ALLOWED_EMAILS="you@example.com,teammate@example.com"
# Your display name, shown in the dashboard UI. Public (NEXT_PUBLIC_).
NEXT_PUBLIC_OWNER_NAME="Owner"
# Public base URL of the site. Usually the same as NEXTAUTH_URL. Public.
NEXT_PUBLIC_BASE_URL="http://localhost:3000"
# ─── Hermes / bridge ─────────────────────────────────────────────
# How the website coordinates with your local Hermes agent via the bridge.
# The bridge (hermes-bridge/) reads HERMES_* and DATABASE_URL on your machine;
# the website reads the shared ones below for scheduling and internal calls.
# Kanban board slug the bridge mirrors into HermesTask.
HERMES_BOARD="default"
# Path to the `hermes` CLI on the bridge machine, if it is not already on PATH.
HERMES_BIN="hermes"
# Path to your Hermes memory-wiki root (git-tracked markdown) that the bridge
# mirrors into HermesMemory. Set on the bridge machine.
HERMES_WIKI="/path/to/hermes/wiki"
# Hour of day (0-23, local time) to generate the Chief-of-Staff daily brief.
BRIEF_HOUR="7"
# Shared secret required by internal API routes (agent bus, bridge callbacks).
# Generate with: openssl rand -hex 32
INTERNAL_API_SECRET="your-internal-api-secret"
# Shared secret required by scheduled/cron endpoints so only your scheduler can
# trigger them. Generate with: openssl rand -hex 32
CRON_SECRET="your-cron-secret"
# ─── OPTIONAL · AI providers ─────────────────────────────────────
# Only needed for features that call an LLM (briefs, scoring, content). Leave
# blank to disable those features.
# OpenAI API key — https://platform.openai.com/api-keys
OPENAI_API_KEY="sk-xxxxx"
# OpenRouter API key (multi-model gateway) — https://openrouter.ai/keys
OPENROUTER_API_KEY="sk-or-xxxxx"
# xAI (Grok) API key — https://console.x.ai
XAI_API_KEY="xai-xxxxx"
# Brave Search API key, used for web lookups — https://brave.com/search/api
BRAVE_API_KEY="your-brave-api-key"
# ─── OPTIONAL · YouTube ──────────────────────────────────────────
# Only needed for the YouTube idea/script features. Get a key from the Google
# Cloud console (enable "YouTube Data API v3").
YOUTUBE_API_KEY="your-youtube-api-key"
# The channel ID you want to pull stats/videos for (starts with UC...).
YOUTUBE_CHANNEL_ID="UCxxxxxxxxxxxxxxxxxxxxxx"
# ─── OPTIONAL · X / Twitter ──────────────────────────────────────
# Only needed for Content OS metrics. Create an app at
# https://developer.twitter.com and copy the Bearer token.
TWITTER_BEARER_TOKEN="your-twitter-bearer-token"
# ─── OPTIONAL · Notion ───────────────────────────────────────────
# Only needed if you sync clients/data from Notion. Create an integration at
# https://www.notion.so/my-integrations and share the relevant pages with it.
NOTION_API_KEY="secret_xxxxx"
# ─── OPTIONAL · Client Pulse ─────────────────────────────────────
# The Client Pulse board reads client-health data from your Postgres. This
# template ships the dashboard + API only — wire up your own ingestion (a cron,
# a bot, a webhook) to populate it. Skip this block if you don't use the board.
# Admin secret for the Client Pulse admin/map-chat routes (falls back to CRON_SECRET).
CLIENT_PULSE_ADMIN_SECRET="your-client-pulse-admin-secret"
# ─── OPTIONAL · Trading / Watchlist Radar ────────────────────────
# Only needed if you use the trading-related widgets. Leave blank otherwise.
# A public wallet address to display balances/positions for (read-only).
HL_WALLET="0xyour-wallet-address"
+47
View File
@@ -0,0 +1,47 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env*.local
/services/client-pulse-bot/dist/
+14
View File
@@ -0,0 +1,14 @@
scanner/
data/
scripts/
*.backup
*.bak
*.fix
*.fix2
*.fix3
src/app/globals.css.backup
src/app/page.tsx.backup
src/components/sidebar.tsx.backup
src/components/sidebar.tsx.bak
prisma/migrations/
.env*
+137
View File
@@ -0,0 +1,137 @@
# Hermy HQ — Agent Onboarding Prompt
> **How to use this:** copy everything in the block below and paste it to your
> Hermes agent, in the directory where you cloned this repo. Hermes will walk you
> through the whole install, pausing to ask you for each real secret. It will not
> invent credentials, and it will keep side-effecting actions approval-gated.
---
You are helping your operator install **Hermy HQ**, a self-hostable mission-control
dashboard (Next.js 16, React 19, Tailwind v4, Prisma + Postgres, NextAuth Google
login) that pairs with you, their local Hermes agent, over a shared Postgres database
used as a message bus. A small **bridge** on the operator's machine polls Postgres
for tasks, runs them via your `hermes` CLI, and mirrors your state (kanban, cron,
health, memory) back to the website.
Guide the operator through the numbered steps below, one at a time. Follow these
rules throughout:
- **Never fabricate or guess secrets.** Whenever a step needs a real value (a
database URL, an OAuth secret, an API key), STOP and ask the operator to paste it.
Wait for their answer before continuing. If they do not have one yet, tell them
exactly where to get it and pause.
- **Confirm before acting.** Show the command you are about to run and wait for a go
signal before running anything that changes their system or their accounts.
- **Keep side-effecting actions approval-gated.** Do not disable, bypass, or work
around the approval flow. Anything that writes to external services stays gated.
- **Explain results.** After each step, briefly confirm what happened and what comes
next. If something errors, help debug before moving on.
- **Redact secrets** in anything you echo back. Never print full keys or tokens.
Work through the steps in order:
### Step 1 — Confirm prerequisites
Verify the operator has each of these; ask them to confirm or help them install:
- **Node.js 20+** (`node -v`) and **git** (`git --version`)
- A **PostgreSQL database URL** they control (Neon, Prisma Postgres, Supabase, or
Vercel Postgres). Ask them to have the connection string ready — do not ask them
to paste it yet.
- A **Vercel account** (for deploying the website)
- A **Google OAuth app** (OAuth 2.0 Web client) for login. If they do not have one,
point them to https://console.cloud.google.com/apis/credentials.
- **You**, Hermes, are installed and reachable — confirm the `hermes` CLI resolves
on this machine (`which hermes`).
### Step 2 — Clone and install dependencies
If the repo is not already present, clone it, then install:
```sh
git clone <repo-url> hermy-hq && cd hermy-hq
npm install
```
Confirm `npm install` finished without errors.
### Step 3 — Create the `.env` file
Copy the template and fill it in together:
```sh
cp .env.example .env
```
Open `.env` and go through the **Required · Core** group with the operator. For each
of these, ask them to paste the real value, then write it into `.env` for them —
never invent one:
- `DATABASE_URL` and `POSTGRES_URL` (usually the same Postgres URL)
- `NEXTAUTH_URL` — use `http://localhost:3000` for now
- `NEXTAUTH_SECRET` — you MAY generate this one locally with
`openssl rand -base64 32` (it is a random secret, not an account credential),
then show it to the operator
- `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` — ask the operator to paste from
their Google OAuth app
- `ALLOWED_EMAILS` — the Google email(s) allowed to sign in
- `NEXT_PUBLIC_OWNER_NAME` and `NEXT_PUBLIC_BASE_URL`
Then set the **Hermes / bridge** group: `HERMES_BOARD`, `HERMES_BIN`, `HERMES_WIKI`,
`BRIEF_HOUR`, and generate `INTERNAL_API_SECRET` and `CRON_SECRET`
(`openssl rand -hex 32` each). Leave the **Optional** groups blank unless the
operator wants those features now.
### Step 4 — Create the database tables
Push the Prisma schema to their Postgres:
```sh
npx prisma db push
```
Confirm it reports the schema is in sync. If it fails, the `DATABASE_URL` is almost
always the cause — recheck it with the operator.
### Step 5 — Run locally to verify
```sh
npm run dev
```
Ask the operator to open http://localhost:3000 and sign in with a Google account
listed in `ALLOWED_EMAILS`. Confirm they see the dashboard. Stop the dev server when
they confirm.
### Step 6 — Deploy to Vercel
Guide them through:
```sh
npm i -g vercel # if needed
vercel # link the project + preview deploy
vercel --prod # production
```
Then have the operator:
1. Add **every** variable from `.env` in Vercel → Project → Settings → Environment
Variables. Ask them to paste values into Vercel themselves (or read them back so
you can guide) — do not fabricate any.
2. Update `NEXTAUTH_URL` and `NEXT_PUBLIC_BASE_URL` to the production URL, both
locally and in Vercel.
3. In the Google Cloud console, add the authorized redirect URI
`https://<their-domain>/api/auth/callback/google`.
Confirm they can sign in on the production URL.
### Step 7 — Set up the bridge (connect the bus)
On this machine (where you, Hermes, live), set up the bridge so the website and you
share the Postgres bus:
```sh
cd hermes-bridge
npm install
```
Do a one-off test run using the **same** `DATABASE_URL` as the website (ask the
operator to confirm it matches):
```sh
DATABASE_URL='postgres://…same as website…' HERMES_BOARD=default node bridge.mjs
```
You should see `hermes-bridge up …` in the logs. Then make it permanent:
- **macOS:** edit the placeholders in `hermes-bridge/ai.hermyhq.bridge.plist` (path,
`DATABASE_URL`, `PATH`), copy it to `~/Library/LaunchAgents/`, and
`launchctl load` it.
- **Linux:** create an equivalent systemd service that runs
`node /path/to/hermes-bridge/bridge.mjs` with `DATABASE_URL` set, and enable it.
See `hermes-bridge/README.md` for the exact commands.
### Step 8 — Verify the bridge connected
Ask the operator to open the website's `/hermes` activity feed and confirm a
**"Bridge connected"** event appears. Once it does, the loop is closed: the website
can dispatch requests, and you will pick up `queued`/`approved` ones and mirror your
kanban, cron, health, and memory back.
Finally, remind the operator: side-effecting requests land in the **Approval Inbox**
and will not run until they approve them — this is intentional, keep it that way.
+227
View File
@@ -0,0 +1,227 @@
# Hermy HQ
**Your own mission control for a self-hosted AI agent.**
Hermy HQ is a self-hostable dashboard template that pairs with your own local
[Hermes agent](https://github.com/NousResearch/hermes-agent). The website is your
cockpit: dispatch work, review the agent's plans, approve anything with side effects,
browse its memory, and watch it run — all from one place. Your Hermes stays on your
own machine; the website and the agent talk to each other through a shared Postgres
database acting as a message bus.
It is a **template and starting point**, not a hosted product. You bring your own
database, your own Hermes, and your own API keys. Everything is configured through
environment variables.
---
## 🤖 Easiest setup: let your agent do it
> **Send this repo to your Hermes agent and paste [`ONBOARDING.md`](./ONBOARDING.md).**
> It is a ready-to-use prompt that walks your operator through the entire install
> step by step — prerequisites, `.env`, database, local run, Vercel deploy, and the
> bridge — pausing to ask *you* for each real secret. It never invents credentials,
> and it keeps every side-effecting action approval-gated.
Prefer to do it by hand? The [Quick start](#quick-start) below has you covered.
---
## Features
**Cockpit**
- Dashboard home with an at-a-glance view of the agent, tasks, and the day
- ⌘K command palette to jump anywhere or fire an action
- Chief-of-Staff daily brief, generated on a schedule
**Hermes control hub** (`/hermes`)
- Dispatch one-shot prompts or kanban tasks to the agent
- Approval inbox — side-effecting requests wait here for your explicit go-ahead
- Live activity feed and run history
- Cron list and health mirrored from the agent
**Memory**
- Memory Wiki — browse and edit the agent's memory (facts, notes, links) as a wiki
**Work & content**
- Tasks and Ideas boards
- Content OS — an X / Twitter content pipeline (drafts, scoring, scheduling)
- Longform + YouTube script studios
- Client Pulse — a client-health tracking board (bring your own data ingestion)
- Agents roster, Garden, and Watchlist Radar
> Some features need your own API keys (YouTube, X/Twitter, OpenAI, etc.). They are
> all optional and configured via env — the core dashboard runs without them.
---
## Architecture
The deployed website never talks to your agent directly. Both sides read and write
the **same Postgres database**, which acts as a message bus. A small **bridge**
process runs on the machine where Hermes lives.
```
┌───────────────────────┐ ┌────────────────────────┐
│ Hermy HQ (website) │ │ Your machine / server │
│ Next.js on Vercel │ │ │
│ │ │ ┌───────────────┐ │
│ • dispatch requests │ │ │ hermes-bridge │ │
│ • approval inbox │ │ │ (bridge.mjs) │ │
│ • activity / runs │ │ └──────┬────────┘ │
│ • memory wiki │ │ │ hermes CLI │
└──────────┬────────────┘ │ ┌──────▼────────┐ │
│ │ │ Hermes │ │
│ │ │ agent │ │
│ │ └───────────────┘ │
│ └───────────┬────────────┘
│ │
▼ ▼
┌────────────────────────────────────────────────────────┐
│ Postgres (message bus) │
│ AgentRequest · AgentEvent · HermesTask · HermesMemory │
│ DataStore · Brief · … │
└────────────────────────────────────────────────────────┘
```
- **Website → agent:** the site inserts an `AgentRequest`. Safe requests are
`queued`; anything side-effecting is `awaiting_approval` until you approve it in
the inbox.
- **Bridge → agent:** the bridge polls Postgres, runs `queued`/`approved` requests
via the `hermes` CLI, and writes results back. It never runs `awaiting_approval`.
- **Agent → website:** the bridge mirrors the kanban board (`HermesTask`), cron +
health (`DataStore`), memory (`HermesMemory`), and activity (`AgentEvent`) back
into Postgres, where the website reads them.
Nothing on your machine is exposed to the internet — the bridge only needs outbound
access to Postgres and your local `hermes` CLI. See
[`hermes-bridge/README.md`](./hermes-bridge/README.md) for details.
---
## Tech stack
- **Framework:** Next.js 16 (App Router) + React 19
- **Styling:** Tailwind CSS v4
- **Data:** Prisma ORM + PostgreSQL
- **Auth:** NextAuth with Google login (email allowlist)
- **Charts / DnD:** Recharts, react-dnd
- **Deploy:** Vercel (website) + launchd/systemd (bridge)
- **Agent:** [Hermes](https://github.com/NousResearch/hermes-agent) `hermes` CLI
---
## Quick start
### Prerequisites
- **Node.js 20+** (22 recommended) and **git**
- A **PostgreSQL** database URL — e.g. [Neon](https://neon.tech),
[Prisma Postgres](https://www.prisma.io/postgres), [Supabase](https://supabase.com),
or Vercel Postgres
- A **Hermes agent** installed on a machine you control (the `hermes` CLI on PATH)
- **Google OAuth** credentials (Client ID + Secret) for login
- A **Vercel** account (or any Node host) for deploying the website
### 1. Clone and install
```sh
git clone <your-fork-url> hermy-hq
cd hermy-hq
npm install
```
### 2. Configure environment
```sh
cp .env.example .env
```
Open `.env` and fill in at least the **required core** vars: `DATABASE_URL`,
`POSTGRES_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `GOOGLE_CLIENT_ID`,
`GOOGLE_CLIENT_SECRET`, `ALLOWED_EMAILS`, and `NEXT_PUBLIC_OWNER_NAME`.
Every variable is documented inline in [`.env.example`](./.env.example).
Generate a secret with:
```sh
openssl rand -base64 32
```
### 3. Create the database tables
```sh
npx prisma db push
```
### 4. Run locally
```sh
npm run dev
```
Open <http://localhost:3000> and sign in with a Google account whose email is in
`ALLOWED_EMAILS`.
### 5. Deploy to Vercel
```sh
npm i -g vercel # if you don't have it
vercel # link + deploy a preview
vercel --prod # production
```
Then:
1. Add **every** env var from your `.env` in **Project → Settings → Environment
Variables**.
2. Set `NEXTAUTH_URL` and `NEXT_PUBLIC_BASE_URL` to your production URL.
3. In the Google Cloud console, add the authorized redirect URI
`https://<your-domain>/api/auth/callback/google`.
### 6. Connect your Hermes (the bridge)
On the machine where Hermes lives, set up the bridge so the bus connects:
```sh
cd hermes-bridge
npm install
DATABASE_URL='postgres://…same as the website…' HERMES_BOARD=default node bridge.mjs
```
You should see `hermes-bridge up …` and a **"Bridge connected"** event appear in the
website's activity feed. To keep it running, install it via launchd (macOS) or
systemd (Linux) — full instructions in
[`hermes-bridge/README.md`](./hermes-bridge/README.md).
---
## Project layout
```
src/ Next.js App Router (dashboard, /hermes, memory-wiki, content-os, …)
prisma/ schema.prisma (message-bus + feature models)
hermes-bridge/ the bridge that runs on your machine next to Hermes
.env.example every env var, documented
ONBOARDING.md copy-paste prompt to have your Hermes install this for you
```
---
## Safety
Hermy HQ is designed so the agent can act, but not surprise you:
- Requests that have side effects are marked `sideEffecting` and land in the
**Approval Inbox** as `awaiting_approval`. The bridge will not run them until you
approve.
- The bridge lives on your machine and only reaches out to Postgres and the local
`hermes` CLI — nothing inbound.
- Login is Google OAuth gated to the emails in `ALLOWED_EMAILS`.
---
## License
Provided as a template for you to fork, self-host, and adapt. Add the license of
your choice before publishing your own instance.
View File
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+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.
+16
View File
@@ -0,0 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Vercel-compatible settings
output: undefined, // default — Vercel handles this automatically
images: {
unoptimized: false,
},
experimental: {
serverActions: {
bodySizeLimit: "4mb",
},
},
};
export default nextConfig;
+10053
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "hermy-hq",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"postinstall": "prisma generate",
"start": "next start",
"lint": "eslint",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio",
"db:seed": "npx tsx prisma/seed.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^1.6.0",
"@prisma/client": "^6.19.2",
"@types/better-sqlite3": "^7.6.13",
"@types/formidable": "^3.4.6",
"better-sqlite3": "^12.11.1",
"drizzle-orm": "^0.45.2",
"formidable": "^3.5.4",
"googleapis": "^171.4.0",
"grammy": "^1.44.0",
"lucide-react": "^0.564.0",
"next": "16.1.6",
"next-auth": "^4.24.13",
"pg": "^8.19.0",
"prisma": "^6.19.2",
"react": "19.2.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.2.3",
"recharts": "^3.7.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tauri-apps/cli": "^2.10.0",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"tsx": "^4.22.4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+519
View File
@@ -0,0 +1,519 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-arm64-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Draft {
id String @id @default(uuid())
text String
title String?
model String?
status String @default("pending")
type String?
category String?
hookType String?
feedbackRating String?
feedbackReason String?
viralScore Json?
impressionScore Json?
scheduledDate String?
scheduledTime String?
visualUrl String?
variantGroup String?
postedAt DateTime?
postedUrl String?
tweetUrl String?
tweetId String?
metrics Json?
editHistory Json? @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
quoteUrl String?
quoteText String?
// Content intelligence fields
source String? // "sage" | "organic" | "csv_import"
engagementScore Float? // time-weighted composite score (updated by scoring engine)
engagementTier String? // "top20" | "bottom20" | null (cleared/set on rescore)
@@index([status])
@@index([createdAt])
@@index([tweetId])
@@index([source])
@@index([engagementTier])
}
model TweetMetric {
id String @id @default(cuid())
draftId String
tweetId String?
url String?
postedAt DateTime?
checkpoints Json?
createdAt DateTime @default(now())
@@index([draftId])
}
model Idea {
id String @id @default(uuid())
title String
description String?
category String?
type String?
model String?
status String?
timestamp DateTime @default(now())
@@index([category])
@@index([type])
}
model ContentCalendar {
id String @id @default(cuid())
week String @unique
data Json
}
model YoutubeIdea {
id String @id @default(cuid())
title String
hook String?
angle String?
hookType String?
funnelStage String?
status String @default("pending")
rejectedReason String?
sourceUrl String?
sourceSummary String?
competitionScore String? // "gap" | "moderate" | "crowded"
competitionData Json? // { resultCount, avgViews, topVideos[], scoredAt }
createdAt DateTime @default(now())
@@index([status])
}
model YoutubeScript {
id String @id @default(cuid())
title String
hook String?
storySetup String?
conflict String?
insight String?
cta String?
caption String?
onScreenText String?
hookType String?
funnelStage String?
factCheck Json?
fullScript String?
status String @default("draft")
rejectedReason String?
// SEO Package
seoTitle String?
seoDescription String?
seoTags Json?
seoChapters Json?
titleVariants Json?
thumbnailConcept String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
}
model YoutubeFeedback {
id String @id @default(cuid())
title String
reason String?
type String?
date DateTime @default(now())
}
model LongformScript {
id String @id @default(cuid())
title String
type String?
status String @default("draft")
platform String?
platforms Json?
description String?
hook String?
outline String?
fullScript String?
targetLength String?
factCheck Json?
notes String?
rejectedReason String?
youtubeUrl String?
thumbnailUrl String?
tweetUrl String?
youtubeViews Int?
youtubeLikes Int?
youtubeComments Int?
tweetViews Int?
tweetLikes Int?
tweetBookmarks Int?
tweetRetweets Int?
tweetReplies Int?
postedAt DateTime?
competitionScore String? // "gap" | "moderate" | "crowded"
competitionData Json? // { resultCount, avgViews, topVideos[], scoredAt }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
}
model AgentState {
id String @id
name String
emoji String?
role String?
status String @default("offline")
lastActive DateTime?
tasksCompleted Int @default(0)
totalCost Float @default(0)
currentTask String?
recentActivity Json? @default("[]")
updatedAt DateTime @updatedAt
}
model AgentBusMessage {
id String @id @default(uuid())
fromAgent String
toAgent String
type String
content String
metadata Json?
read Boolean @default(false)
timestamp DateTime @default(now())
@@index([toAgent, read])
@@index([fromAgent])
}
model BattleRoyaleBot {
id String @id
state Json
updatedAt DateTime @updatedAt
}
model ContentRequest {
id String @id @default(uuid())
prompt String
status String @default("pending")
resultDraftIds Json?
createdAt DateTime @default(now())
completedAt DateTime?
}
// ─── Generic Key-Value Store ────────────────────────────────────
model DataStore {
key String @id
data Json
updatedAt DateTime @updatedAt
}
// ─── Daily Briefs ───────────────────────────────────────────────
model Brief {
id String @id @default(cuid())
date String @unique
content String @db.Text
source String @default("brief")
createdAt DateTime @default(now())
}
// ─── Missions ───────────────────────────────────────────────────
model Mission {
id String @id @default(uuid())
agentId String
title String
description String @db.Text
status String @default("pending") // pending, active, completed, failed
priority String @default("medium") // high, medium, low
result String? @db.Text
createdAt DateTime @default(now())
completedAt DateTime?
@@index([agentId])
@@index([status])
}
// ─── Articles ───────────────────────────────────────────────────
model Article {
id String @id @default(uuid())
title String
body String @db.Text
track String // "mega-viral" | "local-viral" | "icp-viral"
status String @default("idea") // "idea" | "draft" | "ready" | "posted"
qtTweet String? @db.Text
qtUrl String?
heroImageUrl String?
heroImageHtml String? @db.Text
inspirationUrls String? @db.Text // JSON array of URLs
themes String? // JSON array of themes
scheduledDate String?
postedAt DateTime?
postedUrl String?
impressions Int?
likes Int?
bookmarks Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([track])
@@index([scheduledDate])
}
model SavedTitle {
id String @id @default(cuid())
title String
track String
themes String? // JSON array
createdAt DateTime @default(now())
@@index([track])
}
// ─── Client Pulse Telegram Bot ─────────────────────────────────
model ClientPulseClient {
id String @id @default(cuid())
notionPageId String? @unique
clientName String
status String?
services Json @default("[]")
productManagerName String?
renewalDate DateTime?
endDate DateTime?
expectedCheckinHours Int @default(72)
sourceUpdatedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
chats ClientPulseChat[]
analyses ClientPulseAnalysis[]
alerts ClientPulseAlert[]
@@index([status])
@@index([clientName])
@@index([productManagerName])
}
model ClientPulseChat {
id String @id @default(cuid())
telegramChatId String @unique
title String
linkedClientId String?
status String @default("active")
internalUserIds Json @default("[]")
lastSeenAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
linkedClient ClientPulseClient? @relation(fields: [linkedClientId], references: [id], onDelete: SetNull)
messages ClientPulseMessage[]
alerts ClientPulseAlert[]
@@index([linkedClientId])
@@index([status])
}
model ClientPulseMessage {
id String @id @default(cuid())
telegramMessageId String
telegramChatId String
chatId String?
senderTelegramId String
senderName String
isInternal Boolean @default(false)
isBot Boolean @default(false)
text String @db.Text
sentAt DateTime
intent String @default("other")
sentiment String @default("neutral")
needsReply Boolean @default(false)
rawJson Json?
createdAt DateTime @default(now())
chat ClientPulseChat? @relation(fields: [chatId], references: [id], onDelete: SetNull)
@@unique([telegramChatId, telegramMessageId])
@@index([telegramChatId, sentAt])
@@index([chatId, sentAt])
@@index([needsReply])
}
model ClientPulseAnalysis {
id String @id @default(cuid())
clientId String
windowStart DateTime
windowEnd DateTime
sentimentScore Int
responseScore Int
cadenceScore Int
renewalRiskScore Int
positiveSignalScore Int @default(70)
overallScore Int
category String
reasons Json @default("[]")
evidence Json @default("[]")
recommendedAction String? @db.Text
llmSummary String? @db.Text
analysisSource String @default("fallback")
analyzedAt DateTime @default(now())
client ClientPulseClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@index([clientId, analyzedAt])
@@index([category])
@@index([overallScore])
}
model ClientPulseAlert {
id String @id @default(cuid())
clientId String?
chatId String?
type String
severity String
evidence Json @default("[]")
reason String @db.Text
recommendedAction String? @db.Text
sentTo String?
sentAt DateTime?
acknowledgedAt DateTime?
createdAt DateTime @default(now())
client ClientPulseClient? @relation(fields: [clientId], references: [id], onDelete: SetNull)
chat ClientPulseChat? @relation(fields: [chatId], references: [id], onDelete: SetNull)
@@index([clientId, createdAt])
@@index([severity])
@@index([sentAt])
@@index([acknowledgedAt])
}
// ─── Hermes bus (Hermy HQ website ↔ Hermes on the Mac mini) ─────
// Shared Postgres is the message bus. A bridge on the mini polls
// AgentRequest, runs approved/safe ones via the `hermes` CLI, and
// mirrors kanban tasks + activity back into HermesTask / AgentEvent.
model AgentRequest {
id String @id @default(cuid())
origin String @default("web") // "web" | "hermes"
kind String @default("oneshot") // "oneshot" | "kanban" | "chat"
title String
prompt String?
sideEffecting Boolean @default(false)
status String @default("queued") // queued | awaiting_approval | approved | running | done | failed | rejected
result String?
error String?
hermesTaskId String?
decidedAt DateTime?
startedAt DateTime?
finishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([createdAt])
}
model AgentEvent {
id String @id @default(cuid())
kind String @default("activity") // activity | run | status | memory
title String
detail String?
agent String?
level String @default("info") // info | up | warn | down
meta Json?
createdAt DateTime @default(now())
@@index([createdAt])
}
model HermesTask {
id String @id // Hermes task id
board String @default("default")
title String
assignee String?
status String @default("todo")
priority Int?
result String?
updatedAt DateTime @default(now())
syncedAt DateTime @updatedAt
@@index([board])
@@index([status])
}
// ─── Hermes Memory Wiki (warm tier — git-tracked markdown, mirrored) ─────
model HermesMemory {
id String @id // wiki entry slug (relative path w/o .md)
path String // file path relative to wiki root
type String @default("fact")
title String
status String @default("active")
confidence String?
provenance String?
tags String[]
links String[]
body String @default("")
validFrom DateTime?
validTo DateTime?
updatedAt DateTime @default(now())
syncedAt DateTime @updatedAt
@@index([type])
@@index([status])
}
+307
View File
@@ -0,0 +1,307 @@
import { PrismaClient } from '@prisma/client'
import * as fs from 'fs'
import * as path from 'path'
const prisma = new PrismaClient()
const DATA_DIR = './data'
function readJson(filename: string): unknown {
const p = path.join(DATA_DIR, filename)
if (!fs.existsSync(p)) { console.warn(`⚠️ Missing: ${filename}`); return null }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
}
function safeDate(val: unknown, fallback = new Date()): Date {
if (!val) return fallback
const d = new Date(val as string)
return isNaN(d.getTime()) ? fallback : d
}
async function seedDrafts() {
const raw = readJson('drafts.json') as any[]
if (!raw) return
console.log(`📝 Seeding ${raw.length} drafts...`)
for (const d of raw) {
const feedback = d.feedback ?? {}
await prisma.draft.upsert({
where: { id: d.id },
update: {},
create: {
id: d.id,
text: d.text ?? '',
title: d.title ?? null,
model: d.model ?? null,
status: d.status ?? 'pending',
type: d.type ?? null,
category: d.category ?? null,
hookType: d.hookType ?? null,
feedbackRating: feedback.rating ?? null,
feedbackReason: feedback.reason ?? null,
viralScore: d.viralScore ?? undefined,
impressionScore: d.impressionScore ?? undefined,
scheduledDate: d.scheduledDate ?? null,
scheduledTime: d.scheduledTime ?? null,
visualUrl: d.visualUrl ?? null,
variantGroup: d.variantGroup ?? null,
postedAt: d.postedAt ? safeDate(d.postedAt) : null,
postedUrl: d.postedUrl ?? null,
tweetUrl: d.tweetUrl ?? null,
tweetId: d.tweetId ?? null,
metrics: d.metrics ?? undefined,
editHistory: d.editHistory ?? [],
createdAt: safeDate(d.createdAt),
updatedAt: safeDate(d.updatedAt),
},
})
}
console.log(` ✅ ${raw.length} drafts done`)
}
async function seedIdeas() {
const raw = readJson('ideas.json') as any[]
if (!raw) return
console.log(`💡 Seeding ${raw.length} ideas...`)
for (const d of raw) {
await prisma.idea.upsert({
where: { id: d.id },
update: {},
create: {
id: d.id,
title: d.title ?? '',
description: d.description ?? null,
category: d.category ?? null,
type: d.type ?? null,
model: d.model ?? null,
status: d.status ?? null,
timestamp: safeDate(d.timestamp),
},
})
}
console.log(` ✅ ${raw.length} ideas done`)
}
async function seedLongformScripts() {
const raw = readJson('longform-scripts.json') as any[]
if (!raw) return
console.log(`📄 Seeding ${raw.length} longform scripts...`)
for (const d of raw) {
await prisma.longformScript.upsert({
where: { id: d.id },
update: {},
create: {
id: d.id,
title: d.title ?? '',
type: d.type ?? null,
status: d.status ?? 'draft',
platform: d.platform ?? null,
platforms: d.platforms ?? undefined,
description: d.description ?? null,
hook: d.hook ?? null,
outline: d.outline ?? null,
fullScript: d.fullScript ?? null,
targetLength: d.targetLength ?? null,
factCheck: d.factCheck ?? undefined,
notes: d.notes ?? null,
createdAt: safeDate(d.createdAt),
updatedAt: safeDate(d.updatedAt),
},
})
}
console.log(` ✅ ${raw.length} longform scripts done`)
}
async function seedYoutubeScripts() {
const raw = readJson('youtube-scripts.json') as any[]
if (!raw) return
console.log(`🎬 Seeding ${raw.length} YouTube scripts...`)
for (const d of raw) {
await prisma.youtubeScript.upsert({
where: { id: d.id },
update: {},
create: {
id: d.id,
title: d.title ?? '',
hook: d.hook ?? null,
storySetup: d.storySetup ?? null,
conflict: d.conflict ?? null,
insight: d.insight ?? null,
cta: d.cta ?? null,
caption: d.caption ?? null,
onScreenText: d.onScreenText ?? null,
hookType: d.hookType ?? null,
funnelStage: d.funnelStage ?? null,
factCheck: d.factCheck ?? undefined,
status: d.status ?? 'draft',
rejectedReason: d.rejectedReason ?? null,
createdAt: safeDate(d.createdAt),
updatedAt: safeDate(d.updatedAt),
},
})
}
console.log(` ✅ ${raw.length} YouTube scripts done`)
}
async function seedYoutubeIdeas() {
const raw = readJson('youtube-ideas.json') as any[]
if (!raw) return
console.log(`🎯 Seeding ${raw.length} YouTube ideas...`)
// Delete and re-insert (no stable IDs in source file)
await prisma.youtubeIdea.deleteMany()
for (const d of raw) {
await prisma.youtubeIdea.create({
data: {
title: d.title ?? '',
hook: d.hook ?? null,
angle: d.angle ?? null,
hookType: d.hookType ?? null,
funnelStage: d.funnelStage ?? null,
status: d.status ?? 'pending',
rejectedReason: d.rejectedReason ?? null,
createdAt: safeDate(d.createdAt),
},
})
}
console.log(` ✅ ${raw.length} YouTube ideas done`)
}
async function seedYoutubeFeedback() {
const raw = readJson('youtube-feedback.json') as any[]
if (!raw) return
console.log(`💬 Seeding ${raw.length} YouTube feedback...`)
await prisma.youtubeFeedback.deleteMany()
for (const d of raw) {
await prisma.youtubeFeedback.create({
data: {
title: d.title ?? '',
reason: d.reason ?? null,
type: d.type ?? null,
date: safeDate(d.date),
},
})
}
console.log(` ✅ ${raw.length} YouTube feedback done`)
}
async function seedTweetMetrics() {
const raw = readJson('tweet-metrics.json') as any[]
if (!raw) return
console.log(`📊 Seeding ${raw.length} tweet metrics...`)
await prisma.tweetMetric.deleteMany()
for (const d of raw) {
await prisma.tweetMetric.create({
data: {
draftId: d.draftId ?? '',
tweetId: d.tweetId ?? null,
url: d.url ?? null,
postedAt: d.postedAt ? safeDate(d.postedAt) : null,
checkpoints: d.checkpoints ?? undefined,
createdAt: safeDate(d.createdAt),
},
})
}
console.log(` ✅ ${raw.length} tweet metrics done`)
}
async function seedAgentState() {
const raw = readJson('agent-state.json') as any[]
if (!raw) return
console.log(`🤖 Seeding ${raw.length} agent states...`)
for (const d of raw) {
await prisma.agentState.upsert({
where: { id: d.id },
update: {
name: d.name,
emoji: d.emoji ?? null,
role: d.role ?? null,
status: d.status ?? 'offline',
lastActive: d.lastActive ? safeDate(d.lastActive) : null,
tasksCompleted: d.tasksCompleted ?? 0,
totalCost: d.totalCost ?? 0,
currentTask: d.currentTask ?? null,
recentActivity: d.recentActivity ?? [],
updatedAt: new Date(),
},
create: {
id: d.id,
name: d.name,
emoji: d.emoji ?? null,
role: d.role ?? null,
status: d.status ?? 'offline',
lastActive: d.lastActive ? safeDate(d.lastActive) : null,
tasksCompleted: d.tasksCompleted ?? 0,
totalCost: d.totalCost ?? 0,
currentTask: d.currentTask ?? null,
recentActivity: d.recentActivity ?? [],
updatedAt: new Date(),
},
})
}
console.log(` ✅ ${raw.length} agent states done`)
}
async function seedContentCalendar() {
const raw = readJson('content-calendar.json') as any
if (!raw) return
// File is { week: "...", days: [...] }
const week = raw.week
if (!week) { console.warn('⚠️ content-calendar.json missing week field'); return }
console.log(`📅 Seeding content calendar (week ${week})...`)
await prisma.contentCalendar.upsert({
where: { week },
update: { data: raw },
create: { week, data: raw },
})
console.log(` ✅ Calendar done`)
}
async function seedContentRequests() {
const raw = readJson('content-requests.json') as any[]
if (!raw || !raw.length) return
console.log(`📬 Seeding ${raw.length} content requests...`)
for (const d of raw) {
await prisma.contentRequest.upsert({
where: { id: d.id },
update: {},
create: {
id: d.id,
prompt: d.prompt ?? '',
status: d.status ?? 'pending',
resultDraftIds: d.resultDraftIds ?? undefined,
createdAt: safeDate(d.createdAt),
completedAt: d.completedAt ? safeDate(d.completedAt) : null,
},
})
}
console.log(` ✅ ${raw.length} content requests done`)
}
async function main() {
console.log('🌱 Starting Max HQ data seed...\n')
await seedAgentState()
await seedDrafts()
await seedIdeas()
await seedLongformScripts()
await seedYoutubeScripts()
await seedYoutubeIdeas()
await seedYoutubeFeedback()
await seedTweetMetrics()
await seedContentCalendar()
await seedContentRequests()
console.log('\n✅ All done! Max HQ database is fully seeded.')
}
main()
.catch((e) => { console.error('❌ Seed failed:', e); process.exit(1) })
.finally(() => prisma.$disconnect())
View File
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+325
View File
@@ -0,0 +1,325 @@
const https = require('https');
const fs = require('fs');
const path = require('path');
// ── Config ──────────────────────────────────────────────────────────────────────
const NICHES = {
'openclaw': { label: '🦞 OpenClaw & AI Agents', queries: ['OpenClaw tutorial', 'build AI agents OpenClaw', 'run AI agents locally 2026'] },
'polymarket-trading': { label: '📈 Polymarket & Trading Bots', queries: ['Polymarket trading bot', 'prediction market bot', 'AI trading bot build 2026'] },
'ai-agent-build': { label: '🤖 AI Agent Builds', queries: ['build AI agent no code', 'AI automation agent tutorial', 'how to build AI employees'] },
'agency-growth': { label: '🏢 Agency & Clients', queries: ['marketing agency growth', 'how to grow a marketing agency', 'agency owner content creator'] },
'ai-tools': { label: '🛠️ AI for Creators', queries: ['AI tools content creators', 'AI automation marketing', 'AI replace marketing team'] },
};
const MAX_CHANNEL_SUBS = 1000000;
const MIN_OUTLIER_SCORE = 1.5;
const DAYS_AGO = 30;
const MAX_RESULTS = 15;
// ── Load API key from .env.local ────────────────────────────────────────────────
function loadApiKey() {
const envPath = path.join(__dirname, '..', '.env.local');
if (!fs.existsSync(envPath)) {
console.error('ERROR: .env.local not found at', envPath);
process.exit(1);
}
const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('YOUTUBE_API_KEY=')) {
const val = trimmed.slice('YOUTUBE_API_KEY='.length).trim();
return val.replace(/^["']|["']$/g, '');
}
}
console.error('ERROR: YOUTUBE_API_KEY not found in .env.local');
process.exit(1);
}
// Add one or more YouTube Data API keys here (or via YOUTUBE_API_KEY in .env.local).
// Multiple keys are rotated when quota is hit.
const API_KEYS = [loadApiKey()].filter(Boolean);
let currentKeyIndex = 0;
function getApiKey() { return API_KEYS[currentKeyIndex]; }
function rotateKey() {
currentKeyIndex = (currentKeyIndex + 1) % API_KEYS.length;
console.log(` ⚡ Rotated to API key ${currentKeyIndex + 1}`);
}
// ── HTTP helper ─────────────────────────────────────────────────────────────────
function fetchJSON(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error('JSON parse error: ' + data.slice(0, 200))); }
});
}).on('error', reject);
});
}
// ── ISO 8601 duration parser ────────────────────────────────────────────────────
function parseDuration(iso) {
if (!iso) return 0;
const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return 0;
const h = parseInt(match[1] || '0', 10);
const m = parseInt(match[2] || '0', 10);
const s = parseInt(match[3] || '0', 10);
return h * 3600 + m * 60 + s;
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
function median(arr) {
if (arr.length === 0) return 0;
const sorted = [...arr].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : Math.floor((sorted[mid - 1] + sorted[mid]) / 2);
}
// ── YouTube API calls ───────────────────────────────────────────────────────────
async function searchVideos(query, publishedAfter) {
const url = `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(query)}&type=video&order=relevance&publishedAfter=${publishedAfter}&maxResults=${MAX_RESULTS}&key=${getApiKey()}`;
const data = await fetchJSON(url);
if (data.error) {
if (data.error.message && data.error.message.includes('quota')) {
rotateKey();
const url2 = `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(query)}&type=video&order=relevance&publishedAfter=${publishedAfter}&maxResults=${MAX_RESULTS}&key=${getApiKey()}`;
const data2 = await fetchJSON(url2);
if (data2.error) { console.error(' Search API error (both keys exhausted):', data2.error.message); return []; }
return (data2.items || []).map((item) => ({ id: item.id.videoId, title: item.snippet.title, publishedAt: item.snippet.publishedAt, channelId: item.snippet.channelId, channelTitle: item.snippet.channelTitle, thumbnail: item.snippet.thumbnails.high ? item.snippet.thumbnails.high.url : item.snippet.thumbnails.default.url }));
}
console.error(' Search API error:', data.error.message);
return [];
}
return (data.items || []).map((item) => ({
id: item.id.videoId,
title: item.snippet.title,
publishedAt: item.snippet.publishedAt,
channelId: item.snippet.channelId,
channelTitle: item.snippet.channelTitle,
thumbnail: item.snippet.thumbnails.high ? item.snippet.thumbnails.high.url : item.snippet.thumbnails.default.url,
}));
}
async function getVideoDetails(videoIds) {
if (videoIds.length === 0) return {};
const url = `https://www.googleapis.com/youtube/v3/videos?part=statistics,contentDetails&id=${videoIds.join(',')}&key=${getApiKey()}`;
const data = await fetchJSON(url);
if (data.error) {
console.error(' Video details API error:', data.error.message);
return {};
}
const map = {};
for (const item of (data.items || [])) {
const dur = parseDuration(item.contentDetails.duration);
map[item.id] = {
views: parseInt(item.statistics.viewCount || '0', 10),
likes: parseInt(item.statistics.likeCount || '0', 10),
comments: parseInt(item.statistics.commentCount || '0', 10),
duration: formatDuration(dur),
durationSeconds: dur,
};
}
return map;
}
async function getChannelInfo(channelId) {
const url = `https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet,contentDetails&id=${channelId}&key=${getApiKey()}`;
const data = await fetchJSON(url);
if (data.error || !data.items || data.items.length === 0) return null;
const ch = data.items[0];
return {
subs: parseInt(ch.statistics.subscriberCount || '0', 10),
uploadsPlaylistId: ch.contentDetails.relatedPlaylists.uploads,
thumb: ch.snippet.thumbnails.default ? ch.snippet.thumbnails.default.url : '',
};
}
async function getChannelMedianViews(uploadsPlaylistId) {
const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails&playlistId=${uploadsPlaylistId}&maxResults=30&key=${getApiKey()}`;
const data = await fetchJSON(url);
if (data.error || !data.items || data.items.length === 0) return 0;
const videoIds = data.items.map((i) => i.contentDetails.videoId);
// Batch in groups of 50 (API limit)
const allViews = [];
for (let i = 0; i < videoIds.length; i += 50) {
const batch = videoIds.slice(i, i + 50);
const vUrl = `https://www.googleapis.com/youtube/v3/videos?part=statistics&id=${batch.join(',')}&key=${getApiKey()}`;
const vData = await fetchJSON(vUrl);
if (vData.items) {
for (const v of vData.items) {
allViews.push(parseInt(v.statistics.viewCount || '0', 10));
}
}
}
return median(allViews);
}
// ── Main scanner ────────────────────────────────────────────────────────────────
async function main() {
console.log('=== YouTube Outlier Scanner ===');
console.log(`Scanning ${Object.keys(NICHES).length} niches, ${Object.values(NICHES).reduce((s, n) => s + n.queries.length, 0)} queries total\n`);
const publishedAfter = new Date(Date.now() - DAYS_AGO * 24 * 60 * 60 * 1000).toISOString();
const seenIds = new Set();
const allVideos = [];
const nicheCounts = {};
const channelCache = {}; // channelId -> { subs, medianViews, thumb }
for (const [nicheKey, niche] of Object.entries(NICHES)) {
console.log(`\n── ${niche.label} ──`);
nicheCounts[nicheKey] = { label: niche.label, count: 0 };
for (const query of niche.queries) {
console.log(` Searching: "${query}"`);
const results = await searchVideos(query, publishedAfter);
if (results.length === 0) { console.log(' No results'); continue; }
// Filter already-seen
const fresh = results.filter((v) => !seenIds.has(v.id));
if (fresh.length === 0) { console.log(' All duplicates, skipping'); continue; }
// Get video details
const details = await getVideoDetails(fresh.map((v) => v.id));
for (const video of fresh) {
if (seenIds.has(video.id)) continue;
const det = details[video.id];
if (!det) continue;
// Skip Shorts (<=90s)
if (det.durationSeconds <= 90) continue;
// Get/cache channel info
if (!channelCache[video.channelId]) {
const chInfo = await getChannelInfo(video.channelId);
if (!chInfo) continue;
if (chInfo.subs > MAX_CHANNEL_SUBS) {
channelCache[video.channelId] = { subs: chInfo.subs, skip: true };
continue;
}
const medViews = await getChannelMedianViews(chInfo.uploadsPlaylistId);
channelCache[video.channelId] = { subs: chInfo.subs, medianViews: medViews, thumb: chInfo.thumb, skip: false };
}
const ch = channelCache[video.channelId];
if (ch.skip) continue;
if (ch.medianViews === 0) continue;
const outlierScore = Math.round((det.views / ch.medianViews) * 10) / 10;
if (outlierScore < MIN_OUTLIER_SCORE) continue;
const daysSincePublish = Math.max(1, Math.floor((Date.now() - new Date(video.publishedAt).getTime()) / (1000 * 60 * 60 * 24)));
const velocityScore = Math.round(det.views / daysSincePublish); // views per day
seenIds.add(video.id);
nicheCounts[nicheKey].count++;
allVideos.push({
id: video.id,
title: video.title,
publishedAt: video.publishedAt,
thumbnail: video.thumbnail,
views: det.views,
likes: det.likes,
comments: det.comments,
duration: det.duration,
durationSeconds: det.durationSeconds,
outlierScore,
velocityScore,
daysSincePublish,
niche: nicheKey,
nicheLabel: niche.label,
query,
channelId: video.channelId,
channelTitle: video.channelTitle,
channelSubs: ch.subs,
channelMedianViews: ch.medianViews,
channelThumb: ch.thumb,
});
console.log(` ✓ ${outlierScore}x | ${det.views.toLocaleString()} views | ${video.title.slice(0, 60)}`);
}
}
}
// Sort by outlierScore descending
allVideos.sort((a, b) => b.outlierScore - a.outlierScore);
const now = new Date();
const dateStr = now.toISOString().slice(0, 10);
const output = {
date: dateStr,
scannedAt: now.toISOString(),
totalOutliers: allVideos.length,
nicheCounts,
videos: allVideos,
};
// Save to data/
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const datePath = path.join(dataDir, `${dateStr}.json`);
const latestPath = path.join(dataDir, 'latest.json');
fs.writeFileSync(datePath, JSON.stringify(output, null, 2));
fs.writeFileSync(latestPath, JSON.stringify(output, null, 2));
console.log(`\n=== Done ===`);
console.log(`Total outliers: ${allVideos.length}`);
console.log(`Saved to: ${datePath}`);
console.log(`Saved to: ${latestPath}`);
for (const [k, v] of Object.entries(nicheCounts)) {
console.log(` ${v.label}: ${v.count}`);
}
// ── Sync to Prisma DB ───────────────────────────────────────────────────────
console.log('\n── Syncing to database…');
try {
const { Client } = require('pg');
// Try .env.production.local first, then .env.local
let dbUrl = '';
for (const envFile of ['.env.production.local', '.env.local']) {
const envPath = path.join(__dirname, '..', envFile);
if (!fs.existsSync(envPath)) continue;
const envLines = fs.readFileSync(envPath, 'utf-8').split('\n');
for (const line of envLines) {
const t = line.trim();
if (t.startsWith('DATABASE_URL=')) {
dbUrl = t.slice('DATABASE_URL='.length).trim().replace(/^["']|["']$/g, '');
break;
}
}
if (dbUrl) break;
}
const client = new Client({ connectionString: dbUrl, ssl: { rejectUnauthorized: false } });
await client.connect();
const dataJson = JSON.stringify(output);
await client.query(
`INSERT INTO "DataStore" (key, data, "updatedAt")
VALUES ($1, $2::jsonb, NOW())
ON CONFLICT (key) DO UPDATE SET data = $2::jsonb, "updatedAt" = NOW()`,
['youtube-outliers', dataJson]
);
await client.end();
console.log('✓ Database synced successfully');
} catch (err) {
console.error('✗ DB sync failed:', err.message);
console.error(' (Local files still saved — run sync manually)');
}
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+4932
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.4", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.0", features = [] }
tauri-plugin-log = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+16
View File
@@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Hermy HQ",
"version": "0.1.0",
"identifier": "com.hermyhq.app",
"build": {
"frontendDist": "../out",
"devUrl": "http://localhost:3000",
"beforeDevCommand": "",
"beforeBuildCommand": ""
},
"app": {
"windows": [
{
"title": "Hermy HQ",
"width": 1200,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+393
View File
@@ -0,0 +1,393 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import OfficeView from "@/components/OfficeView";
interface AgentActivity {
timestamp: string;
action: string;
result?: string;
}
interface Agent {
id: string;
name: string;
emoji: string;
role: string;
status: "idle" | "working" | "error" | "offline";
currentTask?: string;
lastActive?: string;
tasksCompleted: number;
totalCost: number;
recentActivity: AgentActivity[];
}
const statusConfig: Record<string, { color: string; dot: string; label: string; pulse?: boolean }> = {
idle: { color: "var(--warn)", dot: "var(--warn)", label: "Idle" },
working: { color: "var(--accent)", dot: "var(--accent)", label: "Working", pulse: true },
error: { color: "var(--down)", dot: "var(--down)", label: "Error" },
offline: { color: "var(--text-3)", dot: "var(--text-4)", label: "Offline" },
online: { color: "var(--up)", dot: "var(--up)", label: "Online", pulse: true },
active: { color: "var(--up)", dot: "var(--up)", label: "Active", pulse: true },
mixed: { color: "var(--warn)", dot: "var(--warn)", label: "Partial" },
};
const roleColors: Record<string, string> = {
max: "from-amber-500/20 to-amber-600/5 border-amber-500/20",
sage: "from-sky-500/20 to-sky-600/5 border-sky-500/20",
knox: "from-emerald-500/20 to-emerald-600/5 border-emerald-500/20",
nova: "from-purple-500/20 to-purple-600/5 border-purple-500/20",
pixel: "from-blue-500/20 to-blue-600/5 border-blue-500/20",
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function AgentCard({ agent, isExpanded, onToggle }: { agent: Agent; isExpanded: boolean; onToggle: () => void }) {
const status = statusConfig[agent.status] || statusConfig.offline;
return (
<div className="panel panel-interactive overflow-hidden">
{/* Main card */}
<div className="p-5 cursor-pointer" onClick={onToggle}>
<div className="flex items-start gap-3.5">
{/* Avatar */}
<div className="w-12 h-12 rounded-[var(--r-md)] flex items-center justify-center text-2xl shrink-0"
style={{ background: "var(--surface-2)", border: "1px solid var(--line)" }}>
{agent.emoji}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="relative flex w-2 h-2 shrink-0">
{status.pulse && <span className="absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping" style={{ background: status.dot }} />}
<span className="relative inline-flex w-2 h-2 rounded-full" style={{ background: status.dot }} />
</span>
<h3 className="text-[14px] font-semibold text-[var(--text)]">{agent.name}</h3>
<span className="text-[10px] font-medium" style={{ color: status.color }}>{status.label}</span>
</div>
<p className="text-[12px] text-[var(--text-3)] mt-1">{agent.role}</p>
{/* Current task */}
{agent.currentTask && agent.status === "working" && (
<p className="text-[12px] mt-2 truncate" style={{ color: "var(--accent)" }}>{agent.currentTask}</p>
)}
</div>
{/* Stats */}
<div className="text-right shrink-0">
<div className="num text-[22px] font-semibold text-[var(--text)] leading-none">{agent.tasksCompleted}</div>
<div className="eyebrow mt-1.5">tasks</div>
{agent.lastActive && (
<div className="num text-[10px] text-[var(--text-4)] mt-1">{timeAgo(agent.lastActive)}</div>
)}
</div>
</div>
</div>
{/* Expanded activity feed */}
{isExpanded && (
<div className="px-5 py-4 space-y-2.5" style={{ borderTop: "1px solid var(--line)" }}>
<h4 className="eyebrow">Recent Activity</h4>
{agent.recentActivity.length === 0 ? (
<p className="text-[12px] text-[var(--text-3)] py-2">No activity yet</p>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto">
{agent.recentActivity.slice(0, 10).map((activity, i) => (
<div key={i} className="flex items-start gap-2.5 text-[12px]">
<span className="num text-[var(--text-4)] shrink-0 w-14">{timeAgo(activity.timestamp)}</span>
<span className="text-[var(--text-2)]">{activity.action}</span>
{activity.result && (
<span className="text-[var(--text-3)] ml-auto truncate max-w-[200px]">{activity.result}</span>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
// ── Live Agent Chat ───────────────────────────────────────
function AgentChat({ agent, onClose }: { agent: Agent; onClose: () => void }) {
const [input, setInput] = useState("");
const [msgs, setMsgs] = useState<{ role: "user"|"assistant"; content: string }[]>([]);
const [loading, setLoading] = useState(false);
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [msgs]);
async function send() {
const text = input.trim();
if (!text || loading) return;
setInput("");
const newMsgs = [...msgs, { role: "user" as const, content: text }];
setMsgs(newMsgs);
setLoading(true);
try {
const r = await fetch("/api/agent-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId: agent.id, message: text, history: msgs }),
});
const d = await r.json() as { reply: string };
setMsgs([...newMsgs, { role: "assistant", content: d.reply }]);
} catch {
setMsgs([...newMsgs, { role: "assistant", content: "Sorry, something went wrong. Try again." }]);
}
setLoading(false);
}
const agentColor = roleColors[agent.id]?.split(" ")[0]?.replace("from-","text-")?.replace("/20","") || "text-[var(--text-3)]";
return (
<div className="fixed inset-0 z-50 flex items-end md:items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div className="elevated w-full max-w-lg overflow-hidden" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
<div className="text-2xl">{agent.emoji}</div>
<div>
<div className="text-[14px] font-semibold text-[var(--text)]">{agent.name}</div>
<div className="text-[12px] text-[var(--text-3)]">{agent.role}</div>
</div>
<button onClick={onClose} className="ml-auto text-[var(--text-3)] hover:text-[var(--text)] transition-colors text-xl leading-none">×</button>
</div>
{/* Messages */}
<div className="h-80 overflow-y-auto p-4 space-y-3 flex flex-col" style={{ background: "var(--surface-1)" }}>
{msgs.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<p className="text-[var(--text-3)] text-[13px] text-center">Ask {agent.name} anything.<br/>They&apos;re ready.</p>
</div>
)}
{msgs.map((m, i) => (
<div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
<div className="max-w-[80%] rounded-[var(--r-md)] px-3.5 py-2 text-[13px] leading-relaxed"
style={m.role === "user"
? { background: "var(--surface-3)", color: "var(--text)" }
: { background: "var(--surface-2)", border: "1px solid var(--line)", color: "var(--text-2)" }}>
{m.role === "assistant" && <span className="text-xs mr-1">{agent.emoji}</span>}
{m.content}
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="rounded-[var(--r-md)] px-3.5 py-2" style={{ background: "var(--surface-2)", border: "1px solid var(--line)" }}>
<span className="text-[var(--text-3)] text-[13px]">{agent.emoji} thinking…</span>
</div>
</div>
)}
<div ref={endRef} />
</div>
{/* Input */}
<div className="flex gap-2 p-3" style={{ borderTop: "1px solid var(--line)" }}>
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && !e.shiftKey && send()}
placeholder={`Message ${agent.name}…`}
className="flex-1 rounded-full px-4 py-2 text-[13px] text-[var(--text)] focus:outline-none transition-colors"
style={{ background: "var(--surface-1)", border: "1px solid var(--line)" }}
/>
<button
onClick={send}
disabled={!input.trim() || loading}
className="btn-primary px-4 py-2 text-[13px]"
>Send</button>
</div>
</div>
</div>
);
}
export default function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
const [view, setView] = useState<"cards" | "office">("office");
const [chatAgent, setChatAgent] = useState<Agent | null>(null);
const loadAgents = useCallback(async () => {
try {
const res = await fetch("/api/agents");
const data = await res.json();
setAgents(Array.isArray(data) ? data : []);
} catch {}
setLoading(false);
}, []);
useEffect(() => {
loadAgents();
const interval = setInterval(loadAgents, 10000); // poll every 10s
return () => clearInterval(interval);
}, [loadAgents]);
if (loading) {
return (
<div className="relative min-h-screen p-8">
<div className="relative z-10 w-full mx-auto grid grid-cols-1 md:grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => <div key={i} className="sk h-32 rounded-[var(--r-lg)]" />)}
</div>
</div>
);
}
const maxAgent = agents.find(a => a.id === "max");
const teamAgents = agents.filter(a => a.id !== "max");
const online = agents.filter(a => a.status !== "offline").length;
const working = agents.filter(a => a.status === "working").length;
const totalTasks = agents.reduce((sum, a) => sum + a.tasksCompleted, 0);
return (
<>
<div className="relative z-10 w-full mx-auto text-[var(--text)] p-8 pb-16 space-y-8">
{/* Header */}
<div className="hq-rise flex flex-wrap items-end justify-between gap-4">
<div>
<div className="eyebrow mb-2.5">Agent HQ</div>
<h1 className="text-[32px] font-semibold tracking-[-0.025em] leading-none text-[var(--text)]">Your AI Team</h1>
<p className="text-[13px] text-[var(--text-3)] mt-3">Working 24/7</p>
</div>
<div className="flex items-center gap-6">
{/* Stats */}
<div className="flex gap-7 text-center">
<div>
<div className="num text-[22px] font-semibold leading-none" style={{ color: "var(--up)" }}>{online}<span className="text-[var(--text-4)]">/{agents.length}</span></div>
<div className="eyebrow mt-1.5">Online</div>
</div>
<div>
<div className="num text-[22px] font-semibold leading-none" style={{ color: "var(--accent)" }}>{working}</div>
<div className="eyebrow mt-1.5">Working</div>
</div>
<div>
<div className="num text-[22px] font-semibold leading-none text-[var(--text)]">{totalTasks}</div>
<div className="eyebrow mt-1.5">Total Tasks</div>
</div>
</div>
{/* View toggle */}
<div className="flex rounded-full p-1 gap-1" style={{ border: "1px solid var(--line)" }}>
<button
onClick={() => setView("office")}
className={`px-3.5 py-1.5 rounded-full text-[12px] font-medium transition-colors ${
view === "office"
? "bg-white/[0.08] text-[var(--text)]"
: "text-[var(--text-3)] hover:text-[var(--text-2)]"
}`}
>
Office
</button>
<button
onClick={() => setView("cards")}
className={`px-3.5 py-1.5 rounded-full text-[12px] font-medium transition-colors ${
view === "cards"
? "bg-white/[0.08] text-[var(--text)]"
: "text-[var(--text-3)] hover:text-[var(--text-2)]"
}`}
>
Cards
</button>
</div>
</div>
</div>
{/* Live Agent Chat Modal */}
{chatAgent && <AgentChat agent={chatAgent} onClose={() => setChatAgent(null)} />}
{/* Office View */}
{view === "office" && (
<>
<OfficeView agents={agents} />
{/* Chat quick-launch strip */}
<div className="flex flex-wrap gap-2 pt-2">
{agents.filter(a => a.id !== "max").map(a => (
<button key={a.id} onClick={() => setChatAgent(a)}
className="flex items-center gap-2 px-3.5 py-1.5 rounded-full text-[12px] text-[var(--text-2)] transition-colors panel-interactive"
style={{ background: "var(--surface-1)", border: "1px solid var(--line)" }}>
<span>{a.emoji}</span> Chat with {a.name}
</button>
))}
{agents.find(a => a.id === "max") && (
<button onClick={() => setChatAgent(agents.find(a => a.id === "max")!)}
className="flex items-center gap-2 px-3.5 py-1.5 rounded-full text-[12px] transition-colors"
style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 10%, transparent)", border: "1px solid color-mix(in srgb, var(--accent) 28%, transparent)" }}>
🐺 Chat with Max
</button>
)}
</div>
</>
)}
{/* Cards View */}
{view === "cards" && (
<>
{/* Chief of Staff (Max) — full width */}
{maxAgent && (
<AgentCard
agent={maxAgent}
isExpanded={expandedAgent === maxAgent.id}
onToggle={() => setExpandedAgent(expandedAgent === maxAgent.id ? null : maxAgent.id)}
/>
)}
{/* Team grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{teamAgents.map(agent => (
<AgentCard
key={agent.id}
agent={agent}
isExpanded={expandedAgent === agent.id}
onToggle={() => setExpandedAgent(expandedAgent === agent.id ? null : agent.id)}
/>
))}
</div>
{/* Org chart visual */}
<div className="pt-6" style={{ borderTop: "1px solid var(--line)" }}>
<div className="eyebrow mb-5">Team Structure</div>
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2.5 rounded-[var(--r-md)] px-4 py-2.5"
style={{ background: "color-mix(in srgb, var(--accent) 10%, transparent)", border: "1px solid color-mix(in srgb, var(--accent) 24%, transparent)" }}>
<span className="text-xl">🐺</span>
<div>
<div className="text-[13px] font-semibold text-[var(--text)]">Max</div>
<div className="text-[10px] text-[var(--text-3)]">Chief of Staff · Orchestrator</div>
</div>
</div>
<div className="w-px h-6" style={{ background: "var(--line-strong)" }} />
<div className="flex items-center gap-0">
<div className="w-32 h-px" style={{ background: "var(--line-strong)" }} />
<div className="w-px h-4" style={{ background: "var(--line-strong)" }} />
<div className="w-32 h-px" style={{ background: "var(--line-strong)" }} />
<div className="w-px h-4" style={{ background: "var(--line-strong)" }} />
<div className="w-32 h-px" style={{ background: "var(--line-strong)" }} />
</div>
<div className="flex flex-wrap justify-center gap-3">
{teamAgents.map(agent => (
<div key={agent.id} className="flex items-center gap-2.5 rounded-[var(--r-md)] px-3.5 py-2.5"
style={{ background: "var(--surface-1)", border: "1px solid var(--line)", opacity: agent.status === "offline" ? 0.5 : 1 }}>
<span className="text-lg">{agent.emoji}</span>
<div>
<div className="text-[12px] font-semibold text-[var(--text)]">{agent.name}</div>
<div className="text-[10px] text-[var(--text-3)]">{agent.role}</div>
</div>
</div>
))}
</div>
</div>
</div>
</>
)}
</div>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More