Files
pig/docker-compose.yml
T
claude f0173440e4
CI / verify (push) Successful in 7m6s
CI / publish (push) Has been skipped
Put Piggy on Prime Agent, and let it write to the book
Piggy was a hand-rolled OpenAI tool loop. It is now a Prime Agent session —
Prime Intellect's own harness, embedded as a Node library — answering from
PIG's tools and, for the first time, able to put information into the CRM
rather than only read it out.

The harness is a coding agent, so the first job was taking the coding agent
away from it. `noTools: 'all'` plus an explicit allowlist leaves the model
with PIG's ten `pig_*` tools and no bash, no filesystem, no IPython. That
holds under attack: a hostile extension, a skill and a settings file planted
in the agent's own directory, then `setActiveToolsByName` called with every
built-in, still leaves ten tools, all ours. Both lines are load-bearing —
`noTools` alone registers nothing, and the allowlist is what admits our own.

Writing is gated rather than assumed. A change is proposed, not made: the
tool returns a description, the transcript renders a diff card, and nothing
reaches the database until someone presses Apply. Contracts, commitments,
allocations and compliance always stop for a human whatever the mode. Every
write runs through `executeMutation` as the calling user, so their
capabilities and the audit trail apply exactly as they would to a human's.

Four things about the SDK are wrong in its own documentation and cost a
debugging cycle each: models.json does not resolve an env var name for
`apiKey`, it sends the literal string; there is no built-in prime-inference
provider in 0.84.1; a ResourceLoader you pass in is never reloaded for you;
and the stock system prompt is a coding-assistant prompt that must be
replaced — but replacing it also silently removes the tool list, because the
harness only renders that section when it owns the prompt. AGENTS.md records
all four.

The expensive one was thinking level. The harness defaults to `medium`, and
nemotron spent an entire 4,096-token budget reasoning and returned an empty
answer. `low` was worse; `off` omits the parameter so the endpoint's default
wins. An explicit `reasoning_effort: none` via `thinkingLevelMap` took a turn
from 6,195 output tokens to 149.

And a turn is now bounded. The harness loop is `while (true)` with no
iteration cap; a runaway on a frontier model would have eaten the credit it
is supposed to report on. Ceilings on model calls and tokens, enforced both
through the harness hook and independently from the event stream, plus a
per-user daily spend limit — and the ledger now records spend on turns that
fail, which it previously discarded.

Signing in lands on /piggy, which is a workspace: conversations down one
side, the agent in the middle, what it did and what it cost beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 05:26:28 -07:00

221 lines
11 KiB
YAML

# 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:
# `image` alongside `build` means one file serves both paths: with no
# PIG_IMAGE set, `compose build` tags the local build `pig:local` and
# nothing changes; with PIG_IMAGE set to a published tag, `compose pull`
# fetches exactly that image and never builds. scripts/deploy.sh picks.
#
# app and piggy MUST carry the same reference. They are the same image
# running two commands, and a piggy left on an older release talks to the
# new schema with the old code.
image: ${PIG_IMAGE:-pig:local}
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}
PIGGY_INTERNAL_URL: http://piggy:8931
PIGGY_INTERNAL_TOKEN: ${PIGGY_INTERNAL_TOKEN:-}
SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN:-}
SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET:-}
BUZZ_RELAY_URL: ${BUZZ_RELAY_URL:-}
NOTION_CLIENT_ID: ${NOTION_CLIENT_ID:-}
NOTION_CLIENT_SECRET: ${NOTION_CLIENT_SECRET:-}
NOTION_REDIRECT_URI: ${NOTION_REDIRECT_URI:-}
BUZZ_PRIVATE_KEY: ${BUZZ_PRIVATE_KEY:-}
BUZZ_AUTH_TAG: ${BUZZ_AUTH_TAG:-}
# Where the Learn videos are, INSIDE the container. Always this path; the
# host side of the mount is what varies. Named separately from
# PIG_MEDIA_HOST_DIR so the two never get swapped — one is a path in this
# filesystem, the other a path on yours.
PIG_MEDIA_DIR: /app/media
volumes:
# The Learn videos PIG serves itself.
#
# READ-ONLY, and that is the point: the application only ever reads these
# files, so nothing it could be tricked into doing can write to, replace
# or delete a video. Uploads are deliberately not a feature — a file gets
# here by being copied onto the host, which keeps the write path outside
# anything reachable over HTTP.
#
# The host directory must EXIST before `compose up`. Docker creates a
# missing bind source as an empty directory owned by root, which then
# serves 404s for every video and cannot be written to without sudo.
- ${PIG_MEDIA_HOST_DIR:-./media}:/app/media:ro
# 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'
piggy:
# Off unless asked for: a `compose up` with no profile starts the CRM alone.
# Every command that must reach this service — pull, build, up — needs
# `--profile piggy` (or COMPOSE_PROFILES=piggy), and without it compose
# skips the service in silence, exit 0 and no warning. scripts/deploy.sh
# derives the profile from PIGGY_ENABLED in .env, so the agent is upgraded
# with the app rather than left running the image it was started on.
profiles: ['piggy']
# Same reference as `app`, deliberately — see the note there.
image: ${PIG_IMAGE:-pig:local}
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
command: ['npx', 'tsx', 'apps/piggy/src/main.ts']
# Listed rather than mapped, unlike `app`, and the difference is
# load-bearing. A bare `KEY` takes its value from .env when set there and is
# left OUT of the container environment when absent, so
# apps/piggy/src/config.ts stays the one place a default is written. The
# mapped `${KEY:-}` form would pass an empty string instead, and Piggy's
# config coerces: an empty PIGGY_MAX_TOKENS becomes 0 and fails the
# positive-integer check at boot, an empty PIGGY_WORKER_ID becomes the lease
# identity every worker shares — the one thing a lease exists to prevent.
# A blank line in .env still passes the empty string, which is why the
# optional keys are commented out in .env.example rather than left blank.
#
# The credential USED to share that hazard, and it was the nastiest
# instance of it: PRIME_API_KEY and PIGGY_INFERENCE_API_KEY are alternative
# spellings of one key, and a blank line for the spelling you did NOT use is
# not absent — it is an empty string. Piggy crash-looped complaining about a
# key that was set perfectly well under its other name. Both are now read
# through a preprocess that treats '' as absent (apps/piggy/src/config.ts),
# so a leftover blank line for either spelling is harmless. Every OTHER key
# in this block still coerces from the empty string, so the rule above
# stands: comment out what you are not setting rather than leaving it blank.
environment:
- DATABASE_URL=postgres://${POSTGRES_USER:-pig}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-pig}
# The model credential. One key serves Prime Inference and the Prime
# platform API, and apps/piggy/src/config.ts accepts either spelling:
# PIGGY_INFERENCE_API_KEY is kept as the legacy alias so a .env written
# before the harness swap still boots. Both are listed because either may
# be the one the operator set, and the config resolves whichever it finds
# into a single value; setting both to different keys is not a supported
# arrangement — PRIME_API_KEY wins.
- PRIME_API_KEY
- PIGGY_INFERENCE_API_KEY
- PIGGY_INFERENCE_BASE
- PIGGY_MODEL
# Prime Agent settings. Every one of these has a default in
# apps/piggy/src/config.ts, so they are listed in the bare form: absent
# from .env means absent from the container, which is what lets the code
# own the default. The note above the block says why the mapped form
# would be wrong here.
- PIGGY_AGENT_MODEL
- PIGGY_AGENT_MODE
# How hard the model thinks before answering, and the single setting most
# likely to make a working deployment look broken. The harness defaults
# this to `medium`, which is tuned for a coding agent: on nemotron that
# produced 6,195 output tokens of reasoning and an EMPTY answer, because
# the turn hit its token ceiling while still thinking (finish_reason
# `length`). `low` was worse. `off` is Piggy's default and maps to the
# endpoint's `reasoning_effort: none` — 149 output tokens and a correct
# answer for the same question. This is PER MODEL: a deployment that
# changes PIGGY_AGENT_MODEL may need a thinkingLevelMap entry for the new
# model in apps/piggy/src/agent/models.json before raising this.
- PIGGY_AGENT_THINKING
- PIGGY_AGENT_MAX_TOKENS
# Fixed for this container, like the chat host below, and for a stronger
# reason than convenience.
#
# The harness discovers extensions, skills and context files from its cwd,
# and Piggy hands it this directory as both cwd and agent directory. Point
# it at a checkout — or bind-mount one over it — and repo contents become
# reachable from a CRM agent's prompt. The image creates this path owned
# by `node`, mode 0700.
#
# Set explicitly rather than left to the `~/.pig/piggy-agent` default,
# which resolves through HOME: under `docker run` that is /home/node and
# works, but a runtime that starts this image with a numeric user and no
# passwd entry leaves HOME unset, os.homedir() answers `/`, and the agent
# dies creating its directory on the first turn — long after the deploy
# reported success.
#
# NOT persisted, deliberately. Nothing here is worth keeping across a
# restart: models.json is rewritten from the image at every boot, the
# credential store is in-memory by design, sessions are in-memory, and the
# conversations live in Postgres. A volume here would only be a way for a
# file to outlive the image that wrote it.
- PIGGY_AGENT_DIR=/var/lib/piggy-agent
- PIGGY_LEASE_SECONDS
- PIGGY_POLL_INTERVAL_MS
- PIGGY_MAX_TOKENS
- PIGGY_CHAT_MAX_TOKENS
- PIGGY_MAX_TURNS
- PIGGY_REASONING_EFFORT
- PIGGY_PRICE_INPUT_CENTS_PER_MTOK
- PIGGY_PRICE_OUTPUT_CENTS_PER_MTOK
- PIGGY_WORKER_ID
- PIGGY_INTERNAL_TOKEN
# Fixed for this container rather than configurable: the API calls the
# chat server across the Compose network, so it cannot bind loopback only.
# Safe because the port below is exposed, never published.
- PIGGY_CHAT_HOST=0.0.0.0
- PIGGY_CHAT_PORT=8931
- PIGGY_CHAT_ALLOW_NON_LOOPBACK=true
# The image's own HEALTHCHECK asks for :8920/api/health, which only the API
# process serves. Inherited unchanged, this container reports unhealthy for
# ever while answering chat perfectly — and a health status that is always
# wrong is worse than none, because it teaches the operator to ignore the
# column. Piggy's listener answers /internal/health without a token for
# exactly this purpose.
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:8931/internal/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
# Private to the Compose network. There is deliberately no `ports` entry.
expose:
- '8931'
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