diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7106598 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# PIG — production image. +# +# Multi-stage so the runtime image carries no build toolchain and no source +# maps. The front end is built into the API's static directory and served from +# the same origin, which matters for more than tidiness: auth sessions are +# per-origin, so splitting the app across two hostnames turns sign-in into a +# redirect loop that looks like a broken deployment. + +FROM node:22-alpine AS build +WORKDIR /app + +# Manifests first, so a dependency install is cached across source-only edits. +COPY package.json package-lock.json* ./ +COPY packages/core/package.json packages/core/ +COPY packages/db/package.json packages/db/ +COPY packages/prime/package.json packages/prime/ +COPY apps/api/package.json apps/api/ +COPY apps/web/package.json apps/web/ +COPY apps/mcp/package.json apps/mcp/ +RUN npm install --no-audit --no-fund + +COPY . . + +# Typecheck as a build gate. A deploy that does not compile should fail here, +# loudly, rather than at runtime in front of a user. +RUN npx tsc --noEmit -p packages/core/tsconfig.json \ + && npx tsc --noEmit -p packages/db/tsconfig.json \ + && npx tsc --noEmit -p packages/prime/tsconfig.json \ + && npx tsc --noEmit -p apps/api/tsconfig.json \ + && npx tsc --noEmit -p apps/web/tsconfig.json \ + && npx tsc --noEmit -p apps/mcp/tsconfig.json + +RUN npm run build -w @pig/web + +# ---------------------------------------------------------------- runtime +FROM node:22-alpine AS runtime +WORKDIR /app + +ENV NODE_ENV=production + +# Reinstall without dev dependencies. tsx is needed at runtime because the +# server runs TypeScript directly; everything else is production-only. +COPY package.json package-lock.json* ./ +COPY packages/core/package.json packages/core/ +COPY packages/db/package.json packages/db/ +COPY packages/prime/package.json packages/prime/ +COPY apps/api/package.json apps/api/ +COPY apps/mcp/package.json apps/mcp/ +RUN npm install --omit=dev --no-audit --no-fund && npm install tsx --no-audit --no-fund + +COPY packages ./packages +COPY apps/api ./apps/api +COPY apps/mcp ./apps/mcp +COPY --from=build /app/apps/web/dist ./apps/web/dist + +# Run unprivileged. The node image ships a `node` user for exactly this. +RUN chown -R node:node /app +USER node + +EXPOSE 8920 + +# The health endpoint is unauthenticated by design so this works without +# credentials baked into the image. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:8920/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["npx", "tsx", "apps/api/src/server.ts"] diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example new file mode 100644 index 0000000..32557e8 --- /dev/null +++ b/deploy/Caddyfile.example @@ -0,0 +1,29 @@ +# Caddy — reverse proxy for PIG. +# +# Serve the app and the API from ONE hostname. Auth sessions live in +# per-origin browser storage, so splitting them across two hostnames makes +# sign-in loop endlessly in a way that looks like a server fault. + +primeintellectgrowth.com, www.primeintellectgrowth.com { + encode zstd gzip + + # The MCP endpoint, when Streamable HTTP is enabled. Same origin as the + # app so it shares the session and needs no CORS allowance. + reverse_proxy 127.0.0.1:8920 + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + # The app is entirely first-party except for the auth provider, which + # it must reach over XHR. + Content-Security-Policy "default-src 'self'; connect-src 'self' https://*.supabase.co; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'" + -Server + } +} + +# Short alias. A redirect rather than a second origin, deliberately — see above. +pig.karti.ai { + redir https://primeintellectgrowth.com{uri} permanent +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..4270dfa --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,76 @@ +# Deploying PIG + +PIG is one container plus a Postgres, behind any reverse proxy that terminates +TLS. Nothing here is specific to a particular host. + +## 1. DNS + +Point the apex and `www` at the machine. Both must resolve before the proxy can +obtain a certificate. + +``` +A primeintellectgrowth.com -> +A www.primeintellectgrowth.com -> +``` + +## 2. Configuration + +```bash +cp .env.example .env # then edit +``` + +The values that must be set for a production start: + +| Variable | Why | +|---|---| +| `POSTGRES_PASSWORD` | Generate a fresh one; never reuse another service's | +| `PIG_PUBLIC_URL` | The single origin the app is served from | +| `SUPABASE_URL` / `SUPABASE_ANON_KEY` | Authentication. The app refuses to start in production without a Supabase URL, because it would otherwise serve the whole CRM unauthenticated | +| `PIG_ADMIN_EMAILS` | Who may administer. **Every address here must already have an account** — an unregistered address listed as an admin is a standing offer of admin rights to whoever claims it first | + +Optional: `PRIME_API_KEY` (scope it to `Availability → Read` only), +`PIGGY_ENABLED` + `ANTHROPIC_API_KEY`, and the Slack and Buzz credentials. + +## 3. Start + +```bash +docker compose -p pig up -d --build +docker compose -p pig exec app npx tsx packages/db/src/migrate.ts +docker compose -p pig exec app npx tsx packages/db/src/seed/index.ts # optional +``` + +Use `-p pig`. A compose project that shares a name with a neighbouring stack +will adopt its volumes, which is a memorable way to lose a database. + +## 4. Reverse proxy + +See `Caddyfile.example`. Serve the app and API from the **same** origin. + +## 5. Verify + +```bash +curl -s https://primeintellectgrowth.com/api/health +# {"ok":true,"service":"pig","version":"0.1.0"} +``` + +## Upgrading + +```bash +git pull +docker compose -p pig up -d --build +docker compose -p pig exec app npx tsx packages/db/src/migrate.ts +``` + +Migrations are additive and safe to re-run; Drizzle tracks what has been +applied. Take a dump before a major upgrade anyway: + +```bash +docker compose -p pig exec db pg_dump -U pig pig | gzip > pig-$(date +%F).sql.gz +``` + +## A note on the auth project + +PIG verifies JWTs but authorizes from its own `users` table. If the Supabase +project is shared with another application, its users get **nothing** here until +they are explicitly invited. That is deliberate, and it is why a valid token can +still return `403 needs_profile`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8b0be1c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +# PIG — self-hosted deployment. +# +# docker compose -p pig up -d --build +# +# The project name matters. Use something PIG-specific (`-p pig`) so this stack +# never adopts another application's volumes — a compose project silently +# inheriting a neighbouring database is a genuinely nasty way to lose data. + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-pig} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set} + POSTGRES_DB: ${POSTGRES_DB:-pig} + volumes: + - pig-pgdata:/var/lib/postgresql/data + # Not published to the host. The application reaches it over the compose + # network; exposing Postgres publicly is never what you want. + expose: + - '5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-pig} -d ${POSTGRES_DB:-pig}'] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgres://${POSTGRES_USER:-pig}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-pig} + NODE_ENV: production + PIG_PORT: 8920 + PIG_PUBLIC_URL: ${PIG_PUBLIC_URL:?PIG_PUBLIC_URL must be set} + SUPABASE_URL: ${SUPABASE_URL:?SUPABASE_URL must be set in production} + SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} + SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY:-} + PIG_ADMIN_EMAILS: ${PIG_ADMIN_EMAILS:-} + PIG_INVITE_CODE: ${PIG_INVITE_CODE:-} + PRIME_API_KEY: ${PRIME_API_KEY:-} + PRIME_SYNC_ENABLED: ${PRIME_SYNC_ENABLED:-false} + PIGGY_ENABLED: ${PIGGY_ENABLED:-false} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN:-} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET:-} + BUZZ_RELAY_URL: ${BUZZ_RELAY_URL:-} + # Bound to loopback: TLS termination belongs to the reverse proxy in front, + # not to this container. + ports: + - '127.0.0.1:${PIG_HOST_PORT:-8920}:8920' + +volumes: + pig-pgdata: + # Named explicitly so it is obvious which volume holds the data, and so a + # `docker compose down -v` mistake is at least a legible one. + name: pig-pgdata diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..790f521 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,64 @@ +# Connecting an agent + +PIG is a first-class application for agents. The same MCP server serves every +client, so nobody is asked to use a different tool than the one they already +work in. + +## What connects + +| Client | How | +|---|---| +| **Claude Code** | `claude mcp add pig -- npx -y @pig/mcp` | +| **Codex** | Add PIG as an MCP server in its config, with the same env vars | +| **prime-agent** | It is an MCP *client*; add PIG through `/mcp` | +| **Buzz** | Agents reach PIG through the ACP bridge's MCP support | + +## Setup + +Create an API key in PIG under **Settings → API keys**, then: + +```bash +export PIG_URL=https://primeintellectgrowth.com +export PIG_API_KEY=pig_... +``` + +Scope the key to `read` unless the agent genuinely needs to write. An agent +acting for you is a **separate principal** from you: it has its own audit trail +and can be revoked without disturbing your session, and it can never reach +further than you can. + +## The tools + +| Tool | What it answers | +|---|---| +| `pig_whoami` | Who am I acting for, and which teams am I on? | +| `pig_my_pipeline` | Where are we? What needs attention? | +| `pig_capacity_match` | What have we bought that would serve this customer? | +| `pig_margin_report` | What is each block earning against what it cost? | +| `pig_idle_capacity` | What are we paying for and not selling? | +| `pig_inventory_search` | What could we buy to cover demand we cannot serve? | +| `pig_search` | Find an account | +| `pig_get_account` | Everything about one account | +| `pig_log_activity` | Record a call, meeting or note | + +`pig_capacity_match` is the one worth learning. Ask it in plain language: + +> "A customer wants 128 H100s with InfiniBand for three months, ceiling $2.80 +> per GPU-hour. What have we got?" + +It returns ranked matches, preferring blocks that are sitting idle — those +hours are already paid for — and warns explicitly when a match would sell below +break-even. + +## Why the surface is small + +Nine tools, each doing one thing. A sprawling tool list measurably degrades +model performance, and anything genuinely niche is reachable through +`pig_search` or the HTTP API. If you need something that is not here, it is +probably better added as a service method than as a tenth tool. + +## What it cannot do + +The MCP server holds an API key and calls the same HTTP API a browser does. It +has no database credentials and no privileged path. There is deliberately no +tool that provisions infrastructure, spends money, or emails a customer. diff --git a/docs/ontology.md b/docs/ontology.md new file mode 100644 index 0000000..4e70410 --- /dev/null +++ b/docs/ontology.md @@ -0,0 +1,106 @@ +# The ontology + +Why PIG is shaped the way it is. Read `packages/core/src/ontology.ts` alongside +this — the code carries the same reasoning in comments, and it is the version +that cannot go stale. + +## The one table that matters + +``` +capacity_commitment ──┐ + (what we bought, │ + at a known cost) │ + ├──▶ allocation ──▶ margin, utilisation, idle + │ (what we sold, +demand_deal ──┘ at a known price) + (what we sold) +``` + +Margin, utilisation and idle capacity all fall out of that single join. No +generic CRM can compute any of them, because none has a concept of a +cost-bearing commitment sitting behind the pipeline. + +**Cost is charged against the full commitment, not only the hours that sold.** +Unsold hours are already paid for. Charging only the allocated share would +report a healthy margin on a block that is losing money — precisely the failure +this system exists to prevent. + +## Three teams + +**Supply**, **demand**, and **research**. Research is first-class rather than an +afterthought: internal research burn is real capacity consumption competing with +revenue for the same GPUs, and margin math that cannot see it is wrong. + +## Pipelines + +**Demand** — `qualification → legal → scoping → proposal → procurement → POC → +deployment → expansion`. Note that **legal sits second**. Customers do not hand +workloads to an infrastructure provider before paper is executed. Most CRMs put +contracting at the end of the funnel and are simply wrong about it here. + +**Supply** — `sourced → qualifying → technical diligence → financial diligence → +pricing → contracting → onboarding → live → renewal`. Qualification is split in +two because accepting capacity is a two-key decision: engineering judges whether +the cluster can do the work, finance judges whether the economics clear. Both +verdicts are recorded attributably. + +## Capacity is a shape, not a rectangle + +A commitment carries `shape: {intervals[], quantities[]}` — how many GPUs are +held during each interval. Real contracts ramp across tranches and step down at +checkpoints. A single start/end/total flattens that and then reports +availability that does not exist in the month someone wants it. + +Availability at any instant is therefore: + +``` +available(t) = shapeQuantityAt(t) − Σ overlapping allocations(t) +``` + +## Holds reserve; they do not sell + +A live hold removes capacity from everyone else's availability — otherwise two +sellers promise the same GPUs — but does not count toward utilisation or +revenue, because it has not sold. Conflating the two is how a pipeline of +optimistic holds comes to look like a full book. Holds expire on a timer so a +stalled deal releases inventory automatically. + +## Service levels come in three shapes + +A compute aggregator generally **cannot** offer a conventional uptime guarantee +on capacity it resells and does not control, and says so publicly. So `slaKind` +distinguishes: + +- `none` — self-serve, no commitment at all +- `credits_policy` — a reliability tier plus service credits. **Not** an uptime + guarantee, and must never be displayed as one +- `negotiated` — a real signed SLA with committed, measurable metrics + +Remedies matter as much as targets. `remedyType` includes `fee_abatement`, +where payment obligations are *cancelled* for affected capacity until service is +restored — uncapped in duration and materially better than a capped credit. It +cannot be expressed as a credit percentage, so it gets its own representation. + +## Export control is a predicate, not a flag + +US controls on advanced computing apply an **ultimate parent** test that reaches +through the corporate tree: an entity can be restricted because of where its +parent is headquartered, even when the entity itself sits somewhere +unrestricted. Country of incorporation is therefore not a valid key. + +Compliance is evaluated **on the allocation edge** — this buyer, this beneficial +owner, this physical jurisdiction — recorded with its reasoning and rule +version, and re-evaluated on resale or migration. See +`packages/db/src/schema/compliance.ts`. PIG records and surfaces; it does not +make the legal determination for you. + +## Evidence + +Agent-derived claims land in `facts` with a confidence score, a band, evidence +and a source URL. Only `verified` claims self-apply; anything weaker waits for a +human. An agent permitted to write unattributed claims will eventually write a +wrong one, and nobody will be able to tell which. + +The same principle governs seed data about real people: every record carries a +grade and a citation, authorship is never promoted to employment, and no email +address is ever inferred. diff --git a/docs/seed-data.md b/docs/seed-data.md new file mode 100644 index 0000000..d9ac588 --- /dev/null +++ b/docs/seed-data.md @@ -0,0 +1,53 @@ +# Seed data and its provenance + +PIG ships with a roster of publicly documented people so the application is +legible on first run. It is public research, not an assertion of fact. + +## Rules applied + +1. **Every record carries a confidence grade and a source URL.** `confirmed` + means two or more independent sources; `probable` means one good one; + `unverified` means a single weak or self-reported source. The grade is shown + in the interface wherever the record appears — a single-source claim about a + real person must never look as solid as a corroborated one. + +2. **No email addresses.** None are published by the subjects. Guessing them + from a name and a domain is unreliable, and when a guess lands it lands on a + real person who did not ask to be contacted. + +3. **Authorship is not employment.** People named on papers or in repositories + are recorded with the affiliation actually evidenced — `contributor`, + `resident`, `alumni` — never promoted to `staff` to make the roster look + fuller. + +4. **"Not found" is recorded, not invented.** Where a name was supplied but + could not be sourced, it appears in `UNRESOLVED_NAMES` with a note. Absence + is weak evidence: a junior or deliberately non-public employee looks + identical to a failed search. + +## Known limitations + +- Sourced August 2026. It will go stale — that is what `sourceUrl` is for. +- LinkedIn, Glassdoor and several job boards refuse automated fetching, so some + records rest on search-result summaries rather than a page that was read + end to end. Those are graded accordingly. +- One departure is recorded explicitly (a co-author who now lists a different + company) so the roster does not quietly imply current employment. +- One name in the original brief could not be tied to the company by any source + and is deliberately **not** seeded. + +## Removing yourself + +If you are seeded here and would rather not be, open an issue and the record +will be removed. Deleting a contact in the application also removes it +permanently. + +## Turning it off + +Seeding is a separate command and is never automatic: + +```bash +npm run db:seed # opt in +``` + +Skip it and PIG starts empty apart from a development user.