diff --git a/.env.example b/.env.example index f0f20d76..a4067964 100644 --- a/.env.example +++ b/.env.example @@ -1,92 +1,12 @@ -# FOUNDER OS — copy to .env.local and fill in. .env.local is gitignored. - -# ── Email (up to 4 IMAP inboxes) ───────────────────────────────────────── -# Gmail: use an App Password (Google Account → Security → 2FA → App passwords) -INBOX_1_HOST=imap.gmail.com -INBOX_1_USER= -INBOX_1_PASS= -INBOX_1_NAME= -# INBOX_1_PORT=993 # optional, defaults to 993 -INBOX_2_HOST= -INBOX_2_USER= -INBOX_2_PASS= -INBOX_3_HOST= -INBOX_3_USER= -INBOX_3_PASS= -INBOX_4_HOST= -INBOX_4_USER= -INBOX_4_PASS= - -# ── Slack ──────────────────────────────────────────────────────────────── -# Bot token (xoxb-…) with scopes: channels:read, channels:history, users:read -SLACK_BOT_TOKEN= - -# ── Payment processors ─────────────────────────────────────────────────── -STRIPE_SECRET_KEY= # full client implemented (balance + charges) -PAYPAL_CLIENT_ID= # registered; client lands when keys do -PAYPAL_CLIENT_SECRET= -SQUARE_ACCESS_TOKEN= -WHOP_API_KEY= - -# ── Notion ─────────────────────────────────────────────────────────────── -# Internal integration secret; share target pages with the integration -NOTION_API_KEY= - -# ── Launchpad Cohort ─────────────────────────────────────────────────── -WEBINARJAM_API_KEY= # account-wide key; pulls webinar registrants/attendees -TRAKYO_API_KEY= # revenue attribution; status-only until Trakyo ships an API -GHL_API_KEY= # GoHighLevel Private Integration token (Settings → Private Integrations, read scopes) -GHL_LOCATION_ID= # the LC sub-account location id (visible in the GHL URL: /v2/location//…) - -# ── G-Brain ────────────────────────────────────────────────────────────── -# Defaults work: gbrain CLI on PATH, store at knowledge/brain-store -# BRAIN_PROVIDER=gbrain # set to "stub" to disable -# GBRAIN_BIN= # override CLI path -# GBRAIN_STORE= # override brain-store path -# GBRAIN_DISABLE_DIRECT_POOL=1 - -# ── LLM (agent & Conductor chat via Vercel AI Gateway) ─────────────────── -# Without a key, agent chat reports "not configured" (no fake "connected"). -AI_GATEWAY_API_KEY= # Vercel AI Gateway key; enables agent chat -# LLM_PROVIDER=gateway # set to "stub" for deterministic offline tests -# LLM_MODEL=anthropic/claude-sonnet-4-6 # "provider/model" routed via gateway - -# ── Beehiiv (email list) ───────────────────────────────────────────────── -# Without both, the Social email-list card stays seeded dummy (no fake number). -BEEHIIV_API_KEY= # Beehiiv API key -BEEHIIV_PUBLICATION_ID= # publication id (pub_...) - -# ── ManyChat (Instagram DM automation) ─────────────────────────────────── -# Lights up the ManyChat connector on /integrations. ManyChat → Settings → API. -# The API cannot list DMs — the live /social DM inbox is fed by the webhook -# below, not by this key. -MANYCHAT_API_KEY= # ManyChat API key -# Optional shared secret: if set, /api/webhooks/manychat requires header -# `x-manychat-secret` to match (put the same value in the ManyChat External -# Request headers). Leave blank to accept unauthenticated posts (dev only). -MANYCHAT_WEBHOOK_SECRET= - -# ── Payments processors (Finances) ─────────────────────────────────────── -# Stripe is fully wired; the rest are honest "not configured" until keyed. -# STRIPE_SECRET_KEY= # sk_live_… / sk_test_… (already above if set) -PAYPAL_CLIENT_ID= -PAYPAL_CLIENT_SECRET= -FANBASIS_VANTAGE_KEY= # FanBasis · Vantage -FANBASIS_LC_KEY= # FanBasis · Launchpad Cohort -WISE_1_TOKEN= # Wise account 1 (income + outgoing transfers) -WISE_2_TOKEN= # Wise account 2 - -# ── Email send (SMTP, optional per inbox) ──────────────────────────────── -# Comms email replies send for real via SMTP using each inbox's user/pass. -# Defaults: host = INBOX_n_HOST with imap.→smtp., port 465. Override per slot: -# INBOX_1_SMTP_HOST=smtp.gmail.com -# INBOX_1_SMTP_PORT=465 - -# ── Comms lane routing (optional) ──────────────────────────────────────── -# Extra keywords that force a message into the /comms Work lane, comma-separated, -# matched against its inbox / sender / subject. Add your brands + key clients. -# Generic work signals (invoice, contract, proposal, …) are built in. -# COMMS_WORK_KEYWORDS=Acme,Northwind,Jane Client - -# ── App ────────────────────────────────────────────────────────────────── -# FOUNDER_OS_DB= # override SQLite path +# Real provider integration (V2J.1) +# Copy only the variable NAMES you need into .env.local. +# NEVER commit real values. + +ANTHROPIC_API_KEY= +ANTHROPIC_MODEL=claude-sonnet-5 +TAVILY_API_KEY= +ALPHA_VANTAGE_API_KEY= + +# Existing optional runtime paths/secrets can remain in your local environment. +# STARTUP_CONTROL_PLANE_DB= +# STARTUP_RUNTIME_IDENTITY_SECRET= diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..3d12d5de --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,27 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Startup: Start Local Stack", + "type": "shell", + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}\\scripts\\start-local-stack.ps1" + ], + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + } + } + ] +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 283f56f0..9f6d6633 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,40 +1,45 @@ -# FOUNDER OS — agent rules +# STARTUP — Agent Rules -Full project docs live in **CLAUDE.md** (same directory) — read it first. -This file exists so non-Claude agents (Codex, etc.) get the same house rules. +Read `CLAUDE.md` and the canonical company specification before changing the system. + +Authoritative company files: + +- `company/company.yaml` +- `company/organization.yaml` +- `company/governance.yaml` +- `policies/memory_policy.yaml` ## Non-negotiables -- **Never commit or copy secrets.** Credentials live in `.env.local` - (gitignored) and Alex's canonical files; `lib/creds.ts` resolves them. - Never copy keys from `~/knowledge/.env.agents` into the repo. -- **Never push to any remote or touch `main` without Alex's explicit yes.** - Commit locally on `founder-os`, small checkpoints, often. -- **Don't kill the dev server on 4100 or 4101** — other sessions use them. - If your edit crashes the dev server's hot reload, fix it fast: a crash loop - corrupts `.next` and breaks every session's page chunks (kill the port, - `rm -rf .next`, restart). -- `/org` markup is frozen — do not restructure it. -- No em/en dashes in anything written for Alex. - -## How to work - -- TDD: failing test first (`tests/`, one file per module, - `FOUNDER_OS_DB=:memory:`), then implement. `npm test` and - `npm run typecheck` must be green before claiming done. -- Everything reads through the repo layer: `lib/db.ts` repos + `lib/schemas.ts` - Zod validation + `lib/seed.ts` seeds. Never query SQLite from a page/route. -- Theme via CSS vars on `data-theme` (five themes in `app/globals.css`); - Tailwind `os.*` tokens map to them. Keep `tailwind.config.ts` and - `globals.css` in sync. -- Commands: `npm run dev` (port 4100) · `npm test` · `npm run typecheck` · - `npm run seed` · `npm run brain:docs`. - -## Multi-agent etiquette - -Multiple agent sessions (Claude, Codex) work this repo concurrently: -- `git log --oneline` to see where others are; commit small and often. -- Coordinate by surface: don't edit a page/component another session has - uncommitted changes in (`git status` shows them). -- The Playwright browser is shared across sessions — expect interference. -- Leave handoff notes in `docs/` if you stop mid-feature. +- Never commit secrets. +- Never push to any remote without explicit human approval. +- Never invent operational company history or production data. +- Never give an AI agent unrestricted brokerage execution credentials. +- Never bypass governance or approval state transitions. +- Do not restore removed FounderOS functionality merely to satisfy an old test. + +## Engineering rules + +- Run `npm run typecheck`. +- Run `npm test`. +- Keep structured data validated with Zod. +- Use repository abstractions for operational persistence. +- Preserve useful generic infrastructure. +- Remove application-specific FounderOS assumptions. +- Treat empty Foundation Mode datasets as valid. + +## Architecture principle + +Agents propose actions. + +The deterministic Company Control Plane decides whether those actions are technically permitted. + +The human CEO retains final investment and execution authority. + +## Startup Brain + +Startup Brain is the company's institutional memory. + +Do not recreate or depend on the legacy FounderOS G-Brain. + +Raw market history belongs in market-data systems, not institutional memory. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a6b0ad23..4594fc18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,146 +1,183 @@ -# FOUNDER OS +# STARTUP — AI Investment Company -Personal OS / AI agent command center. Live web recreation of the FounderOS -"Conducting AI" board. Runs on port **4100** (command-center owns 4000). +AI-operated investment and personal portfolio management company. + +Current state: Foundation Mode. + +The repository is being rebuilt from FounderOS into the operating system of the investment company. ## Commands ```bash -npm run dev # dev server → http://localhost:4100 -npm test # vitest suite (must stay green) -npm run typecheck # tsc --noEmit -npm run seed # re-seed data/founder-os.db (idempotent) -npm run build && npm start -``` - -## Stack - -Next.js 14 App Router (server components) + TypeScript + Tailwind + -better-sqlite3 (`data/founder-os.db`, WAL, auto-seeded on first touch) + -Zod + Vitest. - -## Architecture: larp-first, real-ready - -This is the load-bearing design rule. v1 looks alive because of rich seeded -data, but every page and API route reads through the repository layer — never -query SQLite directly from a page or route: - -- `lib/data.ts` — `getDb()` app singleton; seeds on first touch -- `lib/db.ts` — `openDb()` + repos (`departments`, `agents`, `metrics`, `tools`, …) -- `lib/seed.ts` — all seeded content lives here -- `lib/schemas.ts` — Zod schemas validate every row on the way OUT of the DB - -Swapping seeded tables for live sources (Attio, Zernio, OpenClaw, MCP status) -is a repo-level change. Keep it that way: new data = new repo method + Zod -schema + seed entry + test. - -## G-Brain — ANSWERED (2026-06-11) - -G-Brain = **GBrain v0.41** (`gbrain` CLI on PATH): markdown -knowledge in `~/knowledge/brain-store/` + Supabase backend ("Second Brain", -free tier — pauses on idle) + ZeroEntropy embeddings (key in -`~/.config/knowledge/config.json`). The real provider in `lib/connectors/gbrain.ts` -shells out to the CLI (`doctor --json --fast`, `query --no-expand`) and falls -back to local brain-store grep when the database is unreachable. Default -`BRAIN_PROVIDER=gbrain`; `stub` exists for tests. - -## Real connectors & agents (v2) - -Alex's directive: real integrations, not larp. Strict black & white theme -(UI polish deferred — he'll design it himself once everything is wired). - -- `lib/connectors/` — 12 connector groups, all returning honest - `ConnectorStatus` (never fake "connected"): `email.ts` (4 IMAP slots), - `slack.ts`, `payments.ts` (Stripe + registry), `notion.ts`, `gbrain.ts`, - `zernio.ts` (key from ~/.config/social/.env — LIVE), `attio.ts` (key reused - from ~/.config/mcp.json mcpServers — LIVE), `arcads.ts` (local `.env` — - LIVE), `miro.ts` (knowledge/.env.agents — LIVE), - `wispr.ts` (local flow.sqlite readonly — LIVE), `obsidian.ts` (vault fs; - needs macOS Documents permission), `local-stack.ts` (local service ports - + tmux + brew binaries). -- `lib/creds.ts` — credential resolution: process.env first, then Alex's - canonical files at runtime. NEVER copy secret values into this repo. -- `lib/agents/runtime.ts` + `real.ts` — agent registry; every seeded agent row - maps 1:1 to a `RuntimeAgent` with a real `run()` (enforced by seed tests). - Runs persist to `agent_runs`. `POST /api/agents/[id]/run`. -- `/integrations` is the live Connections board (`GET /api/connections`). -- Credentials go in `.env.local` (gitignored) — see `.env.example`. NEVER - commit keys; never copy keys from `~/knowledge/.env.agents` into the repo. - -## Views - -`/` operator console (pulse row, connections strip, agent list, compact -G-Brain core) · `/comms` unified feed · `/social` Zernio growth dashboard · -`/agents` roster with Run buttons + last-run state · `/org` hierarchy board -(operator → Conductor super agent → 5 pillars: Sales, Marketing/Growth, TECH, -Finances, Communications → worker pills; broadcast composer; markup frozen — -do not restructure) · `/brain` G-Brain knowledge core (signature `BrainViz` -rings + live `gbrain ›` query card + doctor warnings, with the original -capture / life-map / pipeline / graph / query-path sections kept underneath) · -`/roadmap` phases + quarters · `/analytics` real connector numbers · -`/funnel` living client-journey flow (Vantage + Launchpad Cohort: stage -columns left→right, one node per client, 4–5 touch markers per path; seeded -dummy, real-ready for Trakyo organic + Meta Ads MCP paid attribution) · -`/reference` reference model · `/integrations` live connections board. Chrome: -fixed `Sidebar` (Operate/System groups) + sticky `Topbar` (breadcrumb + ⌘K) + -`CommandPalette` (⌘K, digit-key view jumps). API routes mirror these under -`app/api/*` — note `GET /api/brain?q=` runs a hybrid search; bare `GET` returns -provider status. - -## Cohort invite (demo growth surface) - -Copy + URL live once in `lib/cohort.ts` (`COHORT_URL`, `COHORT_CTA`, -`COHORT_STORAGE_KEY`) so the two placements can't drift: - -- `CohortBanner` — static footer CTA, rendered in `app/layout.tsx` right after - `{children}`, so it is the last thing on **every** view. No client JS. -- `CohortModal` — first-run welcome pop-up, home screen only, once per browser - (`shouldShowCohortModal`; dismissal persists to localStorage). Mounted beside - `ConductorPanel` in the layout; it gates itself on `usePathname()`. - -Contract lives in `tests/cohort.test.ts`. - -## Conventions - -- TDD: failing test first, then implementation. Tests live in `tests/`, - one file per module; use `FOUNDER_OS_DB=:memory:` pattern (see `tests/db.test.ts`). -- Zod-validate anything that crosses the DB or API boundary. -- THEME: **Monolith Signal (`mono`) is the default** (2026-07-12, - `DEFAULT_THEME` in `lib/theme.ts`; bare `:root` in `app/globals.css` carries - the mono tokens). "Terminal" (`dark`) — the phosphor-green command deck on - near-black — stays as a pickable colorway. Tokens live in - `tailwind.config.ts` (`os.*` colors) AND as raw CSS vars in - `app/globals.css` (the brain viz SVG + `color-mix` effects need `var()` - access; keep the two in sync). Terminal tokens: `bg #050807`, `surface - #0a0f0c`, `border #18211b` / `border-strong #243029`, `text #e4efe6` / - `muted #8fa295` / `dim #54665b`, `accent #3df08c` (phosphor green), honest - status colors `ok`/`warn #ffc53d`/`err #ff6259`. G-Brain viz uses its own - independent violet/cyan/green palette (`--brain-1/2/3`). Lettering (Monolith pass, - 2026-07-10): JetBrains Mono everywhere — `font-sans` and `font-mono` both - resolve to `--font-mono`; Space Grotesk is retired. Page titles 25px/700 - uppercase tracking 0.06em (`PageHeader`), eyebrows 9.5px/0.32em with a `//` - prefix, section labels 10px/700/0.26em. Square corners (radius tokens are - 0), square LED status dots (blink, no pulse ring), no emblem hover-spin, - hairline borders, no shadows on cards, 48px grid texture on the canvas - (mono theme flattens it). The `mono` theme is **Monolith Signal**: bare - black `#0a0a0a`, white accent, `--hairline #1c1c1c`, and color means - status only (`ok #2fd36f`/`warn #ffb000`/`err #ff2d3f`). Shared primitives in `components/terminal.tsx` - (`Dot`, `Badge`, `Label`, `SectionHead`, `Kbd`, `Spark`). `/org` keeps its - existing markup — it inherits the tokens through Tailwind classes only. -- Env vars: `FOUNDER_OS_DB`, `BRAIN_PROVIDER`, `GBRAIN_BIN`, `GBRAIN_STORE`, - plus connector creds in `.env.local`. -- Heavy interaction-driven visualizations load via `next/dynamic` - (`ssr: false`) behind dimension-matched skeletons (see - `BrainGraphView`/`AudienceConsistencyLazy`; contract in - `tests/code-splitting.test.ts`). Use `next/image` for any future raster - images — every current visual is SVG/canvas, so nothing needed a retrofit. -- Future: migrate hosting to a dedicated host; Supabase stays managed. - -## Multi-agent etiquette - -Multiple Claude Code sessions work on this repo concurrently: - -- Commit small checkpoints often (`git log --oneline` to see where others are). -- Run `npm test && npm run typecheck` before claiming anything done. -- Don't kill the dev server on 4100 — another session may be using it. -- Leave handoff notes in `docs/` if you stop mid-feature. +npm run dev +npm test +npm run typecheck +npm run seed +npm run build +npm start + +On Windows PowerShell use npm.cmd when required. + +Current stack +Next.js 14 App Router +TypeScript +Tailwind CSS +SQLite prototype via better-sqlite3 +Zod +Vitest + +SQLite is temporary infrastructure. The planned operational source of truth is PostgreSQL. + +Canonical company specification + +The authoritative company model lives in: + +company/company.yaml +company/organization.yaml +company/governance.yaml +policies/memory_policy.yaml + +Application code must not silently contradict these files. + +Company structure + +Three departments: + +Research Investments +Risk Analysis +Portfolio Monitoring & Performance + +Total planned workforce: 19 AI agents. + +The human CEO retains final authority. + +No agent may autonomously: + +buy or sell assets +deposit or withdraw funds +modify or cancel broker orders +obtain unrestricted brokerage credentials + +Agents may research, analyse, propose, review and monitor. + +Foundation Mode + +The application currently seeds only: + +3 departments +19 planned agents + +No fabricated operational history, workflows, tasks, skills, market history or company performance data should be seeded. + +Empty operational datasets are valid. + +Control Plane + +The planned Company Control Plane is deterministic infrastructure responsible for: + +state transitions +permissions +approvals +scheduling +routing constraints +retries and timeouts +audit history + +Core rule: + +Agents propose actions. The Control Plane determines whether those actions are technically permitted. + +Startup Brain + +The future institutional memory system is called Startup Brain. + +Cognee is the planned memory engine. + +Startup Brain stores institutional experience and knowledge, including: + +facts used +interpretations +assumptions +theses +decisions +outcomes +lessons +errors and corrections +process knowledge +source and tool reliability +validated SOP knowledge + +Raw historical market price series do not belong in Startup Brain. + +Market data should be retrieved from external providers when needed. + +No legacy FounderOS G-Brain memory is part of the new company. + +Internet and external research + +Agents are expected to use external information when required. + +Startup Brain is not their only source of information. + +Agents may use: + +public web research +market data providers +filings +economic data +specialist research +external opinions and analysis + +External information must be evaluated rather than accepted automatically. + +Data architecture + +Current prototype: + +SQLite + +Planned production architecture: + +PostgreSQL — operational source of truth +Cognee — institutional memory +MinIO / S3 — large artifacts +Langfuse + OpenTelemetry — observability +Repository conventions +Pages and routes access operational data through repository abstractions. +Validate structured boundaries with Zod. +Do not fabricate production data to make the UI look populated. +Keep tests and type checking green. +Prefer small coherent changes. +Preserve reusable infrastructure where it fits the new company. +Remove FounderOS-specific business logic rather than adapting tests to preserve it. +Git workflow + +Primary development branch: + +foundation/company-spec-v1 + +Before a checkpoint: + +npm run typecheck +npm test + +Do not push changes without explicit human approval. + +Current product surfaces +/ +/agents +/tasks +/skills +/org +/brain +/workflows +/integrations +/analytics + +Legacy FounderOS product surfaces should not be reintroduced. + + +Poi salva con: + +```text +Ctrl + S \ No newline at end of file diff --git a/README.md b/README.md index 8f15187c..3c8697b8 100644 --- a/README.md +++ b/README.md @@ -1,230 +1,94 @@ -# Founder OS +# Startup -**A personal operating system for a one-person business: a live web command -center that runs your company as a set of AI-assisted "departments."** +AI-operated investment and personal portfolio management company. -Founder OS turns the tabs, tools, and mental overhead of running a solo business -into one screen: unified comms, a client funnel, social growth, finances, a -knowledge graph, and a roster of named AI agents that each own a real job. +This repository is being rebuilt from the FounderOS codebase into a governed multi-agent investment company operating system. -This repository is the **open-source demo build**. It ships seeded with -realistic placeholder data, so every page is alive out of the box with no -accounts, no API keys, and nothing to configure. It's the same system taught, -live, in the [Founder OS cohort](https://www.thefounderos.com); this repo lets -you explore and run it yourself. +## Current status -> Want to build your own, live, with guidance? That's what the cohort is for. -> [thefounderos.com](https://www.thefounderos.com) +**Foundation Mode** ---- +The current company model contains: -## Quick start +- 3 departments +- 19 planned AI employees +- human CEO final authority +- no fabricated production history +- no autonomous brokerage execution -Requires **Node 18+**. +## Company -```bash -npm install -cp .env.example .env.local # optional; only needed to wire live integrations -npm run dev # http://localhost:4100 -``` +### Research Investments -A local SQLite database is **seeded with demo data on first run**, so every page -is populated immediately. No credentials are required to browse. Navigate with -the sidebar or the Command Palette (Cmd/Ctrl + K). Five themes ship, including -the Monolith default (white on black, color means status only); pick it from the -palette icon in the top bar. +Researches macroeconomics, markets, equities, crypto and ETFs and performs critical review of investment theses. -```bash -npm run build && npm start # production build -npm test # vitest suite -npm run typecheck # tsc --noEmit -npm run seed # re-seed the demo DB (idempotent) -``` - ---- - -## What you're looking at - -| Route | What it is | -| --- | --- | -| `/` | Operator console: pulse row, connections strip, agent list, knowledge core | -| `/comms` | Unified inbox: email, Slack, WhatsApp, and dictation lanes in one feed | -| `/funnel` | Living client-journey flow: a left-to-right neural view and a radial acquisition wheel, both fullscreenable | -| `/social` | Growth dashboard: per-account follower charts, audience share, posting cadence | -| `/content` | Content pipeline and calendar | -| `/finances` | Income and expense charts, money-out views, expenses by category | -| `/agents` | The AI agent roster, each with a real `run()` and last-run state | -| `/tasks` | Task board fed by the agents | -| `/skills` | Reusable, schedulable agent skills | -| `/org` | Org hierarchy: operator, conductor, pillars, workers | -| `/brain` | The knowledge core and graph (see **Knowledge layer** below) | -| `/workflows` | Multi-step tool workflows | -| `/integrations` | Live connections board with honest status for every connector | -| `/analytics` | Real connector numbers and sparkline history | -| `/roadmap`, `/reference` | Phases and quarters, and the reference model | -| `/personas` | Persona templates that reskin the OS for other business types | - ---- - -## Architecture: larp-first, real-ready - -This is the load-bearing design rule. The demo looks alive because of rich -seeded data, but **every page and API route reads through a repository layer** -(never a raw query), and **every connector returns an honest status** (it never -fakes "connected"). Swapping seeded tables for live sources is a repo-level -change, not a rewrite. - -- **`lib/data.ts`**: `getDb()` app singleton; seeds on first touch. -- **`lib/db.ts`**: `openDb()` plus typed repositories (`agents`, `departments`, - `social`, `funnel`, `finances`, and more). -- **`lib/seed.ts`**: all seeded demo content lives here. -- **`lib/schemas.ts`**: Zod schemas validate every row on the way **out** of the - DB, so bad data fails loud. -- **`lib/connectors/*`**: 20+ connector groups (email/IMAP, Slack, Stripe, - Notion, calendar, CRM, social, and more). Each returns a typed - `ConnectorStatus` of `connected`, `not_configured`, or `error`: always the - truth, never a fake green light. -- **`lib/agents/*`**: every seeded agent maps one-to-one to a runtime agent with - a real `run()`; runs persist and surface on `/agents`. - -New data means a new repo method, a Zod schema, a seed entry, and a test. Keep -it that way. - ---- - -## Knowledge layer: G-Brain and Optimal Engine - -The `/brain` graph you see in the demo is the visible surface of a two-part -knowledge system that powers the production build. - -### G-Brain, the knowledge base - -Plain **Markdown files are the source of truth**. They're chunked and embedded -into a vector store, so one store answers both **keyword** and **semantic** -queries (hybrid retrieval with reciprocal-rank fusion). If the vector backend is -unreachable, retrieval falls back to a local grep over the markdown: fewer -smarts, zero downtime. The demo ships a stub provider; the production provider -reads a live markdown store and a vector index. - -### Optimal Engine, the governed memory runtime - -Where G-Brain stores documents, **Optimal Engine governs what the OS actually -"knows."** It's the system of record for memory, organized as a -**Tenant, Organization, Workspace, Node** topology, with a disciplined truth -lifecycle: - -``` -Source -> Signal -> Claim -> Fact -> Memory -``` - -Raw sources produce signals; signals become claims; claims are reviewed and -promoted into verified facts; facts distill into durable memories the agents -draw on. Agents may write sources, signals, and pending claims, but **facts are -promotion-gated**: nothing becomes "known" without passing review. That keeps -the OS's memory trustworthy as it scales across workspaces. - -Together: **G-Brain is the library; Optimal Engine is the librarian and the -system of record.** The agents query both before they act. - ---- - -## The full plan (production) - -The demo is self-contained (Next.js plus seeded SQLite). The production build -keeps the same repo-layer contract and swaps in real backends: - -- **Hosting: [Railway](https://railway.app).** The Next.js app deploys as a - Railway service; the seeded SQLite store is replaced by a managed database, - and the knowledge services (G-Brain retrieval and Optimal Engine) run as - companion services alongside it. Env vars are managed per environment. -- **Knowledge: G-Brain and Optimal Engine** become the live knowledge and memory - layer behind `/brain` and every agent. -- **Connectors go live.** Because each connector already reports honest status, - wiring a real source (IMAP inboxes, Slack, Stripe, Notion, a CRM, calendar, - social) is just supplying credentials. The UI immediately reflects real state - instead of seeded data. -- **Agents run for real.** Each agent's `run()` executes against the live - connectors and the knowledge layer, on a schedule, with runs persisted. - -Same interface you see in this demo, backed by your real business instead of -placeholder data. - ---- - -## Project structure - -``` -app/ Next.js App Router; one folder per view plus /api routes -components/ UI: dashboard sections, graphs, terminal primitives -lib/ - data.ts db.ts repository layer plus app DB singleton - seed.ts all seeded demo content - schemas.ts Zod schemas (validate every DB/API boundary) - connectors/ 20+ honest-status integrations - agents/ agent registry plus runtimes - knowledge-graph.ts, memory-core.ts brain graph plus memory model -scripts/ seed plus doc-generation scripts -tests/ vitest suite (one file per module) -``` +### Risk Analysis ---- - -## Configuration +Transforms approved research proposals into structured risk analysis and execution proposals. -All configuration is via environment variables. Copy `.env.example` to -`.env.local` and fill in only what you want to wire up; everything else stays in -honest "not configured" mode. `.env.local` is gitignored. +### Portfolio Monitoring & Performance -**Never commit real keys.** The demo runs fully without any of them. +Monitors positions, exposures, performance, employee quality and organizational learning. ---- +## Architecture -## Tech stack +FounderOS currently provides parts of the original application chassis. -- **Next.js 14** (App Router, server components) plus **TypeScript** -- **Tailwind CSS**: monochrome "Monolith" theme with pickable colorways -- **better-sqlite3**: seeded local store (WAL) -- **Zod**: schema validation at every boundary -- **Vitest**: test suite -- **Vercel AI SDK**: agent LLM calls -- **d3-force**, **lucide-react**, **simple-icons**: graph physics and iconography +The target architecture adds: ---- +- deterministic Company Control Plane +- Startup Brain powered by Cognee +- external web and financial-data research +- governed approval workflows +- PostgreSQL operational state +- artifact storage +- observability and audit history -## Testing +Core principle: -```bash -npm test # run the full vitest suite -npm run typecheck # tsc --noEmit -``` +> Agents propose actions. The Control Plane determines whether they are technically permitted. -Tests live in `tests/`, one file per module, using an in-memory SQLite pattern -so they never touch the seeded dev DB. +The human CEO makes final investment and execution decisions. ---- +## Startup Brain -## Deploying to Railway +Startup Brain is the institutional memory of the company. -1. Create a Railway project and point it at this repo. -2. Set the build command to `npm run build` and the start command to `npm start` - (the app serves on the `PORT` Railway provides). -3. Add a database service and any connector credentials as environment variables - in the Railway dashboard. -4. Deploy. The knowledge services (G-Brain and Optimal Engine) run as companion - services and are referenced by URL from the app's environment. +It stores reasoning summaries, facts, assumptions, decisions, outcomes, errors, corrections and validated lessons. ---- +It does not store raw historical market time series. -## Note on the demo data +## Canonical specification -This is a demo build. All names, companies, clients, financial figures, and -social numbers are **placeholder data**. Nothing here is real. +The company model is defined in: -## License +- `company/company.yaml` +- `company/organization.yaml` +- `company/governance.yaml` +- `policies/memory_policy.yaml` -MIT. See [`LICENSE`](LICENSE). +## Development ---- - -Built as the reference implementation for **Founder OS**. -[thefounderos.com](https://www.thefounderos.com) +```bash +npm install +npm run dev +npm run typecheck +npm test + +Windows PowerShell users may need npm.cmd. + +Development currently takes place on: + +foundation/company-spec-v1 +Current application surfaces +Home +Agents +Tasks +Skills +Org Chart +Startup Brain +Workflows +Connections +Analytics + +The project is intentionally kept in a clean foundation state while production infrastructure is introduced progressively. \ No newline at end of file diff --git a/app/agents/page.tsx b/app/agents/page.tsx index ef8188af..a73a803f 100644 --- a/app/agents/page.tsx +++ b/app/agents/page.tsx @@ -1,171 +1,399 @@ +import { + Bot, + Brain, + Network, + ShieldCheck, + Wrench, +} from 'lucide-react'; + import { getDb } from '@/lib/data'; import { PageHeader } from '@/components/PageHeader'; -import { AgentChat } from '@/components/AgentChat'; -import { ConductorChat } from '@/components/ConductorChat'; -import { AgentActivityFeed } from '@/components/AgentActivityFeed'; -import { AgentWorkPanel } from '@/components/AgentWorkPanel'; -import { recentActivity } from '@/lib/agents/activity'; -import { SparkIcon } from '@/components/SparkIcon'; -import { Badge, Dot, Label, SectionHead } from '@/components/terminal'; -import { lifeAreaForDepartment } from '@/lib/life-map'; -import type { Agent, AgentCron, AgentMessage, AgentRun, AgentTask } from '@/lib/schemas'; - -/** Perceived brightness 0–1 of a #rrggbb color (for the white guard below). */ -function brightness(hex: string): number { - const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); - if (!m) return 0; - const n = parseInt(m[1], 16); - return (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; -} +import { Badge, Dot, SectionHead } from '@/components/terminal'; +import type { Agent, Department } from '@/lib/schemas'; +import { + AgentModelSelect, +} from '@/components/AgentModelSelect'; -/** - * The Conductor (super agent) reads black/white via the theme; every other - * agent's emblem takes its department's life-area color (sales / marketing / - * knowledge / finances / communication). Near-white area tints (Operations) - * fall back to the theme text color so emblems stay visible on the light theme. - */ -function emblemShade(agent: Agent): string { - if (agent.id === 'conductor') return 'var(--text)'; - const color = lifeAreaForDepartment(agent.departmentId)?.color; - if (!color || brightness(color) > 0.85) return 'var(--text)'; - return color; -} export const dynamic = 'force-dynamic'; -function AgentRosterCard({ +function statusLabel(status: Agent['status']) { + switch (status) { + case 'active': + return 'Production'; + case 'idle': + return 'Idle'; + case 'training': + return 'Training'; + case 'planned': + return 'Planned'; + default: + return status; + } +} + +function tierLabel(tier: Agent['tier']) { + switch (tier) { + case 'lead': + return 'Supervisor'; + case 'specialist': + return 'Specialist'; + case 'worker': + return 'Worker'; + default: + return tier; + } +} + +function AgentCard({ agent, + department, parent, - lastRun, - tasks, - crons, - messages, }: { agent: Agent; + department: Department; parent: Agent | null; - lastRun: AgentRun | undefined; - tasks: AgentTask[]; - crons: AgentCron[]; - messages: AgentMessage[]; }) { const active = agent.status === 'active'; + + + const hasRuntime = + agent.instance && + agent.instance !== 'builtin'; + return (
-
+
-
- - -

{agent.name}

+
+ + +

+ {agent.name} +

+
+ +
+ {agent.role}
-
{agent.role}
- {agent.tier} + + + {tierLabel(agent.tier)} +
-

{agent.description}

+

+ {agent.description} +

-
- {agent.tools.slice(0, 5).map((tool) => ( - - {tool} +
+
+ + + + Department: - ))} - {agent.tools.length > 5 && ( - - +{agent.tools.length - 5} + + + {department.name} - )} +
+ +
+ + + + Reports to: + + + + {parent + ? parent.name + : 'Human CEO'} + +
+ +
+ + + + Model: + + + +
+ +
+ + + + Runtime: + + + + {hasRuntime + ? agent.instance + : 'Not connected'} + +
-
-
- {parent ? `under ${parent.name}` : `instance ${agent.instance}`} - {agent.status} +
+
+ + + + Planned capabilities +
- {lastRun && ( -
- - {lastRun.ok ? 'OK' : 'FAIL'} - - - last check: {lastRun.summary.slice(0, 56)} - + + {agent.tools.length > 0 ? ( +
+ {agent.tools.map((tool) => ( + + {tool} + + ))} +
+ ) : ( +
+ No capabilities registered yet.
)} - - +
+ +
+
+ + Agent ID: {agent.id} + + + + {statusLabel(agent.status)} + +
); } +function DepartmentOverview({ + department, + agents, +}: { + department: Department; + agents: Agent[]; +}) { + const supervisor = + agents.find((agent) => agent.tier === 'lead') ?? null; + + return ( +
+
+ {department.name} +
+ +
+ {agents.length} +
+ +
+ registered agents +
+ +
+
+ Supervisor +
+ +
+ {supervisor + ? supervisor.name + : 'Not assigned'} +
+
+
+ ); +} + export default function AgentsPage() { const db = getDb(); - const departments = db.departments.all(); + + const departments = db.departments + .all() + .sort((a, b) => a.order - b.order); + const agents = db.agents.all(); - const agentsById = new Map(agents.map((a) => [a.id, a])); - const agentNames = Object.fromEntries(agents.map((a) => [a.id, a.name])); - const activity = recentActivity(db, 40); - const totalRuns = db.agentRuns.recent(1000).length; - const allTasks = db.agentTasks.all(); - const allCrons = db.agentCrons.all(); - const openTasks = allTasks.filter((t) => t.status !== 'done').length; + + const agentsById = new Map( + agents.map((agent) => [agent.id, agent]), + ); + + const activeAgents = agents.filter( + (agent) => agent.status === 'active', + ).length; + + const plannedAgents = agents.filter( + (agent) => agent.status === 'planned', + ).length; + + const supervisors = agents.filter( + (agent) => agent.tier === 'lead', + ).length; return (
-
- +
+

+ This is the official registry of the company's AI workforce. +

+ +

+ Agents may be registered before their models, runtime environments, + tools and external data connections are activated. +

-
- {[ - ['Total', agents.length], - ['Active', agents.filter((a) => a.status === 'active').length], - ['Open tasks', openTasks], - ['Cron jobs', allCrons.length], - ['Runs', totalRuns], - ].map(([label, value]) => ( -
- -
{value}
+
+
+
+ Registered agents
- ))} -
-
- -
+
+ {agents.length} +
+
+ +
+
+ Production agents +
+ +
+ {activeAgents} +
+
+ +
+
+ Planned agents +
+ +
+ {plannedAgents} +
+
+ +
+
+ Supervisors +
+ +
+ {supervisors} +
+
+ + +
+ + +
+ {departments.map((department) => { + const departmentAgents = agents.filter( + (agent) => + agent.departmentId === department.id, + ); + + return ( + + ); + })} +
+
+ +
+ {departments.map((department) => { + const departmentAgents = agents.filter( + (agent) => + agent.departmentId === department.id, + ); + + if (departmentAgents.length === 0) { + return null; + } + + const orderedAgents = [ + ...departmentAgents.filter( + (agent) => agent.tier === 'lead', + ), + ...departmentAgents.filter( + (agent) => agent.tier !== 'lead', + ), + ]; -
- {departments.map((dept) => { - const deptAgents = agents.filter((a) => a.departmentId === dept.id); - if (deptAgents.length === 0) return null; return ( -
- -
{dept.tagline}
-
- {deptAgents.map((agent) => ( - + + +
+ {department.tagline} +
+ +
+ {orderedAgents.map((agent) => ( + t.agentId === agent.id)} - crons={allCrons.filter((c) => c.agentId === agent.id)} - messages={db.agentMessages.byAgent(agent.id)} + department={department} + parent={ + agent.parentId + ? agentsById.get(agent.parentId) ?? null + : null + } /> ))}
@@ -173,6 +401,22 @@ export default function AgentsPage() { ); })}
+ +
+
+ Workforce state +
+ +

+ Agent identities and reporting lines are registered. +

+ +

+ Production runtimes, model assignments, internet access, external + data sources, Startup Brain access and operational permissions will + be connected progressively. +

+
); -} +} \ No newline at end of file diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx index 184497a7..9010fe26 100644 --- a/app/analytics/page.tsx +++ b/app/analytics/page.tsx @@ -1,387 +1,110 @@ -import Link from 'next/link'; -import { Instagram, Linkedin, Music2, Twitter, Youtube, type LucideIcon } from 'lucide-react'; -import { getDb } from '@/lib/data'; -import { buildSocialDashboard, syncFromZernioConfig, audienceGrowthPct, PLATFORM_LABELS } from '@/lib/social'; -import { agentRunVolume, runsWithin } from '@/lib/analytics'; -import { splitMetrics, type MetricInput, type MetricTile } from '@/lib/operating-metrics'; -import { attioStatus } from '@/lib/connectors/attio'; -import { wisprStatus } from '@/lib/connectors/wispr'; -import { readStoreNotes } from '@/lib/connectors/gbrain'; -import { stripeSnapshot } from '@/lib/connectors/payments'; -import { unreadCounts } from '@/lib/connectors/email'; -import { beehiivSubscribers } from '@/lib/connectors/beehiiv'; -import type { SocialPlatform } from '@/lib/schemas'; -import type { PieItem } from '@/lib/social-chart'; +import { BarChart3 } from 'lucide-react'; import { PageHeader } from '@/components/PageHeader'; -import { Badge, Label, SectionHead, Spark } from '@/components/terminal'; -import { SharePie } from '@/components/SharePie'; -import { formatFollowers, GrowthBadge, MiniBars } from '@/components/SocialStats'; export const dynamic = 'force-dynamic'; -const PLATFORM_ICONS: Record = { - instagram: Instagram, - tiktok: Music2, - twitter: Twitter, - youtube: Youtube, - linkedin: Linkedin, -}; - -// Value + small-unit split per tile (compact for audience, $ for money). -function tileValue(value: number, unit: string): { main: string; small: string } { - if (unit === 'usd') return { main: `$${value.toLocaleString('en-US')}`, small: '' }; - if (unit === 'followers') return { main: formatFollowers(value), small: '' }; - return { main: value.toLocaleString('en-US'), small: unit }; -} - -// Deterministic spark shape per metric id until per-metric history lands. -function sparkFor(id: string, value: number): number[] { - const seed = [...id].reduce((sum, ch) => sum + ch.charCodeAt(0), 0); - return Array.from({ length: 7 }, (_, i) => { - const wobble = ((seed * (i + 3)) % 17) / 17 - 0.5; - return Math.max(0, value * (0.82 + 0.18 * (i / 6) + wobble * 0.08)); - }); -} - -// Deterministic rising bars per channel for the by-platform cards. -function barsFor(seed: string): number[] { - const base = [...seed].reduce((s, c) => s + c.charCodeAt(0), 0); - return Array.from({ length: 12 }, (_, i) => 4 + i * 1.3 + ((base + i * 7) % 5)); -} - -function fmtShort(iso: string): string { - return new Date(`${iso}T00:00:00Z`) - .toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) - .toLowerCase(); -} - -const fmtCount = (n: number) => n.toLocaleString('en-US'); - -/** Responsive area chart for daily agent-run counts (real log, honest zeros). */ -function RunVolumeChart({ data }: { data: { date: string; count: number }[] }) { - const W = 640; - const H = 170; - const pad = 8; - const max = Math.max(...data.map((d) => d.count), 1); - const n = data.length; - const xAt = (i: number) => (n <= 1 ? W / 2 : (i / (n - 1)) * W); - const yAt = (v: number) => H - pad - (v / max) * (H - pad * 2); - const pts = data.map((d, i) => `${xAt(i).toFixed(1)},${yAt(d.count).toFixed(1)}`); +export default function AnalyticsPage() { return ( - - ); -} -function MetricCard({ tile }: { tile: MetricTile }) { - const up = tile.delta > 0; - const flat = tile.delta === 0; - const { main, small } = tileValue(tile.value, tile.unit); - return ( -
-
- - - {flat ? '' : up ? '▲ +' : '▼ '} - {flat ? '' : tile.delta} - {!flat && tile.deltaPct ? '%' : ''} - -
-
- {main} - {small && {small}} -
-
- - {tile.source} -
-
- ); -} +
+

+ Company performance, portfolio results, agent KPIs and process + quality metrics will be displayed here. +

-/** A titled card that hosts one share donut — the Distribution row's unit. */ -function PieCard({ - title, sub, items, total, centerLabel, format, ariaLabel, -}: { - title: string; - sub: string; - items: PieItem[]; - total: number; - centerLabel: string; - format: (v: number) => string; - ariaLabel: string; -}) { - return ( -
-
- - {sub} +

+ Analytics starts from a clean state. No legacy business metrics are + loaded. +

- -
- ); -} - -export default async function AnalyticsPage() { - const db = getDb(); - syncFromZernioConfig(db); - const today = new Date().toISOString().slice(0, 10); - - // Real agent-run activity — powers the agent-runs tile, the volume chart, and - // the run-distribution pies. - const runs = db.agentRuns.recent(2000); - const runVolume = agentRunVolume(runs, today, 14); - const windowRuns = runVolume.reduce((s, p) => s + p.count, 0); - const runs7d = runsWithin(runs, today, 7); - - // Real audience — Zernio snapshot totals + true 7d growth. - const dash = buildSocialDashboard(db); - const totalFollowers = dash.totalFollowers; - const audience7d = audienceGrowthPct(db, 7); - // Live reads from the wired connectors (parallel; each degrades to pending). - const [attio, wispr, stripe, emailUnread, subs] = await Promise.all([ - attioStatus().catch(() => null), - wisprStatus().catch(() => null), - stripeSnapshot().catch(() => null), - unreadCounts() - .then((cs) => cs.reduce((sum, c) => sum + c.unread, 0)) - .catch(() => null), - beehiivSubscribers().catch(() => null), - ]); - const pipelineDeals = attio?.state === 'connected' ? Number(attio.meta?.deals ?? 0) : null; - const dictations = wispr?.state === 'connected' ? Number(wispr.meta?.dictations ?? 0) : null; - const stripeAvail = stripe ? Math.round((stripe.available[0]?.amount ?? 0) / 100) : null; - let brainPages = 0; - try { - brainPages = readStoreNotes().length; - } catch { - brainPages = 0; - } +
+
+
+ Portfolio performance +
- // Every tile is a real connector read, or honest pending (value === null). - const inputs: MetricInput[] = [ - { - id: 'audience', - label: 'Audience', - unit: 'followers', - source: '7d · Zernio', - value: totalFollowers || null, - delta: audience7d != null ? Math.round(audience7d * 10) / 10 : 0, - deltaPct: audience7d != null, - }, - { id: 'subscribers', label: 'Subscribers', unit: 'subs', source: 'Beehiiv', value: subs }, - { id: 'pipeline', label: 'Open Pipeline', unit: 'deals', source: 'Attio', value: pipelineDeals }, - { id: 'stripe', label: 'Stripe Available', unit: 'usd', source: 'Stripe', value: stripeAvail }, - { id: 'agent-runs', label: 'Agent Runs', unit: 'runs', source: 'all time', value: runs.length || null, delta: runs7d }, - { id: 'unread', label: 'Unread · all inboxes', unit: 'emails', source: 'Email', value: emailUnread }, - { id: 'brain', label: 'Brain-store Pages', unit: 'pages', source: 'GBrain', value: brainPages }, - { id: 'dictations', label: 'Dictations', unit: 'dictations', source: 'Wispr Flow', value: dictations }, - ]; - const { live, pending } = splitMetrics(inputs); +
+ — +
- // ---- Distribution pies (all real: live snapshots + the real run log) ---- +
+ No portfolio history +
+
- // Audience share by channel — every social platform plus the email list. - const audienceItems: PieItem[] = dash.platforms.map((p) => ({ - key: p.platform, - label: PLATFORM_LABELS[p.platform], - value: p.followers, - })); - if (subs) audienceItems.push({ key: 'email', label: 'Email list', value: subs }); - const audienceReach = totalFollowers + (subs ?? 0); +
+
+ Agent KPIs +
- // Agent runs by agent — top handful, the long tail folded into "Other". - const agentName = new Map(db.agents.all().map((a) => [a.id, a.name])); - const byAgent = new Map(); - for (const r of runs) byAgent.set(r.agentId, (byAgent.get(r.agentId) ?? 0) + 1); - const rankedAgents = [...byAgent.entries()].sort((a, b) => b[1] - a[1]); - const runsByAgentItems: PieItem[] = rankedAgents - .slice(0, 6) - .map(([id, n]) => ({ key: id, label: agentName.get(id) ?? id, value: n })); - const tailRuns = rankedAgents.slice(6).reduce((s, [, n]) => s + n, 0); - if (tailRuns > 0) runsByAgentItems.push({ key: 'other', label: 'Other agents', value: tailRuns }); +
+ 0 +
- // Run outcomes — reliability at a glance. - const okRuns = runs.filter((r) => r.ok).length; - const outcomeItems: PieItem[] = [ - { key: 'ok', label: 'Succeeded', value: okRuns }, - { key: 'fail', label: 'Failed', value: runs.length - okRuns }, - ]; +
+ No evaluated activity +
+
- return ( -
- {live.length} live · {pending.length} pending} - /> +
+
+ Closed operations +
- {/* Live metric tiles */} - {live.length > 0 && ( -
- {live.map((tile) => ( - - ))} -
- )} +
+ 0 +
- {/* Distribution — share donuts across audience, agents, run outcomes */} -
- -
- {audienceReach > 0 && ( - - )} - {runs.length > 0 && ( - - )} - {runs.length > 0 && ( - - )} +
+ No operations recorded +
-
- {/* Agent run volume (real log) + awaiting-credentials sidebar */} -
-
-
- - {windowRuns} runs -
-
- +
+
+ Validated lessons
-
- {fmtShort(runVolume[0].date)} - {fmtShort(runVolume[Math.floor(runVolume.length / 2)].date)} - {fmtShort(runVolume[runVolume.length - 1].date)} + +
+ 0
-
-
- -
- {pending.length === 0 ? ( -
- all connectors live ✓ -
- ) : ( - pending.map((m) => ( -
- -
-
{m.label}
-
{m.source}
-
- — -
- )) - )} +
+ No learning history
- - wire connectors → flip to live -
- {/* Audience by platform — real Zernio snapshot data */} -
- -
- {dash.platforms.map((p) => { - const Icon = PLATFORM_ICONS[p.platform]; - const share = totalFollowers > 0 && p.followers != null ? (p.followers / totalFollowers) * 100 : 0; - return ( - -
-
-
- -
-
-
{PLATFORM_LABELS[p.platform]}
-
{p.handle}
-
-
- -
-
-
- {formatFollowers(p.followers)} -
- -
-
-
-
-
{share.toFixed(0)}% of reach
- - ); - })} +
+
+
+ +
+ +
+
+ No company analytics yet +
+ +

+ Metrics will be created only from activities performed by this + company. +

+ +

+ Future analytics will include portfolio performance, risk + outcomes, employee KPIs, process quality, errors and learning + effectiveness. +

+
); -} +} \ No newline at end of file diff --git a/app/api/agents/[id]/chat/route.ts b/app/api/agents/[id]/chat/route.ts index 8c6ee671..95de71b0 100644 --- a/app/api/agents/[id]/chat/route.ts +++ b/app/api/agents/[id]/chat/route.ts @@ -1,40 +1,94 @@ import { NextResponse } from 'next/server'; + import { getDb } from '@/lib/data'; import { realAgents } from '@/lib/agents/real'; import { chatWithAgent } from '@/lib/agents/chat'; -import { routeConductorMessage } from '@/lib/agents/conductor'; export const dynamic = 'force-dynamic'; -export const runtime = 'nodejs'; // better-sqlite3 is native — keep off the edge runtime +export const runtime = 'nodejs'; -export async function POST(req: Request, { params }: { params: { id: string } }) { +export async function POST( + req: Request, + { + params, + }: { + params: { + id: string; + }; + }, +) { let message = ''; let screenContext: string | undefined; + try { - const body = (await req.json()) as { message?: unknown; context?: unknown }; - message = typeof body.message === 'string' ? body.message.trim() : ''; - screenContext = typeof body.context === 'string' && body.context.trim() ? body.context.slice(0, 4000) : undefined; + const body = (await req.json()) as { + message?: unknown; + context?: unknown; + }; + + message = + typeof body.message === 'string' + ? body.message.trim() + : ''; + + screenContext = + typeof body.context === 'string' && + body.context.trim() + ? body.context.slice(0, 4000) + : undefined; } catch { - // fall through to the empty-message rejection + // Empty or invalid bodies are rejected below. } + if (!message) { - return NextResponse.json({ error: 'message is required' }, { status: 400 }); + return NextResponse.json( + { + error: 'message is required', + }, + { + status: 400, + }, + ); } - // Resolve the target up front so a genuinely-unknown agent is a 404, while a - // downstream failure (gateway error, Zod throw, …) surfaces honestly as a 500 - // instead of masquerading as "unknown agent". - const isConductor = params.id === 'conductor'; - if (!isConductor && !realAgents.some((a) => a.id === params.id)) { - return NextResponse.json({ error: `unknown agent: ${params.id}` }, { status: 404 }); + const agentExists = realAgents.some( + (agent) => agent.id === params.id, + ); + + if (!agentExists) { + return NextResponse.json( + { + error: `unknown agent: ${params.id}`, + }, + { + status: 404, + }, + ); } try { - const result = isConductor - ? await routeConductorMessage(getDb(), realAgents, message, { screenContext }) - : await chatWithAgent(getDb(), realAgents, params.id, message, { screenContext }); + const result = await chatWithAgent( + getDb(), + realAgents, + params.id, + message, + { + screenContext, + }, + ); + return NextResponse.json(result); - } catch (err) { - return NextResponse.json({ error: err instanceof Error ? err.message : String(err) }, { status: 500 }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : String(error), + }, + { + status: 500, + }, + ); } -} +} \ No newline at end of file diff --git a/app/api/agents/[id]/model/route.ts b/app/api/agents/[id]/model/route.ts new file mode 100644 index 00000000..26992458 --- /dev/null +++ b/app/api/agents/[id]/model/route.ts @@ -0,0 +1,244 @@ +import { + NextResponse, +} from 'next/server'; + +import { + getDb, +} from '@/lib/data'; + +import { + assignAgentModel, +} from '@/lib/control-plane/agent-model-assignment'; + + +export const dynamic = + 'force-dynamic'; + +export const runtime = + 'nodejs'; + + +function errorStatus( + message: + string, +): number { + if ( + message.startsWith( + 'agent_not_found:', + ) + ) { + return 404; + } + + + if ( + message === + 'agent_model_assignment_required' || + message === + 'automatic_model_routing_forbidden' || + message.startsWith( + 'agent_model_not_selectable:', + ) + ) { + return 400; + } + + + if ( + message.startsWith( + 'agent_model_assignment_conflict:', + ) + ) { + return 409; + } + + + if ( + message.startsWith( + 'runtime_secret_missing:', + ) || + message.startsWith( + 'omniroute_model_catalog_failed:', + ) + ) { + return 502; + } + + + return 500; +} + + +export async function POST( + req: + Request, + + { + params, + }: { + params: { + id: + string; + }; + }, +) { + let body: + unknown; + + + try { + body = + await req.json(); + } catch { + return NextResponse.json( + { + ok: + false, + + error: + 'invalid_json_body', + }, + { + status: + 400, + }, + ); + } + + + if ( + !body || + typeof body !== + 'object' || + Array.isArray( + body, + ) + ) { + return NextResponse.json( + { + ok: + false, + + error: + 'invalid_request_body', + }, + { + status: + 400, + }, + ); + } + + + const model = + ( + body as { + model?: + unknown; + } + ) + .model; + + + if ( + typeof model !== + 'string' || + !model.trim() || + model.length > + 200 + ) { + return NextResponse.json( + { + ok: + false, + + error: + 'agent_model_assignment_required', + }, + { + status: + 400, + }, + ); + } + + + try { + /* + * This route belongs to the human operator surface. + * + * The current application has one human authority: + * the CEO behind the whole-app access gate. + * + * Agents do not receive this route as a runtime capability. + */ + const result = + await assignAgentModel({ + db: + getDb(), + + principal: { + actor: { + kind: + 'human', + + id: + 'ceo', + }, + }, + + agentId: + params.id, + + model: + model.trim(), + }); + + + return NextResponse.json({ + ok: + true, + + changed: + result.changed, + + agent: { + id: + result.agent.id, + + model: + result.agent.model, + }, + + assignment: + result.assignment, + }); + } catch ( + error + ) { + const message = + error instanceof + Error + ? error.message + : String( + error, + ); + + + return NextResponse.json( + { + ok: + false, + + error: + message, + }, + { + status: + errorStatus( + message, + ), + }, + ); + } +} \ No newline at end of file diff --git a/app/api/brain/dump/route.ts b/app/api/brain/dump/route.ts deleted file mode 100644 index 4230c961..00000000 --- a/app/api/brain/dump/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { ingestBrainDump } from '@/lib/brain-dump'; -import { createGBrainProvider, type CaptureInput } from '@/lib/connectors/gbrain'; - -export const dynamic = 'force-dynamic'; - -const DumpSchema = z.object({ - text: z.string().min(1), - title: z.string().optional(), - folder: z.string().regex(/^[a-z0-9-]+$/i), - tags: z.array(z.string()).default([]), -}); - -export async function POST(request: Request) { - const parsed = DumpSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - } - try { - const result = await ingestBrainDump(parsed.data, { - capture: - process.env.BRAIN_PROVIDER === 'stub' - ? undefined - : (input: CaptureInput) => createGBrainProvider().capture(input), - }); - return NextResponse.json({ ok: true, ...result }); - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'write failed' }, - { status: 500 }, - ); - } -} diff --git a/app/api/brain/graph/route.ts b/app/api/brain/graph/route.ts deleted file mode 100644 index 86e50f3b..00000000 --- a/app/api/brain/graph/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from 'next/server'; -import { readStoreNotes } from '@/lib/connectors/gbrain'; -import { buildBrainGraph } from '@/lib/brain-graph'; -import { BrainGraphSchema } from '@/lib/schemas'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - const graph = buildBrainGraph(readStoreNotes()); - return NextResponse.json(BrainGraphSchema.parse(graph)); -} diff --git a/app/api/brain/overview/route.ts b/app/api/brain/overview/route.ts deleted file mode 100644 index 991a2f0e..00000000 --- a/app/api/brain/overview/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NextResponse } from 'next/server'; -import { createGBrainProvider } from '@/lib/connectors/gbrain'; -import { BrainOverviewSchema } from '@/lib/schemas'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - const overview = await createGBrainProvider().overview(); - return NextResponse.json(BrainOverviewSchema.parse(overview)); -} diff --git a/app/api/brain/route.ts b/app/api/brain/route.ts deleted file mode 100644 index 960cbdbf..00000000 --- a/app/api/brain/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getBrainProvider } from '@/lib/brain'; - -export const dynamic = 'force-dynamic'; - -/** No query → provider status. `?q=` → hybrid search (gbrain with local fallback). */ -// Next requires the first param type be exactly `Request | NextRequest` — an -// optional/defaulted param widens it to `Request | undefined` and fails the build. -export async function GET(request: Request) { - const provider = getBrainProvider(); - const q = new URL(request.url).searchParams.get('q')?.trim(); - if (q) { - const results = await provider.search(q); - return NextResponse.json({ query: q, provider: provider.name, results }); - } - const status = await provider.status(); - return NextResponse.json(status); -} diff --git a/app/api/brain/topology/route.ts b/app/api/brain/topology/route.ts new file mode 100644 index 00000000..d6bfdf31 --- /dev/null +++ b/app/api/brain/topology/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from 'next/server'; + +import { getDb } from '@/lib/data'; +import { + getCompanyAgentRuntimeProfile, +} from '@/lib/control-plane/agent-runtime-profile'; + +export const dynamic = 'force-dynamic'; + +export function GET() { + const db = getDb(); + + const departments = [...db.departments.all()].sort( + (a, b) => a.order - b.order, + ); + + const agents = [...db.agents.all()].sort( + (a, b) => a.name.localeCompare(b.name), + ); + + const departmentsById = new Map( + departments.map((department) => [department.id, department]), + ); + + return NextResponse.json( + { + generatedAt: new Date().toISOString(), + + systems: { + memory: { + id: 'startup-memory', + label: 'Startup Memory', + purpose: + 'Canonical company knowledge: identity, organization, governance, policies, agent instructions, runtime profiles, models and approved procedures.', + }, + brain: { + id: 'startup-brain', + label: 'Startup Brain', + purpose: + 'Experiential company knowledge that grows from real reasoning, decisions, outcomes, errors, reviews and validated learning.', + }, + }, + + retrieval: { + gateway: 'Brain Gateway', + fullDumpAllowed: false, + modes: [ + 'keyword_similarity', + 'semantic_similarity', + 'contextual_similarity', + 'graph_relationships', + ], + }, + + departments: departments.map((department) => ({ + id: department.id, + name: department.name, + order: department.order, + })), + + agents: agents.map((agent) => { + const profile = getCompanyAgentRuntimeProfile(agent.id); + const department = departmentsById.get(agent.departmentId); + + return { + id: agent.id, + name: agent.name, + role: agent.role, + departmentId: agent.departmentId, + departmentName: department?.name ?? agent.departmentId, + tier: agent.tier, + status: agent.status, + model: agent.model, + brainAccess: profile.brain.enabled, + retrievalAccess: { + startupMemory: true, + startupBrain: profile.brain.enabled, + gatewayEnforced: true, + fullMemoryDumpAllowed: false, + fullBrainDumpAllowed: false, + }, + }; + }), + + /* + * The renderer is intentionally graph-native from this checkpoint. + * Today the experiential graph is empty, so no synthetic memories are + * fabricated. In the next Brain milestone this array will be populated + * from the governed persistent Brain store. The 3D renderer will then + * expand automatically as real nodes and edges appear. + */ + brainGraph: { + nodes: [], + edges: [], + }, + + operationalMemory: { + experiences: 0, + decisions: 0, + validatedLessons: 0, + }, + }, + { + headers: { + 'Cache-Control': 'no-store', + }, + }, + ); +} diff --git a/app/api/brain/topology/route.ts.20260925-120721.bak b/app/api/brain/topology/route.ts.20260925-120721.bak new file mode 100644 index 00000000..6ec3c732 --- /dev/null +++ b/app/api/brain/topology/route.ts.20260925-120721.bak @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server'; + +import { getDb } from '@/lib/data'; +import { + getCompanyAgentRuntimeProfile, +} from '@/lib/control-plane/agent-runtime-profile'; + +export const dynamic = 'force-dynamic'; + +export function GET() { + const db = getDb(); + + const departments = [...db.departments.all()].sort( + (a, b) => a.order - b.order, + ); + + const agents = [...db.agents.all()].sort( + (a, b) => a.name.localeCompare(b.name), + ); + + const departmentsById = new Map( + departments.map((department) => [department.id, department]), + ); + + return NextResponse.json( + { + generatedAt: new Date().toISOString(), + systems: { + memory: { + id: 'startup-memory', + label: 'Startup Memory', + purpose: + 'Canonical company knowledge: identity, organization, governance, policies, agent instructions, runtime profiles, tools, models and approved procedures.', + }, + brain: { + id: 'startup-brain', + label: 'Startup Brain', + purpose: + 'Experiential company knowledge: reasoning summaries, decisions, outcomes, errors, reviews, lessons and reusable organizational experience.', + }, + }, + retrieval: { + gateway: 'Brain Gateway', + fullDumpAllowed: false, + modes: [ + 'keyword_similarity', + 'semantic_similarity', + 'contextual_similarity', + 'graph_relationships', + ], + }, + departments: departments.map((department) => ({ + id: department.id, + name: department.name, + order: department.order, + })), + agents: agents.map((agent) => { + const profile = getCompanyAgentRuntimeProfile(agent.id); + const department = departmentsById.get(agent.departmentId); + + return { + id: agent.id, + name: agent.name, + role: agent.role, + departmentId: agent.departmentId, + departmentName: department?.name ?? agent.departmentId, + tier: agent.tier, + status: agent.status, + model: agent.model, + tools: profile.allowedToolCapabilities, + brainAccess: profile.brain.enabled, + retrievalAccess: { + startupMemory: true, + startupBrain: profile.brain.enabled, + gatewayEnforced: true, + fullMemoryDumpAllowed: false, + fullBrainDumpAllowed: false, + }, + }; + }), + operationalMemory: { + experiences: 0, + decisions: 0, + validatedLessons: 0, + }, + }, + { + headers: { + 'Cache-Control': 'no-store', + }, + }, + ); +} diff --git a/app/api/comms/email/action/route.ts b/app/api/comms/email/action/route.ts deleted file mode 100644 index f8a41270..00000000 --- a/app/api/comms/email/action/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { updateEmailThread } from '@/lib/connectors/email'; - -export const dynamic = 'force-dynamic'; - -const ActionSchema = z.object({ - account: z.string().min(1).max(80), - threadId: z.string().min(1).max(500), - uid: z.number().int().positive().optional(), - action: z.enum(['archive', 'trash', 'read', 'unread', 'star', 'unstar']), -}); - -export async function POST(request: Request) { - const parsed = ActionSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - const { account, ...operation } = parsed.data; - const result = await updateEmailThread({ accountId: account, ...operation }); - if (!result.ok) return NextResponse.json({ error: result.error }, { status: 502 }); - return NextResponse.json({ ok: true, ...result.value }); -} diff --git a/app/api/comms/email/attachment/route.ts b/app/api/comms/email/attachment/route.ts deleted file mode 100644 index bd0f11be..00000000 --- a/app/api/comms/email/attachment/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { downloadEmailAttachment } from '@/lib/connectors/email'; - -export const dynamic = 'force-dynamic'; - -const AttachmentQuerySchema = z.object({ - account: z.string().min(1).max(80), - threadId: z.string().min(1).max(500), - uid: z.coerce.number().int().positive(), - part: z.string().regex(/^\d+(?:\.\d+)*$/), -}); - -export async function GET(request: Request) { - const parsed = AttachmentQuerySchema.safeParse(Object.fromEntries(new URL(request.url).searchParams)); - if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - const { account, ...attachment } = parsed.data; - const result = await downloadEmailAttachment({ accountId: account, ...attachment }); - if (!result.ok) return NextResponse.json({ unavailable: true, error: result.error }); - const fallback = result.value.filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_'); - return new Response(new Uint8Array(result.value.data), { - headers: { - 'Content-Type': result.value.contentType, - 'Content-Disposition': `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(result.value.filename)}`, - 'Cache-Control': 'private, no-store', - }, - }); -} diff --git a/app/api/comms/email/search/route.ts b/app/api/comms/email/search/route.ts deleted file mode 100644 index 64abe1b9..00000000 --- a/app/api/comms/email/search/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { searchInboxEmails } from '@/lib/connectors/email'; -import { EmailInboxItemSchema } from '@/lib/email-thread'; - -export const dynamic = 'force-dynamic'; - -const SearchQuerySchema = z.object({ - account: z.string().min(1).max(80), - q: z.string().trim().min(2).max(200), -}); - -export async function GET(request: Request) { - const parsed = SearchQuerySchema.safeParse(Object.fromEntries(new URL(request.url).searchParams)); - if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - const result = await searchInboxEmails(parsed.data.account, parsed.data.q, 50); - if (!result.ok) return NextResponse.json({ items: [], unavailable: true, error: result.error }); - return NextResponse.json({ items: z.array(EmailInboxItemSchema).parse(result.value) }); -} diff --git a/app/api/comms/email/thread/route.ts b/app/api/comms/email/thread/route.ts deleted file mode 100644 index aaf755d1..00000000 --- a/app/api/comms/email/thread/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { fetchEmailThread } from '@/lib/connectors/email'; -import { EmailThreadSchema } from '@/lib/email-thread'; - -export const dynamic = 'force-dynamic'; - -const ThreadQuerySchema = z - .object({ - account: z.string().min(1).max(80), - threadId: z.string().min(1).max(500).optional(), - messageId: z.string().min(1).max(998).optional(), - uid: z.coerce.number().int().positive().optional(), - }) - .refine((value) => value.threadId || value.messageId || value.uid, 'a threadId, messageId, or uid is required'); - -export async function GET(request: Request) { - const url = new URL(request.url); - const parsed = ThreadQuerySchema.safeParse(Object.fromEntries(url.searchParams)); - if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - const { account, ...identifiers } = parsed.data; - const result = await fetchEmailThread({ accountId: account, ...identifiers }); - if (!result.ok) return NextResponse.json({ unavailable: true, error: result.error }); - return NextResponse.json(EmailThreadSchema.parse(result.value)); -} diff --git a/app/api/comms/reply/route.ts b/app/api/comms/reply/route.ts deleted file mode 100644 index 489e9e39..00000000 --- a/app/api/comms/reply/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { sendSlackMessage } from '@/lib/connectors/slack'; -import { sendEmailReply } from '@/lib/connectors/email'; - -export const dynamic = 'force-dynamic'; - -/** - * Replies the server can actually deliver. Slack sends via the bot token; email - * sends for real over SMTP using the originating inbox's credentials. WhatsApp - * stays a client-side deep link (the local store is read-only). A non-ok result - * is honest (502) so the UI can fall back to a mailto: draft. - */ -const ReplySchema = z.discriminatedUnion('source', [ - z.object({ source: z.literal('slack'), channel: z.string().min(1), text: z.string().min(1).max(4000) }), - z.object({ - source: z.literal('email'), - account: z.string().optional(), - to: z.string().email(), - subject: z.string().max(300).optional(), - text: z.string().min(1).max(20000), - inReplyTo: z.string().min(1).max(998).optional(), - references: z.array(z.string().min(1).max(998)).max(100).optional(), - }), -]); - -export async function POST(request: Request) { - const parsed = ReplySchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - } - - if (parsed.data.source === 'slack') { - const result = await sendSlackMessage(parsed.data.channel, parsed.data.text); - return NextResponse.json(result, { status: result.ok ? 200 : 502 }); - } - - const result = await sendEmailReply({ - accountId: parsed.data.account, - to: parsed.data.to, - subject: parsed.data.subject ?? '(no subject)', - text: parsed.data.text, - inReplyTo: parsed.data.inReplyTo, - references: parsed.data.references, - }); - return NextResponse.json(result, { status: result.ok ? 200 : 502 }); -} diff --git a/app/api/comms/route.ts b/app/api/comms/route.ts deleted file mode 100644 index 45141e4d..00000000 --- a/app/api/comms/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NextResponse } from 'next/server'; -import { gatherCommsFeed } from '@/lib/comms-feed'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - const feed = await gatherCommsFeed(); - return NextResponse.json({ feed }); -} diff --git a/app/api/conductor/context/route.ts b/app/api/conductor/context/route.ts deleted file mode 100644 index d37488c6..00000000 --- a/app/api/conductor/context/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NextResponse } from 'next/server'; -import { screenContextFor, screenTitleFor } from '@/lib/screen-context'; - -export const dynamic = 'force-dynamic'; -export const runtime = 'nodejs'; // better-sqlite3 is native — keep off the edge runtime - -// The panel must open instantly: context building can hit live connectors -// (the funnel path awaits Attio + GHL), so it gets a hard time budget and -// degrades to the plain title instead of hanging the dock. -const CONTEXT_BUDGET_MS = 2500; - -/** What the Conductor panel tells the agent about the screen it's docked on. */ -export async function GET(req: Request) { - const path = new URL(req.url).searchParams.get('path') ?? '/'; - const fallback = new Promise<{ title: string; context: string }>((resolve) => - setTimeout(() => resolve({ title: screenTitleFor(path), context: `${screenTitleFor(path)} view of Founder OS.` }), CONTEXT_BUDGET_MS), - ); - const resolved = await Promise.race([screenContextFor(path), fallback]); - return NextResponse.json(resolved); -} diff --git a/app/api/connections/connect/route.ts b/app/api/connections/connect/route.ts index 2a6d451b..fc64dcbf 100644 --- a/app/api/connections/connect/route.ts +++ b/app/api/connections/connect/route.ts @@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'; /** * The Connections board's connect flow. Writes ONLY to .env.local (gitignored) * and only key names the catalog declares for that integration — never to - * Alex's canonical machine files, and never arbitrary env vars. Values are + * // external machine-specific credential files, and never arbitrary env vars. Values are * accepted, stored, and NEVER echoed back. Credential resolution reads * .env.local fresh at call time, so a saved key takes effect without a * server restart. diff --git a/app/api/contacts/tags/route.ts b/app/api/contacts/tags/route.ts deleted file mode 100644 index 55bc51e6..00000000 --- a/app/api/contacts/tags/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { getDb } from '@/lib/data'; -import { CONTACT_TIERS } from '@/lib/life-map'; -import { ContactTagSchema } from '@/lib/schemas'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - return NextResponse.json({ tiers: CONTACT_TIERS, tags: getDb().contactTags.all() }); -} - -export async function POST(request: Request) { - const parsed = ContactTagSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - } - getDb().contactTags.upsert(parsed.data); - return NextResponse.json({ ok: true, tag: parsed.data }); -} - -const RemoveSchema = z.object({ person: z.string().min(1), channel: z.string().min(1) }); - -export async function DELETE(request: Request) { - const parsed = RemoveSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - } - getDb().contactTags.remove(parsed.data.person, parsed.data.channel); - return NextResponse.json({ ok: true }); -} diff --git a/app/api/finances/bank-statement/route.ts b/app/api/finances/bank-statement/route.ts deleted file mode 100644 index 180550b4..00000000 --- a/app/api/finances/bank-statement/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextResponse } from 'next/server'; -import { execFile } from 'node:child_process'; -import { parseBankStatementSummary } from '@/lib/bank-statements'; -import { openBankStore } from '@/lib/bank'; - -export const dynamic = 'force-dynamic'; -export const runtime = 'nodejs'; - -// Extract text from a PDF via the system `pdftotext` (poppler). Tries PATH then -// common Homebrew/usr-local locations; -layout keeps the summary columns aligned. -function pdfToText(buf: Buffer): Promise { - const candidates = ['pdftotext', '/opt/homebrew/bin/pdftotext', '/usr/local/bin/pdftotext']; - return new Promise((resolve, reject) => { - const tryRun = (i: number) => { - if (i >= candidates.length) return reject(new Error('pdftotext not installed (brew install poppler)')); - const child = execFile( - candidates[i], - ['-layout', '-', '-'], - { maxBuffer: 25 * 1024 * 1024, encoding: 'utf8' }, - (err, stdout) => { - if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') return tryRun(i + 1); - if (err) return reject(err); - resolve(stdout); - }, - ); - child.stdin?.end(buf); - }; - tryRun(0); - }); -} - -/** Accept a bank-statement PDF, extract its summary (income/outflow per business - per month), and upsert it into the bank store. Idempotent by account+month. */ -export async function POST(req: Request) { - const ctype = req.headers.get('content-type') ?? ''; - let buf: Buffer | null = null; - try { - if (ctype.includes('multipart/form-data')) { - const file = (await req.formData()).get('file'); - if (file && typeof (file as { arrayBuffer?: unknown }).arrayBuffer === 'function') { - buf = Buffer.from(await (file as File).arrayBuffer()); - } - } else { - buf = Buffer.from(await req.arrayBuffer()); - } - } catch { - buf = null; - } - if (!buf || buf.length === 0) { - return NextResponse.json({ error: 'expected a PDF upload (file field or PDF body)' }, { status: 400 }); - } - - let text: string; - try { - text = await pdfToText(buf); - } catch (e) { - return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 }); - } - - const summary = parseBankStatementSummary(text); - if (!summary) { - return NextResponse.json({ error: 'not a recognizable bank statement summary' }, { status: 400 }); - } - - const store = openBankStore(); - try { - store.upsert(summary); - } finally { - store.close(); - } - return NextResponse.json({ summary }); -} diff --git a/app/api/finances/statements/route.ts b/app/api/finances/statements/route.ts deleted file mode 100644 index 97c89f6c..00000000 --- a/app/api/finances/statements/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NextResponse } from 'next/server'; -import { parseStatementCsv, categorize, type LedgerRow } from '@/lib/statements'; -import { openLedger } from '@/lib/ledger'; - -export const dynamic = 'force-dynamic'; - -/** Accept an uploaded bank/CC statement CSV (multipart `file` field or a raw - text/csv body), parse + categorize it, persist to the separate ledger store, - and report what landed. Non-CSV / unparseable → 400, never a silent success. */ -export async function POST(req: Request) { - const ctype = req.headers.get('content-type') ?? ''; - let text: string | null = null; - try { - if (ctype.includes('multipart/form-data')) { - const form = await req.formData(); - const file = form.get('file'); - if (file && typeof (file as { text?: unknown }).text === 'function') text = await (file as File).text(); - } else { - text = await req.text(); - } - } catch { - text = null; - } - - if (!text || text.trim() === '') { - return NextResponse.json({ error: 'expected a CSV upload (file field or text/csv body)' }, { status: 400 }); - } - - const parsed = parseStatementCsv(text); - if (parsed.length === 0) { - return NextResponse.json( - { error: 'no parseable rows — need Date, Description, and Amount (or Debit/Credit) columns' }, - { status: 400 }, - ); - } - - const rows: LedgerRow[] = parsed.map((r) => ({ ...r, category: categorize(r) })); - const ledger = openLedger(); - try { - const inserted = ledger.insertRows(rows); - return NextResponse.json({ inserted, parsed: parsed.length, byCategory: ledger.monthly() }); - } finally { - ledger.close(); - } -} diff --git a/app/api/funnel/lead-message/route.ts b/app/api/funnel/lead-message/route.ts deleted file mode 100644 index 5b65dba7..00000000 --- a/app/api/funnel/lead-message/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse } from 'next/server'; -import { gatherCommsFeed } from '@/lib/comms-feed'; -import { lastMessageFor } from '@/lib/funnel-contact'; - -export const dynamic = 'force-dynamic'; - -/** The comms feed walks live IMAP — cap it so a pinned dossier never hangs. */ -const FEED_BUDGET_MS = 4000; - -/** - * Last message exchanged with one lead — fetched when a dossier card pins. - * Honest on every path: `message: null` = no thread on record, - * `unavailable: true` = the comms feed itself couldn't answer in time. - */ -export async function GET(request: Request) { - const url = new URL(request.url); - const name = url.searchParams.get('name')?.trim(); - const email = url.searchParams.get('email')?.trim() || null; - if (!name) { - return NextResponse.json({ error: 'name is required' }, { status: 400 }); - } - const feed = await Promise.race([ - gatherCommsFeed(200).catch(() => null), - new Promise((resolve) => setTimeout(() => resolve(null), FEED_BUDGET_MS)), - ]); - if (!feed) { - return NextResponse.json({ message: null, unavailable: true }); - } - return NextResponse.json({ message: lastMessageFor({ name, email }, feed), unavailable: false }); -} diff --git a/app/api/funnel/route.ts b/app/api/funnel/route.ts deleted file mode 100644 index d2b93135..00000000 --- a/app/api/funnel/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getDb } from '@/lib/data'; -import { funnelSummary, splitFunnelJourneys } from '@/lib/funnel'; -import { attioFunnelJourneys } from '@/lib/funnel-live'; -import { ghlFunnelJourneys } from '@/lib/funnel-ghl'; -import { mergeTrakyoTouches, trakyoTouches } from '@/lib/funnel-trakyo'; -import { FunnelVentureSchema, type FunnelVenture } from '@/lib/schemas'; - -export const dynamic = 'force-dynamic'; - -export async function GET(req: Request) { - const raw = new URL(req.url).searchParams.get('venture'); - let venture: FunnelVenture | undefined; - if (raw !== null) { - const parsed = FunnelVentureSchema.safeParse(raw); - if (!parsed.success) { - return NextResponse.json({ error: `unknown venture: ${raw}` }, { status: 400 }); - } - venture = parsed.data; - } - const now = new Date(); - // Live Attio ∪ GHL when available (Attio venture = deal-name heuristic, - // GHL is all LC); seeded funnel otherwise. Quiet >90d splits into `archived`. - const [attioLive, ghlLive] = await Promise.all([attioFunnelJourneys(now), ghlFunnelJourneys(now)]); - const liveJourneys = [...(attioLive?.journeys ?? []), ...(ghlLive?.journeys ?? [])]; - const isLive = liveJourneys.length > 0; - const all = isLive - ? mergeTrakyoTouches(liveJourneys, await trakyoTouches()).filter((j) => !venture || j.venture === venture) - : getDb().funnel.journeys(venture); - const { active, archived } = splitFunnelJourneys(all, now); - return NextResponse.json({ - summary: funnelSummary(active), - journeys: active, - archived, - source: isLive - ? [attioLive?.journeys.length ? 'attio' : null, ghlLive?.journeys.length ? 'ghl' : null] - .filter(Boolean) - .join('+') - : 'seed', - ...(isLive - ? { - excluded: (attioLive?.closedLost ?? 0) + (ghlLive?.excluded ?? 0), - total: (attioLive?.total ?? 0) + (ghlLive?.total ?? 0), - } - : {}), - }); -} diff --git a/app/api/lead-magnets/[id]/route.ts b/app/api/lead-magnets/[id]/route.ts deleted file mode 100644 index 30d5286e..00000000 --- a/app/api/lead-magnets/[id]/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { getDb } from '@/lib/data'; -import { LeadMagnetStatusSchema } from '@/lib/schemas'; - -export const dynamic = 'force-dynamic'; - -/** - * One lead magnet. PATCH edits it (retire a page, fix a typo, repoint a URL - * after a custom domain lands); DELETE drops it. Both 404 honestly when the id - * is not there rather than reporting a success that did nothing. - */ -const PatchSchema = z - .object({ - name: z.string().min(1).max(120), - url: z.string().url(), - offer: z.string().max(400), - status: LeadMagnetStatusSchema, - captures: z.enum(['email', 'booking', 'none']), - destination: z.string().max(200), - source: z.string().max(300), - launchedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'launchedAt must be YYYY-MM-DD'), - notes: z.string().max(2000), - }) - .partial(); - -export async function PATCH(req: Request, { params }: { params: { id: string } }) { - const parsed = PatchSchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - - const db = getDb(); - const existing = db.leadMagnets.byId(params.id); - if (!existing) return NextResponse.json({ error: 'lead magnet not found' }, { status: 404 }); - - // id and origin are not editable: the id is referenced by whatever links to - // it, and origin is what protects an OS-made row from the seed. - const updated = { ...existing, ...parsed.data, id: existing.id, origin: existing.origin }; - db.leadMagnets.insert(updated); - return NextResponse.json({ leadMagnet: updated }); -} - -export async function DELETE(_req: Request, { params }: { params: { id: string } }) { - const removed = getDb().leadMagnets.remove(params.id); - if (!removed) return NextResponse.json({ error: 'lead magnet not found' }, { status: 404 }); - return NextResponse.json({ ok: true, id: params.id }); -} diff --git a/app/api/lead-magnets/route.ts b/app/api/lead-magnets/route.ts deleted file mode 100644 index 69114f11..00000000 --- a/app/api/lead-magnets/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { getDb } from '@/lib/data'; -import { LeadMagnetStatusSchema } from '@/lib/schemas'; - -export const dynamic = 'force-dynamic'; - -/** - * The lead magnet register. GET lists every page we ship; POST adds one from - * inside the OS (a content pipeline can post here after it deploys a page, and - * the /content/lead-magnets form posts here by hand). - * - * Rows created here are stamped origin 'os' by the repo, so re-seeding the - * database can never delete them. - */ -const CreateSchema = z.object({ - name: z.string().min(1).max(120), - url: z.string().url(), - offer: z.string().max(400).default(''), - status: LeadMagnetStatusSchema.default('live'), - captures: z.enum(['email', 'booking', 'none']).default('email'), - destination: z.string().max(200).default(''), - source: z.string().max(300).default(''), - launchedAt: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/, 'launchedAt must be YYYY-MM-DD') - .optional(), - notes: z.string().max(2000).default(''), -}); - -/** "The Operator Teardown" -> "the-operator-teardown" */ -function slugify(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, '') - .slice(0, 60); -} - -export async function GET() { - return NextResponse.json({ leadMagnets: getDb().leadMagnets.all() }); -} - -export async function POST(req: Request) { - const parsed = CreateSchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - } - const input = parsed.data; - const db = getDb(); - - const base = slugify(input.name) || 'lead-magnet'; - const taken = new Set(db.leadMagnets.all().map((m) => m.id)); - let id = base; - for (let n = 2; taken.has(id); n++) id = `${base}-${n}`; - - const row = { - ...input, - id, - launchedAt: input.launchedAt ?? new Date().toISOString().slice(0, 10), - origin: 'os' as const, - }; - db.leadMagnets.insert(row); - return NextResponse.json({ leadMagnet: row }, { status: 201 }); -} diff --git a/app/api/life/map/route.ts b/app/api/life/map/route.ts deleted file mode 100644 index 30da8735..00000000 --- a/app/api/life/map/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NextResponse } from 'next/server'; -import { buildLifeMap } from '@/lib/life-map'; -import { LifeMapSchema } from '@/lib/schemas'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - return NextResponse.json(LifeMapSchema.parse(buildLifeMap())); -} diff --git a/app/api/metrics/route.ts b/app/api/metrics/route.ts deleted file mode 100644 index 25be3314..00000000 --- a/app/api/metrics/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getDb } from '@/lib/data'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - const db = getDb(); - return NextResponse.json({ metrics: db.metrics.all() }); -} diff --git a/app/api/models/omniroute/route.ts b/app/api/models/omniroute/route.ts new file mode 100644 index 00000000..64a72ae5 --- /dev/null +++ b/app/api/models/omniroute/route.ts @@ -0,0 +1,44 @@ +import { + NextResponse, +} from 'next/server'; + +import { + listOmniRouteSelectableModels, +} from '@/lib/control-plane/omniroute-model-catalog'; + + +export const dynamic = + 'force-dynamic'; + + +export async function GET() { + try { + const models = + await listOmniRouteSelectableModels(); + + + return NextResponse.json({ + available: + true, + + models, + }); + } catch { + /* + * OmniRoute being unavailable is an infrastructure state, + * not a reason for the whole Startup API to fail. + * + * Never expose secret/configuration details here. + */ + return NextResponse.json({ + available: + false, + + models: + [], + + error: + 'omniroute_unavailable', + }); + } +} \ No newline at end of file diff --git a/app/api/roadmap/route.ts b/app/api/roadmap/route.ts deleted file mode 100644 index 7f89058c..00000000 --- a/app/api/roadmap/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getDb } from '@/lib/data'; -import { groupRoadmapByQuarter } from '@/lib/roadmap'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - const db = getDb(); - return NextResponse.json({ quarters: groupRoadmapByQuarter(db.roadmap.all()) }); -} diff --git a/app/api/runtime/health/route.ts b/app/api/runtime/health/route.ts new file mode 100644 index 00000000..7724c32e --- /dev/null +++ b/app/api/runtime/health/route.ts @@ -0,0 +1,28 @@ +import { + NextResponse, +} from 'next/server'; + +import { + buildInfrastructureHealthSnapshot, +} from '@/lib/control-plane/infrastructure-health'; + + +export const dynamic = + 'force-dynamic'; + + +export async function GET() { + const snapshot = + await buildInfrastructureHealthSnapshot(); + + + return NextResponse.json( + snapshot, + { + headers: { + 'cache-control': + 'no-store', + }, + }, + ); +} \ No newline at end of file diff --git a/app/api/social/[platform]/route.ts b/app/api/social/[platform]/route.ts deleted file mode 100644 index ede1a81c..00000000 --- a/app/api/social/[platform]/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getDb } from '@/lib/data'; -import { platformDetail, syncFromZernioConfig } from '@/lib/social'; -import type { SocialPlatform } from '@/lib/schemas'; - -export const dynamic = 'force-dynamic'; - -export async function GET(_req: Request, { params }: { params: { platform: string } }) { - const db = getDb(); - syncFromZernioConfig(db); - const detail = platformDetail(db, params.platform as SocialPlatform); - if (!detail) { - return NextResponse.json({ error: `unknown platform: ${params.platform}` }, { status: 404 }); - } - return NextResponse.json(detail); -} diff --git a/app/api/social/dm/reply/route.ts b/app/api/social/dm/reply/route.ts deleted file mode 100644 index 4d725672..00000000 --- a/app/api/social/dm/reply/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { getDb } from '@/lib/data'; -import { sendManyChatText } from '@/lib/connectors/manychat'; -import type { SocialDmMessage } from '@/lib/schemas'; - -export const dynamic = 'force-dynamic'; - -const ReplySchema = z.object({ - subscriberId: z.string().min(1), - text: z.string().min(1).max(2000), -}); - -/** - * Answer an Instagram DM from the /social inbox. Sends for real via ManyChat - * (sendContent). Only on a real 2xx do we store the outbound message — a failed - * or unconfigured send returns 502 and stores nothing, so the inbox never shows - * a reply that didn't actually go out. - */ -export async function POST(request: Request): Promise { - const parsed = ReplySchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ ok: false, error: parsed.error.flatten() }, { status: 400 }); - } - const { subscriberId, text } = parsed.data; - - const result = await sendManyChatText(subscriberId, text); - if (!result.ok) { - return NextResponse.json({ ok: false, error: result.detail }, { status: 502 }); - } - - const db = getDb(); - // Carry the display name/handle from the existing thread so the stored - // outbound message renders consistently. - const prior = db.social.dmMessages('instagram').find((m) => m.subscriberId === subscriberId); - const ts = new Date().toISOString(); - const message: SocialDmMessage = { - id: `mc-out-${subscriberId}-${ts}`, - platform: 'instagram', - subscriberId, - name: prior?.name ?? subscriberId, - handle: prior?.handle ?? null, - text, - direction: 'out', - tag: null, - ts, - source: 'manychat', - }; - db.social.upsertDmMessage(message); - - return NextResponse.json({ ok: true, message }); -} diff --git a/app/api/social/history/route.ts b/app/api/social/history/route.ts deleted file mode 100644 index f100fd18..00000000 --- a/app/api/social/history/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NextResponse } from 'next/server'; -import { zernioRecentPosts } from '@/lib/connectors/zernio'; - -export const dynamic = 'force-dynamic'; - -/** Live published-post history from Zernio/Late (real captions + post URLs). - Distinct from /api/social/posts, which is the outgoing publish queue. - `?limit=` caps the count. */ -export async function GET(req: Request) { - const limit = Number(new URL(req.url).searchParams.get('limit')) || 6; - const posts = await zernioRecentPosts(Math.min(Math.max(limit, 1), 24)); - return NextResponse.json({ posts }); -} diff --git a/app/api/social/posts/route.ts b/app/api/social/posts/route.ts deleted file mode 100644 index 1559f39c..00000000 --- a/app/api/social/posts/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextResponse } from 'next/server'; -import { randomUUID } from 'node:crypto'; -import { z } from 'zod'; -import { getDb } from '@/lib/data'; -import { SocialPlatformSchema, type SocialPost } from '@/lib/schemas'; - -export const dynamic = 'force-dynamic'; - -/** The post queue, newest first. */ -export async function GET() { - return NextResponse.json({ posts: getDb().socialPosts.all() }); -} - -const CreateSchema = z.object({ - caption: z.string().min(1, 'caption is required'), - platforms: z.array(SocialPlatformSchema).min(1, 'pick at least one platform'), - mediaUrl: z.string().url().nullish(), - scheduledFor: z.string().nullish(), -}); - -/** - * Queue a post for the Zernio-publishing agent. This does NOT post live — it - * persists with status 'queued'; the Social agent on the Agents page picks it - * up. Wiring an actual Zernio publish is a deliberate later step. - */ -export async function POST(request: Request) { - const parsed = CreateSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - - const post: SocialPost = { - id: randomUUID(), - caption: parsed.data.caption, - mediaUrl: parsed.data.mediaUrl ?? null, - platforms: parsed.data.platforms, - status: 'queued', - scheduledFor: parsed.data.scheduledFor ?? null, - createdAt: new Date().toISOString(), - }; - getDb().socialPosts.enqueue(post); - return NextResponse.json({ post }, { status: 201 }); -} diff --git a/app/api/social/route.ts b/app/api/social/route.ts deleted file mode 100644 index 10fbc7f1..00000000 --- a/app/api/social/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getDb } from '@/lib/data'; -import { - audienceGrowth, - audienceTotal, - buildSocialDashboard, - dmGrowth, - monthlyAudienceGrowthPct, - syncFromZernioConfig, - totalDms, -} from '@/lib/social'; -import { buildEmailList } from '@/lib/email-list'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - const db = getDb(); - // Every read captures today's follower counts from the Zernio config, so - // growth history accrues for real just by using the dashboard. - syncFromZernioConfig(db); - return NextResponse.json({ - ...buildSocialDashboard(db), - emailList: buildEmailList(db), - totalDms: totalDms(db), - audienceTotal: audienceTotal(db), - audienceGrowth: audienceGrowth(db), // { d7, d30, d60, allTime } - dmGrowth: dmGrowth(db), // { d7, d30, d60, allTime } - monthlyGrowthPct: monthlyAudienceGrowthPct(db), // back-compat - }); -} diff --git a/app/api/social/series/route.ts b/app/api/social/series/route.ts deleted file mode 100644 index 8fb2e134..00000000 --- a/app/api/social/series/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getDb } from '@/lib/data'; -import { - audienceSeries, - dmSeries, - syncFromZernioConfig, - DM_COLOR, - GROWTH_RANGES, -} from '@/lib/social'; - -export const dynamic = 'force-dynamic'; - -/** - * Labelled history series for the Social pop-out charts. - * ?metric=audience → per-channel + "All audience" follower/subscriber series - * ?metric=dms → total DMs-over-time - * Growth per range is derived client-side from these points. - */ -export async function GET(request: Request) { - const metric = new URL(request.url).searchParams.get('metric'); - const db = getDb(); - syncFromZernioConfig(db); - - if (metric === 'audience') { - const { channels, all } = audienceSeries(db); - return NextResponse.json({ metric, ranges: GROWTH_RANGES, series: [all, ...channels] }); - } - - if (metric === 'dms') { - return NextResponse.json({ - metric, - ranges: GROWTH_RANGES, - series: [{ key: 'total', label: 'Total DMs', color: DM_COLOR, points: dmSeries(db) }], - }); - } - - return NextResponse.json({ error: "metric must be 'audience' or 'dms'" }, { status: 400 }); -} diff --git a/app/api/social/sync/route.ts b/app/api/social/sync/route.ts deleted file mode 100644 index 1ca62102..00000000 --- a/app/api/social/sync/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getDb } from '@/lib/data'; -import { syncFromZernioLive } from '@/lib/social-live'; -import { zernioLiveAccounts } from '@/lib/connectors/zernio'; - -export const dynamic = 'force-dynamic'; - -/** Force a live follower-count sync from Zernio/Late and report what landed. - GET and POST both work so it's trivial to trigger from a browser or curl. */ -async function runSync() { - const db = getDb(); - const accounts = await zernioLiveAccounts(); - const recorded = await syncFromZernioLive(db, { source: async () => accounts }); - return NextResponse.json({ - ok: true, - recorded, - syncedAt: new Date().toISOString(), - source: Object.keys(accounts).length > 0 ? 'zernio-live' : 'config-fallback', - accounts, - }); -} - -export async function POST() { - return runSync(); -} - -export async function GET() { - return runSync(); -} diff --git a/app/api/ventures/route.ts b/app/api/ventures/route.ts deleted file mode 100644 index c4d6b21b..00000000 --- a/app/api/ventures/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NextResponse } from 'next/server'; -import { VENTURES } from '@/lib/ventures'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - return NextResponse.json({ ventures: VENTURES }); -} diff --git a/app/api/webhooks/manychat/route.ts b/app/api/webhooks/manychat/route.ts deleted file mode 100644 index 2b9b532e..00000000 --- a/app/api/webhooks/manychat/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getDb } from '@/lib/data'; -import { parseManyChatWebhook } from '@/lib/connectors/manychat-webhook'; - -export const dynamic = 'force-dynamic'; - -/** - * ManyChat "External Request" ingest. ManyChat's API can't be polled for DMs, - * so this push endpoint is how the /social Instagram DM inbox goes live: point a - * ManyChat automation's External Request (POST) at this URL with a JSON body - * carrying the contact + message. Each message upserts by id, so replays don't - * duplicate. - * - * If MANYCHAT_WEBHOOK_SECRET is set, the request must carry a matching - * `x-manychat-secret` header (add it in the ManyChat External Request headers). - */ -export async function POST(request: Request): Promise { - const secret = process.env.MANYCHAT_WEBHOOK_SECRET; - if (secret && request.headers.get('x-manychat-secret') !== secret) { - return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); - } - - const raw = await request.json().catch(() => null); - const message = parseManyChatWebhook(raw); - if (!message) { - return NextResponse.json({ error: 'payload missing a subscriber id' }, { status: 400 }); - } - - getDb().social.upsertDmMessage(message); - return NextResponse.json({ ok: true, id: message.id, subscriberId: message.subscriberId }); -} - -/** Lightweight health check: how many DMs are stored. */ -export async function GET(): Promise { - const secret = process.env.MANYCHAT_WEBHOOK_SECRET; - return NextResponse.json({ - ok: true, - endpoint: 'manychat-webhook', - secured: Boolean(secret), - stored: getDb().social.dmMessages('instagram').length, - }); -} diff --git a/app/approvals/page.tsx b/app/approvals/page.tsx new file mode 100644 index 00000000..4c2ebace --- /dev/null +++ b/app/approvals/page.tsx @@ -0,0 +1,369 @@ +import { + Bell, + CheckCircle2, + ShieldAlert, +} from 'lucide-react'; + +import { + PageHeader, +} from '@/components/PageHeader'; + +import { + getControlPlaneRuntimeStore, +} from '@/lib/control-plane/runtime-data'; + +import { + acknowledgeCeoInboxAction, + approveResearchAction, + confirmManualExecutionAction, + rejectResearchAction, +} from '@/lib/control-plane/operator-actions'; + +export const dynamic = + 'force-dynamic'; + +function iconFor( + category: string, +) { + if ( + category === + 'security_alert' + ) { + return ShieldAlert; + } + + if ( + category === + 'approval_request' + ) { + return CheckCircle2; + } + + return Bell; +} + +export default function ApprovalsPage() { + const store = + getControlPlaneRuntimeStore(); + + const items = + store.listCeoInbox(); + + const unresolved = + items.filter( + (item) => + item.status !== + 'resolved', + ); + + return ( +
+ + +
+

+ Governed approval requests, notifications and security alerts + addressed to the human CEO. +

+ +

+ Decision buttons below do not edit workflow state directly. They + submit normal artifacts and transition requests to the deterministic + Control Plane, which applies the same permissions and gates as every + other company action. +

+
+ +
+
+
+ Unresolved +
+
+ {unresolved.length} +
+
+ +
+
+ Approval requests +
+
+ { + items.filter( + (item) => + item.category === + 'approval_request' && + item.status !== + 'resolved', + ).length + } +
+
+ +
+
+ Security alerts +
+
+ { + items.filter( + (item) => + item.category === + 'security_alert' && + item.status !== + 'resolved', + ).length + } +
+
+
+ + {items.length === 0 ? ( +
+
+ CEO inbox is empty +
+ +

+ No human approval request or operator alert has been produced by a + governed workflow yet. +

+
+ ) : ( +
+ {items.map((item) => { + const Icon = + iconFor( + item.category, + ); + + return ( +
+
+
+ +
+ +
+
+ + {item.category} + + + {item.status} + +
+ +
+ {item.summary} +
+ +
+ action: {item.action} + {item.workflowId + ? ` · workflow: ${item.workflowId}` + : ''} + {item.stateAtCreation + ? ` · state: ${item.stateAtCreation}` + : ''} +
+ + {item.status === + 'pending' && ( +
+ + + +
+ )} + + {item.status !== + 'resolved' && + item.action === + 'decide_research_proposal' && ( +
+
+ + +
+ Approve for risk analysis +
+ +