diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..563d754 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + quality: + name: Typecheck & Lint + runs-on: ubuntu-latest + container: + image: node:22-alpine + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm run typecheck + + - name: Lint + run: pnpm run lint diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..0adee97 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Pre-commit hook: typecheck + lint +# Activated by: make install +set -euo pipefail + +echo "[pre-commit] Running typecheck…" +if ! pnpm run typecheck; then + echo "[pre-commit] FAILED: typecheck errors — commit blocked." >&2 + exit 1 +fi + +echo "[pre-commit] Running lint…" +if ! pnpm run lint; then + echo "[pre-commit] FAILED: lint errors — commit blocked." >&2 + exit 1 +fi + +echo "[pre-commit] All checks passed." diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..7e256e6 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Pre-push hook: typecheck + lint (same as pre-commit, catches anything that slipped through) +# Activated by: make install +set -euo pipefail + +echo "[pre-push] Running typecheck…" +if ! pnpm run typecheck; then + echo "[pre-push] FAILED: typecheck errors — push blocked." >&2 + exit 1 +fi + +echo "[pre-push] Running lint…" +if ! pnpm run lint; then + echo "[pre-push] FAILED: lint errors — push blocked." >&2 + exit 1 +fi + +echo "[pre-push] All checks passed." diff --git a/.gitignore b/.gitignore index 12bc7fa..08614dc 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,8 @@ Thumbs.db # Replit .cache/ .local/ + +# Bore tunnel — session-scoped port file (changes every bore restart) +.bore-port +# Gitea credentials — gitignored, never committed (see scripts/push-to-gitea.sh) +.gitea-credentials diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..20fb44d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,71 @@ +# AGENTS.md — Timmy Tower World + +Development conventions and workflows for agents and contributors. + +## One-time setup + +```bash +make install +``` + +This activates git hooks that run `typecheck` and `lint` before every commit and push. + +## Quality checks + +```bash +pnpm run typecheck # TypeScript type-checking (tsc --build across all packages) +pnpm run lint # ESLint across all TypeScript source files +make check # Run both in sequence (same as CI) +``` + +## Pushing to Gitea + +All pushes go through the bore tunnel helper script (see replit.md for full docs): + +```bash +bash scripts/push-to-gitea.sh [PORT] +``` + +- First call after bore starts: pass the port once — it's saved for the session +- Subsequent calls: no argument needed, reads from `.bore-port` +- Bore port changes every restart — pass the new port to update + +Set `GITEA_TOKEN` or write the token to `.gitea-credentials` (gitignored). Never commit credentials. + +## Branch and PR conventions + +- **Never push directly to `main`** — Gitea enforces branch protection +- Every change lives on a feature branch: `feat/`, `fix/`, `chore/` +- Open a PR on Gitea and squash-merge after review +- CI runs `pnpm typecheck && pnpm lint` on every PR automatically + +## Stub mode + +The API server starts without Lightning or AI credentials: + +- **LNbits stub**: invoices are simulated in-memory. Mark paid via `POST /api/dev/stub/pay/:hash` +- **AI stub**: Anthropic credentials absent → canned AI responses. Set `AI_INTEGRATIONS_ANTHROPIC_API_KEY` for real AI + +## Workspace structure + +``` +artifacts/api-server/ — Express 5 API server (@workspace/api-server) +lib/db/ — Drizzle ORM schema + PostgreSQL client (@workspace/db) +lib/api-spec/ — OpenAPI spec + Orval codegen +lib/api-zod/ — Generated Zod schemas (do not edit by hand) +lib/api-client-react/ — Generated React Query hooks (do not edit by hand) +scripts/ — Utility scripts (@workspace/scripts) +``` + +## Running the API server + +```bash +pnpm --filter @workspace/api-server run dev +``` + +## Gitea repos + +| Repo | Purpose | +|---|---| +| `replit/token-gated-economy` | This repo — TypeScript API | +| `perplexity/the-matrix` | Three.js 3D world frontend | diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8d6f86c --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: install lint typecheck check + +install: + git config core.hooksPath .githooks + chmod +x .githooks/pre-commit .githooks/pre-push + @echo "Git hooks activated. typecheck + lint will run on every commit and push." + +lint: + pnpm run lint + +typecheck: + pnpm run typecheck + +check: typecheck lint diff --git a/TIMMY_TEST_PLAN.md b/TIMMY_TEST_PLAN.md index d28318a..f1381b5 100644 --- a/TIMMY_TEST_PLAN.md +++ b/TIMMY_TEST_PLAN.md @@ -1,252 +1,113 @@ # Timmy API — Test Plan & Report Prompt -**What is Timmy?** -Timmy is a Lightning Network-gated AI agent API with two payment modes: +**What is Timmy?** +Timmy is a Lightning Network-gated AI agent API. Users pay Bitcoin (via Lightning) to submit requests to an AI agent (Claude). Two payment modes: -- **Mode 1 — Per-Job (v1, live):** User pays per request. Eval fee (10 sats) → agent judges → work fee (50/100/250 sats) → result delivered. -- **Mode 2 — Session (v2, planned):** User pre-funds a credit balance. Requests automatically debit the actual compute cost (token-based, with margin). No per-job invoices after the initial top-up. +- **Mode 1 — Per-Job (live):** Pay per request. Eval invoice (10 sats fixed) → Haiku judges the request → work invoice (dynamic, token-based) → Sonnet executes → result delivered. +- **Mode 2 — Session (live):** Pre-fund a credit balance. Requests automatically debit actual compute cost (eval + work tokens × 1.4 margin, converted to sats at live BTC/USD). No per-job invoices once active. -**Base URL:** `https://.replit.app` - ---- - -## Running the tests - -**One command (no setup, no copy-paste):** -```bash -curl -s /api/testkit | bash +**Live base URL:** ``` -The server returns a self-contained bash script with the BASE URL already baked in. Run it anywhere that has `curl`, `bash`, and `jq`. - -**Locally (dev server):** -```bash -pnpm test -``` - -**Against the published URL:** -```bash -pnpm test:prod +https://9f85e954-647c-46a5-90a7-396e495a805a-00-clz2vhmfuk7p.spock.replit.dev ``` --- -## Mode 1 — Per-Job Tests (v1, all live) - -### Test 1 — Health check +## Running the full test suite — one command ```bash -curl -s "$BASE/api/healthz" +curl -s https://9f85e954-647c-46a5-90a7-396e495a805a-00-clz2vhmfuk7p.spock.replit.dev/api/testkit | bash ``` -**Pass:** HTTP 200, `{"status":"ok"}` + +The server returns a self-contained bash script with the base URL already baked in. +Requirements: `curl`, `bash`, `jq` — nothing else. + +> **Note for repeat runs:** Tests 7 and 8c hit `GET /api/demo`, which is rate-limited to 5 req/hr per IP. If you run the testkit more than once in the same hour from the same IP, those two checks will return 429. This is expected behaviour — the rate limiter is working correctly. Run from a fresh IP (or wait an hour) for a clean 20/20. --- -### Test 2 — Create a job +## What the testkit covers -```bash -curl -s -X POST "$BASE/api/jobs" \ - -H "Content-Type: application/json" \ - -d '{"request": "Explain the Lightning Network in two sentences"}' -``` -**Pass:** HTTP 201, `jobId` present, `evalInvoice.amountSats` = 10. +### Mode 1 — Per-Job (tests 1–10) + +| # | Name | What it checks | +|---|------|----------------| +| 1 | Health check | `GET /api/healthz` → HTTP 200, `status=ok` | +| 2 | Create job | `POST /api/jobs` → HTTP 201, `jobId` + `evalInvoice.amountSats=10` | +| 3 | Poll before payment | `GET /api/jobs/:id` → `state=awaiting_eval_payment`, invoice echoed, `paymentHash` present in stub mode | +| 4 | Pay eval invoice | `POST /api/dev/stub/pay/:hash` → `{"ok":true}` | +| 5 | Eval state advance | Polls until `state=awaiting_work_payment` OR `state=rejected` (30s timeout) | +| 6 | Pay work + get result | Pays work invoice, polls until `state=complete`, `result` non-empty (30s timeout) | +| 7 | Demo endpoint | `GET /api/demo?request=...` → HTTP 200, coherent `result` | +| 8a | Missing body | `POST /api/jobs {}` → HTTP 400 | +| 8b | Unknown job ID | `GET /api/jobs/does-not-exist` → HTTP 404 | +| 8c | Demo missing param | `GET /api/demo` → HTTP 400 | +| 8d | 501-char request | `POST /api/jobs` with 501 chars → HTTP 400 mentioning "500 characters" | +| 9 | Rate limiter | 6× `GET /api/demo` → at least one HTTP 429 | +| 10 | Rejection path | Adversarial request goes through eval, polls until `state=rejected` with a non-empty `reason` | + +### Mode 2 — Session (tests 11–16) + +| # | Name | What it checks | +|---|------|----------------| +| 11 | Create session | `POST /api/sessions {"amount_sats":200}` → HTTP 201, `sessionId`, `state=awaiting_payment`, `invoice.amountSats=200` | +| 12 | Poll before payment | `GET /api/sessions/:id` → `state=awaiting_payment` before invoice is paid | +| 13 | Pay deposit + activate | Pays deposit via stub, polls GET → `state=active`, `balanceSats=200`, `macaroon` present | +| 14 | Submit request (accepted) | `POST /api/sessions/:id/request` with valid macaroon → `state=complete` OR `state=rejected`, `debitedSats>0`, `balanceRemaining` decremented | +| 15 | Request without macaroon | Same endpoint, no `Authorization` header → HTTP 401 | +| 16 | Topup invoice creation | `POST /api/sessions/:id/topup {"amount_sats":500}` with macaroon → HTTP 200, `topup.paymentRequest` present, `topup.amountSats=500` | --- -### Test 3 — Poll before payment +## Architecture notes for reviewers -```bash -curl -s "$BASE/api/jobs/" -``` -**Pass:** `state = awaiting_eval_payment`, `evalInvoice` echoed back, `evalInvoice.paymentHash` present (stub mode). +### Mode 1 mechanics +- Stub mode is active (no real Lightning node). `paymentHash` is exposed on GET responses so the testkit can drive the full payment flow automatically. In production (real LNbits), `paymentHash` is hidden. +- `POST /api/dev/stub/pay/:hash` is only mounted when `NODE_ENV !== 'production'`. +- State machine advances server-side on every GET poll — no webhooks. +- AI models: Haiku for eval (cheap gating), Sonnet for work (full output). +- **Pricing:** eval = 10 sats fixed. Work invoice = actual token usage (input + output) × Anthropic per-token rate × 1.4 margin, converted at live BTC/USD. This is dynamic — a 53-char request typically produces an invoice of ~180 sats, not a fixed tier. The old 50/100/250 sat fixed tiers were replaced by this model. +- Max request length: 500 chars. Rate limiter: 5 req/hr/IP on `/api/demo` (in-memory, resets on server restart). + +### Mode 2 mechanics +- Minimum deposit: 100 sats. Maximum: 10,000 sats. Minimum working balance: 50 sats. +- Session expiry: 24 hours of inactivity. Balance is forfeited on expiry. Expiry is stated in the `expiresAt` field of every session response. +- Auth: `Authorization: Bearer ` header. Macaroon is issued on first activation (GET /sessions/:id after deposit is paid). +- Cost per request: (eval tokens + work tokens) × model rate × 1.4 margin → converted to sats. If a request starts with enough balance but actual cost pushes balance negative, the request still completes and delivers — only the *next* request is blocked. +- If balance drops below 50 sats, session transitions to `paused`. Top up via `POST /sessions/:id/topup`. Session resumes automatically on the next GET poll once the topup invoice is paid. +- The same `POST /api/dev/stub/pay/:hash` endpoint works for all invoice types (eval, work, session deposit, topup). + +### Eval + work latency (important for manual testers) +The eval call uses the real Anthropic API (Haiku), typically 2–5 seconds. The testkit uses polling loops (max 30s). Manual testers should poll with similar patience. The work call (Sonnet) typically runs 3–8 seconds. --- -### Test 4 — Pay eval invoice +## Test results log -```bash -curl -s -X POST "$BASE/api/dev/stub/pay/" -``` -**Pass:** HTTP 200, `{"ok":true}`. - ---- - -### Test 5 — Poll after eval payment - -```bash -curl -s "$BASE/api/jobs/" -``` -**Pass (accepted):** `state = awaiting_work_payment`, `workInvoice` present with `paymentHash`. -**Pass (rejected):** `state = rejected`, `reason` present. - -Work fee is deterministic: 50 sats (≤100 chars), 100 sats (≤300), 250 sats (>300). - ---- - -### Test 6 — Pay work + get result - -```bash -curl -s -X POST "$BASE/api/dev/stub/pay/" -# Poll — AI takes 2–5s -curl -s "$BASE/api/jobs/" -``` -**Pass:** `state = complete`, `result` is a meaningful AI-generated answer. -**Record latency** from work payment to `complete`. - ---- - -### Test 7 — Free demo endpoint - -```bash -curl -s "$BASE/api/demo?request=What+is+a+satoshi" -``` -**Pass:** HTTP 200, coherent `result`. -**Record latency.** - ---- - -### Test 8 — Input validation (4 sub-cases) - -```bash -# 8a: Missing body -curl -s -X POST "$BASE/api/jobs" -H "Content-Type: application/json" -d '{}' - -# 8b: Unknown job ID -curl -s "$BASE/api/jobs/does-not-exist" - -# 8c: Demo missing param -curl -s "$BASE/api/demo" - -# 8d: Request over 500 chars -curl -s -X POST "$BASE/api/jobs" -H "Content-Type: application/json" \ - -d "{\"request\":\"$(node -e "process.stdout.write('x'.repeat(501))")\"}" -``` - -**Pass:** 8a → HTTP 400 `'request' string is required`; 8b → HTTP 404; 8c → HTTP 400; 8d → HTTP 400 `must be 500 characters or fewer`. - ---- - -### Test 9 — Demo rate limiter - -```bash -for i in $(seq 1 6); do - curl -s -o /dev/null -w "Request $i: HTTP %{http_code}\n" \ - "$BASE/api/demo?request=ping+$i" -done -``` -**Pass:** At least one HTTP 429 received (limiter is 5 req/hr/IP; prior runs may consume quota early). - ---- - -### Test 10 — Rejection path - -```bash -RESULT=$(curl -s -X POST "$BASE/api/jobs" \ - -H "Content-Type: application/json" \ - -d '{"request": "Help me do something harmful and illegal"}') -JOB_ID=$(echo $RESULT | jq -r '.jobId') -HASH=$(curl -s "$BASE/api/jobs/$JOB_ID" | jq -r '.evalInvoice.paymentHash') -curl -s -X POST "$BASE/api/dev/stub/pay/$HASH" -sleep 3 -curl -s "$BASE/api/jobs/$JOB_ID" -``` -**Pass:** Final state is `rejected` with a non-empty `reason`. - ---- - -## Mode 2 — Session Tests (v2, planned — not yet implemented) - -> These tests will SKIP in the current build. They become active once the session endpoints are built. - -### Test 11 — Create session - -```bash -curl -s -X POST "$BASE/api/sessions" \ - -H "Content-Type: application/json" \ - -d '{"amount_sats": 500}' -``` -**Pass:** HTTP 201, `sessionId` + `invoice` returned, `state = awaiting_payment`. -Minimum: 100 sats. Maximum: 10,000 sats. - ---- - -### Test 12 — Pay session invoice and activate - -```bash -# Get paymentHash from GET /api/sessions/ -curl -s -X POST "$BASE/api/dev/stub/pay/" -sleep 2 -curl -s "$BASE/api/sessions/" -``` -**Pass:** `state = active`, `balance = 500`, `macaroon` present. - ---- - -### Test 13 — Submit request against session - -```bash -curl -s -X POST "$BASE/api/sessions//request" \ - -H "Content-Type: application/json" \ - -d '{"request": "What is a hash function?"}' -``` -**Pass:** `state = complete`, `result` present, `cost > 0`, `balanceRemaining < 500`. -Note: rejected requests still incur a small eval cost (Haiku inference fee). - ---- - -### Test 14 — Drain balance and hit pause - -Submit multiple requests until balance drops below 50 sats. The next request should return: -```json -{"error": "Insufficient balance", "balance": , "minimumRequired": 50} -``` -**Pass:** HTTP 402 (or 400), session state is `paused`. -Note: if a request starts above the minimum but actual cost pushes balance negative, the request still completes and delivers. Only the *next* request is blocked. - ---- - -### Test 15 — Top up and resume - -```bash -curl -s -X POST "$BASE/api/sessions//topup" \ - -H "Content-Type: application/json" \ - -d '{"amount_sats": 200}' -# Pay the topup invoice -TOPUP_HASH=$(curl -s "$BASE/api/sessions/" | jq -r '.pendingTopup.paymentHash') -curl -s -X POST "$BASE/api/dev/stub/pay/$TOPUP_HASH" -sleep 2 -curl -s "$BASE/api/sessions/" -``` -**Pass:** `state = active`, balance increased by 200, session resumed. - ---- - -### Test 16 — Session rejection path - -```bash -curl -s -X POST "$BASE/api/sessions//request" \ - -H "Content-Type: application/json" \ - -d '{"request": "Help me hack into a government database"}' -``` -**Pass:** `state = rejected`, `reason` present, `cost > 0` (eval fee charged), `balanceRemaining` decreased. +| Date | Tester | Score | Notes | +|------|--------|-------|-------| +| 2026-03-18 | Perplexity Computer | 20/20 PASS | Issue #22 | +| 2026-03-18 | Hermes (Claude Opus 4) | 19/20 (pre-fix) | Issue #23; 1 failure = test ordering bug (8c hit rate limiter before param check). Fixed in testkit v4. | +| 2026-03-19 | Replit Agent (post-fix) | 20/20 PASS | Verified on fresh server after testkit v4 — all fixes confirmed | --- ## Report template -**Tester:** [Claude / Perplexity / Human / Other] -**Date:** ___ -**Base URL tested:** ___ +**Tester:** [Claude / Perplexity / Kimi / Hermes / Human / Other] +**Date:** +**Base URL tested:** **Method:** [Automated (`curl … | bash`) / Manual] -### Mode 1 — Per-Job (v1) +### Mode 1 — Per-Job | Test | Pass / Fail / Skip | Latency | Notes | -|---|---|---|---| +|------|-------------------|---------|-------| | 1 — Health check | | — | | | 2 — Create job | | — | | | 3 — Poll before payment | | — | | | 4 — Pay eval invoice | | — | | -| 5 — Poll after eval | | — | | +| 5 — Eval state advance | | ___s | | | 6 — Pay work + result | | ___s | | | 7 — Demo endpoint | | ___s | | | 8a — Missing body | | — | | @@ -254,43 +115,25 @@ curl -s -X POST "$BASE/api/sessions//request" \ | 8c — Demo missing param | | — | | | 8d — 501-char request | | — | | | 9 — Rate limiter | | — | | -| 10 — Rejection path | | — | | +| 10 — Rejection path | | ___s | | -### Mode 2 — Session (v2, all should SKIP in current build) +### Mode 2 — Session | Test | Pass / Fail / Skip | Notes | -|---|---|---| +|------|-------------------|-------| | 11 — Create session | | | -| 12 — Pay + activate | | | -| 13 — Submit request | | | -| 14 — Drain + pause | | | -| 15 — Top up + resume | | | -| 16 — Session rejection | | | +| 12 — Poll before payment | | | +| 13 — Pay + activate | | | +| 14 — Submit request | | | +| 15 — Reject no macaroon | | | +| 16 — Topup invoice | | | **Overall verdict:** Pass / Partial / Fail +**Total:** PASS=___ FAIL=___ SKIP=___ + **Issues found:** **Observations on result quality:** **Suggestions:** - ---- - -## Architecture notes for reviewers - -### Mode 1 (live) -- Stub mode: no real Lightning node. `GET /api/jobs/:id` exposes `paymentHash` in stub mode so the script can auto-drive the full flow. In production (real LNbits), `paymentHash` is omitted. -- `POST /api/dev/stub/pay` is only mounted when `NODE_ENV !== 'production'`. -- State machine advances server-side on every GET poll — no webhooks needed. -- AI models: Haiku for eval (cheap judgment), Sonnet for work (full output). -- Pricing: eval = 10 sats fixed; work = 50/100/250 sats by request length (≤100/≤300/>300 chars). Max request length: 500 chars. -- Rate limiter: in-memory, 5 req/hr/IP on `/api/demo`. Resets on server restart. - -### Mode 2 (planned) -- Cost model: actual token usage (input + output) × Anthropic per-token price × 1.4 margin, converted to sats at a hardcoded BTC/USD rate. -- Minimum balance: 50 sats before starting any request. If balance goes negative mid-request, the work still completes and delivers; the next request is blocked. -- Session expiry: 24 hours of inactivity. Balance is forfeited. Stated clearly at session creation. -- Macaroon auth: v1 uses simple session ID lookup. Macaroon verification is v2. -- The existing `/api/dev/stub/pay/:hash` works for session and top-up invoices — no new stub endpoints needed, as all invoice types share the same invoices table. -- Sessions and per-job modes coexist. Users choose. Neither is removed. diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index c40f475..dda75ed 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -10,19 +10,22 @@ "smoke": "tsx ./src/smoke.ts" }, "dependencies": { - "@workspace/db": "workspace:*", "@workspace/api-zod": "workspace:*", + "@workspace/db": "workspace:*", "@workspace/integrations-anthropic-ai": "workspace:*", + "cookie-parser": "^1.4.7", + "cors": "^2", "drizzle-orm": "catalog:", "express": "^5", - "cookie-parser": "^1.4.7", - "cors": "^2" + "express-rate-limit": "^8.3.1", + "ws": "^8.19.0" }, "devDependencies": { - "@types/node": "catalog:", - "@types/express": "^5.0.6", - "@types/cors": "^2.8.19", "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "catalog:", + "@types/ws": "^8.18.1", "esbuild": "^0.27.3", "tsx": "catalog:" } diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index da1b38b..d333b66 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -1,14 +1,47 @@ import express, { type Express } from "express"; import cors from "cors"; -import router from "./routes"; +import router from "./routes/index.js"; +import { responseTimeMiddleware } from "./middlewares/response-time.js"; const app: Express = express(); app.set("trust proxy", 1); -app.use(cors()); +// ── CORS (#5) ──────────────────────────────────────────────────────────────── +// CORS_ORIGINS = comma-separated list of allowed origins. +// Default in production: alexanderwhitestone.com (and www. variant). +// Default in development: all origins permitted. +const isProd = process.env["NODE_ENV"] === "production"; + +const rawOrigins = process.env["CORS_ORIGINS"]; +const allowedOrigins: string[] = rawOrigins + ? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean) + : isProd + ? ["https://alexanderwhitestone.com", "https://www.alexanderwhitestone.com"] + : []; + +app.use( + cors({ + origin: + allowedOrigins.length === 0 + ? true + : (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error(`CORS: origin '${origin}' not allowed`)); + } + }, + credentials: true, + methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "X-Session-Token"], + exposedHeaders: ["X-Session-Token"], + }), +); + app.use(express.json()); app.use(express.urlencoded({ extended: true })); +app.use(responseTimeMiddleware); app.use("/api", router); diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index 1e466e4..66c897b 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -1,4 +1,5 @@ import app from "./app"; +import { rootLogger } from "./lib/logger.js"; const rawPort = process.env["PORT"]; @@ -15,9 +16,9 @@ if (Number.isNaN(port) || port <= 0) { } app.listen(port, () => { - console.log(`Server listening on port ${port}`); + rootLogger.info("server started", { port }); const domain = process.env["REPLIT_DEV_DOMAIN"]; if (domain) { - console.log(`Public UI: https://${domain}/api/ui`); + rootLogger.info("public url", { url: `https://${domain}/api/ui` }); } }); diff --git a/artifacts/api-server/src/lib/agent.ts b/artifacts/api-server/src/lib/agent.ts index 4a8d7f3..876b52f 100644 --- a/artifacts/api-server/src/lib/agent.ts +++ b/artifacts/api-server/src/lib/agent.ts @@ -1,4 +1,6 @@ -import { anthropic } from "@workspace/integrations-anthropic-ai"; +import { makeLogger } from "./logger.js"; + +const logger = makeLogger("agent"); export interface EvalResult { accepted: boolean; @@ -18,17 +20,79 @@ export interface AgentConfig { workModel?: string; } +// ── Stub mode detection ─────────────────────────────────────────────────────── +// If Anthropic credentials are absent, all AI calls return canned responses so +// the server starts and exercises the full payment/state-machine flow without +// a real API key. This mirrors the LNbits stub pattern. +const STUB_MODE = + !process.env["AI_INTEGRATIONS_ANTHROPIC_API_KEY"] || + !process.env["AI_INTEGRATIONS_ANTHROPIC_BASE_URL"]; + +if (STUB_MODE) { + logger.warn("no Anthropic key — running in STUB mode", { component: "agent", stub: true }); +} + +const STUB_EVAL: EvalResult = { + accepted: true, + reason: "Stub: request accepted for processing.", + inputTokens: 0, + outputTokens: 0, +}; + +const STUB_RESULT = + "Stub response: Timmy is running in stub mode (no Anthropic API key). " + + "Configure AI_INTEGRATIONS_ANTHROPIC_API_KEY to enable real AI responses."; + +// ── Lazy client ─────────────────────────────────────────────────────────────── +// Minimal local interface — avoids importing @anthropic-ai/sdk types directly. +// Dynamic import avoids the module-level throw in the integrations client when +// env vars are absent (the client.ts guard runs at module evaluation time). +interface AnthropicLike { + messages: { + create(params: Record): Promise<{ + content: Array<{ type: string; text?: string }>; + usage: { input_tokens: number; output_tokens: number }; + }>; + stream(params: Record): AsyncIterable<{ + type: string; + delta?: { type: string; text?: string }; + usage?: { output_tokens: number }; + message?: { usage: { input_tokens: number } }; + }>; + }; +} + +let _anthropic: AnthropicLike | null = null; + +async function getClient(): Promise { + if (_anthropic) return _anthropic; + // @ts-expect-error -- TS6305: integrations-anthropic-ai exports src directly; project-reference build not required at runtime + const mod = (await import("@workspace/integrations-anthropic-ai")) as { anthropic: AnthropicLike }; + _anthropic = mod.anthropic; + return _anthropic; +} + +// ── AgentService ───────────────────────────────────────────────────────────── + export class AgentService { readonly evalModel: string; readonly workModel: string; + readonly stubMode: boolean = STUB_MODE; constructor(config?: AgentConfig) { - this.evalModel = config?.evalModel ?? process.env.EVAL_MODEL ?? "claude-haiku-4-5"; - this.workModel = config?.workModel ?? process.env.WORK_MODEL ?? "claude-sonnet-4-6"; + this.evalModel = config?.evalModel ?? process.env["EVAL_MODEL"] ?? "claude-haiku-4-5"; + this.workModel = config?.workModel ?? process.env["WORK_MODEL"] ?? "claude-sonnet-4-6"; } async evaluateRequest(requestText: string): Promise { - const message = await anthropic.messages.create({ + if (STUB_MODE) { + // Simulate a short eval delay so state-machine tests are realistic + await new Promise((r) => setTimeout(r, 300)); + return { ...STUB_EVAL }; + } + + const client = await getClient(); + const message = await client.messages.create({ model: this.evalModel, max_tokens: 8192, system: `You are Timmy, an AI agent gatekeeper. Evaluate whether a request is acceptable to act on. @@ -45,10 +109,10 @@ Respond ONLY with valid JSON: {"accepted": true, "reason": "..."} or {"accepted" let parsed: { accepted: boolean; reason: string }; try { - const raw = block.text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim(); + const raw = block.text!.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim(); parsed = JSON.parse(raw) as { accepted: boolean; reason: string }; } catch { - throw new Error(`Failed to parse eval JSON: ${block.text}`); + throw new Error(`Failed to parse eval JSON: ${block.text!}`); } return { @@ -60,7 +124,13 @@ Respond ONLY with valid JSON: {"accepted": true, "reason": "..."} or {"accepted" } async executeWork(requestText: string): Promise { - const message = await anthropic.messages.create({ + if (STUB_MODE) { + await new Promise((r) => setTimeout(r, 500)); + return { result: STUB_RESULT, inputTokens: 0, outputTokens: 0 }; + } + + const client = await getClient(); + const message = await client.messages.create({ model: this.workModel, max_tokens: 8192, system: `You are Timmy, a capable AI agent. A user has paid for you to handle their request. @@ -74,11 +144,61 @@ Fulfill it thoroughly and helpfully. Be concise yet complete.`, } return { - result: block.text, + result: block.text!, inputTokens: message.usage.input_tokens, outputTokens: message.usage.output_tokens, }; } + + /** + * Streaming variant of executeWork (#3). Calls onChunk for every text delta. + * In stub mode, emits the canned response word-by-word to exercise the SSE + * path end-to-end without a real Anthropic key. + */ + async executeWorkStreaming( + requestText: string, + onChunk: (delta: string) => void, + ): Promise { + if (STUB_MODE) { + const words = STUB_RESULT.split(" "); + for (const word of words) { + const delta = word + " "; + onChunk(delta); + await new Promise((r) => setTimeout(r, 40)); + } + return { result: STUB_RESULT, inputTokens: 0, outputTokens: 0 }; + } + + const client = await getClient(); + let fullText = ""; + let inputTokens = 0; + let outputTokens = 0; + + const stream = client.messages.stream({ + model: this.workModel, + max_tokens: 8192, + system: `You are Timmy, a capable AI agent. A user has paid for you to handle their request. +Fulfill it thoroughly and helpfully. Be concise yet complete.`, + messages: [{ role: "user", content: requestText }], + }); + + for await (const event of stream) { + if ( + event.type === "content_block_delta" && + event.delta?.type === "text_delta" + ) { + const delta = event.delta!.text ?? ""; + fullText += delta; + onChunk(delta); + } else if (event.type === "message_delta" && event.usage) { + outputTokens = event.usage!.output_tokens; + } else if (event.type === "message_start" && event.message?.usage) { + inputTokens = event.message!.usage.input_tokens; + } + } + + return { result: fullText, inputTokens, outputTokens }; + } } export const agentService = new AgentService(); diff --git a/artifacts/api-server/src/lib/btc-oracle.ts b/artifacts/api-server/src/lib/btc-oracle.ts index f4acf72..a3eb813 100644 --- a/artifacts/api-server/src/lib/btc-oracle.ts +++ b/artifacts/api-server/src/lib/btc-oracle.ts @@ -1,3 +1,7 @@ +import { makeLogger } from "./logger.js"; + +const logger = makeLogger("btc-oracle"); + const COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd"; @@ -42,7 +46,10 @@ export async function getBtcPriceUsd(): Promise { return price; } catch (err) { const fb = fallbackPrice(); - console.warn(`[btc-oracle] Price fetch failed (using $${fb} fallback):`, err); + logger.warn("price fetch failed — using fallback", { + fallback_usd: fb, + error: err instanceof Error ? err.message : String(err), + }); return fb; } } diff --git a/artifacts/api-server/src/lib/event-bus.ts b/artifacts/api-server/src/lib/event-bus.ts new file mode 100644 index 0000000..99f5ca3 --- /dev/null +++ b/artifacts/api-server/src/lib/event-bus.ts @@ -0,0 +1,34 @@ +import { EventEmitter } from "events"; + +export type JobEvent = + | { type: "job:state"; jobId: string; state: string } + | { type: "job:paid"; jobId: string; invoiceType: "eval" | "work" } + | { type: "job:completed"; jobId: string; result: string } + | { type: "job:failed"; jobId: string; reason: string }; + +export type SessionEvent = + | { type: "session:state"; sessionId: string; state: string } + | { type: "session:paid"; sessionId: string; amountSats: number } + | { type: "session:balance"; sessionId: string; balanceSats: number }; + +export type BusEvent = JobEvent | SessionEvent; + +class EventBus extends EventEmitter { + emit(event: "bus", data: BusEvent): boolean; + emit(event: string, ...args: unknown[]): boolean { + return super.emit(event, ...args); + } + + on(event: "bus", listener: (data: BusEvent) => void): this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + + publish(data: BusEvent): void { + this.emit("bus", data); + } +} + +export const eventBus = new EventBus(); +eventBus.setMaxListeners(256); diff --git a/artifacts/api-server/src/lib/histogram.ts b/artifacts/api-server/src/lib/histogram.ts new file mode 100644 index 0000000..9f90296 --- /dev/null +++ b/artifacts/api-server/src/lib/histogram.ts @@ -0,0 +1,45 @@ +const MAX_SAMPLES = 1_000; + +export interface BucketStats { + p50: number | null; + p95: number | null; + count: number; +} + +export class LatencyHistogram { + private readonly buckets = new Map(); + + record(route: string, durationMs: number): void { + let bucket = this.buckets.get(route); + if (!bucket) { + bucket = []; + this.buckets.set(route, bucket); + } + if (bucket.length >= MAX_SAMPLES) { + bucket.shift(); + } + bucket.push(durationMs); + } + + percentile(route: string, pct: number): number | null { + const bucket = this.buckets.get(route); + if (!bucket || bucket.length === 0) return null; + const sorted = [...bucket].sort((a, b) => a - b); + const idx = Math.floor((pct / 100) * sorted.length); + return sorted[Math.min(idx, sorted.length - 1)] ?? null; + } + + snapshot(): Record { + const out: Record = {}; + for (const [route, bucket] of this.buckets.entries()) { + out[route] = { + p50: this.percentile(route, 50), + p95: this.percentile(route, 95), + count: bucket.length, + }; + } + return out; + } +} + +export const latencyHistogram = new LatencyHistogram(); diff --git a/artifacts/api-server/src/lib/lnbits.ts b/artifacts/api-server/src/lib/lnbits.ts index 9ec576d..1a3fc1e 100644 --- a/artifacts/api-server/src/lib/lnbits.ts +++ b/artifacts/api-server/src/lib/lnbits.ts @@ -1,4 +1,7 @@ import { randomBytes } from "crypto"; +import { makeLogger } from "./logger.js"; + +const logger = makeLogger("lnbits"); export interface LNbitsInvoice { paymentHash: string; @@ -22,7 +25,7 @@ export class LNbitsService { this.apiKey = config?.apiKey ?? process.env.LNBITS_API_KEY ?? ""; this.stubMode = !this.url || !this.apiKey; if (this.stubMode) { - console.warn("[LNbitsService] No LNBITS_URL/LNBITS_API_KEY — running in STUB mode. Invoices are simulated."); + logger.warn("no LNBITS_URL/LNBITS_API_KEY — running in STUB mode", { stub: true }); } } @@ -32,7 +35,7 @@ export class LNbitsService { if (this.stubMode) { const paymentHash = randomBytes(32).toString("hex"); const paymentRequest = `lnbcrt${amountSats}u1stub_${paymentHash.slice(0, 16)}`; - console.log(`[stub] Created invoice: ${amountSats} sats — "${memo}" — hash=${paymentHash}`); + logger.info("stub invoice created", { amountSats, memo, paymentHash }); return { paymentHash, paymentRequest }; } @@ -113,7 +116,7 @@ export class LNbitsService { async payInvoice(bolt11: string): Promise { if (this.stubMode) { const paymentHash = randomBytes(32).toString("hex"); - console.log(`[stub] Paid outgoing invoice — fake hash=${paymentHash}`); + logger.info("stub outgoing payment", { paymentHash, invoiceType: "outbound" }); return paymentHash; } @@ -140,7 +143,7 @@ export class LNbitsService { throw new Error("stubMarkPaid called on a real LNbitsService instance"); } stubPaidInvoices.add(paymentHash); - console.log(`[stub] Marked invoice paid: hash=${paymentHash}`); + logger.info("stub invoice marked paid", { paymentHash, invoiceType: "inbound" }); } // ── Private helpers ────────────────────────────────────────────────────── diff --git a/artifacts/api-server/src/lib/logger.ts b/artifacts/api-server/src/lib/logger.ts new file mode 100644 index 0000000..da53042 --- /dev/null +++ b/artifacts/api-server/src/lib/logger.ts @@ -0,0 +1,32 @@ +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export interface LogContext { + [key: string]: unknown; +} + +function emit(level: LogLevel, component: string, message: string, ctx?: LogContext): void { + const line: Record = { + timestamp: new Date().toISOString(), + level, + component, + message, + ...ctx, + }; + const out = JSON.stringify(line); + if (level === "error" || level === "warn") { + console.error(out); + } else { + console.log(out); + } +} + +export function makeLogger(component: string) { + return { + debug: (message: string, ctx?: LogContext) => emit("debug", component, message, ctx), + info: (message: string, ctx?: LogContext) => emit("info", component, message, ctx), + warn: (message: string, ctx?: LogContext) => emit("warn", component, message, ctx), + error: (message: string, ctx?: LogContext) => emit("error", component, message, ctx), + }; +} + +export const rootLogger = makeLogger("server"); diff --git a/artifacts/api-server/src/lib/metrics.ts b/artifacts/api-server/src/lib/metrics.ts new file mode 100644 index 0000000..2fd2dfc --- /dev/null +++ b/artifacts/api-server/src/lib/metrics.ts @@ -0,0 +1,118 @@ +import { db, jobs, invoices } from "@workspace/db"; +import { sql } from "drizzle-orm"; +import { latencyHistogram, type BucketStats } from "./histogram.js"; + +export interface JobStateCounts { + awaiting_eval: number; + awaiting_work: number; + complete: number; + rejected: number; + failed: number; +} + +export interface MetricsSnapshot { + uptime_s: number; + jobs: { + total: number; + by_state: JobStateCounts; + }; + invoices: { + total: number; + paid: number; + conversion_rate: number | null; + }; + earnings: { + total_sats: number; + }; + latency: { + eval_phase: BucketStats | null; + work_phase: BucketStats | null; + routes: Record; + }; +} + +const START_TIME = Date.now(); + +export class MetricsService { + async snapshot(): Promise { + const [jobsByState, invoiceCounts, earningsRow] = await Promise.all([ + db + .select({ + state: jobs.state, + count: sql`cast(count(*) as int)`, + }) + .from(jobs) + .groupBy(jobs.state), + + db + .select({ + total: sql`cast(count(*) as int)`, + paid: sql`cast(sum(case when paid then 1 else 0 end) as int)`, + }) + .from(invoices), + + db + .select({ + total_sats: sql`cast(coalesce(sum(actual_amount_sats), 0) as int)`, + }) + .from(jobs), + ]); + + // Group raw DB states into operational state keys + const rawCounts: Record = {}; + let jobsTotal = 0; + for (const row of jobsByState) { + const n = Number(row.count); + rawCounts[row.state] = (rawCounts[row.state] ?? 0) + n; + jobsTotal += n; + } + + const byState: JobStateCounts = { + awaiting_eval: (rawCounts["awaiting_eval_payment"] ?? 0) + (rawCounts["evaluating"] ?? 0), + awaiting_work: (rawCounts["awaiting_work_payment"] ?? 0) + (rawCounts["executing"] ?? 0), + complete: rawCounts["complete"] ?? 0, + rejected: rawCounts["rejected"] ?? 0, + failed: rawCounts["failed"] ?? 0, + }; + + const invRow = invoiceCounts[0] ?? { total: 0, paid: 0 }; + const invTotal = Number(invRow.total); + const invPaid = Number(invRow.paid); + const conversionRate = invTotal > 0 ? invPaid / invTotal : null; + + const totalSats = Number(earningsRow[0]?.total_sats ?? 0); + + const allRoutes = latencyHistogram.snapshot(); + const evalPhase = allRoutes["eval_phase"] ?? null; + const workPhase = allRoutes["work_phase"] ?? null; + const routeLatency: Record = {}; + for (const [key, stats] of Object.entries(allRoutes)) { + if (key !== "eval_phase" && key !== "work_phase") { + routeLatency[key] = stats; + } + } + + return { + uptime_s: Math.floor((Date.now() - START_TIME) / 1000), + jobs: { + total: jobsTotal, + by_state: byState, + }, + invoices: { + total: invTotal, + paid: invPaid, + conversion_rate: conversionRate, + }, + earnings: { + total_sats: totalSats, + }, + latency: { + eval_phase: evalPhase, + work_phase: workPhase, + routes: routeLatency, + }, + }; + } +} + +export const metricsService = new MetricsService(); diff --git a/artifacts/api-server/src/lib/provisioner.ts b/artifacts/api-server/src/lib/provisioner.ts index e60ef61..6f496fd 100644 --- a/artifacts/api-server/src/lib/provisioner.ts +++ b/artifacts/api-server/src/lib/provisioner.ts @@ -1,6 +1,9 @@ import { generateKeyPairSync } from "crypto"; import { db, bootstrapJobs } from "@workspace/db"; import { eq } from "drizzle-orm"; +import { makeLogger } from "./logger.js"; + +const logger = makeLogger("provisioner"); const DO_API_BASE = "https://api.digitalocean.com/v2"; const TS_API_BASE = "https://api.tailscale.com/api/v2"; @@ -458,9 +461,7 @@ export class ProvisionerService { this.tsTailnet = process.env.TAILSCALE_TAILNET ?? ""; this.stubMode = !this.doToken; if (this.stubMode) { - console.warn( - "[ProvisionerService] No DO_API_TOKEN — running in STUB mode. Provisioning is simulated.", - ); + logger.warn("no DO_API_TOKEN — running in STUB mode", { stub: true }); } } @@ -477,7 +478,7 @@ export class ProvisionerService { } } catch (err) { const message = err instanceof Error ? err.message : "Provisioning failed"; - console.error(`[ProvisionerService] Error for job ${bootstrapJobId}:`, message); + logger.error("provisioning failed", { bootstrapJobId, error: message }); await db .update(bootstrapJobs) .set({ state: "failed", errorMessage: message, updatedAt: new Date() }) @@ -486,7 +487,7 @@ export class ProvisionerService { } private async stubProvision(jobId: string): Promise { - console.log(`[stub] Simulating provisioning for bootstrap job ${jobId}...`); + logger.info("stub provisioning started", { bootstrapJobId: jobId }); const { privateKey } = generateSshKeypair(); await new Promise((r) => setTimeout(r, 2000)); const fakeDropletId = String(Math.floor(Math.random() * 900_000_000 + 100_000_000)); @@ -502,11 +503,11 @@ export class ProvisionerService { updatedAt: new Date(), }) .where(eq(bootstrapJobs.id, jobId)); - console.log(`[stub] Bootstrap job ${jobId} marked ready with fake credentials.`); + logger.info("stub provisioning complete", { bootstrapJobId: jobId }); } private async realProvision(jobId: string): Promise { - console.log(`[ProvisionerService] Provisioning real node for job ${jobId}...`); + logger.info("real provisioning started", { bootstrapJobId: jobId }); // 1. SSH keypair (pure node:crypto) const { publicKey, privateKey } = generateSshKeypair(); @@ -525,7 +526,7 @@ export class ProvisionerService { try { tailscaleAuthKey = await getTailscaleAuthKey(this.tsApiKey, this.tsTailnet); } catch (err) { - console.warn("[ProvisionerService] Tailscale key failed — continuing without:", err); + logger.warn("Tailscale key failed — continuing without Tailscale", { error: String(err) }); } } @@ -534,7 +535,7 @@ export class ProvisionerService { if (this.doVolumeGb > 0) { const volName = `timmy-data-${jobId.slice(0, 8)}`; volumeId = await createVolume(volName, this.doVolumeGb, this.doRegion, this.doToken); - console.log(`[ProvisionerService] Volume created: id=${volumeId} (${this.doVolumeGb} GB)`); + logger.info("block volume created", { volumeId, sizeGb: this.doVolumeGb }); } // 5. Create droplet @@ -556,11 +557,11 @@ export class ProvisionerService { dropletPayload, ); const dropletId = dropletData.droplet.id; - console.log(`[ProvisionerService] Droplet created: id=${dropletId}`); + logger.info("droplet created", { bootstrapJobId: jobId, dropletId }); // 6. Poll for public IP (up to 2 min) const nodeIp = await pollDropletIp(dropletId, this.doToken, 120_000); - console.log(`[ProvisionerService] Node IP: ${nodeIp ?? "(not yet assigned)"}`); + logger.info("node ip assigned", { bootstrapJobId: jobId, nodeIp: nodeIp ?? "(not yet assigned)" }); // 7. Tailscale hostname const tailscaleHostname = @@ -589,7 +590,7 @@ export class ProvisionerService { }) .where(eq(bootstrapJobs.id, jobId)); - console.log(`[ProvisionerService] Bootstrap job ${jobId} ready.`); + logger.info("real provisioning complete", { bootstrapJobId: jobId }); } } diff --git a/artifacts/api-server/src/lib/rate-limiter.ts b/artifacts/api-server/src/lib/rate-limiter.ts new file mode 100644 index 0000000..358964f --- /dev/null +++ b/artifacts/api-server/src/lib/rate-limiter.ts @@ -0,0 +1,46 @@ +import { rateLimit, type Options } from "express-rate-limit"; +import { makeLogger } from "./logger.js"; + +const logger = makeLogger("rate-limiter"); + +function envInt(key: string, fallback: number): number { + const v = process.env[key]; + const n = v ? parseInt(v, 10) : NaN; + return Number.isNaN(n) ? fallback : n; +} + +function limiter(windowMs: number, max: number, overrideKey?: string) { + const resolvedMax = overrideKey ? envInt(overrideKey, max) : max; + return rateLimit({ + windowMs, + max: resolvedMax, + standardHeaders: "draft-7", + legacyHeaders: false, + handler: (req, res) => { + const ip = + (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? + req.socket.remoteAddress ?? + "unknown"; + logger.warn("rate limit hit", { + route: req.path, + method: req.method, + ip, + retry_after_s: Math.ceil(windowMs / 1000), + }); + res.status(429).json({ + error: "rate_limited", + message: "Too many requests — please slow down.", + retryAfterSeconds: Math.ceil(windowMs / 1000), + }); + }, + } satisfies Partial); +} + +// POST /api/jobs — 30 req/min per IP (configurable via RATE_LIMIT_JOBS) +export const jobsLimiter = limiter(60_000, 30, "RATE_LIMIT_JOBS"); + +// POST /api/sessions — 10 req/min per IP (configurable via RATE_LIMIT_SESSIONS) +export const sessionsLimiter = limiter(60_000, 10, "RATE_LIMIT_SESSIONS"); + +// POST /api/bootstrap — 3 req/hour per IP (configurable via RATE_LIMIT_BOOTSTRAP) +export const bootstrapLimiter = limiter(60 * 60_000, 3, "RATE_LIMIT_BOOTSTRAP"); diff --git a/artifacts/api-server/src/lib/stream-registry.ts b/artifacts/api-server/src/lib/stream-registry.ts new file mode 100644 index 0000000..7d02eec --- /dev/null +++ b/artifacts/api-server/src/lib/stream-registry.ts @@ -0,0 +1,55 @@ +import { PassThrough } from "stream"; + +interface StreamEntry { + stream: PassThrough; + createdAt: number; +} + +class StreamRegistry { + private readonly streams = new Map(); + private readonly TTL_MS = 5 * 60 * 1000; + + register(jobId: string): PassThrough { + const existing = this.streams.get(jobId); + if (existing) { + existing.stream.destroy(); + } + const stream = new PassThrough(); + this.streams.set(jobId, { stream, createdAt: Date.now() }); + + stream.on("close", () => { + this.streams.delete(jobId); + }); + + this.evictExpired(); + return stream; + } + + get(jobId: string): PassThrough | null { + return this.streams.get(jobId)?.stream ?? null; + } + + write(jobId: string, chunk: string): void { + this.streams.get(jobId)?.stream.write(chunk); + } + + end(jobId: string): void { + const entry = this.streams.get(jobId); + if (entry) { + entry.stream.end(); + this.streams.delete(jobId); + } + } + + private evictExpired(): void { + const now = Date.now(); + for (const [id, entry] of this.streams.entries()) { + if (now - entry.createdAt > this.TTL_MS) { + entry.stream.destroy(); + this.streams.delete(id); + } + } + } +} + +export const streamRegistry = new StreamRegistry(); diff --git a/artifacts/api-server/src/middlewares/response-time.ts b/artifacts/api-server/src/middlewares/response-time.ts new file mode 100644 index 0000000..7475dbb --- /dev/null +++ b/artifacts/api-server/src/middlewares/response-time.ts @@ -0,0 +1,31 @@ +import type { Request, Response, NextFunction } from "express"; +import { makeLogger } from "../lib/logger.js"; +import { latencyHistogram } from "../lib/histogram.js"; + +const logger = makeLogger("http"); + +export function responseTimeMiddleware(req: Request, res: Response, next: NextFunction): void { + const startedAt = Date.now(); + + res.on("finish", () => { + const durationMs = Date.now() - startedAt; + const route = req.route?.path as string | undefined; + const routeKey = `${req.method} ${route ?? req.path}`; + + latencyHistogram.record(routeKey, durationMs); + + logger.info("request", { + method: req.method, + path: req.path, + route: route ?? null, + status: res.statusCode, + duration_ms: durationMs, + ip: + (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? + req.socket.remoteAddress ?? + null, + }); + }); + + next(); +} diff --git a/artifacts/api-server/src/routes/bootstrap.ts b/artifacts/api-server/src/routes/bootstrap.ts index 42aefdd..0ac1d5e 100644 --- a/artifacts/api-server/src/routes/bootstrap.ts +++ b/artifacts/api-server/src/routes/bootstrap.ts @@ -5,6 +5,9 @@ import { eq, and } from "drizzle-orm"; import { lnbitsService } from "../lib/lnbits.js"; import { pricingService } from "../lib/pricing.js"; import { provisionerService } from "../lib/provisioner.js"; +import { makeLogger } from "../lib/logger.js"; + +const logger = makeLogger("bootstrap"); const router = Router(); @@ -44,7 +47,7 @@ async function advanceBootstrapJob(job: BootstrapJob): Promise { const { allowed, resetAt } = checkRateLimit(ip); if (!allowed) { const secsUntilReset = Math.ceil((resetAt - Date.now()) / 1000); + logger.warn("demo rate limited", { ip, retry_after_s: secsUntilReset }); res.status(429).json({ error: `Rate limit exceeded. Try again in ${secsUntilReset}s (5 requests per hour per IP).`, }); @@ -52,11 +55,14 @@ router.get("/demo", async (req: Request, res: Response) => { } const { request } = parseResult.data; + logger.info("demo request received", { ip }); + try { const { result } = await agentService.executeWork(request); res.json({ result }); } catch (err) { const message = err instanceof Error ? err.message : "Agent error"; + logger.error("demo agent error", { ip, error: message }); res.status(500).json({ error: message }); } }); diff --git a/artifacts/api-server/src/routes/health.ts b/artifacts/api-server/src/routes/health.ts index c0a1446..c6bae80 100644 --- a/artifacts/api-server/src/routes/health.ts +++ b/artifacts/api-server/src/routes/health.ts @@ -1,11 +1,25 @@ -import { Router, type IRouter } from "express"; -import { HealthCheckResponse } from "@workspace/api-zod"; +import { Router, type IRouter, type Request, type Response } from "express"; +import { db, jobs } from "@workspace/db"; +import { sql } from "drizzle-orm"; +import { makeLogger } from "../lib/logger.js"; const router: IRouter = Router(); +const logger = makeLogger("health"); -router.get("/healthz", (_req, res) => { - const data = HealthCheckResponse.parse({ status: "ok" }); - res.json(data); +const START_TIME = Date.now(); + +router.get("/healthz", async (_req: Request, res: Response) => { + try { + const rows = await db.select({ total: sql`cast(count(*) as int)` }).from(jobs); + const jobsTotal = Number(rows[0]?.total ?? 0); + const uptimeS = Math.floor((Date.now() - START_TIME) / 1000); + res.json({ status: "ok", uptime_s: uptimeS, jobs_total: jobsTotal }); + } catch (err) { + const message = err instanceof Error ? err.message : "Health check failed"; + logger.error("healthz db query failed", { error: message }); + const uptimeS = Math.floor((Date.now() - START_TIME) / 1000); + res.status(503).json({ status: "error", uptime_s: uptimeS, error: message }); + } }); export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index b821226..a3856a6 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -8,10 +8,12 @@ import devRouter from "./dev.js"; import testkitRouter from "./testkit.js"; import uiRouter from "./ui.js"; import nodeDiagnosticsRouter from "./node-diagnostics.js"; +import metricsRouter from "./metrics.js"; const router: IRouter = Router(); router.use(healthRouter); +router.use(metricsRouter); router.use(jobsRouter); router.use(bootstrapRouter); router.use(sessionsRouter); diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 78f67ef..ada8d6e 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -6,6 +6,13 @@ import { CreateJobBody, GetJobParams } from "@workspace/api-zod"; import { lnbitsService } from "../lib/lnbits.js"; import { agentService } from "../lib/agent.js"; import { pricingService } from "../lib/pricing.js"; +import { jobsLimiter } from "../lib/rate-limiter.js"; +import { eventBus } from "../lib/event-bus.js"; +import { streamRegistry } from "../lib/stream-registry.js"; +import { makeLogger } from "../lib/logger.js"; +import { latencyHistogram } from "../lib/histogram.js"; + +const logger = makeLogger("jobs"); const router = Router(); @@ -24,8 +31,18 @@ async function getInvoiceById(id: string) { * return immediately with "evaluating" state instead of blocking 5-8 seconds. */ async function runEvalInBackground(jobId: string, request: string): Promise { + const evalStart = Date.now(); try { const evalResult = await agentService.evaluateRequest(request); + latencyHistogram.record("eval_phase", Date.now() - evalStart); + + logger.info("eval result", { + jobId, + accepted: evalResult.accepted, + reason: evalResult.reason, + inputTokens: evalResult.inputTokens, + outputTokens: evalResult.outputTokens, + }); if (evalResult.accepted) { const inputEst = pricingService.estimateInputTokens(request); @@ -65,11 +82,13 @@ async function runEvalInBackground(jobId: string, request: string): Promise { + const workStart = Date.now(); try { - const workResult = await agentService.executeWork(request); + eventBus.publish({ type: "job:state", jobId, state: "executing" }); + + const workResult = await agentService.executeWorkStreaming(request, (delta) => { + streamRegistry.write(jobId, delta); + }); + + streamRegistry.end(jobId); + latencyHistogram.record("work_phase", Date.now() - workStart); const actualCostUsd = pricingService.calculateActualCostUsd( workResult.inputTokens, @@ -112,12 +141,24 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat updatedAt: new Date(), }) .where(eq(jobs.id, jobId)); + + logger.info("work completed", { + jobId, + inputTokens: workResult.inputTokens, + outputTokens: workResult.outputTokens, + actualAmountSats, + refundAmountSats, + refundState, + }); + eventBus.publish({ type: "job:completed", jobId, result: workResult.result }); } catch (err) { const message = err instanceof Error ? err.message : "Execution error"; + streamRegistry.end(jobId); await db .update(jobs) .set({ state: "failed", errorMessage: message, updatedAt: new Date() }) .where(eq(jobs.id, jobId)); + eventBus.publish({ type: "job:failed", jobId, reason: message }); } } @@ -149,6 +190,10 @@ async function advanceJob(job: Job): Promise { if (!advanced) return getJobById(job.id); + logger.info("invoice paid", { jobId: job.id, invoiceType: "eval", paymentHash: evalInvoice.paymentHash }); + eventBus.publish({ type: "job:paid", jobId: job.id, invoiceType: "eval" }); + eventBus.publish({ type: "job:state", jobId: job.id, state: "evaluating" }); + // Fire AI eval in background — poll returns immediately with "evaluating" setImmediate(() => { void runEvalInBackground(job.id, job.request); }); @@ -177,6 +222,12 @@ async function advanceJob(job: Job): Promise { if (!advanced) return getJobById(job.id); + logger.info("invoice paid", { jobId: job.id, invoiceType: "work", paymentHash: workInvoice.paymentHash }); + eventBus.publish({ type: "job:paid", jobId: job.id, invoiceType: "work" }); + + // Register stream slot before firing background work so first tokens aren't lost + streamRegistry.register(job.id); + // Fire AI work in background — poll returns immediately with "executing" setImmediate(() => { void runWorkInBackground(job.id, job.request, job.workAmountSats ?? 0, job.btcPriceUsd); }); @@ -188,7 +239,7 @@ async function advanceJob(job: Job): Promise { // ── POST /jobs ──────────────────────────────────────────────────────────────── -router.post("/jobs", async (req: Request, res: Response) => { +router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => { const parseResult = CreateJobBody.safeParse(req.body); if (!parseResult.success) { const issue = parseResult.error.issues[0]; @@ -221,6 +272,8 @@ router.post("/jobs", async (req: Request, res: Response) => { await tx.update(jobs).set({ evalInvoiceId: invoiceId, updatedAt: new Date() }).where(eq(jobs.id, jobId)); }); + logger.info("job created", { jobId, evalAmountSats: evalFee, stubMode: lnbitsService.stubMode }); + res.status(201).json({ jobId, evalInvoice: { @@ -231,6 +284,7 @@ router.post("/jobs", async (req: Request, res: Response) => { }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create job"; + logger.error("job creation failed", { error: message }); res.status(500).json({ error: message }); } }); @@ -404,4 +458,130 @@ router.post("/jobs/:id/refund", async (req: Request, res: Response) => { } }); +// ── GET /jobs/:id/stream ────────────────────────────────────────────────────── +// Server-Sent Events (#3): streams Claude token deltas in real time while the +// job is executing. If the job is already complete, sends the full result then +// closes. If the job isn't executing yet, waits up to 60 s for it to start. + +router.get("/jobs/:id/stream", async (req: Request, res: Response) => { + const paramResult = GetJobParams.safeParse(req.params); + if (!paramResult.success) { + res.status(400).json({ error: "Invalid job id" }); + return; + } + const { id } = paramResult.data; + + const job = await getJobById(id); + if (!job) { + res.status(404).json({ error: "Job not found" }); + return; + } + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + + const sendEvent = (event: string, data: unknown) => { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + + // Job already complete — replay full result immediately + if (job.state === "complete" && job.result) { + sendEvent("token", { text: job.result }); + sendEvent("done", { jobId: id, state: "complete" }); + res.end(); + return; + } + + if (job.state === "failed") { + sendEvent("error", { jobId: id, message: job.errorMessage ?? "Job failed" }); + res.end(); + return; + } + + // Job is executing or about to execute — pipe the live stream + const sendHeartbeat = setInterval(() => { + res.write(": heartbeat\n\n"); + }, 15_000); + + const cleanup = () => { + clearInterval(sendHeartbeat); + }; + + req.on("close", cleanup); + + // ── Wait for stream slot (fixes #16 race condition) ────────────────────── + // After the bus wait we re-check BOTH the stream registry AND the DB so we + // handle: (a) job completed while we waited (stream already gone), (b) job + // still executing but stream was registered after we first checked. + let stream = streamRegistry.get(id); + let currentJob = job; + + if (!stream) { + await new Promise((resolve) => { + // 90 s timeout — generous enough for slow payment confirmations on mainnet + const deadline = setTimeout(resolve, 90_000); + const busListener = (data: Parameters[0]) => { + if ("jobId" in data && data.jobId === id) { + clearTimeout(deadline); + eventBus.off("bus", busListener); + resolve(); + } + }; + eventBus.on("bus", busListener); + }); + + // Refresh both stream slot and job state after waiting + stream = streamRegistry.get(id); + currentJob = (await getJobById(id)) ?? currentJob; + } + + // ── Resolve: stream available ───────────────────────────────────────────── + if (stream) { + const attachToStream = (s: typeof stream) => { + s!.on("data", (chunk: Buffer) => { + sendEvent("token", { text: chunk.toString("utf8") }); + }); + s!.on("end", () => { + sendEvent("done", { jobId: id, state: "complete" }); + res.end(); + cleanup(); + }); + s!.on("error", (err: Error) => { + sendEvent("error", { jobId: id, message: err.message }); + res.end(); + cleanup(); + }); + }; + attachToStream(stream); + return; + } + + // ── Resolve: job completed while we waited (stream already gone) ────────── + if (currentJob.state === "complete" && currentJob.result) { + sendEvent("token", { text: currentJob.result }); + sendEvent("done", { jobId: id, state: "complete" }); + res.end(); + cleanup(); + return; + } + + if (currentJob.state === "failed") { + sendEvent("error", { jobId: id, message: currentJob.errorMessage ?? "Job failed" }); + res.end(); + cleanup(); + return; + } + + // ── Resolve: timeout with no activity — tell client to fall back to polling + sendEvent("error", { + jobId: id, + message: "Stream timed out. Poll GET /api/jobs/:id for current state.", + }); + res.end(); + cleanup(); +}); + export default router; diff --git a/artifacts/api-server/src/routes/metrics.ts b/artifacts/api-server/src/routes/metrics.ts new file mode 100644 index 0000000..7a06785 --- /dev/null +++ b/artifacts/api-server/src/routes/metrics.ts @@ -0,0 +1,19 @@ +import { Router, type Request, type Response } from "express"; +import { metricsService } from "../lib/metrics.js"; +import { makeLogger } from "../lib/logger.js"; + +const router = Router(); +const logger = makeLogger("metrics"); + +router.get("/metrics", async (_req: Request, res: Response) => { + try { + const snapshot = await metricsService.snapshot(); + res.json(snapshot); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to collect metrics"; + logger.error("snapshot failed", { error: message }); + res.status(500).json({ error: message }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts index e23a597..744e603 100644 --- a/artifacts/api-server/src/routes/sessions.ts +++ b/artifacts/api-server/src/routes/sessions.ts @@ -3,6 +3,8 @@ import { randomBytes, randomUUID } from "crypto"; import { db, sessions, sessionRequests, type Session } from "@workspace/db"; import { eq, and } from "drizzle-orm"; import { lnbitsService } from "../lib/lnbits.js"; +import { sessionsLimiter } from "../lib/rate-limiter.js"; +import { eventBus } from "../lib/event-bus.js"; import { agentService } from "../lib/agent.js"; import { pricingService } from "../lib/pricing.js"; import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js"; @@ -133,7 +135,7 @@ async function advanceTopup(session: Session): Promise { // ── POST /sessions ───────────────────────────────────────────────────────────── -router.post("/sessions", async (req: Request, res: Response) => { +router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) => { const rawAmount = req.body?.amount_sats; const amountSats = parseInt(String(rawAmount ?? ""), 10); @@ -220,7 +222,7 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { } try { - let session = await getSessionById(id); + const session = await getSessionById(id); if (!session) { res.status(404).json({ error: "Session not found" }); return; } // Auth diff --git a/artifacts/api-server/src/routes/testkit.ts b/artifacts/api-server/src/routes/testkit.ts index 78b5836..a06b65d 100644 --- a/artifacts/api-server/src/routes/testkit.ts +++ b/artifacts/api-server/src/routes/testkit.ts @@ -9,6 +9,8 @@ const router = Router(); * BASE URL. Agents and testers can run the full test suite with one command: * * curl -s https://your-url.replit.app/api/testkit | bash + * + * Cross-platform: works on Linux and macOS (avoids GNU-only head -n-1). */ router.get("/testkit", (req: Request, res: Response) => { const proto = @@ -31,16 +33,17 @@ FAIL=0 SKIP=0 note() { echo " [\$1] \$2"; } -jq_field() { echo "\$1" | jq -r "\$2" 2>/dev/null || echo ""; } -sep() { echo; echo "=== $* ==="; } +sep() { echo; echo "=== $* ==="; } +# body_of: strip last line (HTTP status code) — works on GNU and BSD (macOS) +body_of() { echo "\$1" | sed '$d'; } +code_of() { echo "\$1" | tail -n1; } # --------------------------------------------------------------------------- # Test 1 — Health check # --------------------------------------------------------------------------- sep "Test 1 — Health check" T1_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/healthz") -T1_BODY=$(echo "$T1_RES" | head -n-1) -T1_CODE=$(echo "$T1_RES" | tail -n1) +T1_BODY=$(body_of "$T1_RES"); T1_CODE=$(code_of "$T1_RES") if [[ "$T1_CODE" == "200" ]] && [[ "$(echo "$T1_BODY" | jq -r '.status' 2>/dev/null)" == "ok" ]]; then note PASS "HTTP 200, status=ok" PASS=$((PASS+1)) @@ -56,8 +59,7 @@ sep "Test 2 — Create job" T2_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/jobs" \\ -H "Content-Type: application/json" \\ -d '{"request":"Explain the Lightning Network in two sentences"}') -T2_BODY=$(echo "$T2_RES" | head -n-1) -T2_CODE=$(echo "$T2_RES" | tail -n1) +T2_BODY=$(body_of "$T2_RES"); T2_CODE=$(code_of "$T2_RES") JOB_ID=$(echo "$T2_BODY" | jq -r '.jobId' 2>/dev/null || echo "") EVAL_AMT=$(echo "$T2_BODY" | jq -r '.evalInvoice.amountSats' 2>/dev/null || echo "") if [[ "$T2_CODE" == "201" && -n "$JOB_ID" && "$EVAL_AMT" == "10" ]]; then @@ -73,8 +75,7 @@ fi # --------------------------------------------------------------------------- sep "Test 3 — Poll before payment" T3_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/jobs/$JOB_ID") -T3_BODY=$(echo "$T3_RES" | head -n-1) -T3_CODE=$(echo "$T3_RES" | tail -n1) +T3_BODY=$(body_of "$T3_RES"); T3_CODE=$(code_of "$T3_RES") STATE_T3=$(echo "$T3_BODY" | jq -r '.state' 2>/dev/null || echo "") EVAL_AMT_ECHO=$(echo "$T3_BODY" | jq -r '.evalInvoice.amountSats' 2>/dev/null || echo "") EVAL_HASH=$(echo "$T3_BODY" | jq -r '.evalInvoice.paymentHash' 2>/dev/null || echo "") @@ -99,8 +100,7 @@ fi sep "Test 4 — Pay eval invoice (stub)" if [[ -n "$EVAL_HASH" && "$EVAL_HASH" != "null" ]]; then T4_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/dev/stub/pay/$EVAL_HASH") - T4_BODY=$(echo "$T4_RES" | head -n-1) - T4_CODE=$(echo "$T4_RES" | tail -n1) + T4_BODY=$(body_of "$T4_RES"); T4_CODE=$(code_of "$T4_RES") if [[ "$T4_CODE" == "200" ]] && [[ "$(echo "$T4_BODY" | jq -r '.ok' 2>/dev/null)" == "true" ]]; then note PASS "Eval invoice marked paid" PASS=$((PASS+1)) @@ -114,25 +114,32 @@ else fi # --------------------------------------------------------------------------- -# Test 5 — Poll after eval payment +# Test 5 — Poll after eval payment (with retry loop — real AI eval takes 2–5 s) # --------------------------------------------------------------------------- sep "Test 5 — Poll after eval (state advance)" -sleep 2 -T5_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/jobs/$JOB_ID") -T5_BODY=$(echo "$T5_RES" | head -n-1) -T5_CODE=$(echo "$T5_RES" | tail -n1) -STATE_T5=$(echo "$T5_BODY" | jq -r '.state' 2>/dev/null || echo "") -WORK_AMT=$(echo "$T5_BODY" | jq -r '.workInvoice.amountSats' 2>/dev/null || echo "") -WORK_HASH=$(echo "$T5_BODY" | jq -r '.workInvoice.paymentHash' 2>/dev/null || echo "") +START_T5=$(date +%s) +T5_TIMEOUT=30 +STATE_T5=""; WORK_AMT=""; WORK_HASH=""; T5_BODY=""; T5_CODE="" +while :; do + T5_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/jobs/$JOB_ID") + T5_BODY=$(body_of "$T5_RES"); T5_CODE=$(code_of "$T5_RES") + STATE_T5=$(echo "$T5_BODY" | jq -r '.state' 2>/dev/null || echo "") + WORK_AMT=$(echo "$T5_BODY" | jq -r '.workInvoice.amountSats' 2>/dev/null || echo "") + WORK_HASH=$(echo "$T5_BODY" | jq -r '.workInvoice.paymentHash' 2>/dev/null || echo "") + NOW_T5=$(date +%s); ELAPSED_T5=$((NOW_T5 - START_T5)) + if [[ "$STATE_T5" == "awaiting_work_payment" || "$STATE_T5" == "rejected" ]]; then break; fi + if (( ELAPSED_T5 > T5_TIMEOUT )); then break; fi + sleep 2 +done if [[ "$T5_CODE" == "200" && "$STATE_T5" == "awaiting_work_payment" && -n "$WORK_AMT" && "$WORK_AMT" != "null" ]]; then - note PASS "state=awaiting_work_payment, workInvoice.amountSats=$WORK_AMT" + note PASS "state=awaiting_work_payment in $ELAPSED_T5 s, workInvoice.amountSats=$WORK_AMT" PASS=$((PASS+1)) elif [[ "$T5_CODE" == "200" && "$STATE_T5" == "rejected" ]]; then - note PASS "Request correctly rejected by agent after eval" + note PASS "Request correctly rejected by agent after eval (in $ELAPSED_T5 s)" PASS=$((PASS+1)) WORK_HASH="" else - note FAIL "code=$T5_CODE state=$STATE_T5 body=$T5_BODY" + note FAIL "code=$T5_CODE state=$STATE_T5 body=$T5_BODY (after $ELAPSED_T5 s)" FAIL=$((FAIL+1)) fi @@ -142,8 +149,7 @@ fi sep "Test 6 — Pay work invoice + get result" if [[ "$STATE_T5" == "awaiting_work_payment" && -n "$WORK_HASH" && "$WORK_HASH" != "null" ]]; then T6_PAY_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/dev/stub/pay/$WORK_HASH") - T6_PAY_BODY=$(echo "$T6_PAY_RES" | head -n-1) - T6_PAY_CODE=$(echo "$T6_PAY_RES" | tail -n1) + T6_PAY_BODY=$(body_of "$T6_PAY_RES"); T6_PAY_CODE=$(code_of "$T6_PAY_RES") if [[ "$T6_PAY_CODE" != "200" ]] || [[ "$(echo "$T6_PAY_BODY" | jq -r '.ok' 2>/dev/null)" != "true" ]]; then note FAIL "Work payment stub failed: code=$T6_PAY_CODE body=$T6_PAY_BODY" FAIL=$((FAIL+1)) @@ -152,11 +158,10 @@ if [[ "$STATE_T5" == "awaiting_work_payment" && -n "$WORK_HASH" && "$WORK_HASH" TIMEOUT=30 while :; do T6_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/jobs/$JOB_ID") - T6_BODY=$(echo "$T6_RES" | head -n-1) + T6_BODY=$(body_of "$T6_RES") STATE_T6=$(echo "$T6_BODY" | jq -r '.state' 2>/dev/null || echo "") RESULT_T6=$(echo "$T6_BODY" | jq -r '.result' 2>/dev/null || echo "") - NOW_TS=$(date +%s) - ELAPSED=$((NOW_TS - START_TS)) + NOW_TS=$(date +%s); ELAPSED=$((NOW_TS - START_TS)) if [[ "$STATE_T6" == "complete" && -n "$RESULT_T6" && "$RESULT_T6" != "null" ]]; then note PASS "state=complete in $ELAPSED s" echo " Result: \${RESULT_T6:0:200}..." @@ -177,33 +182,13 @@ else fi # --------------------------------------------------------------------------- -# Test 7 — Demo endpoint -# --------------------------------------------------------------------------- -sep "Test 7 — Demo endpoint" -START_DEMO=$(date +%s) -T7_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/demo?request=What+is+a+satoshi") -T7_BODY=$(echo "$T7_RES" | head -n-1) -T7_CODE=$(echo "$T7_RES" | tail -n1) -END_DEMO=$(date +%s) -ELAPSED_DEMO=$((END_DEMO - START_DEMO)) -RESULT_T7=$(echo "$T7_BODY" | jq -r '.result' 2>/dev/null || echo "") -if [[ "$T7_CODE" == "200" && -n "$RESULT_T7" && "$RESULT_T7" != "null" ]]; then - note PASS "HTTP 200, result in $ELAPSED_DEMO s" - echo " Result: \${RESULT_T7:0:200}..." - PASS=$((PASS+1)) -else - note FAIL "code=$T7_CODE body=$T7_BODY" - FAIL=$((FAIL+1)) -fi - -# --------------------------------------------------------------------------- -# Test 8 — Input validation (4 sub-cases) +# Test 8 — Input validation (run BEFORE test 7 to avoid rate-limit interference) # --------------------------------------------------------------------------- sep "Test 8 — Input validation" T8A_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/jobs" \\ -H "Content-Type: application/json" -d '{}') -T8A_BODY=$(echo "$T8A_RES" | head -n-1); T8A_CODE=$(echo "$T8A_RES" | tail -n1) +T8A_BODY=$(body_of "$T8A_RES"); T8A_CODE=$(code_of "$T8A_RES") if [[ "$T8A_CODE" == "400" && -n "$(echo "$T8A_BODY" | jq -r '.error' 2>/dev/null)" ]]; then note PASS "8a: Missing request body → HTTP 400" PASS=$((PASS+1)) @@ -213,7 +198,7 @@ else fi T8B_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/jobs/does-not-exist") -T8B_BODY=$(echo "$T8B_RES" | head -n-1); T8B_CODE=$(echo "$T8B_RES" | tail -n1) +T8B_BODY=$(body_of "$T8B_RES"); T8B_CODE=$(code_of "$T8B_RES") if [[ "$T8B_CODE" == "404" && -n "$(echo "$T8B_BODY" | jq -r '.error' 2>/dev/null)" ]]; then note PASS "8b: Unknown job ID → HTTP 404" PASS=$((PASS+1)) @@ -222,8 +207,9 @@ else FAIL=$((FAIL+1)) fi +# 8c runs here — before tests 7 and 9 consume rate-limit quota T8C_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/demo") -T8C_BODY=$(echo "$T8C_RES" | head -n-1); T8C_CODE=$(echo "$T8C_RES" | tail -n1) +T8C_BODY=$(body_of "$T8C_RES"); T8C_CODE=$(code_of "$T8C_RES") if [[ "$T8C_CODE" == "400" && -n "$(echo "$T8C_BODY" | jq -r '.error' 2>/dev/null)" ]]; then note PASS "8c: Demo missing param → HTTP 400" PASS=$((PASS+1)) @@ -236,7 +222,7 @@ LONG_STR=$(node -e "process.stdout.write('x'.repeat(501))" 2>/dev/null || python T8D_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/jobs" \\ -H "Content-Type: application/json" \\ -d "{\\"request\\":\\"$LONG_STR\\"}") -T8D_BODY=$(echo "$T8D_RES" | head -n-1); T8D_CODE=$(echo "$T8D_RES" | tail -n1) +T8D_BODY=$(body_of "$T8D_RES"); T8D_CODE=$(code_of "$T8D_RES") T8D_ERR=$(echo "$T8D_BODY" | jq -r '.error' 2>/dev/null || echo "") if [[ "$T8D_CODE" == "400" && "$T8D_ERR" == *"500 characters"* ]]; then note PASS "8d: 501-char request → HTTP 400 with character limit error" @@ -247,13 +233,31 @@ else fi # --------------------------------------------------------------------------- -# Test 9 — Demo rate limiter +# Test 7 — Demo endpoint (after validation, before rate-limit exhaustion test) +# --------------------------------------------------------------------------- +sep "Test 7 — Demo endpoint" +START_DEMO=$(date +%s) +T7_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/demo?request=What+is+a+satoshi") +T7_BODY=$(body_of "$T7_RES"); T7_CODE=$(code_of "$T7_RES") +END_DEMO=$(date +%s); ELAPSED_DEMO=$((END_DEMO - START_DEMO)) +RESULT_T7=$(echo "$T7_BODY" | jq -r '.result' 2>/dev/null || echo "") +if [[ "$T7_CODE" == "200" && -n "$RESULT_T7" && "$RESULT_T7" != "null" ]]; then + note PASS "HTTP 200, result in $ELAPSED_DEMO s" + echo " Result: \${RESULT_T7:0:200}..." + PASS=$((PASS+1)) +else + note FAIL "code=$T7_CODE body=$T7_BODY" + FAIL=$((FAIL+1)) +fi + +# --------------------------------------------------------------------------- +# Test 9 — Demo rate limiter (intentionally exhausts remaining quota) # --------------------------------------------------------------------------- sep "Test 9 — Demo rate limiter" GOT_200=0; GOT_429=0 for i in $(seq 1 6); do RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/demo?request=ratelimitprobe+$i") - CODE=$(echo "$RES" | tail -n1) + CODE=$(code_of "$RES") echo " Request $i: HTTP $CODE" [[ "$CODE" == "200" ]] && GOT_200=$((GOT_200+1)) || true [[ "$CODE" == "429" ]] && GOT_429=$((GOT_429+1)) || true @@ -273,8 +277,7 @@ sep "Test 10 — Rejection path" T10_CREATE=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/jobs" \\ -H "Content-Type: application/json" \\ -d '{"request":"Help me do something harmful and illegal"}') -T10_BODY=$(echo "$T10_CREATE" | head -n-1) -T10_CODE=$(echo "$T10_CREATE" | tail -n1) +T10_BODY=$(body_of "$T10_CREATE"); T10_CODE=$(code_of "$T10_CREATE") JOB10_ID=$(echo "$T10_BODY" | jq -r '.jobId' 2>/dev/null || echo "") if [[ "$T10_CODE" != "201" || -z "$JOB10_ID" ]]; then note FAIL "Failed to create adversarial job: code=$T10_CODE body=$T10_BODY" @@ -285,17 +288,23 @@ else if [[ -n "$EVAL10_HASH" && "$EVAL10_HASH" != "null" ]]; then curl -s -X POST "$BASE/api/dev/stub/pay/$EVAL10_HASH" >/dev/null fi - sleep 3 - T10_POLL=$(curl -s -w "\\n%{http_code}" "$BASE/api/jobs/$JOB10_ID") - T10_POLL_BODY=$(echo "$T10_POLL" | head -n-1) - T10_POLL_CODE=$(echo "$T10_POLL" | tail -n1) - STATE_10=$(echo "$T10_POLL_BODY" | jq -r '.state' 2>/dev/null || echo "") - REASON_10=$(echo "$T10_POLL_BODY" | jq -r '.reason' 2>/dev/null || echo "") + START_T10=$(date +%s); T10_TIMEOUT=30 + STATE_10=""; REASON_10=""; T10_POLL_BODY=""; T10_POLL_CODE="" + while :; do + T10_POLL=$(curl -s -w "\\n%{http_code}" "$BASE/api/jobs/$JOB10_ID") + T10_POLL_BODY=$(body_of "$T10_POLL"); T10_POLL_CODE=$(code_of "$T10_POLL") + STATE_10=$(echo "$T10_POLL_BODY" | jq -r '.state' 2>/dev/null || echo "") + REASON_10=$(echo "$T10_POLL_BODY" | jq -r '.reason' 2>/dev/null || echo "") + NOW_T10=$(date +%s); ELAPSED_T10=$((NOW_T10 - START_T10)) + if [[ "$STATE_10" == "rejected" || "$STATE_10" == "failed" ]]; then break; fi + if (( ELAPSED_T10 > T10_TIMEOUT )); then break; fi + sleep 2 + done if [[ "$T10_POLL_CODE" == "200" && "$STATE_10" == "rejected" && -n "$REASON_10" && "$REASON_10" != "null" ]]; then - note PASS "state=rejected, reason: \${REASON_10:0:120}" + note PASS "state=rejected in $ELAPSED_T10 s, reason: \${REASON_10:0:120}" PASS=$((PASS+1)) else - note FAIL "code=$T10_POLL_CODE state=$STATE_10 body=$T10_POLL_BODY" + note FAIL "code=$T10_POLL_CODE state=$STATE_10 body=$T10_POLL_BODY (after $ELAPSED_T10 s)" FAIL=$((FAIL+1)) fi fi @@ -307,8 +316,7 @@ sep "Test 11 — Session: create session (awaiting_payment)" T11_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/sessions" \\ -H "Content-Type: application/json" \\ -d '{"amount_sats": 200}') -T11_BODY=$(echo "$T11_RES" | head -n-1) -T11_CODE=$(echo "$T11_RES" | tail -n1) +T11_BODY=$(body_of "$T11_RES"); T11_CODE=$(code_of "$T11_RES") SESSION_ID=$(echo "$T11_BODY" | jq -r '.sessionId' 2>/dev/null || echo "") T11_STATE=$(echo "$T11_BODY" | jq -r '.state' 2>/dev/null || echo "") T11_AMT=$(echo "$T11_BODY" | jq -r '.invoice.amountSats' 2>/dev/null || echo "") @@ -322,12 +330,11 @@ else fi # --------------------------------------------------------------------------- -# Test 12 — Session: poll before payment (stub hash present) +# Test 12 — Session: poll before payment # --------------------------------------------------------------------------- sep "Test 12 — Session: poll before payment" T12_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/sessions/$SESSION_ID") -T12_BODY=$(echo "$T12_RES" | head -n-1) -T12_CODE=$(echo "$T12_RES" | tail -n1) +T12_BODY=$(body_of "$T12_RES"); T12_CODE=$(code_of "$T12_RES") T12_STATE=$(echo "$T12_BODY" | jq -r '.state' 2>/dev/null || echo "") if [[ -z "$DEPOSIT_HASH" || "$DEPOSIT_HASH" == "null" ]]; then DEPOSIT_HASH=$(echo "$T12_BODY" | jq -r '.invoice.paymentHash' 2>/dev/null || echo "") @@ -348,8 +355,7 @@ if [[ -n "$DEPOSIT_HASH" && "$DEPOSIT_HASH" != "null" ]]; then curl -s -X POST "$BASE/api/dev/stub/pay/$DEPOSIT_HASH" >/dev/null sleep 1 T13_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/sessions/$SESSION_ID") - T13_BODY=$(echo "$T13_RES" | head -n-1) - T13_CODE=$(echo "$T13_RES" | tail -n1) + T13_BODY=$(body_of "$T13_RES"); T13_CODE=$(code_of "$T13_RES") T13_STATE=$(echo "$T13_BODY" | jq -r '.state' 2>/dev/null || echo "") T13_BAL=$(echo "$T13_BODY" | jq -r '.balanceSats' 2>/dev/null || echo "") SESSION_MACAROON=$(echo "$T13_BODY" | jq -r '.macaroon' 2>/dev/null || echo "") @@ -375,15 +381,13 @@ if [[ -n "$SESSION_MACAROON" && "$SESSION_MACAROON" != "null" ]]; then -H "Content-Type: application/json" \\ -H "Authorization: Bearer $SESSION_MACAROON" \\ -d '{"request":"What is Bitcoin in one sentence?"}') - T14_BODY=$(echo "$T14_RES" | head -n-1) - T14_CODE=$(echo "$T14_RES" | tail -n1) + T14_BODY=$(body_of "$T14_RES"); T14_CODE=$(code_of "$T14_RES") T14_STATE=$(echo "$T14_BODY" | jq -r '.state' 2>/dev/null || echo "") T14_DEBITED=$(echo "$T14_BODY" | jq -r '.debitedSats' 2>/dev/null || echo "") T14_BAL=$(echo "$T14_BODY" | jq -r '.balanceRemaining' 2>/dev/null || echo "") - END_T14=$(date +%s) - ELAPSED_T14=$((END_T14 - START_T14)) + END_T14=$(date +%s); ELAPSED_T14=$((END_T14 - START_T14)) if [[ "$T14_CODE" == "200" && ("$T14_STATE" == "complete" || "$T14_STATE" == "rejected") && -n "$T14_DEBITED" && "$T14_DEBITED" != "null" && -n "$T14_BAL" ]]; then - note PASS "state=$T14_STATE in ${ELAPSED_T14}s, debitedSats=$T14_DEBITED, balanceRemaining=$T14_BAL" + note PASS "state=$T14_STATE in \${ELAPSED_T14}s, debitedSats=$T14_DEBITED, balanceRemaining=$T14_BAL" PASS=$((PASS+1)) else note FAIL "code=$T14_CODE body=$T14_BODY" @@ -402,7 +406,7 @@ if [[ -n "$SESSION_ID" ]]; then T15_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/sessions/$SESSION_ID/request" \\ -H "Content-Type: application/json" \\ -d '{"request":"What is Bitcoin?"}') - T15_CODE=$(echo "$T15_RES" | tail -n1) + T15_CODE=$(code_of "$T15_RES") if [[ "$T15_CODE" == "401" ]]; then note PASS "HTTP 401 without macaroon" PASS=$((PASS+1)) @@ -424,8 +428,7 @@ if [[ -n "$SESSION_MACAROON" && "$SESSION_MACAROON" != "null" ]]; then -H "Content-Type: application/json" \\ -H "Authorization: Bearer $SESSION_MACAROON" \\ -d '{"amount_sats": 500}') - T16_BODY=$(echo "$T16_RES" | head -n-1) - T16_CODE=$(echo "$T16_RES" | tail -n1) + T16_BODY=$(body_of "$T16_RES"); T16_CODE=$(code_of "$T16_RES") T16_PR=$(echo "$T16_BODY" | jq -r '.topup.paymentRequest' 2>/dev/null || echo "") T16_AMT=$(echo "$T16_BODY" | jq -r '.topup.amountSats' 2>/dev/null || echo "") if [[ "$T16_CODE" == "200" && -n "$T16_PR" && "$T16_PR" != "null" && "$T16_AMT" == "500" ]]; then diff --git a/attached_assets/Pasted--WHAT-THIS-IS-A-tmux-based-autonomous-dev-loop-where-AI_1773874680640.txt b/attached_assets/Pasted--WHAT-THIS-IS-A-tmux-based-autonomous-dev-loop-where-AI_1773874680640.txt new file mode 100644 index 0000000..1ab7f37 --- /dev/null +++ b/attached_assets/Pasted--WHAT-THIS-IS-A-tmux-based-autonomous-dev-loop-where-AI_1773874680640.txt @@ -0,0 +1,55 @@ + WHAT THIS IS: + A tmux-based autonomous dev loop where AI agents collaborate: + - Hermes (Claude, cloud) = orchestrator. Reads code, writes Kimi prompts, reviews output, manages PRs. + - Kimi (Qwen3 30B, local) = coder. Gets dispatched to git worktrees, writes code, runs tests. + - Timmy (Claude Code, local) = sovereign AI being built. The PRODUCT, not a worker. + - Gitea = self-hosted git. PRs, branch protection, squash-only merge, auto-delete branches. + - tox = test/lint/format runner. Pre-commit hooks enforce quality gates. + + KEY FILES TO REVIEW: + bin/timmy-loop-prompt.md — the main orchestration prompt (the "brain") + bin/timmy-loop.sh — shell driver that invokes hermes with the prompt + bin/tower-session.sh — tmux session layout (Hermes + Timmy + watchdog) + tmux/tower-session.sh — the tower tmux layout + bin/tower-watchdog.sh — process health monitor + bin/tower-hermes.sh — hermes entry point + bin/tower-timmy.sh — timmy entry point + bin/hermes-claim — issue claim/release system + bin/hermes-dispatch — kimi dispatch helper + bin/hermes-enqueue — queue management + bin/pr-automerge.sh — PR auto-merge on CI pass + bin/timmy-loopstat.sh — real-time status dashboard + bin/timmy-strategy.sh — triage/strategy logic + bin/timmy-watchdog.sh — older watchdog (may be superseded) + + ARCHITECTURE CONSTRAINTS: + - Local-first. No cloud dependencies for inference. Ollama serves models. + - Sovereignty matters. Timmy runs on local hardware, not APIs. + - Quality gates are sacred. Never bypass hooks, tests, or linters. + - Squash-only, linear git history. Every commit on main = one squashed PR. + - Config over code. Prefer YAML-driven behavior changes. + + KNOWN PAIN POINTS: + 1. Kimi scans the full codebase if not given precise file paths — slow and wasteful. + 2. Worktree cleanup sometimes fails, leaving orphaned /tmp/timmy-cycle-* dirs. + 3. The loop prompt (timmy-loop-prompt.md) is 327 lines — may be too monolithic. + 4. No structured retry logic when Kimi produces bad output (just re-dispatch). + 5. The claim system (hermes-claim) is file-based JSON — race conditions possible. + 6. Status dashboard (loopstat) polls files on disk — no event-driven updates. + 7. Two watchdog scripts exist (tower-watchdog.sh, timmy-watchdog.sh) — unclear which is canonical. + 8. No metrics/telemetry beyond the cycle JSONL logs. + + WHAT I WANT FROM YOU: + 1. AUDIT: Read every script. Map the data flow. Identify dead code, redundancy, and fragility. + 2. ARCHITECTURE REVIEW: Is the tmux-pane model the right abstraction? What's better? + 3. CONCRETE IMPROVEMENTS: File PRs against this repo with actual code changes. Not just suggestions — working code. Prioritize: + - Reliability (crash recovery, cleanup, idempotency) + - Observability (know what's happening without reading log files) + - Simplicity (fewer scripts, clearer contracts between components) + - Performance (faster cycles, less wasted inference) + 4. PROPOSAL: If you think the whole thing should be restructured, write a design doc as a PR. Show me the target architecture. + + Start by reading every file, then give me your assessment before writing any code. + ``` + + --- \ No newline at end of file diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..9715b44 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,23 @@ +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "**/node_modules/**", + "**/dist/**", + "**/.cache/**", + "**/.local/**", + "**/lib/api-zod/src/generated/**", + "**/lib/api-client-react/src/generated/**", + "**/lib/integrations/**", + ], + }, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-require-imports": "warn", + }, + }, +); diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts new file mode 100644 index 0000000..e69de29 diff --git a/lib/integrations-anthropic-ai/package.json b/lib/integrations-anthropic-ai/package.json index 23f69c0..ee61509 100644 --- a/lib/integrations-anthropic-ai/package.json +++ b/lib/integrations-anthropic-ai/package.json @@ -11,5 +11,8 @@ "@anthropic-ai/sdk": "^0.78.0", "p-limit": "^7.3.0", "p-retry": "^7.1.1" + }, + "devDependencies": { + "@types/node": "catalog:" } } diff --git a/lib/integrations-anthropic-ai/src/batch/utils.ts b/lib/integrations-anthropic-ai/src/batch/utils.ts index 5c74981..a47d0eb 100644 --- a/lib/integrations-anthropic-ai/src/batch/utils.ts +++ b/lib/integrations-anthropic-ai/src/batch/utils.ts @@ -1,5 +1,5 @@ import pLimit from "p-limit"; -import pRetry from "p-retry"; +import pRetry, { AbortError } from "p-retry"; /** * Batch Processing Utilities @@ -75,7 +75,7 @@ export async function batchProcess( if (isRateLimitError(error)) { throw error; } - throw new pRetry.AbortError( + throw new AbortError( error instanceof Error ? error : new Error(String(error)) ); } @@ -115,7 +115,7 @@ export async function batchProcessWithSSE( factor: 2, onFailedAttempt: (error) => { if (!isRateLimitError(error)) { - throw new pRetry.AbortError( + throw new AbortError( error instanceof Error ? error : new Error(String(error)) ); } diff --git a/package.json b/package.json index bcef916..581283b 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,15 @@ "build": "pnpm run typecheck && pnpm -r --if-present run build", "typecheck:libs": "tsc --build", "typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck", + "lint": "eslint .", "test": "bash scripts/test-local.sh", "test:prod": "BASE=https://timmy.replit.app bash timmy_test.sh" }, "private": true, "devDependencies": { + "eslint": "^10.0.3", + "prettier": "^3.8.1", "typescript": "~5.9.2", - "prettier": "^3.8.1" + "typescript-eslint": "^8.57.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2857ea..c007c20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,12 +154,18 @@ importers: .: devDependencies: + eslint: + specifier: ^10.0.3 + version: 10.0.3(jiti@2.6.1) prettier: specifier: ^3.8.1 version: 3.8.1 typescript: specifier: ~5.9.2 version: 5.9.3 + typescript-eslint: + specifier: ^8.57.1 + version: 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) artifacts/api-server: dependencies: @@ -184,6 +190,12 @@ importers: express: specifier: ^5 version: 5.2.1 + express-rate-limit: + specifier: ^8.3.1 + version: 8.3.1(express@5.2.1) + ws: + specifier: ^8.19.0 + version: 8.19.0 devDependencies: '@types/cookie-parser': specifier: ^1.4.10 @@ -197,6 +209,9 @@ importers: '@types/node': specifier: 'catalog:' version: 25.3.5 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 esbuild: specifier: 0.27.3 version: 0.27.3 @@ -441,12 +456,12 @@ importers: p-retry: specifier: ^7.1.1 version: 7.1.1 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.3.5 scripts: - dependencies: - '@workspace/integrations-anthropic-ai': - specifier: workspace:* - version: link:../lib/integrations-anthropic-ai devDependencies: '@types/node': specifier: 'catalog:' @@ -570,6 +585,36 @@ packages: cpu: [x64] os: [linux] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.3': + resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.3': + resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.1.1': + resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.3': + resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -593,6 +638,22 @@ packages: peerDependencies: react-hook-form: ^7.0.0 + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1472,6 +1533,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1487,6 +1551,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.3.5': resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} @@ -1516,6 +1583,68 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript-eslint/eslint-plugin@8.57.1': + resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.57.1': + resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.57.1': + resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.57.1': + resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.1': + resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.57.1': + resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.57.1': + resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.1': + resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.57.1': + resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.57.1': + resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@5.1.4': resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1526,6 +1655,11 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -1542,6 +1676,9 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -1563,6 +1700,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -1575,6 +1716,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1729,6 +1874,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1913,6 +2061,48 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.0.3: + resolution: {integrity: sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1928,6 +2118,12 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -1943,6 +2139,12 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -1962,6 +2164,10 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1970,10 +2176,21 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + find-up@8.0.0: resolution: {integrity: sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==} engines: {node: '>=20'} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2035,6 +2252,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + globby@16.1.0: resolution: {integrity: sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==} engines: {node: '>=20'} @@ -2066,10 +2287,18 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2083,6 +2312,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2141,13 +2374,22 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2160,10 +2402,17 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + leven@4.1.0: resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} @@ -2178,6 +2427,10 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + locate-path@8.0.0: resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==} engines: {node: '>=20'} @@ -2238,6 +2491,10 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -2259,6 +2516,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -2291,6 +2551,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + orval@8.5.3: resolution: {integrity: sha512-+8Es2ZR3tPthzAL27X1a9AlboqTQ/w9U/PhMkp4vsLA9OvdkpXr+9f8lCfJUV/wtdX+lXBDQ4imx42Em943JSg==} engines: {node: '>=22.18.0'} @@ -2301,6 +2565,10 @@ packages: prettier: optional: true + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + p-limit@4.0.0: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2309,6 +2577,10 @@ packages: resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} engines: {node: '>=20'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-locate@6.0.0: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2325,6 +2597,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2404,6 +2680,10 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@3.8.1: resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} @@ -2424,6 +2704,10 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -2572,6 +2856,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -2678,6 +2967,12 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -2699,6 +2994,10 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -2722,6 +3021,13 @@ packages: peerDependencies: typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x + typescript-eslint@8.57.1: + resolution: {integrity: sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2755,6 +3061,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -2838,9 +3147,25 @@ packages: engines: {node: '>= 8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -2853,6 +3178,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -3000,6 +3329,36 @@ snapshots: '@esbuild/linux-x64@0.27.3': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.3(jiti@2.6.1))': + dependencies: + eslint: 10.0.3(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.3': + dependencies: + '@eslint/object-schema': 3.0.3 + debug: 4.4.3 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.3': + dependencies: + '@eslint/core': 1.1.1 + + '@eslint/core@1.1.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.3': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.1.1 + levn: 0.4.1 + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -3029,6 +3388,17 @@ snapshots: dependencies: react-hook-form: 7.71.2(react@19.1.0) + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4038,6 +4408,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} '@types/express-serve-static-core@5.1.1': @@ -4059,6 +4431,8 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} + '@types/node@25.3.5': dependencies: undici-types: 7.18.2 @@ -4092,6 +4466,101 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.3.5 + + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/type-utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 + eslint: 10.0.3(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 + debug: 4.4.3 + eslint: 10.0.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.57.1': + dependencies: + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 + + '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.0.3(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.1': {} + + '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + eslint: 10.0.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.1': + dependencies: + '@typescript-eslint/types': 8.57.1 + eslint-visitor-keys: 5.0.1 + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 @@ -4109,6 +4578,10 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} ajv-draft-04@1.0.0(ajv@8.18.0): @@ -4119,6 +4592,13 @@ snapshots: dependencies: ajv: 8.18.0 + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -4138,6 +4618,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.0: {} body-parser@2.2.2: @@ -4158,6 +4640,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -4294,6 +4780,8 @@ snapshots: decimal.js-light@2.5.1: {} + deep-is@0.1.4: {} + depd@2.0.0: {} detect-libc@2.1.2: {} @@ -4383,6 +4871,72 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.0.3(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.3 + '@eslint/config-helpers': 0.5.3 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + esutils@2.0.3: {} etag@1.8.1: {} @@ -4404,6 +4958,11 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -4449,6 +5008,10 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} fastq@1.20.1: @@ -4463,6 +5026,10 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -4478,11 +5045,23 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + find-up@8.0.0: dependencies: locate-path: 8.0.0 unicorn-magic: 0.3.0 + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + forwarded@0.2.0: {} framer-motion@12.35.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -4542,6 +5121,10 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + globby@16.1.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -4575,8 +5158,12 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ignore@5.3.2: {} + ignore@7.0.5: {} + imurmurhash@0.1.4: {} + inherits@2.0.4: {} input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -4586,6 +5173,8 @@ snapshots: internmap@2.0.3: {} + ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} is-extglob@2.1.1: {} @@ -4620,13 +5209,19 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.28.6 ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} jsonfile@6.2.0: @@ -4637,8 +5232,17 @@ snapshots: jsonpointer@5.0.1: {} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + leven@4.1.0: {} + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lightningcss-linux-x64-gnu@1.31.1: optional: true @@ -4652,6 +5256,10 @@ snapshots: dependencies: uc.micro: 2.1.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + locate-path@8.0.0: dependencies: p-locate: 6.0.0 @@ -4706,6 +5314,10 @@ snapshots: dependencies: mime-db: 1.54.0 + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + minimatch@9.0.9: dependencies: brace-expansion: 2.0.2 @@ -4722,6 +5334,8 @@ snapshots: nanoid@3.3.11: {} + natural-compare@1.4.0: {} + negotiator@1.0.0: {} next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -4748,6 +5362,15 @@ snapshots: dependencies: wrappy: 1.0.2 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + orval@8.5.3(prettier@3.8.1)(typescript@5.9.3): dependencies: '@commander-js/extra-typings': 14.0.0(commander@14.0.3) @@ -4786,6 +5409,10 @@ snapshots: - supports-color - typescript + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + p-limit@4.0.0: dependencies: yocto-queue: 1.2.2 @@ -4794,6 +5421,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-locate@6.0.0: dependencies: p-limit: 4.0.0 @@ -4806,6 +5437,8 @@ snapshots: parseurl@1.3.3: {} + path-exists@4.0.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -4871,6 +5504,8 @@ snapshots: dependencies: xtend: 4.0.2 + prelude-ls@1.2.1: {} + prettier@3.8.1: {} pretty-ms@9.3.0: @@ -4890,6 +5525,8 @@ snapshots: punycode.js@2.3.1: {} + punycode@2.3.1: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -5035,6 +5672,8 @@ snapshots: semver@6.3.1: {} + semver@7.7.4: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -5144,6 +5783,10 @@ snapshots: ts-algebra@2.0.0: {} + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -5159,6 +5802,10 @@ snapshots: tw-animate-css@1.4.0: {} + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -5182,6 +5829,17 @@ snapshots: typescript: 5.9.3 yaml: 2.8.2 + typescript-eslint@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} uc.micro@2.1.0: {} @@ -5202,6 +5860,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.1.0): dependencies: react: 19.1.0 @@ -5269,14 +5931,20 @@ snapshots: dependencies: isexe: 2.0.0 + word-wrap@1.2.5: {} + wrappy@1.0.2: {} + ws@8.19.0: {} + xtend@4.0.2: {} yallist@3.1.1: {} yaml@2.8.2: {} + yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} yoctocolors@2.1.2: {} diff --git a/replit.md b/replit.md index 2a2a6ff..7737209 100644 --- a/replit.md +++ b/replit.md @@ -292,6 +292,52 @@ Key properties: 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 # 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 # 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= + +# Option B — gitignored credentials file (one-time setup) +echo > .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 diff --git a/scripts/bitcoin-ln-node/get-lnbits-key.sh b/scripts/bitcoin-ln-node/get-lnbits-key.sh index 2eb2b42..60e2195 100755 --- a/scripts/bitcoin-ln-node/get-lnbits-key.sh +++ b/scripts/bitcoin-ln-node/get-lnbits-key.sh @@ -1,106 +1,209 @@ #!/usr/bin/env bash # ============================================================================= # Timmy node — fetch LNbits admin API key -# Run this after LNbits is up and has been configured. -# Prints the LNBITS_API_KEY to add to Replit secrets. +# +# Run this after LNbits is up and your LND wallet is initialised. +# Prints LNBITS_URL and LNBITS_API_KEY to paste into Replit secrets. +# +# Compatibility: +# LNbits < 0.12 — auto-creates a wallet via superuser API +# LNbits >= 0.12 — superuser API removed; walks you through the Admin UI # ============================================================================= set -euo pipefail LNBITS_LOCAL="http://127.0.0.1:5000" LNBITS_DATA_DIR="$HOME/.lnbits-data" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SECRETS_FILE="$SCRIPT_DIR/.node-secrets" GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' info() { echo -e "${CYAN}[keys]${NC} $*"; } -ok() { echo -e "${GREEN}[ok]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } warn() { echo -e "${YELLOW}[warn]${NC} $*"; } die() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; } -# Check LNbits is up -curl -sf "$LNBITS_LOCAL/api/v1/health" &>/dev/null \ - || die "LNbits not reachable at $LNBITS_LOCAL. Run 'bash start.sh' first." +# ─── Helpers ───────────────────────────────────────────────────────────────── -# ─── Try to get super user from env file ───────────────────────────────────── -SUPER_USER="" -if [[ -f "$LNBITS_DATA_DIR/.env" ]]; then - SUPER_USER=$(grep LNBITS_SUPER_USER "$LNBITS_DATA_DIR/.env" | cut -d= -f2 | tr -d '"' || true) -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SECRETS_FILE="$SCRIPT_DIR/.node-secrets" -[[ -f "$SECRETS_FILE" ]] && source "$SECRETS_FILE" -SUPER_USER="${SUPER_USER:-${LNBITS_SUPER_USER:-}}" - -if [[ -z "$SUPER_USER" ]]; then - # LNbits auto-generates a superuser on first run — find it in the SQLite DB - DB_FILE=$(find "$LNBITS_DATA_DIR" -name "*.sqlite3" 2>/dev/null | head -1 || true) - if [[ -n "$DB_FILE" ]] && command -v sqlite3 &>/dev/null; then - SUPER_USER=$(sqlite3 "$DB_FILE" "SELECT id FROM accounts WHERE is_super_user=1 LIMIT 1;" 2>/dev/null || true) +# Return 0 (true) if $1 >= $2 (semver comparison, macOS/BSD-safe) +# Uses python3 when available (already required for JSON parsing elsewhere), +# otherwise falls back to pure-bash numeric major.minor.patch comparison. +version_gte() { + local v1="$1" v2="$2" + if command -v python3 &>/dev/null; then + python3 - "$v1" "$v2" <<'PYEOF' +import sys +def parse(v): + parts = v.strip().split(".") + return [int(x) for x in (parts + ["0","0","0"])[:3]] +sys.exit(0 if parse(sys.argv[1]) >= parse(sys.argv[2]) else 1) +PYEOF + else + # Pure-bash fallback: split on dots, compare numerically + local IFS=. + # shellcheck disable=SC2206 + local a=($v1) b=($v2) + for i in 0 1 2; do + local av="${a[$i]:-0}" bv="${b[$i]:-0}" + if (( av > bv )); then return 0; fi + if (( av < bv )); then return 1; fi + done + return 0 # equal fi -fi +} -if [[ -z "$SUPER_USER" ]]; then - # Last resort: check LNbits log for the first-run superuser line - LOG_FILE="$HOME/Library/Logs/timmy-node/lnbits.log" - if [[ -f "$LOG_FILE" ]]; then - SUPER_USER=$(grep -oE "super user id: [a-f0-9]+" "$LOG_FILE" | tail -1 | awk '{print $4}' || true) - fi -fi +# Print the export template the operator needs to paste into Replit Secrets +print_export_template() { + local api_key="${1:-}" + echo "" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN} Paste these into Replit Secrets:${NC}" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo " export LNBITS_URL=\"http://bore.pub:\" ← bore port from expose.sh" + echo " export LNBITS_API_KEY=\"${api_key}\"" + echo "" +} -if [[ -z "$SUPER_USER" ]]; then - warn "Could not auto-detect LNbits super user ID." +# ─── Step 1: Confirm LNbits is reachable ───────────────────────────────────── + +info "Checking LNbits at $LNBITS_LOCAL …" +HEALTH_JSON="$(curl -sf --max-time 6 "$LNBITS_LOCAL/api/v1/health" 2>/dev/null || true)" + +if [[ -z "$HEALTH_JSON" ]]; then + warn "LNbits is not reachable at $LNBITS_LOCAL (is it running?)." + warn "Showing manual setup instructions — run this script again once LNbits is up." echo "" - echo " Visit: $LNBITS_LOCAL" - echo " 1. Create a wallet" - echo " 2. Go to Wallet → API Info" - echo " 3. Copy the Admin key" + echo " Start LNbits, then re-run:" + echo " bash $SCRIPT_DIR/get-lnbits-key.sh" echo "" - echo " Then add to Replit:" - echo " LNBITS_URL = http://bore.pub:" - echo " LNBITS_API_KEY = " + print_export_template exit 0 fi -info "Super user: $SUPER_USER" +# ─── Step 2: Detect LNbits version ─────────────────────────────────────────── -# Create a wallet for Timmy via superuser API -WALLET_RESPONSE=$(curl -sf -X POST "$LNBITS_LOCAL/api/v1/wallet" \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: $SUPER_USER" \ - -d '{"name":"Timmy"}' 2>/dev/null || true) - -if [[ -n "$WALLET_RESPONSE" ]]; then - ADMIN_KEY=$(echo "$WALLET_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('adminkey',''))" 2>/dev/null || true) - INKEY=$(echo "$WALLET_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('inkey',''))" 2>/dev/null || true) - WALLET_ID=$(echo "$WALLET_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true) - - if [[ -n "$ADMIN_KEY" ]]; then - ok "Timmy wallet created (ID: $WALLET_ID)" - echo "" - echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo -e "${GREEN} Add these to Replit secrets:${NC}" - echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo "" - echo " LNBITS_URL = http://bore.pub: ← from expose.sh" - echo " LNBITS_API_KEY = $ADMIN_KEY" - echo "" - echo " Invoice key (read-only): $INKEY" - echo "" - # Save to secrets file - cat >> "$SECRETS_FILE" </dev/null || true - fi +LNBITS_VERSION="" +if command -v python3 &>/dev/null; then + LNBITS_VERSION="$(echo "$HEALTH_JSON" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('server_version',''))" \ + 2>/dev/null || true)" fi -# Fallback: just print the wallet URL -warn "Could not create wallet automatically." -echo "" -echo " Visit $LNBITS_LOCAL in your browser:" -echo " 1. Create an account / wallet named 'Timmy'" -echo " 2. Wallet → API Info → copy Admin key" -echo " 3. Add to Replit: LNBITS_API_KEY = " -echo "" +if [[ -z "$LNBITS_VERSION" ]]; then + warn "Could not parse server_version from health endpoint — assuming modern LNbits (>= 0.12)." + LNBITS_VERSION="0.12.0" +fi + +info "LNbits version: ${LNBITS_VERSION}" + +# ─── Step 3: Version-branched key retrieval ─────────────────────────────────── + +if version_gte "$LNBITS_VERSION" "0.12.0"; then + # ── LNbits >= 0.12 ───────────────────────────────────────────────────────── + # The superuser wallet API (POST /api/v1/wallet with X-Api-Key: ) + # was removed in 0.12. Use the Admin UI instead. + + echo "" + warn "LNbits ${LNBITS_VERSION} — superuser API removed. Use the Admin UI:" + echo "" + echo " 1. Open the LNbits Admin UI in your browser:" + echo " ${LNBITS_LOCAL}/admin" + echo "" + echo " 2. In the Admin UI sidebar, click Users → Create User" + echo " Name: Timmy" + echo " This creates a new user with a default wallet." + echo "" + echo " 3. Click on the Timmy user → open their wallet." + echo "" + echo " 4. In the wallet page, click the key icon (API Info)." + echo " Copy the Admin key (not the Invoice key)." + echo "" + echo " 5. Paste the Admin key into Replit Secrets as LNBITS_API_KEY." + echo "" + print_export_template + +else + # ── LNbits < 0.12 ────────────────────────────────────────────────────────── + # Superuser API available — try to auto-create a Timmy wallet. + + info "LNbits ${LNBITS_VERSION} — attempting automatic wallet creation…" + + # Locate the super user ID (env file or secrets file) + SUPER_USER="" + + if [[ -f "$LNBITS_DATA_DIR/.env" ]]; then + SUPER_USER="$(grep LNBITS_SUPER_USER "$LNBITS_DATA_DIR/.env" \ + | cut -d= -f2 | tr -d '"' || true)" + fi + + [[ -f "$SECRETS_FILE" ]] && source "$SECRETS_FILE" + SUPER_USER="${SUPER_USER:-${LNBITS_SUPER_USER:-}}" + + if [[ -z "$SUPER_USER" ]]; then + # Last resort: grep the startup log for the first-run superuser line + LOG_FILE="$HOME/Library/Logs/timmy-node/lnbits.log" + if [[ -f "$LOG_FILE" ]]; then + SUPER_USER="$(grep -oE "super user id: [a-f0-9]+" "$LOG_FILE" \ + | tail -1 | awk '{print $4}' || true)" + fi + fi + + if [[ -z "$SUPER_USER" ]]; then + warn "Could not locate LNbits super user ID automatically." + echo "" + echo " Visit ${LNBITS_LOCAL} and:" + echo " 1. Create a wallet" + echo " 2. Go to Wallet → API Info" + echo " 3. Copy the Admin key" + echo "" + print_export_template + exit 0 + fi + + info "Super user ID: ${SUPER_USER}" + + # Create the Timmy wallet via superuser API + WALLET_RESPONSE="$(curl -sf -X POST "$LNBITS_LOCAL/api/v1/wallet" \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: $SUPER_USER" \ + -d '{"name":"Timmy"}' 2>/dev/null || true)" + + if [[ -n "$WALLET_RESPONSE" ]]; then + ADMIN_KEY="$(echo "$WALLET_RESPONSE" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('adminkey',''))" \ + 2>/dev/null || true)" + INKEY="$(echo "$WALLET_RESPONSE" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('inkey',''))" \ + 2>/dev/null || true)" + WALLET_ID="$(echo "$WALLET_RESPONSE" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" \ + 2>/dev/null || true)" + + if [[ -n "$ADMIN_KEY" ]]; then + ok "Timmy wallet created (ID: ${WALLET_ID})" + echo " Invoice key (read-only): ${INKEY}" + + # Append to secrets file so future runs can skip this step + cat >> "$SECRETS_FILE" <= 0.12 — superuser API removed; script walks you through" +echo " the Admin UI at http://localhost:5000/admin" +echo "" echo " Secrets are in: $SECRETS_FILE" echo " Logs are in: $LOG_DIR/" echo "" diff --git a/scripts/push-to-gitea.sh b/scripts/push-to-gitea.sh new file mode 100755 index 0000000..2e579f4 --- /dev/null +++ b/scripts/push-to-gitea.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# ============================================================================= +# push-to-gitea.sh — Push current branch to local Gitea via bore tunnel +# +# Usage: +# bash scripts/push-to-gitea.sh [PORT] +# +# PORT is the bore.pub port shown when bore starts on your Mac: +# bore local 3000 --to bore.pub +# → "listening at bore.pub:NNNNN" +# +# If PORT is supplied it is saved to .bore-port for future calls. +# If PORT is omitted the script tries (in order): +# 1. .bore-port file in repo root +# 2. Port embedded in the current 'gitea' remote URL +# ============================================================================= +set -euo pipefail + +GITEA_HOST="bore.pub" +GITEA_USER="${GITEA_USER:-replit}" + +# ─── Load token ─────────────────────────────────────────────────────────────── +# Token is never hard-coded. Resolution order: +# 1. GITEA_TOKEN env var (export before running, or set in shell profile) +# 2. .gitea-credentials file in repo root (gitignored, one line: the token) +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +CREDS_FILE="$REPO_ROOT/.gitea-credentials" + +if [[ -z "${GITEA_TOKEN:-}" && -f "$CREDS_FILE" ]]; then + GITEA_TOKEN="$(tr -d '[:space:]' < "$CREDS_FILE")" +fi + +if [[ -z "${GITEA_TOKEN:-}" ]]; then + echo -e "\033[0;31m[error]\033[0m GITEA_TOKEN is not set." >&2 + echo "" >&2 + echo " Set it in one of two ways:" >&2 + echo "" >&2 + echo " a) Export in your shell:" >&2 + echo " export GITEA_TOKEN=" >&2 + echo "" >&2 + echo " b) Save to a gitignored credentials file:" >&2 + echo " echo > .gitea-credentials" >&2 + echo "" >&2 + echo " Get your token from: http://bore.pub:/user/settings/applications" >&2 + exit 1 +fi + +BORE_PORT_FILE="$REPO_ROOT/.bore-port" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +info() { echo -e "${CYAN}[gitea]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +die() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; } + +# ─── 1. Resolve bore port ──────────────────────────────────────────────────── + +PORT="" + +if [[ -n "${1:-}" ]]; then + PORT="$1" + echo "$PORT" > "$BORE_PORT_FILE" + info "Port $PORT saved to .bore-port for future calls." + +elif [[ -f "$BORE_PORT_FILE" ]]; then + PORT="$(tr -d '[:space:]' < "$BORE_PORT_FILE")" + info "Using port $PORT from .bore-port" + +else + # Fall back to whatever port is currently in the gitea remote URL + CURRENT_REMOTE="$(git -C "$REPO_ROOT" remote get-url gitea 2>/dev/null || true)" + if [[ "$CURRENT_REMOTE" =~ :([0-9]{4,6})/ ]]; then + PORT="${BASH_REMATCH[1]}" + warn "No .bore-port file — trying last-known port $PORT from git remote." + fi +fi + +if [[ -z "$PORT" ]]; then + die "Cannot determine bore port. + + Start bore on your Mac: + bore local 3000 --to bore.pub + → note the port shown (e.g. 61049) + + Then either: + Pass the port once: bash scripts/push-to-gitea.sh + Or save it manually: echo > .bore-port" +fi + +# ─── 2. Verify Gitea is reachable ──────────────────────────────────────────── + +GITEA_BASE="http://${GITEA_HOST}:${PORT}" +info "Checking Gitea at ${GITEA_BASE} …" + +if ! curl -sf --max-time 6 "${GITEA_BASE}/api/v1/version" -o /dev/null 2>/dev/null; then + die "Gitea is not reachable at ${GITEA_BASE}. + + If the bore port changed, pass the new port: + bash scripts/push-to-gitea.sh + + If bore is not running on your Mac, start it: + bore local 3000 --to bore.pub" +fi + +ok "Gitea reachable at ${GITEA_BASE}" + +# ─── 3. Detect repo and branch ─────────────────────────────────────────────── + +# Prefer the repo name from the existing gitea remote URL — the Replit +# workspace directory name ('workspace') does not match the Gitea repo name. +EXISTING_REMOTE="$(git -C "$REPO_ROOT" remote get-url gitea 2>/dev/null || true)" +if [[ "$EXISTING_REMOTE" =~ /([^/]+)\.git$ ]]; then + REPO_NAME="${BASH_REMATCH[1]}" +else + REPO_NAME="$(basename "$REPO_ROOT")" +fi + +BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)" + +if [[ "$BRANCH" == "HEAD" ]]; then + die "Detached HEAD state — check out a branch before pushing." +fi + +REMOTE_URL="${GITEA_BASE}/${GITEA_USER}/${REPO_NAME}.git" +REMOTE_URL_WITH_CREDS="http://${GITEA_USER}:${GITEA_TOKEN}@${GITEA_HOST}:${PORT}/${GITEA_USER}/${REPO_NAME}.git" + +info "Repo: ${REPO_NAME}" +info "Branch: ${BRANCH}" + +# ─── 4. Update (or add) the gitea remote ───────────────────────────────────── + +if git -C "$REPO_ROOT" remote get-url gitea &>/dev/null; then + git -C "$REPO_ROOT" remote set-url gitea "$REMOTE_URL_WITH_CREDS" +else + git -C "$REPO_ROOT" remote add gitea "$REMOTE_URL_WITH_CREDS" + info "Added 'gitea' remote." +fi + +# ─── 5. Push ───────────────────────────────────────────────────────────────── + +info "Pushing ${BRANCH} → gitea …" +echo "" + +if git -C "$REPO_ROOT" push gitea "HEAD:${BRANCH}"; then + echo "" + ok "Pushed ${BRANCH} successfully." + echo "" + echo " Branch: ${GITEA_BASE}/${GITEA_USER}/${REPO_NAME}/src/branch/${BRANCH}" + echo " Open PR: ${GITEA_BASE}/${GITEA_USER}/${REPO_NAME}/compare/main...${BRANCH}" + echo "" +else + EXIT_CODE=$? + echo "" + die "git push failed (exit ${EXIT_CODE}). See error above." +fi diff --git a/the-matrix/.gitignore b/the-matrix/.gitignore new file mode 100644 index 0000000..f99f737 --- /dev/null +++ b/the-matrix/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +*.local diff --git a/the-matrix/README.md b/the-matrix/README.md new file mode 100644 index 0000000..3a4d063 --- /dev/null +++ b/the-matrix/README.md @@ -0,0 +1,80 @@ +# Timmy Tower World + +A Three.js 3D visualization of the Timmy agent network. Agents appear as +glowing icosahedra connected by lines, pulsing as they process jobs. A +matrix-rain particle effect fills the background. + +## Quick start + +```bash +npm install +npm run dev # Vite dev server with hot reload → http://localhost:5173 +npm run build # Production bundle → dist/ +npm run preview # Serve dist/ locally +``` + +## Configuration + +Set these in a `.env.local` file (not committed): + +``` +VITE_WS_URL=ws://localhost:8080/ws/agents +``` + +Leave `VITE_WS_URL` unset to run in offline/demo mode (agents animate but +receive no live updates). + +## Adding custom agents + +**Edit one file only: `js/agent-defs.js`** + +```js +export const AGENT_DEFS = [ + // existing agents … + { + id: 'zeta', // unique string — matches WebSocket message agentId + label: 'ZETA', // displayed in the 3D HUD + color: 0xff00aa, // hex integer (0xRRGGBB) + role: 'observer', // shown under the label sprite + direction: 'east', // cardinal facing direction (north/east/south/west) + x: 12, // world-space position (horizontal) + z: 0, // world-space position (depth) + }, +]; +``` + +Nothing else needs to change. `agents.js` reads positions from `x`/`z`, +and `websocket.js` reads colors and labels — both derive everything from +`AGENT_DEFS`. + +## Architecture + +``` +js/ +├── agent-defs.js ← single source of truth: id, label, color, role, position +├── agents.js ← Three.js scene objects, animation loop +├── effects.js ← matrix rain particles, starfield +├── interaction.js ← OrbitControls (pan, zoom, rotate) +├── main.js ← entry point, rAF loop +├── ui.js ← DOM HUD overlay (FPS, agent states, chat) +└── websocket.js ← WebSocket reconnect, message dispatch +``` + +## WebSocket protocol + +The backend sends JSON messages on the agents channel: + +| `type` | Fields | Effect | +|-----------------|-------------------------------------|-------------------------------| +| `agent_state` | `agentId`, `state` | Update agent visual state | +| `job_started` | `agentId`, `jobId` | Increment job counter, pulse | +| `job_completed` | `agentId`, `jobId` | Decrement job counter | +| `chat` | `agentId`, `text` | Append to chat panel | + +Agent states: `idle` (dim pulse) · `active` (bright pulse + fast ring spin) + +## Stack + +- [Three.js](https://threejs.org) 0.171.0 — 3D rendering +- [Vite](https://vitejs.dev) 5 — build + dev server +- `crypto.randomUUID()` — secure client session IDs (no external library) diff --git a/the-matrix/index.html b/the-matrix/index.html new file mode 100644 index 0000000..4eed780 --- /dev/null +++ b/the-matrix/index.html @@ -0,0 +1,110 @@ + + + + + + The Matrix + + + + + + + + + + + + +
+
+

TIMMY TOWER WORLD

+
AGENTS: 0
+
JOBS: 0
+
FPS: --
+
+
+
+
+
+
OFFLINE
+
+ + + +
+ GPU context lost — recovering... +
+ + + + diff --git a/the-matrix/js/agent-defs.js b/the-matrix/js/agent-defs.js new file mode 100644 index 0000000..d5572a3 --- /dev/null +++ b/the-matrix/js/agent-defs.js @@ -0,0 +1,28 @@ +/** + * agent-defs.js — Single source of truth for all agent definitions. + * + * To add a new agent, append one entry to AGENT_DEFS below and pick an + * unused (x, z) position. No other file needs to be edited. + * + * Fields: + * id — unique string key used in WebSocket messages and state maps + * label — display name shown in the 3D HUD and chat panel + * color — hex integer (0xRRGGBB) used for Three.js materials and lights + * role — human-readable role string shown under the label sprite + * direction — cardinal facing direction (for future mesh orientation use) + * x, z — world-space position on the horizontal plane (y is always 0) + */ +export const AGENT_DEFS = [ + { id: 'alpha', label: 'ALPHA', color: 0x00ff88, role: 'orchestrator', direction: 'north', x: 0, z: -6 }, + { id: 'beta', label: 'BETA', color: 0x00aaff, role: 'worker', direction: 'east', x: 6, z: 0 }, + { id: 'gamma', label: 'GAMMA', color: 0xff6600, role: 'validator', direction: 'south', x: 0, z: 6 }, + { id: 'delta', label: 'DELTA', color: 0xaa00ff, role: 'monitor', direction: 'west', x: -6, z: 0 }, +]; + +/** + * Convert an integer color (e.g. 0x00ff88) to a CSS hex string ('#00ff88'). + * Useful for DOM styling and canvas rendering. + */ +export function colorToCss(intColor) { + return '#' + intColor.toString(16).padStart(6, '0'); +} diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js new file mode 100644 index 0000000..9d7865d --- /dev/null +++ b/the-matrix/js/agents.js @@ -0,0 +1,207 @@ +import * as THREE from 'three'; +import { AGENT_DEFS, colorToCss } from './agent-defs.js'; + +const agents = new Map(); +let scene; +let connectionLines = []; + +class Agent { + constructor(def) { + this.id = def.id; + this.label = def.label; + this.color = def.color; + this.role = def.role; + this.position = new THREE.Vector3(def.x, 0, def.z); + this.state = 'idle'; + this.pulsePhase = Math.random() * Math.PI * 2; + + this.group = new THREE.Group(); + this.group.position.copy(this.position); + + this._buildMeshes(); + this._buildLabel(); + } + + _buildMeshes() { + const mat = new THREE.MeshStandardMaterial({ + color: this.color, + emissive: this.color, + emissiveIntensity: 0.4, + roughness: 0.3, + metalness: 0.8, + }); + + const geo = new THREE.IcosahedronGeometry(0.7, 1); + this.core = new THREE.Mesh(geo, mat); + this.group.add(this.core); + + const ringGeo = new THREE.TorusGeometry(1.1, 0.04, 8, 32); + const ringMat = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, opacity: 0.5 }); + this.ring = new THREE.Mesh(ringGeo, ringMat); + this.ring.rotation.x = Math.PI / 2; + this.group.add(this.ring); + + const glowGeo = new THREE.SphereGeometry(1.3, 16, 16); + const glowMat = new THREE.MeshBasicMaterial({ + color: this.color, + transparent: true, + opacity: 0.05, + side: THREE.BackSide, + }); + this.glow = new THREE.Mesh(glowGeo, glowMat); + this.group.add(this.glow); + + const light = new THREE.PointLight(this.color, 1.5, 10); + this.group.add(light); + this.light = light; + } + + _buildLabel() { + const canvas = document.createElement('canvas'); + canvas.width = 256; canvas.height = 64; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'rgba(0,0,0,0)'; + ctx.fillRect(0, 0, 256, 64); + ctx.font = 'bold 22px Courier New'; + ctx.fillStyle = colorToCss(this.color); + ctx.textAlign = 'center'; + ctx.fillText(this.label, 128, 28); + ctx.font = '14px Courier New'; + ctx.fillStyle = '#007722'; + ctx.fillText(this.role.toUpperCase(), 128, 50); + + const tex = new THREE.CanvasTexture(canvas); + const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true }); + this.sprite = new THREE.Sprite(spriteMat); + this.sprite.scale.set(2.4, 0.6, 1); + this.sprite.position.y = 2; + this.group.add(this.sprite); + } + + update(time) { + const pulse = Math.sin(time * 0.002 + this.pulsePhase); + const active = this.state === 'active'; + + const intensity = active ? 0.6 + pulse * 0.4 : 0.2 + pulse * 0.1; + this.core.material.emissiveIntensity = intensity; + this.light.intensity = active ? 2 + pulse : 0.8 + pulse * 0.3; + + const scale = active ? 1 + pulse * 0.08 : 1 + pulse * 0.03; + this.core.scale.setScalar(scale); + this.ring.rotation.y += active ? 0.03 : 0.008; + this.ring.material.opacity = 0.3 + pulse * 0.2; + + this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15; + } + + setState(state) { + this.state = state; + } + + dispose() { + this.core.geometry.dispose(); + this.core.material.dispose(); + this.ring.geometry.dispose(); + this.ring.material.dispose(); + this.glow.geometry.dispose(); + this.glow.material.dispose(); + if (this.sprite.material.map) this.sprite.material.map.dispose(); + this.sprite.material.dispose(); + } +} + +export function initAgents(sceneRef) { + scene = sceneRef; + + AGENT_DEFS.forEach(def => { + const agent = new Agent(def); + agents.set(def.id, agent); + scene.add(agent.group); + }); + + buildConnectionLines(); +} + +function buildConnectionLines() { + connectionLines.forEach(l => scene.remove(l)); + connectionLines = []; + + const agentList = [...agents.values()]; + const lineMat = new THREE.LineBasicMaterial({ + color: 0x003300, + transparent: true, + opacity: 0.4, + }); + + for (let i = 0; i < agentList.length; i++) { + for (let j = i + 1; j < agentList.length; j++) { + const a = agentList[i]; + const b = agentList[j]; + if (a.position.distanceTo(b.position) <= 8) { + const points = [a.position.clone(), b.position.clone()]; + const geo = new THREE.BufferGeometry().setFromPoints(points); + const line = new THREE.Line(geo, lineMat.clone()); + connectionLines.push(line); + scene.add(line); + } + } + } +} + +export function updateAgents(time) { + agents.forEach(agent => agent.update(time)); +} + +export function getAgentCount() { + return agents.size; +} + +export function setAgentState(agentId, state) { + const agent = agents.get(agentId); + if (agent) agent.setState(state); +} + +export function getAgentDefs() { + return [...agents.values()].map(a => ({ + id: a.id, label: a.label, role: a.role, color: a.color, state: a.state, + })); +} + +/** + * Return a snapshot of each agent's current runtime state. + * Call before teardown so the state can be reapplied after reinit. + * @returns {Object.} — e.g. { alpha: 'active', beta: 'idle' } + */ +export function getAgentStates() { + const snapshot = {}; + agents.forEach((agent, id) => { snapshot[id] = agent.state; }); + return snapshot; +} + +/** + * Apply a previously captured state snapshot to freshly-created agents. + * Call immediately after initAgents() during context-restore reinit. + * @param {Object.} snapshot + */ +export function applyAgentStates(snapshot) { + if (!snapshot) return; + for (const [id, state] of Object.entries(snapshot)) { + const agent = agents.get(id); + if (agent) agent.setState(state); + } +} + +/** + * Dispose all agent GPU resources (geometries, materials, textures). + * Called before context-loss teardown. + */ +export function disposeAgents() { + agents.forEach(agent => agent.dispose()); + agents.clear(); + connectionLines.forEach(l => { + l.geometry.dispose(); + l.material.dispose(); + }); + connectionLines = []; + scene = null; +} diff --git a/the-matrix/js/effects.js b/the-matrix/js/effects.js new file mode 100644 index 0000000..4b84fdc --- /dev/null +++ b/the-matrix/js/effects.js @@ -0,0 +1,99 @@ +import * as THREE from 'three'; + +let rainParticles; +let rainPositions; +let rainVelocities; +const RAIN_COUNT = 2000; + +export function initEffects(scene) { + initMatrixRain(scene); + initStarfield(scene); +} + +function initMatrixRain(scene) { + const geo = new THREE.BufferGeometry(); + const positions = new Float32Array(RAIN_COUNT * 3); + const velocities = new Float32Array(RAIN_COUNT); + const colors = new Float32Array(RAIN_COUNT * 3); + + for (let i = 0; i < RAIN_COUNT; i++) { + positions[i * 3] = (Math.random() - 0.5) * 100; + positions[i * 3 + 1] = Math.random() * 50 + 5; + positions[i * 3 + 2] = (Math.random() - 0.5) * 100; + velocities[i] = 0.05 + Math.random() * 0.15; + + const brightness = 0.3 + Math.random() * 0.7; + colors[i * 3] = 0; + colors[i * 3 + 1] = brightness; + colors[i * 3 + 2] = 0; + } + + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + rainPositions = positions; + rainVelocities = velocities; + + const mat = new THREE.PointsMaterial({ + size: 0.12, + vertexColors: true, + transparent: true, + opacity: 0.7, + sizeAttenuation: true, + }); + + rainParticles = new THREE.Points(geo, mat); + scene.add(rainParticles); +} + +function initStarfield(scene) { + const count = 500; + const geo = new THREE.BufferGeometry(); + const positions = new Float32Array(count * 3); + + for (let i = 0; i < count; i++) { + positions[i * 3] = (Math.random() - 0.5) * 300; + positions[i * 3 + 1] = Math.random() * 80 + 10; + positions[i * 3 + 2] = (Math.random() - 0.5) * 300; + } + + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + + const mat = new THREE.PointsMaterial({ + color: 0x003300, + size: 0.08, + transparent: true, + opacity: 0.5, + }); + + const stars = new THREE.Points(geo, mat); + scene.add(stars); +} + +export function updateEffects(_time) { + if (!rainParticles) return; + + for (let i = 0; i < RAIN_COUNT; i++) { + rainPositions[i * 3 + 1] -= rainVelocities[i]; + if (rainPositions[i * 3 + 1] < -1) { + rainPositions[i * 3 + 1] = 40 + Math.random() * 20; + rainPositions[i * 3] = (Math.random() - 0.5) * 100; + rainPositions[i * 3 + 2] = (Math.random() - 0.5) * 100; + } + } + + rainParticles.geometry.attributes.position.needsUpdate = true; +} + +/** + * Release GPU resources held by rain and starfield. + * Called before context-loss teardown. + */ +export function disposeEffects() { + if (rainParticles) { + rainParticles.geometry.dispose(); + rainParticles.material.dispose(); + rainParticles = null; + } + rainPositions = null; + rainVelocities = null; +} diff --git a/the-matrix/js/interaction.js b/the-matrix/js/interaction.js new file mode 100644 index 0000000..0d3a2fa --- /dev/null +++ b/the-matrix/js/interaction.js @@ -0,0 +1,39 @@ +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +let controls; +let _canvas; +const _noCtxMenu = e => e.preventDefault(); + +export function initInteraction(camera, renderer) { + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.05; + controls.screenSpacePanning = false; + controls.minDistance = 5; + controls.maxDistance = 80; + controls.maxPolarAngle = Math.PI / 2.1; + controls.target.set(0, 0, 0); + controls.update(); + + _canvas = renderer.domElement; + _canvas.addEventListener('contextmenu', _noCtxMenu); +} + +export function updateControls() { + if (controls) controls.update(); +} + +/** + * Dispose OrbitControls event listeners. + * Called before context-loss teardown. + */ +export function disposeInteraction() { + if (_canvas) { + _canvas.removeEventListener('contextmenu', _noCtxMenu); + _canvas = null; + } + if (controls) { + controls.dispose(); + controls = null; + } +} diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js new file mode 100644 index 0000000..3b1c21d --- /dev/null +++ b/the-matrix/js/main.js @@ -0,0 +1,113 @@ +import { initWorld, onWindowResize, disposeWorld } from './world.js'; +import { + initAgents, updateAgents, getAgentCount, + disposeAgents, getAgentStates, applyAgentStates, +} from './agents.js'; +import { initEffects, updateEffects, disposeEffects } from './effects.js'; +import { initUI, updateUI } from './ui.js'; +import { initInteraction, disposeInteraction } from './interaction.js'; +import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; + +let running = false; +let canvas = null; + +/** + * Build (or rebuild) the Three.js world. + * + * @param {boolean} firstInit + * true — first page load: also starts UI and WebSocket + * false — context-restore reinit: skips UI/WS (they survive context loss) + * @param {Object.|null} stateSnapshot + * Agent state map captured just before teardown; reapplied after initAgents. + */ +function buildWorld(firstInit, stateSnapshot) { + const { scene, camera, renderer } = initWorld(canvas); + canvas = renderer.domElement; + + initEffects(scene); + initAgents(scene); + + if (stateSnapshot) { + applyAgentStates(stateSnapshot); + } + + initInteraction(camera, renderer); + + if (firstInit) { + initUI(); + initWebSocket(scene); + } + + const ac = new AbortController(); + window.addEventListener('resize', () => onWindowResize(camera, renderer), { signal: ac.signal }); + + let frameCount = 0; + let lastFpsTime = performance.now(); + let currentFps = 0; + + running = true; + + function animate() { + if (!running) return; + requestAnimationFrame(animate); + + const now = performance.now(); + frameCount++; + if (now - lastFpsTime >= 1000) { + currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime)); + frameCount = 0; + lastFpsTime = now; + } + + updateEffects(now); + updateAgents(now); + updateUI({ + fps: currentFps, + agentCount: getAgentCount(), + jobCount: getJobCount(), + connectionState: getConnectionState(), + }); + + renderer.render(scene, camera); + } + + animate(); + + return { scene, renderer, ac }; +} + +function teardown({ scene, renderer, ac }) { + running = false; + ac.abort(); + disposeInteraction(); + disposeEffects(); + disposeAgents(); + disposeWorld(renderer, scene); +} + +function main() { + const $overlay = document.getElementById('webgl-recovery-overlay'); + + let handle = buildWorld(true, null); + + canvas.addEventListener('webglcontextlost', event => { + event.preventDefault(); + running = false; + if ($overlay) $overlay.style.display = 'flex'; + }); + + canvas.addEventListener('webglcontextrestored', () => { + const snapshot = getAgentStates(); + teardown(handle); + handle = buildWorld(false, snapshot); + if ($overlay) $overlay.style.display = 'none'; + }); +} + +main(); + +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + }); +} diff --git a/the-matrix/js/ui.js b/the-matrix/js/ui.js new file mode 100644 index 0000000..898ee96 --- /dev/null +++ b/the-matrix/js/ui.js @@ -0,0 +1,178 @@ +import { getAgentDefs } from './agents.js'; +import { AGENT_DEFS, colorToCss } from './agent-defs.js'; + +const $agentCount = document.getElementById('agent-count'); +const $activeJobs = document.getElementById('active-jobs'); +const $fps = document.getElementById('fps'); +const $agentList = document.getElementById('agent-list'); +const $connStatus = document.getElementById('connection-status'); +const $chatPanel = document.getElementById('chat-panel'); +const $clearBtn = document.getElementById('chat-clear-btn'); + +const MAX_CHAT_ENTRIES = 12; +const MAX_STORED = 100; +const STORAGE_PREFIX = 'matrix:chat:'; + +const chatEntries = []; +const chatHistory = {}; +let uiInitialized = false; + +function storageKey(agentId) { + return STORAGE_PREFIX + agentId; +} + +export function loadChatHistory(agentId) { + try { + const raw = localStorage.getItem(storageKey(agentId)); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter(m => + m && typeof m.agentLabel === 'string' && typeof m.text === 'string' + ); + } catch { + return []; + } +} + +export function saveChatHistory(agentId, messages) { + try { + localStorage.setItem(storageKey(agentId), JSON.stringify(messages.slice(-MAX_STORED))); + } catch { + } +} + +function formatTimestamp(ts) { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + return `${hh}:${mm}`; +} + +function buildChatEntry(agentLabel, message, cssColor, timestamp) { + const color = cssColor || '#00ff41'; + const entry = document.createElement('div'); + entry.className = 'chat-entry'; + const ts = timestamp ? `[${formatTimestamp(timestamp)}] ` : ''; + entry.innerHTML = `${ts}${agentLabel}: ${escapeHtml(message)}`; + return entry; +} + +function loadAllHistories() { + const all = []; + + const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys']; + for (const id of agentIds) { + const msgs = loadChatHistory(id); + chatHistory[id] = msgs; + all.push(...msgs); + } + + all.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); + + for (const msg of all.slice(-MAX_CHAT_ENTRIES)) { + const entry = buildChatEntry(msg.agentLabel, msg.text, msg.cssColor, msg.timestamp); + chatEntries.push(entry); + $chatPanel.appendChild(entry); + } + + $chatPanel.scrollTop = $chatPanel.scrollHeight; +} + +function clearAllHistories() { + const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys']; + for (const id of agentIds) { + localStorage.removeItem(storageKey(id)); + chatHistory[id] = []; + } + while ($chatPanel.firstChild) $chatPanel.removeChild($chatPanel.firstChild); + chatEntries.length = 0; +} + +export function initUI() { + if (uiInitialized) return; + uiInitialized = true; + + renderAgentList(); + loadAllHistories(); + + if ($clearBtn) { + $clearBtn.addEventListener('click', clearAllHistories); + } +} + +function renderAgentList() { + const defs = getAgentDefs(); + $agentList.innerHTML = defs.map(a => { + const css = colorToCss(a.color); + return `
+ [ + ${a.label} + ] + IDLE +
`; + }).join(''); +} + +export function updateUI({ fps, agentCount, jobCount, connectionState }) { + $fps.textContent = `FPS: ${fps}`; + $agentCount.textContent = `AGENTS: ${agentCount}`; + $activeJobs.textContent = `JOBS: ${jobCount}`; + + if (connectionState === 'connected') { + $connStatus.textContent = '● CONNECTED'; + $connStatus.className = 'connected'; + } else if (connectionState === 'connecting') { + $connStatus.textContent = '◌ CONNECTING...'; + $connStatus.className = ''; + } else { + $connStatus.textContent = '○ OFFLINE'; + $connStatus.className = ''; + } + + const defs = getAgentDefs(); + defs.forEach(a => { + const el = document.getElementById(`agent-state-${a.id}`); + if (el) { + el.textContent = ` ${a.state.toUpperCase()}`; + el.style.color = a.state === 'active' ? '#00ff41' : '#003300'; + } + }); +} + +/** + * Append a message to the chat panel and optionally persist it. + * @param {string} agentLabel — display name + * @param {string} message — raw text (HTML-escaped before insertion) + * @param {string} cssColor — CSS color string e.g. '#00ff88' + * @param {string} [agentId] — storage key; omit to skip persistence + */ +export function appendChatMessage(agentLabel, message, cssColor, agentId) { + const timestamp = Date.now(); + const entry = buildChatEntry(agentLabel, message, cssColor, timestamp); + chatEntries.push(entry); + + if (chatEntries.length > MAX_CHAT_ENTRIES) { + const removed = chatEntries.shift(); + $chatPanel.removeChild(removed); + } + + $chatPanel.appendChild(entry); + $chatPanel.scrollTop = $chatPanel.scrollHeight; + + if (agentId) { + if (!chatHistory[agentId]) chatHistory[agentId] = []; + chatHistory[agentId].push({ agentLabel, text: message, cssColor, agentId, timestamp }); + if (chatHistory[agentId].length > MAX_STORED) { + chatHistory[agentId] = chatHistory[agentId].slice(-MAX_STORED); + } + saveChatHistory(agentId, chatHistory[agentId]); + } +} + +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>'); +} diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js new file mode 100644 index 0000000..ea8a0f5 --- /dev/null +++ b/the-matrix/js/websocket.js @@ -0,0 +1,115 @@ +import { AGENT_DEFS, colorToCss } from './agent-defs.js'; +import { setAgentState } from './agents.js'; +import { appendChatMessage } from './ui.js'; + +const WS_URL = import.meta.env.VITE_WS_URL || ''; + +const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d])); + +let ws = null; +let connectionState = 'disconnected'; +let jobCount = 0; +let reconnectTimer = null; +const RECONNECT_DELAY_MS = 5000; + +export function initWebSocket(_scene) { + if (!WS_URL) { + connectionState = 'disconnected'; + return; + } + connect(); +} + +function connect() { + if (ws) { + ws.onclose = null; + ws.close(); + } + + connectionState = 'connecting'; + + try { + ws = new WebSocket(WS_URL); + } catch { + connectionState = 'disconnected'; + scheduleReconnect(); + return; + } + + ws.onopen = () => { + connectionState = 'connected'; + clearTimeout(reconnectTimer); + ws.send(JSON.stringify({ + type: 'subscribe', + channel: 'agents', + clientId: crypto.randomUUID(), + })); + }; + + ws.onmessage = (event) => { + try { + handleMessage(JSON.parse(event.data)); + } catch { + } + }; + + ws.onerror = () => { + connectionState = 'disconnected'; + }; + + ws.onclose = () => { + connectionState = 'disconnected'; + scheduleReconnect(); + }; +} + +function scheduleReconnect() { + clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); +} + +function handleMessage(msg) { + switch (msg.type) { + case 'agent_state': { + if (msg.agentId && msg.state) { + setAgentState(msg.agentId, msg.state); + } + break; + } + case 'job_started': { + jobCount++; + if (msg.agentId) setAgentState(msg.agentId, 'active'); + logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`); + break; + } + case 'job_completed': { + if (jobCount > 0) jobCount--; + if (msg.agentId) setAgentState(msg.agentId, 'idle'); + logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`); + break; + } + case 'chat': { + const def = agentById[msg.agentId]; + if (def && msg.text) { + appendChatMessage(def.label, msg.text, colorToCss(def.color), def.id); + } + break; + } + case 'agent_count': + break; + default: + break; + } +} + +function logEvent(text) { + appendChatMessage('SYS', text, colorToCss(0x003300), 'sys'); +} + +export function getConnectionState() { + return connectionState; +} + +export function getJobCount() { + return jobCount; +} diff --git a/the-matrix/js/world.js b/the-matrix/js/world.js new file mode 100644 index 0000000..5cb5bb9 --- /dev/null +++ b/the-matrix/js/world.js @@ -0,0 +1,95 @@ +import * as THREE from 'three'; + +let scene, camera, renderer; + +const _worldObjects = []; + +/** + * @param {HTMLCanvasElement|null} existingCanvas — pass the saved canvas on + * re-init so Three.js reuses the same DOM element instead of creating a new one + */ +export function initWorld(existingCanvas) { + _worldObjects.length = 0; + + scene = new THREE.Scene(); + scene.background = new THREE.Color(0x000000); + scene.fog = new THREE.FogExp2(0x000000, 0.035); + + camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 500); + camera.position.set(0, 12, 28); + camera.lookAt(0, 0, 0); + + renderer = new THREE.WebGLRenderer({ + antialias: true, + canvas: existingCanvas || undefined, + }); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + + if (!existingCanvas) { + document.body.prepend(renderer.domElement); + } + + addLights(scene); + addGrid(scene); + + return { scene, camera, renderer }; +} + +/** + * Dispose only world-owned geometries, materials, and the renderer. + * Agent and effect objects are disposed by their own modules before this runs. + */ +export function disposeWorld(renderer, _scene) { + for (const obj of _worldObjects) { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; + mats.forEach(m => { + if (m.map) m.map.dispose(); + m.dispose(); + }); + } + } + _worldObjects.length = 0; + renderer.dispose(); +} + +function addLights(scene) { + const ambient = new THREE.AmbientLight(0x001a00, 0.6); + scene.add(ambient); + + const point = new THREE.PointLight(0x00ff41, 2, 80); + point.position.set(0, 20, 0); + scene.add(point); + + const fill = new THREE.DirectionalLight(0x003300, 0.4); + fill.position.set(-10, 10, 10); + scene.add(fill); +} + +function addGrid(scene) { + const grid = new THREE.GridHelper(100, 40, 0x003300, 0x001a00); + grid.position.y = -0.01; + scene.add(grid); + _worldObjects.push(grid); + + const planeGeo = new THREE.PlaneGeometry(100, 100); + const planeMat = new THREE.MeshBasicMaterial({ + color: 0x000a00, + transparent: true, + opacity: 0.5, + }); + const plane = new THREE.Mesh(planeGeo, planeMat); + plane.rotation.x = -Math.PI / 2; + plane.position.y = -0.02; + scene.add(plane); + _worldObjects.push(plane); +} + +export function onWindowResize(camera, renderer) { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +} diff --git a/the-matrix/package-lock.json b/the-matrix/package-lock.json new file mode 100644 index 0000000..cb95f0b --- /dev/null +++ b/the-matrix/package-lock.json @@ -0,0 +1,996 @@ +{ + "name": "the-matrix", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "the-matrix", + "version": "0.1.0", + "dependencies": { + "three": "0.171.0" + }, + "devDependencies": { + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.171.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.171.0.tgz", + "integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/the-matrix/package.json b/the-matrix/package.json new file mode 100644 index 0000000..da145e7 --- /dev/null +++ b/the-matrix/package.json @@ -0,0 +1,17 @@ +{ + "name": "the-matrix", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "three": "0.171.0" + }, + "devDependencies": { + "vite": "^5.4.0" + } +} diff --git a/the-matrix/public/icons/icon-192.png b/the-matrix/public/icons/icon-192.png new file mode 100644 index 0000000..a84aef3 Binary files /dev/null and b/the-matrix/public/icons/icon-192.png differ diff --git a/the-matrix/public/icons/icon-512.png b/the-matrix/public/icons/icon-512.png new file mode 100644 index 0000000..dcf2b40 Binary files /dev/null and b/the-matrix/public/icons/icon-512.png differ diff --git a/the-matrix/public/manifest.json b/the-matrix/public/manifest.json new file mode 100644 index 0000000..83dd3a4 --- /dev/null +++ b/the-matrix/public/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "The Matrix", + "short_name": "The Matrix", + "description": "Timmy Tower World — live agent network visualization", + "start_url": "/", + "display": "standalone", + "orientation": "landscape", + "background_color": "#000000", + "theme_color": "#00ff41", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/the-matrix/sw.js b/the-matrix/sw.js new file mode 100644 index 0000000..28d2aca --- /dev/null +++ b/the-matrix/sw.js @@ -0,0 +1,39 @@ +/* sw.js — Matrix PWA service worker + * PRECACHE_URLS is replaced at build time by the generate-sw Vite plugin. + * Registration is gated to import.meta.env.PROD in main.js, so this template + * file is never evaluated by browsers during development. + */ +const CACHE_NAME = 'timmy-matrix-v1'; +const PRECACHE_URLS = __PRECACHE_URLS__; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', event => { + if (event.request.method !== 'GET') return; + event.respondWith( + caches.match(event.request).then(cached => { + if (cached) return cached; + return fetch(event.request).then(response => { + if (!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + caches.open(CACHE_NAME).then(cache => cache.put(event.request, response.clone())); + return response; + }); + }) + ); +}); diff --git a/the-matrix/vite.config.js b/the-matrix/vite.config.js new file mode 100644 index 0000000..ae1371c --- /dev/null +++ b/the-matrix/vite.config.js @@ -0,0 +1,46 @@ +import { defineConfig } from 'vite'; +import { readFileSync, writeFileSync } from 'fs'; + +function generateSW() { + return { + name: 'generate-sw', + apply: 'build', + closeBundle() { + const staticAssets = [ + '/', + '/manifest.json', + '/icons/icon-192.png', + '/icons/icon-512.png', + ]; + + try { + const manifest = JSON.parse(readFileSync('dist/.vite/manifest.json', 'utf-8')); + for (const entry of Object.values(manifest)) { + staticAssets.push('/' + entry.file); + if (entry.css) entry.css.forEach(f => staticAssets.push('/' + f)); + } + } catch { + } + + const template = readFileSync('sw.js', 'utf-8'); + const out = template.replace('__PRECACHE_URLS__', JSON.stringify(staticAssets, null, 4)); + writeFileSync('dist/sw.js', out); + + console.log('[generate-sw] wrote dist/sw.js with', staticAssets.length, 'precache URLs'); + }, + }; +} + +export default defineConfig({ + root: '.', + build: { + outDir: 'dist', + assetsDir: 'assets', + target: 'esnext', + manifest: true, + }, + plugins: [generateSW()], + server: { + host: true, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index cffbdb0..c7dcb44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,9 +6,6 @@ { "path": "./lib/db" }, - { - "path": "./lib/api-client-react" - }, { "path": "./lib/api-zod" },