Files
timmy-tower/replit.md

423 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Workspace
## Overview
pnpm workspace monorepo using TypeScript. Each package manages its own dependencies.
## Stack
- **Monorepo tool**: pnpm workspaces
- **Node.js version**: 24
- **Package manager**: pnpm
- **TypeScript version**: 5.9
- **API framework**: Express 5
- **Database**: PostgreSQL + Drizzle ORM
- **Validation**: Zod (`zod/v4`), `drizzle-zod`
- **API codegen**: Orval (from OpenAPI spec)
- **Build**: esbuild (CJS bundle)
## Structure
```text
artifacts-monorepo/
├── artifacts/ # Deployable applications
│ └── api-server/ # Express API server
├── lib/ # Shared libraries
│ ├── api-spec/ # OpenAPI spec + Orval codegen config
│ ├── api-client-react/ # Generated React Query hooks
│ ├── api-zod/ # Generated Zod schemas from OpenAPI
│ └── db/ # Drizzle ORM schema + DB connection
├── scripts/ # Utility scripts (single workspace package)
│ └── src/ # Individual .ts scripts, run via `pnpm --filter @workspace/scripts run <script>`
├── pnpm-workspace.yaml # pnpm workspace (artifacts/*, lib/*, lib/integrations/*, scripts)
├── tsconfig.base.json # Shared TS options (composite, bundler resolution, es2022)
├── tsconfig.json # Root TS project references
└── package.json # Root package with hoisted devDeps
```
## TypeScript & Composite Projects
Every package extends `tsconfig.base.json` which sets `composite: true`. The root `tsconfig.json` lists all packages as project references. This means:
- **Always typecheck from the root** — run `pnpm run typecheck` (which runs `tsc --build --emitDeclarationOnly`). This builds the full dependency graph so that cross-package imports resolve correctly. Running `tsc` inside a single package will fail if its dependencies haven't been built yet.
- **`emitDeclarationOnly`** — we only emit `.d.ts` files during typecheck; actual JS bundling is handled by esbuild/tsx/vite...etc, not `tsc`.
- **Project references** — when package A depends on package B, A's `tsconfig.json` must list B in its `references` array. `tsc --build` uses this to determine build order and skip up-to-date packages.
## Root Scripts
- `pnpm run build` — runs `typecheck` first, then recursively runs `build` in all packages that define it
- `pnpm run typecheck` — runs `tsc --build --emitDeclarationOnly` using project references
## Environment Variables & Secrets
### Automatically provisioned (do not set manually)
| Secret | Purpose |
|---|---|
| `AI_INTEGRATIONS_ANTHROPIC_BASE_URL` | Replit AI Integrations proxy base URL for Anthropic |
| `AI_INTEGRATIONS_ANTHROPIC_API_KEY` | Replit AI Integrations proxy API key (dummy value, auto-managed) |
| `AI_INTEGRATIONS_GEMINI_BASE_URL` | Replit AI Integrations proxy base URL for Gemini (`localhost:1106/...`) |
| `AI_INTEGRATIONS_GEMINI_API_KEY` | Replit AI Integrations proxy key for Gemini (dummy value, auto-managed) |
| `DATABASE_URL` | PostgreSQL connection string (Replit-managed) |
| `SESSION_SECRET` | Express session secret (Replit-managed) |
### Required secrets (set via Replit Secrets tab)
| Secret | Description | Example |
|---|---|---|
| `LNBITS_URL` | Base URL of your LNbits instance | `https://legend.lnbits.com` |
| `LNBITS_API_KEY` | Invoice/Admin API key from your LNbits wallet | `a3f...` |
> **Note:** If `LNBITS_URL` and `LNBITS_API_KEY` are absent, `LNbitsService` automatically runs in **stub mode** — invoices are simulated in-memory and can be marked paid via `svc.stubMarkPaid(hash)`. This is intentional for development without a Lightning node.
### Cost-based work fee pricing
| Secret | Description | Default |
|---|---|---|
| `HAIKU_INPUT_COST_PER_1K_TOKENS` | Haiku input cost per 1K tokens (USD) | `0.0008` |
| `HAIKU_OUTPUT_COST_PER_1K_TOKENS` | Haiku output cost per 1K tokens (USD) | `0.004` |
| `SONNET_INPUT_COST_PER_1K_TOKENS` | Sonnet input cost per 1K tokens (USD) | `0.003` |
| `SONNET_OUTPUT_COST_PER_1K_TOKENS` | Sonnet output cost per 1K tokens (USD) | `0.015` |
| `DO_MONTHLY_COST_USD` | Monthly DO infra cost amortised per request | `100` |
| `DO_MONTHLY_REQUEST_VOLUME` | Expected monthly request volume (divisor) | `1000` |
| `ORIGINATOR_MARGIN_PCT` | Margin percentage on top of cost | `25` |
| `OUTPUT_TOKENS_SHORT_EST` | Estimated output tokens for short requests | `200` |
| `OUTPUT_TOKENS_MEDIUM_EST` | Estimated output tokens for medium requests | `400` |
| `OUTPUT_TOKENS_LONG_EST` | Estimated output tokens for long requests | `800` |
| `WORK_SYSTEM_PROMPT_TOKENS_EST` | Work model system-prompt size in tokens | `50` |
| `SHORT_MAX_CHARS` | Max chars for "short" request tier | `100` |
| `MEDIUM_MAX_CHARS` | Max chars for "medium" request tier | `300` |
| `EVAL_FEE_SATS` | Fixed eval invoice amount | `10` |
| `BTC_PRICE_USD_FALLBACK` | BTC/USD price fallback if CoinGecko is unreachable | `100000` |
| `EVAL_MODEL` | Anthropic model used for evaluation | `claude-haiku-4-5` |
| `WORK_MODEL` | Anthropic model used for work execution | `claude-sonnet-4-6` |
Work fee flow: estimate tokens → fetch BTC price from CoinGecko (60s cache) → `(token_cost + DO_infra) × (1 + margin%)` → convert USD → sats.
After work runs, actual token counts and raw Anthropic spend are stored in `jobs` as `actual_input_tokens`, `actual_output_tokens`, `actual_cost_usd`.
### Honest accounting and automatic refunds
After every job completes, Timmy re-prices the job using **actual** token counts, adds DO infra amortisation and margin, converts back to sats using the **same BTC price locked at invoice time**, and computes the overpayment:
```
actual_amount_sats = ceil((actual_cost_usd + DO_infra) × (1 + margin%) / btc_price_usd × 1e8)
refund_amount_sats = max(0, work_amount_sats - actual_amount_sats)
```
The `costLedger` in `GET /api/jobs/:id` shows all figures side-by-side. If `refundState = "pending"` and `refundAmountSats > 0`, the user submits a BOLT11 invoice for that exact amount to `POST /api/jobs/:id/refund` to receive the Lightning payment. The endpoint is idempotent — subsequent calls return 409 with the original `refundPaymentHash`.
### Node bootstrap secrets (for `POST /api/bootstrap`)
| Secret | Description | Default |
|---|---|---|
| `BOOTSTRAP_FEE_SATS` | Startup fee in sats the user pays | `10000` (dev) |
| `DO_API_TOKEN` | Digital Ocean personal access token | absent = stub mode |
| `DO_REGION` | DO datacenter region | `nyc3` |
| `DO_SIZE` | DO droplet size slug | `s-4vcpu-8gb` |
| `DO_VOLUME_SIZE_GB` | Block volume to attach in GB (`0` = none) | `0` |
| `DO_VPC_UUID` | Digital Ocean VPC UUID to deploy droplet into | (required) |
| `DO_SSH_KEY_FINGERPRINT` | Digital Ocean SSH Key Fingerprint for droplet access | (required) |
| `TAILSCALE_API_KEY` | Tailscale API key for generating auth keys | optional |
| `TAILSCALE_TAILNET` | Tailscale tailnet name (e.g. `example.com`) | required with above |
> **Note:** If `DO_API_TOKEN` is absent, `ProvisionerService` automatically runs in **stub mode** — provisioning is simulated with fake credentials and a real SSH keypair. Set `DO_API_TOKEN` for real node creation.
## Packages
### `artifacts/api-server` (`@workspace/api-server`)
Express 5 API server. Routes live in `src/routes/` and use `@workspace/api-zod` for request and response validation and `@workspace/db` for persistence.
- Entry: `src/index.ts` — reads `PORT`, starts Express
- App setup: `src/app.ts` — mounts CORS, JSON/urlencoded parsing, routes at `/api`
- Routes: `src/routes/index.ts` mounts sub-routers; `src/routes/health.ts` exposes `GET /health` (full path: `/api/health`)
- Depends on: `@workspace/db`, `@workspace/api-zod`
- `pnpm --filter @workspace/api-server run dev` — run the dev server
- `pnpm --filter @workspace/api-server run build` — production esbuild bundle (`dist/index.cjs`)
- Build bundles an allowlist of deps (express, cors, pg, drizzle-orm, zod, etc.) and externalizes the rest
### `lib/db` (`@workspace/db`)
Database layer using Drizzle ORM with PostgreSQL. Exports a Drizzle client instance and schema models.
- `src/index.ts` — creates a `Pool` + Drizzle instance, exports schema
- `src/schema/index.ts` — barrel re-export of all models
- `src/schema/<modelname>.ts` — table definitions with `drizzle-zod` insert schemas (no models definitions exist right now)
- `drizzle.config.ts` — Drizzle Kit config (requires `DATABASE_URL`, automatically provided by Replit)
- Exports: `.` (pool, db, schema), `./schema` (schema only)
Production migrations are handled by Replit when publishing. In development, we just use `pnpm --filter @workspace/db run push`, and we fallback to `pnpm --filter @workspace/db run push-force`.
### `lib/api-spec` (`@workspace/api-spec`)
Owns the OpenAPI 3.1 spec (`openapi.yaml`) and the Orval config (`orval.config.ts`). Running codegen produces output into two sibling packages:
1. `lib/api-client-react/src/generated/` — React Query hooks + fetch client
2. `lib/api-zod/src/generated/` — Zod schemas
Run codegen: `pnpm --filter @workspace/api-spec run codegen`
### `lib/api-zod` (`@workspace/api-zod`)
Generated Zod schemas from the OpenAPI spec (e.g. `HealthCheckResponse`). Used by `api-server` for response validation.
### `lib/api-client-react` (`@workspace/api-client-react`)
Generated React Query hooks and fetch client from the OpenAPI spec (e.g. `useHealthCheck`, `healthCheck`).
### `artifacts/api-server` — Timmy API endpoints
#### Payment-gated job flow
```bash
BASE="https://${REPLIT_DEV_DOMAIN}" # or http://localhost:8080 in dev
# 1. Create a job (returns eval invoice)
curl -s -X POST "$BASE/api/jobs" \
-H "Content-Type: application/json" \
-d '{"request": "Write a haiku about lightning payments"}'
# → {"jobId":"…","evalInvoice":{"paymentRequest":"lnbcrt10u1…","amountSats":10}}
# 2. Poll status (returns eval invoice while unpaid)
curl -s "$BASE/api/jobs/<jobId>"
# 3. (Stub mode only) Mark eval invoice paid
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"
# 4. Poll again — auto-advances to awaiting_work_payment, returns work invoice
curl -s "$BASE/api/jobs/<jobId>"
# 5. (Stub mode only) Mark work invoice paid
curl -s -X POST "$BASE/api/dev/stub/pay/<workPaymentHash>"
# 6. Poll again — auto-advances to complete, returns result
curl -s "$BASE/api/jobs/<jobId>"
# → {"jobId":"…","state":"complete","result":"…"}
```
Job states: `awaiting_eval_payment``evaluating``awaiting_work_payment``executing``complete` | `rejected` | `failed`
#### Lightning-gated node bootstrap
Pay a one-time startup fee → Timmy auto-provisions a Bitcoin full node on Digital Ocean.
```bash
# 1. Create bootstrap job (returns invoice)
curl -s -X POST "$BASE/api/bootstrap"
# → {"bootstrapJobId":"…","invoice":{"paymentRequest":"…","amountSats":10000},
# "message":"Stub mode: simulate payment with POST /api/dev/stub/pay/<hash>…"}
# 2. (Stub mode) Simulate payment
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"
# 3. Poll status — transitions: awaiting_payment → provisioning → ready
curl -s "$BASE/api/bootstrap/<bootstrapJobId>"
# 4. When ready, response includes credentials (SSH key delivered once):
# {
# "state": "ready",
# "credentials": {
# "nodeIp": "…",
# "tailscaleHostname": "timmy-node-xxxx.tailnet.ts.net",
# "lnbitsUrl": "https://timmy-node-xxxx.tailnet.ts.net",
# "sshPrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----…" ← null on subsequent polls
# },
# "nextSteps": [ "SSH in", "Monitor sync", "Run lnd-init.sh", "Configure sweep" ]
# }
```
Bootstrap states: `awaiting_payment``provisioning``ready` | `failed`
Key properties:
- **Stub mode** auto-activates when `DO_API_TOKEN` is absent — returns fake credentials with a real SSH keypair
- **SSH key** delivered once then cleared from DB; subsequent polls show `sshKeyNote`
- **Tailscale** auth key auto-generated if `TAILSCALE_API_KEY` + `TAILSCALE_TAILNET` are set
- Bitcoin sync takes 12 weeks; `ready` means provisioned + bootstrapped, not fully synced
#### Free demo endpoint (rate-limited: 5 req/hour per IP)
```bash
curl -s "$BASE/api/demo?request=Explain+proof+of+work+in+one+sentence"
# → {"result":"…"}
```
#### Dev-only stub payment trigger
`POST /api/dev/stub/pay/:paymentHash` — marks a stub invoice paid in-memory.
Only available in development (`NODE_ENV !== 'production'`).
### `scripts` (`@workspace/scripts`)
Utility scripts package. Each script is a `.ts` file in `src/` with a corresponding npm script in `package.json`. Run scripts via `pnpm --filter @workspace/scripts run <script>`. Scripts can import any workspace package (e.g., `@workspace/db`) by adding it as a dependency in `scripts/package.json`.
### Pre-funded session mode (Mode 2)
Pay once, run many requests. Balance is debited at actual cost per request — no per-job invoices.
```bash
BASE="http://localhost:8080"
# 1. Create a session (returns deposit invoice)
curl -s -X POST "$BASE/api/sessions" \
-H "Content-Type: application/json" \
-d '{"amount_sats": 500}'
# → {"sessionId":"…","state":"awaiting_payment","invoice":{"paymentRequest":"…","amountSats":500,"paymentHash":"…"}}
# 2. (Stub mode only) Pay deposit
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"
# 3. Poll session — auto-advances to active, issues macaroon
curl -s "$BASE/api/sessions/<sessionId>"
# → {"sessionId":"…","state":"active","balanceSats":500,"macaroon":"…","minimumBalanceSats":50}
# 4. Submit requests (use macaroon as Bearer token)
curl -s -X POST "$BASE/api/sessions/<sessionId>/request" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <macaroon>" \
-d '{"request":"What is a satoshi?"}'
# → {"requestId":"…","state":"complete","result":"…","debitedSats":178,"balanceRemaining":322}
# 5. Top up when balance is low (session auto-pauses below 50 sats)
curl -s -X POST "$BASE/api/sessions/<sessionId>/topup" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <macaroon>" \
-d '{"amount_sats": 500}'
```
Session states: `awaiting_payment``active``paused` (low balance) → `expired` (24h TTL, refreshed on each request)
Key properties:
- **No per-job invoices** — balance is debited at actual compute cost (eval + work tokens + DO infra + margin)
- **Rejected requests** still incur a small eval fee (Haiku model only)
- **Macaroon auth** — 32-byte hex token issued on activation; required as `Authorization: Bearer` header
- **Pause on low balance** — session auto-pauses when balance < 50 sats; pay topup invoice to resume
- **TTL** — sessions expire 24 hours after last successful request
- **Deposit limits** — 10010,000 sats; env vars: `SESSION_MIN_DEPOSIT_SATS`, `SESSION_MAX_DEPOSIT_SATS`, `SESSION_MIN_BALANCE_SATS`, `SESSION_EXPIRY_HOURS`
DB tables: `sessions` (state machine, balance, macaroon), `session_requests` (per-request token + cost accounting)
## Pushing to Gitea
Hermes VPS Gitea is the primary remote. No bore tunnel or Tailscale needed.
```bash
bash scripts/push-to-gitea.sh # push to Hermes Gitea from any session
```
The script authenticates as the `replit` user using the token in `.gitea-credentials`.
The `gitea` remote points to `http://143.198.27.163:3000/replit/timmy-tower.git`.
### Gitea — Hermes VPS (primary remote)
All code lives on Hermes Gitea. The old Mac bore-tunnel Gitea is obsolete.
| Item | Value |
|---|---|
| Web UI | `http://143.198.27.163:3000/replit/timmy-tower` |
| SSH (git) | `ssh://git@143.198.27.163:2222` |
| DB backend | PostgreSQL (`gitea` DB on hermes) |
| Admin user | `rockachopa` (user id 1) |
| replit user | `replit` / token in `.gitea-credentials` |
**Push from Replit** (any session):
```bash
bash scripts/push-to-gitea.sh
```
No bore tunnel, no Tailscale needed — Hermes is publicly accessible.
**Postgres backup:**
```bash
# On hermes VPS
sudo -u postgres pg_dump gitea > /root/gitea-backup-$(date +%Y%m%d).sql
```
## Deployment
### Sovereign push-to-deploy pipeline
Push to `main` on Hermes Gitea → webhook fires → VPS pulls, builds, restarts.
No Replit required. Deploy from any device with git access.
```
git push gitea main → Gitea webhook → VPS deploy.sh → service restart
```
**Deploy infrastructure lives in `vps/`** (versioned in this repo):
| File | Purpose |
|---|---|
| `vps/deploy.sh` | Clones repo, builds with pnpm, deploys bundle, health-checks, rolls back on failure |
| `vps/webhook.js` | Node.js webhook receiver — validates HMAC, runs deploy.sh |
| `vps/timmy-deploy-hook.service` | systemd unit for webhook receiver |
| `vps/timmy-health.service/.timer` | systemd timer — health-checks every 5 min, auto-restarts |
| `vps/install.sh` | One-time setup: installs all of the above on the VPS |
**One-time install** (run once from a machine with VPS SSH access):
```bash
# With pre-configured Gitea webhook secret:
WEBHOOK_SECRET=$(cat .local/deploy-webhook-secret) \
ssh root@143.198.27.163 'bash -s' < vps/install.sh
```
**Gitea webhook** — configured on `replit/timmy-tower` (id: 3):
- URL: `http://143.198.27.163/webhook/deploy`
- Secret: stored in `.local/deploy-webhook-secret` (gitignored)
- Trigger: push to any branch (filtered to `main` in webhook.js)
**Monitoring:**
```bash
# Watch deploy logs live
ssh root@143.198.27.163 'tail -f /opt/timmy-tower/deploy.log'
# Watch health check logs
ssh root@143.198.27.163 'tail -f /opt/timmy-tower/health.log'
# Manual deploy (bypasses webhook)
ssh root@143.198.27.163 'bash /opt/timmy-tower/deploy.sh'
# Webhook service status
ssh root@143.198.27.163 'systemctl status timmy-deploy-hook'
```
**Rollback:** `git revert HEAD && git push gitea main` — triggers a re-deploy automatically.
### VPS production instance
| Item | Value |
|---|---|
| URL | `http://143.198.27.163/` |
| Service | `systemctl status timmy-tower` |
| Deploy dir | `/opt/timmy-tower/` |
| Env file | `/opt/timmy-tower/.env` |
| Deploy log | `/opt/timmy-tower/deploy.log` |
| DB | `postgres://timmy:...@localhost:5432/timmy_tower` |
| Nostr npub | `npub1e3gu2j08t6hymjd5sz9dmy4u5pcl22mj5hl60avkpj5tdpaq3dasjax6tv` |
| AI backend | OpenRouter (`https://openrouter.ai/api/v1`) via Anthropic SDK compat layer |
External packages required in `/opt/timmy-tower/node_modules/`:
- `nostr-tools` (^2.23.3)
- `cookie-parser` (^1.4.7)
These are externalized by esbuild (not in the bundle allowlist in `build.ts`).
### Replit deployment (secondary)
The Replit VM deployment is still active for development/staging use.
Artifact config: `artifacts/api-server/.replit-artifact/artifact.toml`
```
deploymentTarget = "vm"
buildCommand = "pnpm --filter @workspace/api-server run build"
runCommand = "node artifacts/api-server/dist/index.js"
```
## Roadmap
### Nostr integration
Nostr (NIP-04/NIP-44 encrypted DMs) is planned as the delivery layer for the bootstrap flow and beyond:
- **Node credential delivery** — provisioned node generates a Nostr keypair during cloud-init and publishes credentials as an encrypted DM to the user's pubkey; eliminates the current HTTP one-time SSH key polling mechanism
- **Job status events** — job state transitions (`awaiting_payment → complete`) published as Nostr events; clients subscribe instead of polling
- **Node identity** — each Timmy node gets a persistent Nostr identity (npub) for discovery and communication
Until Nostr is wired in, the current HTTP polling + one-time SSH key delivery serves as the POC placeholder.