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>
@@ -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"
|
||||||
@@ -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/
|
||||||
@@ -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*
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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); });
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -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 |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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 |
@@ -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 |
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 922 B |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -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");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||