Files
timmy-tower/replit.md

350 lines
16 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) |
| `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` |
| `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
Gitea runs on the local Mac behind a bore tunnel. The bore port changes every session.
### One-time setup
```bash
bash scripts/push-to-gitea.sh <PORT> # saves port to .bore-port, then pushes
```
### Every subsequent push this session
```bash
bash scripts/push-to-gitea.sh # reads port from .bore-port automatically
```
### When bore restarts (new port)
Bore assigns a new random port on each restart. You must pass it once — after that `.bore-port` remembers it:
```bash
bash scripts/push-to-gitea.sh <NEW_PORT> # overwrites .bore-port, then pushes
```
**How to find the bore port:** The port is shown in the Mac terminal where bore is running:
```
bore local 3000 --to bore.pub
→ "listening at bore.pub:NNNNN"
```
**Credentials (GITEA_TOKEN):** The script never hard-codes a token. Set it one of two ways:
```bash
# Option A — env var (add to shell profile for persistence)
export GITEA_TOKEN=<your-token>
# Option B — gitignored credentials file (one-time setup)
echo <your-token> > .gitea-credentials
```
Get your token from Gitea → User Settings → Applications → Generate Token.
**Rules:**
- Always create a branch and open a PR — never push directly to `main` (Gitea enforces this)
- The `.bore-port` and `.gitea-credentials` files are gitignored — never committed
### Gitea repos
- `replit/token-gated-economy` — TypeScript API server (this repo)
- `perplexity/the-matrix` — Three.js 3D world frontend
## 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.