diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f48dea7..6312f17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,11 @@ jobs: node-version: ${{ matrix.node-version }} cache: npm - - run: npm ci + - name: Install dependencies + run: | + npm ci + cd viewer + npm ci - name: Build run: npm run build diff --git a/README.md b/README.md index 7fa57df..6b7c212 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,10 @@ On first launch the interactive onboarding wizard will guide you through setup: 1. **Setup mode** — register a new agent or import an existing one 2. **Agent name** — display name for the network 3. **Inference provider** — OpenRouter or self-hosted (e.g. Ollama) -4. **API key / URL** — OpenRouter API key or local inference endpoint +4. **API key / URL** — OpenRouter API key or self-hosted inference endpoint 5. **Model** — LLM model name (e.g. `qwen/qwen3.5-35b-a3b`) -6. **Role** — `ANSWERER_AND_JUDGE`, `ANSWERER`, or `JUDGE` -The wizard validates your model, registers the agent on the network, and starts it automatically. +The wizard validates your model, registers the node on the network, and starts it automatically. ## Supported Inference Providers @@ -45,7 +44,7 @@ Uses the [OpenRouter](https://openrouter.ai) API (OpenAI-compatible). Requires a ```bash fortytwo config set inference_type openrouter fortytwo config set openrouter_api_key sk-or-... -fortytwo config set llm_model qwen/qwen3.5-35b-a3b +fortytwo config set model_name qwen/qwen3.5-35b-a3b ``` ### Self-hosted Inference @@ -53,9 +52,9 @@ fortytwo config set llm_model qwen/qwen3.5-35b-a3b Works with any OpenAI-compatible inference server (Ollama, vLLM, llama.cpp, etc.) — running locally or on a remote machine. Example: ```bash -fortytwo config set inference_type local -fortytwo config set llm_api_base http://localhost:11434/v1 -fortytwo config set llm_model gemma3:12b +fortytwo config set inference_type self-hosted +fortytwo config set self_hosted_api_base http://localhost:11434/v1 +fortytwo config set model_name gemma3:12b ``` ## Modes @@ -68,7 +67,7 @@ fortytwo Runs with UI layout: - Status: agent name, role -- Agent's Stats: balance, model, LLM concurrency, query/answer/judging counters +- Node's Stats: balance, model, LLM concurrency, query/answer/judging counters - Log Window: 200-line rolling buffer - Command Prompt @@ -78,10 +77,14 @@ Runs with UI layout: |---------|-------------| | `/help` | Show available commands | | `/ask ` | Submit a question to the network | -| `/identity` | Show agent_id and secret | +| `/identity` | Show node_id and node_secret | +| `/profile list` | List all profiles | +| `/profile create` | Create a new profile (interactive wizard) | +| `/profile switch ` | Switch active profile | | `/config show` | Show all config values | | `/config set ` | Change a config value, see [Configuration](#configuration). | | `/verbose on\|off` | Toggle verbose logging | +| `/version` | Show current version | | `/exit` | Quit the application | ### Headless Mode @@ -102,7 +105,13 @@ fortytwo run [-v] Run agent headless fortytwo ask Submit a question to the network fortytwo config show Show current config fortytwo config set Update a config value -fortytwo identity Show agent credentials +fortytwo identity Show node credentials +fortytwo profile list List all profiles +fortytwo profile switch Switch active profile +fortytwo profile create Create new profile (interactive) +fortytwo profile delete Delete a profile +fortytwo profile show [name] Show profile config +fortytwo version Show current version fortytwo help Show help ``` @@ -114,18 +123,18 @@ Register a new agent from the command line without the interactive wizard. Examp fortytwo setup \ --name "My Agent" \ --inference-type openrouter \ - --api-key sk-or-... \ - --model qwen/qwen3.5-35b-a3b \ + --openrouter-api-key sk-or-... \ + --model-name qwen/qwen3.5-35b-a3b \ --role ANSWERER_AND_JUDGE ``` | Flag | Required | Description | |------|----------|-------------| | `--name` | yes | Agent display name | -| `--inference-type` | yes | `openrouter` or `local` | -| `--api-key` | if openrouter | OpenRouter API key | -| `--llm-api-base` | if local | Local inference URL (e.g. `http://localhost:11434/v1`) | -| `--model` | yes | Model name | +| `--inference-type` | yes | `openrouter` or `self-hosted` | +| `--openrouter-api-key` | if openrouter | OpenRouter API key | +| `--self-hosted-api-base` | if self-hosted | Local inference URL (e.g. `http://localhost:11434/v1`) | +| `--model-name` | yes | Model name | | `--role` | yes | `ANSWERER_AND_JUDGE`, `ANSWERER`, or `JUDGE` | | `--skip-validation` | no | Skip model validation check | @@ -135,11 +144,11 @@ Import an existing agent using credentials. Example: ```bash fortytwo import \ - --agent-id \ + --node-id \ --secret \ --inference-type openrouter \ - --api-key sk-or-... \ - --model qwen/qwen3.5-35b-a3b \ + --openrouter-api-key sk-or-... \ + --model-name qwen/qwen3.5-35b-a3b \ --role ANSWERER_AND_JUDGE ``` @@ -147,7 +156,7 @@ Same flags as `setup`, plus: | Flag | Required | Description | |------|----------|-------------| -| `--agent-id` | yes | Agent UUID | +| `--node-id` | yes | Agent UUID | | `--secret` | yes | Agent secret | ### `ask` @@ -158,11 +167,44 @@ Submit a question to the Fortytwo Network. fortytwo ask "What is the meaning of life?" ``` +### `profile` + +Manage multiple agent profiles. Each profile has its own config and identity. + +```bash +fortytwo profile list # list all profiles +fortytwo profile switch # switch active profile +fortytwo profile create # create a new profile (interactive wizard) +fortytwo profile delete # delete a profile +fortytwo profile show [name] # show profile config (defaults to active) +``` + +### `version` + +Show current version. + +```bash +fortytwo version +``` + +### `profile` + +Manage multiple agent profiles. Each profile has its own config and identity. + +```bash +fortytwo profile list # list all profiles +fortytwo profile switch # switch active profile +fortytwo profile create # create a new profile (interactive wizard) +fortytwo profile delete # delete a profile +fortytwo profile show [name] # show profile config (defaults to active) +``` + ### Global Flags -| Flag | Description | -|------|-------------| -| `-v`, `--verbose` | Enable verbose logging | +| Flag | Description | +|--------------------------|------------------------------------------| +| `-v`, `--verbose` | Enable verbose logging | +| `-p`, `--profile ` | Use a specific profile for this command | ## Configuration @@ -172,18 +214,18 @@ All configuration is stored in `config.json`. It's created automatically during | Parameter | Default | Description | |-----------|---------|-------------| -| `agent_name` | | Agent display name | -| `inference_type` | `openrouter` | `openrouter` or `local` | +| `node_name` | | Node display name | +| `inference_type` | `openrouter` | `openrouter` or `self-hosted` | | `openrouter_api_key` | | OpenRouter API key | -| `llm_api_base` | | Local inference base URL | +| `self_hosted_api_base` | | Local inference base URL | | `fortytwo_api_base` | `https://app.fortytwo.network/api` | Fortytwo API endpoint | | `identity_file` | `~/.fortytwo/identity.json` | Path to identity/credentials file | | `poll_interval` | `120` | Polling interval in seconds | -| `llm_model` | `qwen/qwen3.5-35b-a3b` | LLM model name | +| `model_name` | `qwen/qwen3.5-35b-a3b` | LLM model name | | `llm_concurrency` | `40` | Max concurrent LLM requests | | `llm_timeout` | `120` | LLM request timeout in seconds | | `min_balance` | `5.0` | Minimum FOR balance before account reset | -| `bot_role` | `ANSWERER_AND_JUDGE` | `ANSWERER_AND_JUDGE`, `ANSWERER`, or `JUDGE` | +| `node_role` | `ANSWERER_AND_JUDGE` | `ANSWERER_AND_JUDGE`, `ANSWERER`, or `JUDGE` | | `answerer_system_prompt` | `You are a helpful assistant.` | System prompt for answer generation | You can update any value at runtime. For example: @@ -192,15 +234,15 @@ You can update any value at runtime. For example: # change inference source in Headless Mode fortytwo config set inference_type openrouter fortytwo config set openrouter_api_key sk-or-... -fortytwo config set llm_model nvidia/nemotron-3-super-120b-a12b:free +fortytwo config set model_name nvidia/nemotron-3-super-120b-a12b:free # change inference source in Interactive Mode -/config set inference_type local -/config set llm_api_base http://127.0.0.1:1337/v1 -/config set llm_model unsloth/Qwen3_5-35B-A3B-Q4_K_M +/config set inference_type self-hosted +/config set self_hosted_api_base http://127.0.0.1:1337/v1 +/config set model_name unsloth/Qwen3_5-35B-A3B-Q4_K_M ``` -Changes to LLM-related keys take effect immediately — the LLM client is automatically reinitialized: `llm_model`, `openrouter_api_key`, `inference_type`, `llm_api_base`, `llm_timeout`, `llm_concurrency`. +Changes to LLM-related keys take effect immediately — the LLM client is automatically reinitialized: `model_name`, `openrouter_api_key`, `inference_type`, `self_hosted_api_base`, `llm_timeout`, `llm_concurrency`. ## Identity @@ -210,8 +252,8 @@ Agent credentials are stored in `identity.json`. It's created automatically duri ```json { - "agent_id": "uuid", - "secret": "secret-string", + "node_id": "uuid", + "node_secret": "secret-string", "public_key_pem": "...", "private_key_pem": "..." } diff --git a/package-lock.json b/package-lock.json index bc0b672..e4d47c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,22 @@ { - "name": "fortytwo-app-client", + "name": "@fortytwo-network/fortytwo-cli", "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "fortytwo-app-client", + "name": "@fortytwo-network/fortytwo-cli", "version": "0.1.4", - "license": "ISC", + "license": "Apache-2.0", "dependencies": { "@inkjs/ui": "^2.0.0", "ink": "^6.8.0", "openai": "^6.24.0", "react": "^19.2.4" }, + "bin": { + "fortytwo": "dist/cli.js" + }, "devDependencies": { "@types/node": "^25.3.0", "@types/react": "^19.2.14", @@ -21,6 +24,9 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -972,7 +978,6 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -983,7 +988,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1546,7 +1550,6 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.8.0.tgz", "integrity": "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", @@ -1821,7 +1824,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1863,7 +1865,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2156,7 +2157,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2213,7 +2213,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2289,7 +2288,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/package.json b/package.json index 58a9bcc..8038985 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fortytwo-network/fortytwo-cli", - "version": "0.1.4", - "description": "Fortytwo Swarm Client — CLI for running AI agents on app.fortytwo.network", + "version": "0.1.6", + "description": "Fortytwo Network Client — CLI for running AI agents on app.fortytwo.network", "type": "module", "bin": { "fortytwo": "./dist/cli.js" @@ -11,8 +11,10 @@ "dist" ], "scripts": { + "setup": "npm install && cd viewer && npm install", "start": "tsx src/cli.ts", - "build": "tsc", + "dev": "(cd viewer && npm run dev) & tsx src/cli.ts", + "build": "cd viewer && npm run build && cd .. && tsc", "prepublishOnly": "npm run build", "test": "vitest run" }, @@ -22,9 +24,9 @@ "keywords": [ "fortytwo", "ai", - "agent", + "node", "cli", - "swarm" + "network" ], "author": "aivashov@fortytwo.network", "license": "Apache-2.0", diff --git a/src/answering.ts b/src/answering.ts index d40b2d5..d245c07 100644 --- a/src/answering.ts +++ b/src/answering.ts @@ -2,6 +2,7 @@ import * as config from "./config.js"; import { log, pinTask, unpinTask } from "./utils.js"; import { FortyTwoClient } from "./api-client.js"; import * as llm from "./llm.js"; +import { viewerBus } from "./event-bus.js"; const ANSWERER_LLM_RETRIES = 1; @@ -45,13 +46,14 @@ export async function answerQuery(client: FortyTwoClient, queryId: string): Prom const timer = setTimeout(() => ac.abort(), timeoutMs); try { - log(`[${tag}] Answering started`); + log(`[${tag}] ↳ Answering started`); + viewerBus.setState("JOINING"); // Step 0: Fetch query detail to check state and precise deadline let query = await client.getQuery(queryId); if (query.has_answered) { - log(`[${tag}] Already answered, skipping`); + log(`[${tag}] ↳ Already answered, skipping`); return; } @@ -59,36 +61,36 @@ export async function answerQuery(client: FortyTwoClient, queryId: string): Prom const minAnswerTime = cfg.llm_timeout + 30; if (remaining <= 0) { - log(`[${tag}] Skipping: deadline passed or unavailable`); + log(`[${tag}] ↳ Skipping: deadline passed`); return; } if (remaining > 0 && remaining < minAnswerTime) { - log(`[${tag}] Skipping: only ${Math.round(remaining)}s until deadline (need ${minAnswerTime}s)`); + log(`[${tag}] ↳ Skipping: only ${Math.round(remaining)}s left (need ${minAnswerTime}s)`); return; } const status = (query.status ?? "") as string; if (status !== "active" && status !== "answering_grace") { - log(`[${tag}] Query status is '${status}', not answerable — skipping`); + log(`[${tag}] ↳ Query status '${status}', not answerable — skipping`); return; } // Step 1: Join the query if (query.has_joined) { - log(`[${tag}] Already joined, proceeding to answer`); + log(`[${tag}] ↳ Already joined, proceeding`); } else { try { const joinResult = await client.joinQuery(queryId); - log(`[${tag}] Joined, stake: ${joinResult.stake_amount ?? "?"} FOR`); + log(`[${tag}] ✓ Joined, stake: ${joinResult.stake_amount ?? "?"} FOR`); } catch (err) { const msg = String(err).toLowerCase(); if (msg.includes("maximum") || msg.includes("full") || msg.includes("participants")) { - log(`[${tag}] Query full, skipping`); + log(`[${tag}] ↳ Query full, skipping`); return; } if (msg.includes("already")) { - log(`[${tag}] Already joined, proceeding`); + log(`[${tag}] ↳ Already joined, proceeding`); } else { throw err; } @@ -101,6 +103,12 @@ export async function answerQuery(client: FortyTwoClient, queryId: string): Prom if (!problem) throw new Error(`No decrypted content for query ${queryId}`); // Step 3: Generate answer via LLM + viewerBus.setState("THINKING"); + viewerBus.updateStats({ + activeQueryId: queryId, + activeQuestionText: problem, + activeQuestionCat: String(query.specialization ?? "general"), + }); pinTask(queryId, `Answering ${tag}`); try { const tGen = Date.now(); @@ -110,20 +118,26 @@ export async function answerQuery(client: FortyTwoClient, queryId: string): Prom ANSWERER_LLM_RETRIES, ac.signal, ); - log(`[${tag}] Generated answer in ${Date.now() - tGen}ms`); + log(`[${tag}] ✓ Generated answer in ${Date.now() - tGen}ms`); + viewerBus.setState("SUBMITTING"); const encryptedContent = Buffer.from(answerText, "utf-8").toString("base64"); - log(`[${tag}] Submitting answer...`); + log(`[${tag}] ↳ Submitting answer...`); const result = await client.submitAnswer(queryId, encryptedContent); - log(`[${tag}] Answer submitted! answer_id=${result.id ?? "?"}`); + log(`[${tag}] ✓ Answer submitted! answer_id=${result.id ?? "?"}`); + viewerBus.updateStats({ answers: (viewerBus.stats.answers || 0) + 1 }); } finally { unpinTask(queryId); - log(`[${tag}] Answering finished`); + viewerBus.updateStats({ + activeQueryId: null, + activeQuestionText: null, + activeQuestionCat: null, + }); } } catch (err) { if (ac.signal.aborted) { - log(`[${tag}] Answering timed out after ${cfg.llm_timeout}s`); + log(`[${tag}] ✕ Answering timed out after ${cfg.llm_timeout}s`); return; } throw err; diff --git a/src/api-client.ts b/src/api-client.ts index 89c0a00..e43f86c 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -3,8 +3,8 @@ import { sleep, verbose } from "./utils.js"; export class FortyTwoClient { private baseUrl: string; - agentId = ""; - private secret = ""; + nodeId = ""; + private nodeSecret = ""; private accessToken = ""; private refreshTokenValue = ""; private tokenExpiresAt = 0; @@ -15,11 +15,11 @@ export class FortyTwoClient { // ── Auth ────────────────────────────────────────────────────── - async login(agentId: string, secret: string): Promise> { - this.agentId = agentId; - this.secret = secret; + async login(nodeId: string, nodeSecret: string): Promise> { + this.nodeId = nodeId; + this.nodeSecret = nodeSecret; const data = await this.request("POST", "/auth/login", { - body: { agent_id: agentId, secret }, + body: { agent_id: nodeId, secret: nodeSecret }, auth: false, }); this.storeTokens(data); @@ -49,20 +49,21 @@ export class FortyTwoClient { async register(publicKeyPem: string, displayName?: string): Promise> { const payload: Record = { public_key: publicKeyPem }; if (displayName) payload.display_name = displayName; - return this.request("POST", "/auth/register", { body: payload, auth: false }); + return this.request("POST", "/auth/register", { body: payload, auth: false, timeout: 60_000 }); } async completeRegistration(sessionId: string, responses: Record[]): Promise> { return this.request("POST", "/auth/register/complete", { body: { challenge_session_id: sessionId, responses }, auth: false, + timeout: 60_000, }); } // ── Rankings ────────────────────────────────────────────────── async getPendingChallenges(page = 1, pageSize = 20): Promise> { - return this.request("GET", `/rankings/pending/${this.agentId}`, { + return this.request("GET", `/rankings/pending/${this.nodeId}`, { params: { page, page_size: pageSize }, }); } @@ -129,11 +130,12 @@ export class FortyTwoClient { }); } - async startReactivation(agentId: string, secret: string): Promise> { - return this.request("POST", "/auth/reactivate/start", { - body: { agent_id: agentId, secret }, + async startReactivation(nodeId: string, nodeSecret: string): Promise> { + const data = await this.request("POST", "/auth/reactivate/start", { + body: { agent_id: nodeId, secret: nodeSecret }, auth: false, }); + return data; } async completeReactivation(sessionId: string, responses: Record[]): Promise> { @@ -146,15 +148,15 @@ export class FortyTwoClient { // ── Economy ─────────────────────────────────────────────────── async getBalance(): Promise> { - return this.request("GET", `/economy/balance/${this.agentId}`); + return this.request("GET", `/economy/balance/${this.nodeId}`); } async getAgent(): Promise> { - return this.request("GET", `/agents/${this.agentId}`); + return this.request("GET", `/agents/${this.nodeId}`); } async getAgentStats(): Promise> { - return this.request("GET", `/agents/${this.agentId}/stats`); + return this.request("GET", `/agents/${this.nodeId}/stats`); } async getLikesRemaining(): Promise> { @@ -165,8 +167,8 @@ export class FortyTwoClient { private async ensureAuthenticated(): Promise { if (!this.accessToken) { - if (this.agentId && this.secret) { - await this.login(this.agentId, this.secret); + if (this.nodeId && this.nodeSecret) { + await this.login(this.nodeId, this.nodeSecret); } return; } @@ -174,8 +176,8 @@ export class FortyTwoClient { try { await this.refresh(); } catch { - if (this.agentId && this.secret) { - await this.login(this.agentId, this.secret); + if (this.nodeId && this.nodeSecret) { + await this.login(this.nodeId, this.nodeSecret); } } } @@ -189,9 +191,10 @@ export class FortyTwoClient { params?: Record; auth?: boolean; maxRetries?: number; + timeout?: number; } = {}, ): Promise> { - const { body, params, auth = true, maxRetries = 3 } = opts; + const { body, params, auth = true, maxRetries = 3, timeout = 30_000 } = opts; let url = `${this.baseUrl}${path}`; if (params) { @@ -210,9 +213,6 @@ export class FortyTwoClient { headers["Authorization"] = `Bearer ${this.accessToken}`; } - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30_000); - verbose(`→ ${method} ${url}${body ? ` body=${JSON.stringify(body).slice(0, 200)}` : ""}`); let resp: Response; @@ -221,10 +221,9 @@ export class FortyTwoClient { method, headers, body: body ? JSON.stringify(body) : undefined, - signal: controller.signal, + signal: AbortSignal.timeout(timeout), }); } catch (err) { - clearTimeout(timeout); verbose(`✗ ${method} ${path} — network error: ${err}`); if (attempt < maxRetries) { const wait = 2 ** attempt * 1000; @@ -232,8 +231,6 @@ export class FortyTwoClient { continue; } throw err; - } finally { - clearTimeout(timeout); } verbose(`← ${resp.status} ${method} ${path}`); @@ -241,8 +238,8 @@ export class FortyTwoClient { // 401: try refreshing tokens once if (resp.status === 401 && auth && attempt === 0) { try { - if (this.agentId && this.secret) { - await this.login(this.agentId, this.secret); + if (this.nodeId && this.nodeSecret) { + await this.login(this.nodeId, this.nodeSecret); } } catch { /* ignore */ } continue; @@ -266,7 +263,14 @@ export class FortyTwoClient { if (resp.status >= 400) { const text = await resp.text(); let detail: string | undefined; - try { detail = JSON.parse(text).detail; } catch {} + try { + const parsed = JSON.parse(text).detail; + if (typeof parsed === "string") { + detail = parsed; + } else if (Array.isArray(parsed)) { + detail = parsed.map((e: any) => e.msg ?? String(e)).join("; "); + } + } catch {} throw new Error(detail || `API error ${resp.status} on ${method} ${path}: ${text.slice(0, 500)}`); } diff --git a/src/app.tsx b/src/app.tsx index 46858eb..6e80840 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,66 +1,96 @@ -import { useState } from "react"; -import { Box, Text } from "ink"; +import { useState, useCallback, useRef } from "react"; +import { Box, Text, Static } from "ink"; import { configExists, reloadConfig, get as getConfig } from "./config.js"; import { loadIdentity } from "./identity.js"; +import { resetLlmClient } from "./llm.js"; +import { COLORS } from "./constants.js"; import Onboard from "./onboard.js"; import BotScreen from "./bot.js"; -const banner = [ - "███████╗ ██████╗ ██████╗ ████████╗██╗ ██╗████████╗██╗ ██╗ ██████╗ ", - "██╔════╝██╔═══██╗██╔══██╗╚══██╔══╝╚██╗ ██╔╝╚══██╔══╝██║ ██║██╔═══██╗", - "█████╗ ██║ ██║██████╔╝ ██║ ╚████╔╝ ██║ ██║ █╗ ██║██║ ██║", - "██╔══╝ ██║ ██║██╔══██╗ ██║ ╚██╔╝ ██║ ██║███╗██║██║ ██║", - "██║ ╚██████╔╝██║ ██║ ██║ ██║ ██║ ╚███╔███╔╝╚██████╔╝", - "╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══╝╚══╝ ╚═════╝ ", +const LOGO = [ + " ▒█████░ ▒█████░ █████████░ █████████░", + " ▓███████▓ ▓███████▓ █████████░ █████████░", + " ░█████████░ ░█████████░ █████████░ █████████░", + " ▓███████▓ ▓███████▓ █████████░ █████████░", + " ▒█████░ ▒█████░ █████████░ █████████░", + " █████████░ █████████░", + " ▒█████░ ▒█████░ █████████░ █████████░", + " ▓███████▓ ▓███████▓ █████████░ █████████░", + " ░█████████░ ░█████████░ █████████░ █████████░", + " ▓███████▓ ▓███████▓ █████████░ █████████░", + " ▒█████░ ▒█████░ █████████░ █████████░", ]; -const COLOR = "rgb(42, 42, 242)"; - type Screen = "onboard" | "register" | "running"; function getInitialScreen(): Screen { if (!configExists()) return "onboard"; const cfg = getConfig(); - if (!cfg.identity_file || !loadIdentity(cfg.identity_file)) return "register"; + if (!cfg.node_identity_file || !loadIdentity(cfg.node_identity_file)) return "register"; return "running"; } export default function App() { const [screen, setScreen] = useState(getInitialScreen); + const [botKey, setBotKey] = useState(0); + + const handleSwitchProfile = useCallback(() => { + resetLlmClient(); + setBotKey((k) => k + 1); + }, []); + + const logoShownRef = useRef(false); + const showLogo = !logoShownRef.current && screen !== "running"; + if (showLogo) logoShownRef.current = true; + + const fromCreateRef = useRef(false); + + const handleCreateProfile = useCallback(() => { + fromCreateRef.current = true; + setScreen("onboard"); + }, []); + + const handleCancelCreate = useCallback(() => { + fromCreateRef.current = false; + setScreen("running"); + }, []); + + const handleOnboardDone = useCallback(() => { + fromCreateRef.current = false; + reloadConfig(); + resetLlmClient(); + setBotKey((k) => k + 1); + setScreen("running"); + }, []); return ( - - {screen !== "running" && ( - - {banner.map((line, i) => ( - - {line} - - ))} - - )} + + + {() => ( + + ╔═════════ WELCOME TO FORTYTWO, NETWORK NODE + + {LOGO.map((line, i) => ( + {line} + ))} + + ╚═════════ ONBOARDING + + )} + {screen === "onboard" && ( - { - reloadConfig(); - setScreen("running"); - }} - /> + )} {screen === "register" && ( - { - reloadConfig(); - setScreen("running"); - }} - /> + )} - {screen === "running" && } + {screen === "running" && ( + + )} ); diff --git a/src/bot.tsx b/src/bot.tsx index ec897a9..6207d92 100644 --- a/src/bot.tsx +++ b/src/bot.tsx @@ -1,15 +1,20 @@ import { useState, useEffect, useCallback } from "react"; import { Box, Text, useStdout } from "ink"; +import chalk from "chalk"; import { CommandInput } from "./command-input.js"; import { get as getConfig } from "./config.js"; -import { setLogFn, setVerbose, log, sleep, getPinnedTasks } from "./utils.js"; -import type { PinnedTask } from "./utils.js"; +import { COLORS } from "./constants.js"; +import { setLogFn, setVerbose, log, sleep, getPinnedTasks, formatNumber, truncateName, getRoleLabel } from "./utils.js"; import { FortyTwoClient } from "./api-client.js"; -import { loadIdentity } from "./identity.js"; -import { runCycle, checkBalance, InsufficientFundsError } from "./main.js"; +import { loadIdentity, resetAccount, reactivateAccount } from "./identity.js"; +import { runCycle, checkBalance, InsufficientFundsError, initViewerBus } from "./main.js"; import { getLlmStats } from "./llm.js"; -import { resetAccount } from "./identity.js"; import { executeCommand, SUGGESTIONS } from "./commands.js"; +import { validateConfig, validateModel } from "./setup-logic.js"; +import { viewerBus } from "./event-bus.js"; +import { checkForUpdate, UPDATE_COMMAND } from "./update-check.js"; + +import pkg from "../package.json" with { type: "json" }; type AgentStats = { queries: number; @@ -18,43 +23,62 @@ type AgentStats = { answersWon: number; winRate: number; judgments: number; + judgmentsWon: number; accuracy: number; }; -const COLOR = "rgb(42, 42, 242)"; +type AgentProfile = { + intelligenceScore: number; + judgingScore: number; +}; + +const VERSION = pkg.version; const LOGO = [ -"██╗██╗ ██╗██╗", -"╚═╝╚═╝ ██║██║", -"██╗██╗ ██║██║", -"╚═╝╚═╝ ╚═╝╚═╝", -] + " ▒██▓░ ▒██▓░ ░████▓░ ░████▓░", + " ░████▓░ ░████▓░ ░████▓░ ░████▓░", + " ▒██▓░ ▒██▓░ ░████▓░ ░████▓░", + " ░████▓░ ░████▓░", + " ▒██▓░ ▒██▓░ ░████▓░ ░████▓░", + " ░████▓░ ░████▓░ ░████▓░ ░████▓░", + " ▒██▓░ ▒██▓░ ░████▓░ ░████▓░", +]; const MAX_LINES = 200; -const MAX_PINNED_LINES = 3; -// padding(2) + logo+stats(3) + tasks(3) + separator(1) + prompt(1) + gaps(4) +// frame header(1) + empty(1) + logo(7) + empty(1) + frame footer(1) + gap(1) + separator(1) + prompt+footer(1) + gaps const CHROME_LINES = 14; -function formatCountdown(ms: number): string { - const s = Math.ceil(ms / 1000); - const m = Math.floor(s / 60); - const sec = s % 60; - return m > 0 ? `${m}m ${sec}s` : `${sec}s`; +function padRight(str: string, len: number): string { + return str.length >= len ? str : str + " ".repeat(len - str.length); } -export default function BotScreen() { +interface BotScreenProps { + onSwitchProfile?: () => void; + onCreateProfile?: () => void; +} + +export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScreenProps = {}) { const [lines, setLines] = useState([]); const [error, setError] = useState(null); - const [status, setStatus] = useState("Starting..."); + const [agentName, setAgentName] = useState("Agent"); + const [agentRole, setAgentRole] = useState(""); const [balance, setBalance] = useState(null); - const [nextPollAt, setNextPollAt] = useState(null); - const [countdown, setCountdown] = useState(null); + const [staked, setStaked] = useState(null); const [llmActive, setLlmActive] = useState(0); const [stats, setStats] = useState(null); - const [tasks, setTasks] = useState([]); + const [profile, setProfile] = useState(null); const { stdout } = useStdout(); - const termRows = stdout.rows ?? 24; + const [termSize, setTermSize] = useState({ cols: stdout.columns ?? 80, rows: stdout.rows ?? 24 }); + + useEffect(() => { + const onResize = () => setTermSize({ cols: stdout.columns ?? 80, rows: stdout.rows ?? 24 }); + stdout.on("resize", onResize); + return () => { stdout.off("resize", onResize); }; + }, [stdout]); + + const termCols = termSize.cols; + const termRows = termSize.rows; const visibleCount = Math.max(termRows - CHROME_LINES, 5); const pushLine = useCallback((msg: string) => { @@ -70,6 +94,13 @@ export default function BotScreen() { const raw = input.trim(); if (!raw) return; + pushLine(" "); + + const styledInput = chalk.bgHex(COLORS.BLUE_UNDERLINE)(`${chalk.hex(COLORS.GREY_NEUTRAL)(" ❯ ")}${chalk.hex(COLORS.WHITE)(raw)} `); + pushLine(styledInput); + + pushLine(" "); + const stripped = raw.startsWith("/") ? raw.slice(1) : raw; if (stripped.startsWith("ask ") || stripped === "ask") { @@ -85,57 +116,70 @@ export default function BotScreen() { pushLine(`Submitting question...`); const encrypted = Buffer.from(question, "utf-8").toString("base64"); client.createQuery(encrypted, "general") - .then((res) => pushLine(`Question submitted! ID: ${res.id ?? "?"}`)) - .catch((err) => pushLine(`Error: ${err}`)); + .then((res) => pushLine(`✓ Question submitted! ID: ${res.id ?? "?"}`)) + .catch((err) => pushLine(`✕ Error: ${err}`)); return; } const results = executeCommand(raw); - for (const line of results) pushLine(line); - }, [pushLine, client]); - - // Countdown ticker — updates every second - useEffect(() => { - if (nextPollAt === null) { - setCountdown(null); - return; - } - const tick = () => { - const remaining = nextPollAt - Date.now(); - if (remaining <= 0) { - setCountdown(null); - setNextPollAt(null); - } else { - setCountdown(formatCountdown(remaining)); + let switching = false; + let creating = false; + for (const line of results) { + if (line.startsWith("__SWITCH_PROFILE__:")) { + switching = true; + continue; } - }; - tick(); - const id = setInterval(tick, 1000); - return () => clearInterval(id); - }, [nextPollAt]); + if (line === "__CREATE_PROFILE__") { + creating = true; + continue; + } + pushLine(line); + } + pushLine(" "); + if (switching && onSwitchProfile) { + onSwitchProfile(); + } + if (creating && onCreateProfile) { + onCreateProfile(); + } + }, [pushLine, client, onSwitchProfile, onCreateProfile]); + - // Balance + stats ticker — every 30s, independent of main cycle + // Balance + stats + profile ticker — every 30s useEffect(() => { if (!client) return; let cancelled = false; const tick = async () => { try { - const [available, raw] = await Promise.all([ - checkBalance(client), + const [balanceData, rawStats, agentData] = await Promise.all([ + client.getBalance().catch(() => null), client.getAgentStats().catch(() => null), + client.getAgent().catch(() => null), ]); if (cancelled) return; - setBalance(available); - if (raw) { + + if (balanceData) { + setBalance(parseFloat(balanceData.available ?? "0")); + setStaked(parseFloat(balanceData.staked ?? "0")); + } + if (rawStats) { setStats({ - queries: raw.queries_submitted ?? 0, - queriesCompleted: raw.queries_completed ?? 0, - answers: raw.answers_submitted ?? 0, - answersWon: raw.answers_won ?? 0, - winRate: parseFloat(raw.answer_win_rate ?? "0"), - judgments: raw.judgments_made ?? 0, - accuracy: parseFloat(raw.judgment_accuracy ?? "0"), + queries: rawStats.queries_submitted ?? 0, + queriesCompleted: rawStats.queries_completed ?? 0, + answers: rawStats.answers_submitted ?? 0, + answersWon: rawStats.answers_won ?? 0, + winRate: parseFloat(rawStats.answer_win_rate ?? "0"), + judgments: rawStats.judgments_made ?? 0, + judgmentsWon: rawStats.judgments_won ?? 0, + accuracy: parseFloat(rawStats.judgment_accuracy ?? "0"), + }); + } + if (agentData) { + const p = agentData.profile ?? agentData; + setProfile({ + intelligenceScore: parseFloat(p.intelligence_score ?? p.intellect_score ?? "0"), + judgingScore: parseFloat(p.judging_score ?? p.judge_score ?? "0"), }); } } catch { /* ignore */ } @@ -150,11 +194,20 @@ export default function BotScreen() { useEffect(() => { const id = setInterval(() => { setLlmActive(getLlmStats().active); - setTasks(getPinnedTasks()); }, 1000); return () => clearInterval(id); }, []); + // Update check — fire-and-forget on mount + useEffect(() => { + checkForUpdate().then(info => { + if (info?.updateAvailable) { + pushLine(chalk.red(`⚠ Your version v${info.currentVersion} is outdated! Latest: v${info.latestVersion}`)); + pushLine(chalk.red(` Run: ${UPDATE_COMMAND}`)); + } + }).catch(() => {}); + }, [pushLine]); + // Main bot loop useEffect(() => { setLogFn(pushLine); @@ -167,58 +220,103 @@ export default function BotScreen() { (async () => { try { const cfg = getConfig(); - const identity = loadIdentity(cfg.identity_file); + const identity = loadIdentity(cfg.node_identity_file); if (!identity) { setError("No identity found. Run onboarding first."); return; } + // Validate config before proceeding + const cfgCheck = validateConfig(cfg as unknown as Record); + if (!cfgCheck.ok) { + setError(`Config error: ${cfgCheck.error}`); + return; + } + + log("Validating model..."); + const modelCheck = await validateModel(cfg as unknown as Record); + if (!modelCheck.ok) { + setError(`Config error: ${modelCheck.error}`); + return; + } + log("✓ Configuration valid"); + + viewerBus.setState("AUTHENTICATING"); const c = new FortyTwoClient(); - await c.login(identity.agent_id, identity.secret); + await c.login(identity.node_id, identity.node_secret); setClient(c); - const name = cfg.agent_name || cfg.display_name || "Agent"; - setStatus(`${name} | ${cfg.bot_role}`); - log(`Logged in as ${name} — ${identity.agent_id}`); - log(`Role: ${cfg.bot_role} | Poll: ${cfg.poll_interval}s | Model: ${cfg.llm_model}`); + const name = cfg.node_name || cfg.node_display_name || "Agent"; + setAgentName(name); + setAgentRole(cfg.node_role); + log(`Logged in as ${name} — ${identity.node_id}`); + log(`Role: ${getRoleLabel(cfg.node_role)} | Poll: ${cfg.poll_interval}s | Model: ${cfg.model_name}`); + + await initViewerBus(c, cfg, identity.node_id); + let cycles = 0; while (!cancelled) { - setNextPollAt(null); const cycleStart = Date.now(); try { const available = await checkBalance(c); if (!cancelled) setBalance(available); if (available < cfg.min_balance) { throw new InsufficientFundsError( - `Balance ${available.toFixed(2)} FOR < minimum ${cfg.min_balance.toFixed(2)} FOR`, + `Insufficient FOR balance: ${available.toFixed(2)} available, ${cfg.min_balance.toFixed(2)} required`, ); } const count = await runCycle(c); - if (count > 0) log(`Processed ${count} items this cycle`); + cycles++; + viewerBus.updateStats({ cycles }); + if (count > 0) log(`✓ Processed ${count} items this cycle`); } catch (err) { if (cancelled) return; if (err instanceof InsufficientFundsError) { - log(`${err.message} — resetting account...`); + log(`✕ ${err.message} — resetting account...`); + viewerBus.pushError(err.message); await resetAccount(c, pushLine); - log("Account reset complete!"); + log("✓ Account reset complete!"); continue; } - log(`Error in cycle: ${err}`); + const errMsg = (err as Error).message ?? String(err); + if (errMsg.toLowerCase().includes("inactive") || errMsg.toLowerCase().includes("deactivated")) { + log(`Account deactivated — reactivating...`); + viewerBus.updateStats({ accountInactive: true }); + await reactivateAccount(c, identity.node_id, identity.node_secret); + await c.login(identity.node_id, identity.node_secret); + viewerBus.updateStats({ accountInactive: false }); + log("✓ Reactivation complete!"); + continue; + } + log(`✕ Error in cycle: ${err}`); + viewerBus.pushError(errMsg); } if (cancelled) return; + viewerBus.setState("COOLDOWN"); const elapsed = Date.now() - cycleStart; const delay = cfg.poll_interval * 1000 - elapsed; if (delay > 0) { - setNextPollAt(Date.now() + delay); - await sleep(delay); + const totalSec = Math.round(delay / 1000); + for (let rem = totalSec; rem > 0; rem--) { + if (cancelled) return; + viewerBus.updateStats({ cooldownRemaining: rem }); + await sleep(1000); + } + viewerBus.updateStats({ cooldownRemaining: 0 }); } else { log(`Cycle took ${Math.round(elapsed / 1000)}s (> ${cfg.poll_interval}s), starting next immediately`); } } } catch (err) { - if (!cancelled) setError(String(err)); + if (!cancelled) { + setError(String(err)); + viewerBus.setState("ERROR"); + viewerBus.pushError(String(err)); + } + } finally { + viewerBus.setRunning(false); } })(); @@ -231,60 +329,85 @@ export default function BotScreen() { const visible = lines.slice(-visibleCount); const last = lines.length - 1; const offset = lines.length - visible.length; - - const balanceColor = balance !== null && balance < (getConfig().min_balance ?? 5) ? "red" : "green"; + const cfg = getConfig(); + + const providerStr = cfg.inference_type === "self-hosted" + ? `Self-hosted ${cfg.self_hosted_api_base.replace(/^https?:\/\//, "").replace(/\/.*$/, "")}` + : "OpenRouter"; + + const displayName = truncateName(agentName.toUpperCase()); + const intScore = profile ? formatNumber(profile.intelligenceScore, 4) : "—"; + const jdgScore = profile ? formatNumber(profile.judgingScore, 3) : "—"; + + const roleDisplay = getRoleLabel(agentRole || cfg.node_role); + + const qStr = stats ? formatNumber(stats.queries) : "—"; + const finStr = stats ? formatNumber(stats.queriesCompleted) : "—"; + const aStr = stats ? formatNumber(stats.answers) : "—"; + const aWonStr = stats ? formatNumber(stats.answersWon) : "—"; + const aRateStr = stats ? `${Math.round(stats.winRate)}%` : "—"; + const jStr = stats ? formatNumber(stats.judgments) : "—"; + const jWonStr = stats ? formatNumber(stats.judgmentsWon) : "—"; + const jRateStr = stats ? `${Math.round(stats.accuracy)}%` : "—"; + const balStr = balance !== null ? formatNumber(balance) : "—"; + const stakedStr = staked !== null ? formatNumber(staked) : "—"; + + const versionText = ` App Fortytwo Client v${VERSION} ──`; + const centerMarker = " ::|| "; + const leftDashes = Math.floor((termCols - centerMarker.length) / 2); + const rightTotal = termCols - leftDashes - centerMarker.length; + const rightDashes = Math.max(0, rightTotal - versionText.length); + const topSep = "─".repeat(termCols); return ( - - + + ╔═════════ {displayName} · INT {intScore} · JDG {jdgScore} + + {LOGO.map((line, i) => ( - {line} + {line} ))} - - {status} - - {balance !== null - ? {balance.toFixed(2)} FOR - : loading...} - · {getConfig().llm_model} - · LLM {llmActive}/{getConfig().llm_concurrency} - - {stats - ? Q: {stats.queries} ({stats.queriesCompleted} done) · A: {stats.answers} ({stats.answersWon} wins) · J: {stats.judgments}{countdown ? ` · ${countdown}` : ""} - : {countdown ? countdown : "loading stats..."}} + + {providerStr} + {cfg.model_name} + Poll {cfg.poll_interval}s · Concurrency {llmActive}/{cfg.llm_concurrency} + {padRight(`Q ${qStr}`, 14)}{padRight(`fin ${finStr}`, 14)} + {padRight(`A ${aStr}`, 14)}{padRight(`won ${aWonStr}`, 14)}{`rate ${aRateStr}`} + {padRight(`J ${jStr}`, 14)}{padRight(`won ${jWonStr}`, 14)}{`rate ${jRateStr}`} + FOR {balStr} staked {stakedStr} - - {tasks.slice(0, MAX_PINNED_LINES).map((t) => ( - - ● {t.label} ({formatCountdown(Date.now() - t.startedAt)}) - - ))} - - - {"─".repeat(Math.min(stdout.columns ?? 72, 72))} - + + ╚═════════ {roleDisplay} | WATCH YOUR NODE: http://127.0.0.1:4242 {visible.map((line, i) => { const globalIdx = offset + i; - const isCurrent = globalIdx === last; + const isCurrent = globalIdx === last && line.trim() !== ""; return ( - + {isCurrent ? "▸ " : " "}{line} ); })} - {error && {error}} + {error && ✕ ERROR: {error}} + + {topSep} - {">"} + + + + {"─".repeat(leftDashes)} + {centerMarker} + {"─".repeat(rightDashes)}{versionText} + ); } diff --git a/src/cli.ts b/src/cli.ts index 1e534ef..0bd4600 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,16 +1,30 @@ #!/usr/bin/env node +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; import { setVerbose, log } from "./utils.js"; import { configExists, get as getConfig, - saveConfig, reloadConfig, } from "./config.js"; -import { loadIdentity, saveIdentity, registerAgent } from "./identity.js"; +import { loadIdentity, registerAgent } from "./identity.js"; import { FortyTwoClient } from "./api-client.js"; import { main } from "./main.js"; import { executeCommand } from "./commands.js"; import { validateModel, buildConfig } from "./setup-logic.js"; +import { startViewerServer } from "./viewer-server.js"; +import { checkForUpdate, UPDATE_COMMAND } from "./update-check.js"; +import pkg from "../package.json" with { type: "json" }; +import { + initProfiles, + setProfileOverride, + listProfiles, + switchProfile, + deleteProfile, + createProfile, + sanitizeProfileName, + getProfileDir, +} from "./profiles.js"; // ── Arg parser ────────────────────────────────────────────────── @@ -46,6 +60,12 @@ function parseArgs(argv: string[]): ParsedArgs { } } else if (arg === "-v") { flags["verbose"] = "true"; + } else if (arg === "-p") { + const next = rest[i + 1]; + if (next && !next.startsWith("-")) { + flags["profile"] = next; + i++; + } } else { positionals.push(arg); } @@ -66,13 +86,13 @@ function requireFlag(flags: Record, name: string, label: string) } async function cmdSetup(flags: Record) { - const name = requireFlag(flags, "name", "agent display name"); + const nodeName = requireFlag(flags, "node-name", "node name"); const inferenceType = requireFlag(flags, "inference-type", "openrouter | local"); const model = requireFlag(flags, "model", "model name"); - const role = requireFlag(flags, "role", "JUDGE | ANSWERER | ANSWERER_AND_JUDGE"); + const role = requireFlag(flags, "node-role", "JUDGE | ANSWERER | ANSWERER_AND_JUDGE"); - if (!["openrouter", "local"].includes(inferenceType)) { - console.error(`Invalid --inference-type: ${inferenceType}. Must be "openrouter" or "local".`); + if (!["openrouter", "self-hosted"].includes(inferenceType)) { + console.error(`Invalid --inference-type: ${inferenceType}. Must be "openrouter" or "self-hosted".`); process.exit(1); } @@ -82,16 +102,17 @@ async function cmdSetup(flags: Record) { } const values: Record = { - agent_name: name, + node_name: nodeName, + node_display_name: nodeName, inference_type: inferenceType, - llm_model: model, - bot_role: role, + model_name: model, + node_role: role, }; if (inferenceType === "openrouter") { values.openrouter_api_key = requireFlag(flags, "api-key", "OpenRouter API key"); } else { - values.llm_api_base = requireFlag(flags, "llm-api-base", "local inference URL"); + values.self_hosted_api_base = requireFlag(flags, "llm-api-base", "local inference URL"); } if (!flags["skip-validation"]) { @@ -105,25 +126,27 @@ async function cmdSetup(flags: Record) { } const cfg = buildConfig(values); - saveConfig(cfg); + const profileName = sanitizeProfileName(nodeName); + createProfile(profileName, cfg); reloadConfig(); - console.log("Config saved."); + console.log(`Config saved to profile "${profileName}".`); console.log("Starting registration..."); const client = new FortyTwoClient(); - await registerAgent(client, name, console.log); + await registerAgent(client, nodeName, console.log); console.log("Setup complete!"); } async function cmdImport(flags: Record) { - const agentId = requireFlag(flags, "agent-id", "agent UUID"); - const secret = requireFlag(flags, "secret", "agent secret"); - const inferenceType = requireFlag(flags, "inference-type", "openrouter | local"); + const nodeId = requireFlag(flags, "node-id", "agent UUID"); + const nodeSecret = requireFlag(flags, "node-secret", "node secret"); + const rawInferenceType = requireFlag(flags, "inference-type", "openrouter | self-hosted"); + const inferenceType = rawInferenceType === "local" ? "self-hosted" : rawInferenceType; const model = requireFlag(flags, "model", "model name"); - const role = requireFlag(flags, "role", "JUDGE | ANSWERER | ANSWERER_AND_JUDGE"); + const role = requireFlag(flags, "node-role", "JUDGE | ANSWERER | ANSWERER_AND_JUDGE"); - if (!["openrouter", "local"].includes(inferenceType)) { - console.error(`Invalid --inference-type: ${inferenceType}. Must be "openrouter" or "local".`); + if (!["openrouter", "self-hosted"].includes(inferenceType)) { + console.error(`Invalid --inference-type: ${rawInferenceType}. Must be "openrouter" or "self-hosted".`); process.exit(1); } @@ -135,30 +158,31 @@ async function cmdImport(flags: Record) { console.log("Checking credentials..."); const client = new FortyTwoClient(); try { - await client.login(agentId, secret); + await client.login(nodeId, nodeSecret); } catch (err) { console.error(`Invalid credentials: ${err}`); process.exit(1); } - let displayName = agentId; + let nodeDisplayName = nodeId; try { const agent = await client.getAgent(); - displayName = agent?.profile?.display_name || displayName; - } catch { /* keep agentId */ } + nodeDisplayName = agent?.profile?.display_name || nodeDisplayName; + } catch { /* keep nodeId */ } const values: Record = { - agent_name: displayName, - agent_id: agentId, + node_name: nodeDisplayName, + node_display_name: nodeDisplayName, + node_id: nodeId, inference_type: inferenceType, - llm_model: model, - bot_role: role, + model_name: model, + node_role: role, }; if (inferenceType === "openrouter") { - values.openrouter_api_key = requireFlag(flags, "api-key", "OpenRouter API key"); + values.openrouter_api_key = requireFlag(flags, "openrouter-api-key", "OpenRouter API key"); } else { - values.llm_api_base = requireFlag(flags, "llm-api-base", "local inference URL"); + values.self_hosted_api_base = requireFlag(flags, "self-hosted-api-base", "local inference URL"); } if (!flags["skip-validation"]) { @@ -172,11 +196,10 @@ async function cmdImport(flags: Record) { } const cfg = buildConfig(values); - saveConfig(cfg); + const profileName = sanitizeProfileName(nodeDisplayName); + createProfile(profileName, cfg, { node_id: nodeId, node_secret: nodeSecret }); reloadConfig(); - - saveIdentity(getConfig().identity_file, { agent_id: agentId, secret }); - console.log(`Agent "${displayName}" (${agentId}) imported!`); + console.log(`Agent "${nodeDisplayName}" (${nodeId}) imported to profile "${profileName}"!`); } async function cmdRun() { @@ -186,15 +209,19 @@ async function cmdRun() { } const cfg = getConfig(); - const identity = loadIdentity(cfg.identity_file); + const identity = loadIdentity(cfg.node_identity_file); if (!identity) { console.error("No identity found. Run 'setup' or 'import' first."); process.exit(1); } + const viewer = startViewerServer(4242); + log(`Watch your node -> http://127.0.0.1:${viewer.port}`); + const ac = new AbortController(); const shutdown = () => { log("Shutting down..."); + viewer.close(); ac.abort(); }; process.on("SIGINT", shutdown); @@ -216,14 +243,14 @@ async function cmdAsk(positionals: string[]) { } const cfg = getConfig(); - const identity = loadIdentity(cfg.identity_file); + const identity = loadIdentity(cfg.node_identity_file); if (!identity) { console.error("No identity found. Run 'setup' or 'import' first."); process.exit(1); } const client = new FortyTwoClient(); - await client.login(identity.agent_id, identity.secret); + await client.login(identity.node_id, identity.node_secret); const encrypted = Buffer.from(question, "utf-8").toString("base64"); const res = await client.createQuery(encrypted, "general"); @@ -252,35 +279,143 @@ function cmdIdentity() { for (const line of executeCommand("/identity")) console.log(line); } +async function cmdProfile(positionals: string[]) { + const sub = positionals[0]; + + if (!sub || sub === "list") { + const profiles = listProfiles(); + if (profiles.length === 0) { + console.log("No profiles. Run 'fortytwo setup' or 'fortytwo import' to create one."); + return; + } + console.log("Profiles:"); + for (const p of profiles) { + const marker = p.active ? " (active)" : ""; + console.log(` ${p.name}${marker}`); + } + return; + } + + if (sub === "switch") { + const name = positionals[1]; + if (!name) { + const profiles = listProfiles(); + if (profiles.length === 0) { + console.error("No profiles available."); + process.exit(1); + } + console.log("Available profiles:"); + for (const p of profiles) { + const marker = p.active ? " (active)" : ""; + console.log(` ${p.name}${marker}`); + } + console.log("\nUsage: fortytwo profile switch "); + return; + } + try { + switchProfile(name); + console.log(`Switched to profile "${name}".`); + } catch (err) { + console.error(String(err instanceof Error ? err.message : err)); + process.exit(1); + } + return; + } + + if (sub === "create") { + const { setConfigDir } = await import("./config.js"); + const tempName = `new-${Date.now()}`; + setConfigDir(getProfileDir(tempName)); + await import("./index.js"); + return; + } + + if (sub === "delete") { + const name = positionals[1]; + if (!name) { + console.error("Usage: fortytwo profile delete "); + process.exit(1); + } + try { + deleteProfile(name); + console.log(`Profile "${name}" deleted.`); + } catch (err) { + console.error(String(err instanceof Error ? err.message : err)); + process.exit(1); + } + return; + } + + if (sub === "show") { + const name = positionals[1]; + const profiles = listProfiles(); + const target = name + ? profiles.find((p) => p.name === name) + : profiles.find((p) => p.active); + if (!target) { + console.error(name ? `Profile "${name}" not found.` : "No active profile."); + process.exit(1); + } + + const dir = getProfileDir(target.name); + const cfgPath = join(dir, "config.json"); + if (!existsSync(cfgPath)) { + console.error(`Config not found for profile "${target.name}".`); + process.exit(1); + } + const cfg = JSON.parse(readFileSync(cfgPath, "utf-8")); + console.log(`Profile: ${target.name}${target.active ? " (active)" : ""}`); + for (const [k, v] of Object.entries(cfg)) { + const display = (k === "openrouter_api_key" && typeof v === "string" && v.length > 8) + ? v.slice(0, 4) + "***" + v.slice(-4) + : String(v); + console.log(` ${k}: ${display}`); + } + return; + } + + console.error(`Unknown profile command: ${sub}`); + console.error("Usage: fortytwo profile list|switch|create|delete|show"); + process.exit(1); +} + function printHelp() { console.log(`fortytwo — FortyTwo Network Swarm Client Usage: fortytwo Interactive UI - fortytwo setup [flags] Register new agent - fortytwo import [flags] Import existing agent - fortytwo run [-v] Run agent (headless) + fortytwo setup [flags] Register new node + fortytwo import [flags] Import existing node + fortytwo run [-v] Run node (headless) fortytwo ask Submit a question fortytwo config show Show config fortytwo config set Update config - fortytwo identity Show agent credentials + fortytwo identity Show node credentials + fortytwo profile list List all profiles + fortytwo profile switch Switch active profile + fortytwo profile create Create new profile (interactive) + fortytwo profile delete Delete a profile + fortytwo profile show [name] Show profile config + fortytwo version Show version Setup flags: - --name NAME Agent display name - --inference-type TYPE openrouter | local - --api-key KEY OpenRouter API key - --llm-api-base URL Local inference URL - --model MODEL Model name - --role ROLE JUDGE | ANSWERER | ANSWERER_AND_JUDGE + --node-name NAME Node local name + --inference-type TYPE openrouter | self-hosted + --openrouter-api-key KEY OpenRouter API key + --model-name NAME Model name + --self-hosted-api-base URL Local inference URL + --node-name NAME Local name for the node profile (e.g. "my-judge") + --node-role ROLE JUDGE | ANSWERER | ANSWERER_AND_JUDGE --skip-validation Skip model validation Import flags: - --agent-id UUID Agent ID - --secret SECRET Agent secret + --node-id UUID Node ID + --node-secret SECRET Node secret (+ same inference/model/role flags as setup) Global flags: - -v, --verbose Verbose logging`); + -v, --verbose Verbose logging + -p, --profile NAME Use specific profile for this command`); } // ── Dispatch ──────────────────────────────────────────────────── @@ -290,6 +425,13 @@ async function run() { if (flags.verbose || flags.v) setVerbose(true); + // Initialize profile system + if (flags.profile) setProfileOverride(flags.profile); + initProfiles(); + + // Fire-and-forget update check (don't block startup) + const updatePromise = checkForUpdate().catch(() => null); + try { switch (subcommand) { case null: @@ -313,6 +455,12 @@ async function run() { case "identity": cmdIdentity(); break; + case "profile": + await cmdProfile(positionals); + break; + case "version": + console.log(pkg.version); + break; case "help": printHelp(); break; @@ -325,6 +473,15 @@ async function run() { console.error(`Error: ${err}`); process.exit(1); } + + // Show update notification for CLI subcommands (interactive mode handles it in UI) + if (subcommand !== null) { + const updateInfo = await updatePromise; + if (updateInfo?.updateAvailable) { + console.log(`\nUpdate available: ${updateInfo.currentVersion} → ${updateInfo.latestVersion}`); + console.log(`Run: ${UPDATE_COMMAND}`); + } + } } run(); diff --git a/src/command-input.tsx b/src/command-input.tsx index 5c02f35..ed964e8 100644 --- a/src/command-input.tsx +++ b/src/command-input.tsx @@ -1,6 +1,7 @@ import { useState, useMemo, useCallback } from "react"; import { Text, useInput } from "ink"; import chalk from "chalk"; +import { COLORS } from "./constants.js"; type Props = { placeholder?: string; @@ -62,23 +63,23 @@ export function CommandInput({ placeholder = "", suggestions, onSubmit }: Props) const rendered = useMemo(() => { if (value.length === 0) { return placeholder - ? chalk.inverse(placeholder[0]) + chalk.dim(placeholder.slice(1)) + ? chalk.hex(COLORS.GREY_LIGHT).inverse(placeholder[0]) + chalk.hex(COLORS.GREY_LIGHT)(placeholder.slice(1)) : chalk.inverse(" "); } let result = ""; for (let i = 0; i < value.length; i++) { - result += i === cursor ? chalk.inverse(value[i]!) : value[i]; + result += i === cursor ? chalk.hex(COLORS.BLUE_CONTENT).inverse(value[i]!) : chalk.hex(COLORS.BLUE_CONTENT)(value[i]!); } if (suggestion) { if (cursor === value.length) { - result += chalk.inverse(suggestion[0]) + chalk.dim(suggestion.slice(1)); + result += chalk.white.inverse(suggestion[0]) + chalk.hex(COLORS.GREY_NEUTRAL)(suggestion.slice(1)); } else { - result += chalk.dim(suggestion); + result += chalk.hex(COLORS.GREY_NEUTRAL)(suggestion); } } else if (cursor === value.length) { - result += chalk.inverse(" "); + result += chalk.white.inverse(" "); } return result; diff --git a/src/commands.ts b/src/commands.ts index a525aaf..959f0c2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,11 +1,15 @@ -import { get as getConfig, saveConfig, reloadConfig, type UserConfig } from "./config.js"; +import { get as getConfig, saveConfig, reloadConfig } from "./config.js"; import { loadIdentity } from "./identity.js"; import { setVerbose } from "./utils.js"; import { resetLlmClient } from "./llm.js"; +import { validateConfig } from "./setup-logic.js"; +import { listProfiles, switchProfile } from "./profiles.js"; +import { getCachedUpdate, UPDATE_COMMAND } from "./update-check.js"; +import pkg from "../package.json" with { type: "json" }; const LLM_RESET_KEYS = new Set([ - "llm_model", "openrouter_api_key", "inference_type", - "llm_api_base", "llm_timeout", "llm_concurrency", + "model_name", "openrouter_api_key", "inference_type", + "self_hosted_api_base", "llm_timeout", "llm_concurrency", ]); const MASKED_KEYS = new Set(["openrouter_api_key"]); @@ -22,20 +26,25 @@ function mask(key: string, value: string): string { } const CONFIG_KEYS = [ - "agent_name", "display_name", "inference_type", "openrouter_api_key", - "llm_api_base", "fortytwo_api_base", "identity_file", "poll_interval", - "llm_model", "llm_concurrency", "llm_timeout", "min_balance", - "bot_role", "answerer_system_prompt", + "node_name", "node_display_name", "inference_type", "openrouter_api_key", + "self_hosted_api_base", "fortytwo_api_base", "node_identity_file", "poll_interval", + "model_name", "llm_concurrency", "llm_timeout", "min_balance", + "node_role", "answerer_system_prompt", ]; export const SUGGESTIONS = [ "/help", "/ask ", "/identity", + "/profile", + "/profile list", + "/profile create", + "/profile switch ", "/config show", ...CONFIG_KEYS.map((k) => `/config set ${k} `), "/verbose on", "/verbose off", + "/version", "/exit", ]; @@ -52,12 +61,16 @@ export function executeCommand(input: string): string[] { if (cmd === "help") { return [ "Commands:", - " /ask — submit a question to the network", - " /identity — show agent_id and secret", - " /config show — show all config values", - " /config set — change a config value", - " /verbose on|off — toggle verbose logging", - " /exit — quit the application", + " /ask — submit a question to the network", + " /identity — show node_id and node_secret", + " /profile list — list all profiles", + " /profile create — create a new profile", + " /profile switch — switch active profile", + " /config show — show all config values", + " /config set — change a config value", + " /verbose on|off — toggle verbose logging", + " /version — show version and check for updates", + " /exit — quit the application", ]; } @@ -67,12 +80,12 @@ export function executeCommand(input: string): string[] { if (cmd === "identity") { const cfg = getConfig(); - const id = loadIdentity(cfg.identity_file); + const id = loadIdentity(cfg.node_identity_file); if (!id) return ["No identity found."]; return [ "Identity:", - ` agent_id: ${id.agent_id}`, - ` secret: ${id.secret}`, + ` node_id: ${id.node_id}`, + ` node_secret: ${id.node_secret}`, ]; } @@ -114,11 +127,73 @@ export function executeCommand(input: string): string[] { if (LLM_RESET_KEYS.has(key)) resetLlmClient(); - return [`${key} = ${mask(key, String(value))}`]; + const result: string[] = [`${key} = ${mask(key, String(value))}`]; + + const check = validateConfig(updated as unknown as Record); + if (!check.ok) { + result.push(`⚠ ${check.error}`); + } + + return result; } return [`Usage: /config show | /config set `]; } + if (cmd === "profile") { + const sub = parts[1]?.toLowerCase(); + + if (!sub || sub === "list") { + const profiles = listProfiles(); + if (profiles.length === 0) return ["No profiles configured."]; + const lines: string[] = ["Profiles:"]; + for (const p of profiles) { + const marker = p.active ? " (active)" : ""; + lines.push(` ${p.name}${marker}`); + } + return lines; + } + + if (sub === "create") { + return ["__CREATE_PROFILE__", "Starting profile creation..."]; + } + + if (sub === "switch") { + const name = parts[2]; + if (!name) { + const profiles = listProfiles(); + const lines = ["Usage: /profile switch ", "", "Available profiles:"]; + for (const p of profiles) { + lines.push(` ${p.name}${p.active ? " (active)" : ""}`); + } + return lines; + } + try { + switchProfile(name); + resetLlmClient(); + return [`__SWITCH_PROFILE__:${name}`, `Switched to profile "${name}". Restarting...`]; + } catch (err) { + return [String(err instanceof Error ? err.message : err)]; + } + } + + return [ + "Profile commands:", + " /profile list — list all profiles", + " /profile create — create a new profile", + " /profile switch — switch active profile", + ]; + } + + if (cmd === "version") { + const lines = [`Fortytwo Client v${pkg.version}`]; + const info = getCachedUpdate(); + if (info?.updateAvailable) { + lines.push(`Update available: v${info.latestVersion}`); + lines.push(`Run: ${UPDATE_COMMAND}`); + } + return lines; + } + return [`Unknown command: ${cmd}. Type "/help".`]; } diff --git a/src/config.ts b/src/config.ts index d7028bd..b58d550 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,52 +3,92 @@ import { join } from "node:path"; import { homedir } from "node:os"; export const CONFIG_DIR = join(homedir(), ".fortytwo"); -export const CONFIG_PATH = join(CONFIG_DIR, "config.json"); -export type InferenceType = "openrouter" | "local"; +export type InferenceType = "openrouter" | "self-hosted"; export interface UserConfig { - agent_name: string; - display_name: string; + node_name: string; + node_display_name: string; inference_type: InferenceType; openrouter_api_key: string; - llm_api_base: string; + self_hosted_api_base: string; fortytwo_api_base: string; - identity_file: string; + node_identity_file: string; poll_interval: number; - llm_model: string; + model_name: string; llm_concurrency: number; llm_timeout: number; min_balance: number; - bot_role: string; + node_role: string; answerer_system_prompt: string; } -const DEFAULTS: UserConfig = { - agent_name: "", - display_name: "", +export const DEFAULTS: UserConfig = { + node_name: "", + node_display_name: "", inference_type: "openrouter", openrouter_api_key: "", - llm_api_base: "", + self_hosted_api_base: "", fortytwo_api_base: "https://app.fortytwo.network/api", - identity_file: join(CONFIG_DIR, "identity.json"), + node_identity_file: join(CONFIG_DIR, "identity.json"), poll_interval: 120, - llm_model: "qwen/qwen3.5-35b-a3b", + model_name: "qwen/qwen3.5-35b-a3b", llm_concurrency: 40, llm_timeout: 120, min_balance: 5.0, - bot_role: "JUDGE", + node_role: "JUDGE", answerer_system_prompt: "You are a helpful assistant.", }; +let _configDir: string = CONFIG_DIR; + +export function setConfigDir(dir: string): void { + _configDir = dir; +} + +export function getConfigDir(): string { + return _configDir; +} + +export function getConfigPath(): string { + return join(_configDir, "config.json"); +} + export function configExists(): boolean { - return existsSync(CONFIG_PATH); + return existsSync(getConfigPath()); +} + +/** + * Migrate renamed config fields. Add new entries to FIELD_RENAMES + * whenever a config key is renamed — old configs will auto-migrate. + */ +const FIELD_RENAMES: Record = { + bot_role: "node_role", + agent_name: "node_name", + display_name: "node_display_name", + identity_file: "node_identity_file", + llm_model: "model_name", + llm_api_base: "self_hosted_api_base", +}; + +function migrateFields(raw: Record): Record { + for (const [oldKey, newKey] of Object.entries(FIELD_RENAMES)) { + if (oldKey in raw && !(newKey in raw)) { + raw[newKey] = raw[oldKey]; + delete raw[oldKey]; + } + } + if (raw.inference_type === "local") { + raw.inference_type = "self-hosted"; + } + return raw; } export function loadConfig(): UserConfig { - if (!configExists()) return { ...DEFAULTS }; + const path = getConfigPath(); + if (!existsSync(path)) return { ...DEFAULTS }; try { - const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); + const raw = migrateFields(JSON.parse(readFileSync(path, "utf-8"))); return { ...DEFAULTS, ...raw }; } catch { return { ...DEFAULTS }; @@ -56,8 +96,8 @@ export function loadConfig(): UserConfig { } export function saveConfig(cfg: UserConfig): void { - mkdirSync(CONFIG_DIR, { recursive: true }); - writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)); + mkdirSync(_configDir, { recursive: true }); + writeFileSync(join(_configDir, "config.json"), JSON.stringify(cfg, null, 2)); } // Live config — loaded once, modules import these diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..e008ceb --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,16 @@ +export const COLORS = { + WHITE: "#FFFFFF", + BLUE_FRAME: "#2D2DFF", + BLUE_CONTENT: "#3333E0", + BLUE_UNDERLINE: "#1F1FD6", + GREY_LIGHT: "#989999", + GREY_NEUTRAL: "#878787", + GREY_DARK: "#4E4D4D", + RED: "#F00000", +} as const; + +export const ROLE_OPTIONS = [ + { label: "ANSWERER & JUDGE — both", display: "ANSWERER & JUDGE", value: "ANSWERER_AND_JUDGE" }, + { label: "JUDGE — only judge challenges", display: "JUDGE", value: "JUDGE" }, + { label: "ANSWERER — only answer queries", display: "ANSWERER", value: "ANSWERER" }, +]; diff --git a/src/event-bus.ts b/src/event-bus.ts new file mode 100644 index 0000000..981c3b8 --- /dev/null +++ b/src/event-bus.ts @@ -0,0 +1,351 @@ +import { EventEmitter } from "node:events"; + +// ── Types ──────────────────────────────────────────────────── + +export type BotState = + | "IDLE" + | "AUTHENTICATING" + | "SCANNING" + | "JOINING" + | "THINKING" + | "SUBMITTING" + | "JUDGING" + | "COOLDOWN" + | "PAUSED" + | "ERROR"; + +export type LogLevel = "info" | "success" | "warn" | "error" | "dim" | "api"; + +export interface ViewerLog { + id: string; + time: string; + level: LogLevel; + msg: string; +} + +export interface ViewerTx { + id: string; + amount: string; + transaction_type: string; + description: string; + created_at: string; +} + +export interface JudgeDetail { + challengeId: string; + questionText: string; + answers: { id: string; content: string; nodeId?: string }[]; + comparisons: { a: string; b: string; winner: string }[]; + finalRankings: string[]; + goodAnswers: string[]; + phase: "loading" | "reading_answers" | "comparing" | "ranking_all" | "submitting" | "done"; + currentPairA: string | null; + currentPairB: string | null; + comparisonIndex: number; + totalComparisons: number; + scores: Record; +} + +export interface VisibleQuery { + id: string; + specialization: string; + stake: number; + minRank: number; + answerCount: number; + status: string; + questionText?: string; + errorMsg?: string; +} + +export interface ViewerStats { + answers: number; + judgments: number; + energy: number; + staked: number; + total: number; + weekEarned: number; + lifetimeEarned: number; + lifetimeSpent: number; + cycles: number; + uptime: number; + rank: string; + judgeElo: string; + accuracy: string; + wins: number; + matches: number; + activeQueryId: string | null; + activeQuestionText: string | null; + activeQuestionCat: string | null; + questionsAvailable: number; + cooldownRemaining: number; + thinkingText: string; + answerText: string; + isStreaming: boolean; + tokPerSec: number; + stepDetail: string; + accountInactive: boolean; + answersSubmitted: number; + answersWon: number; + answerWinRate: string; + judgmentsMade: number; + judgmentAccuracy: string; + queriesSubmitted: number; + queriesCompleted: number; + likesGiven: number; + likesReceived: number; + forBalance: string; + intelligenceNormalized: string; + judgingNormalized: string; +} + +export interface ViewerConfig { + nodeId: string; + modelName: string; + inferenceType: string; + provider: string; + cycleIntervalMs: number; + autoRestart: boolean; +} + +export interface ViewerEvent { + type: string; + data: any; +} + +function defaultStats(): ViewerStats { + return { + answers: 0, + judgments: 0, + energy: 0, + staked: 0, + total: 0, + weekEarned: 0, + lifetimeEarned: 0, + lifetimeSpent: 0, + cycles: 0, + uptime: 0, + rank: "—", + judgeElo: "—", + accuracy: "—", + wins: 0, + matches: 0, + activeQueryId: null, + activeQuestionText: null, + activeQuestionCat: null, + questionsAvailable: 0, + cooldownRemaining: 0, + thinkingText: "", + answerText: "", + isStreaming: false, + tokPerSec: 0, + stepDetail: "", + accountInactive: false, + answersSubmitted: 0, + answersWon: 0, + answerWinRate: "0", + judgmentsMade: 0, + judgmentAccuracy: "0", + queriesSubmitted: 0, + queriesCompleted: 0, + likesGiven: 0, + likesReceived: 0, + forBalance: "0", + intelligenceNormalized: "0", + judgingNormalized: "0", + }; +} + +class ViewerEventBus extends EventEmitter { + private _state: BotState = "IDLE"; + private _isPaused = false; + private _isRunning = false; + private _stats: ViewerStats = defaultStats(); + private _logs: ViewerLog[] = []; + private _txs: ViewerTx[] = []; + private _errors: { msg: string; time: string }[] = []; + private _queries: VisibleQuery[] = []; + private _lastJudge: JudgeDetail | null = null; + private _config: ViewerConfig = { + nodeId: "", + modelName: "", + inferenceType: "", + provider: "", + cycleIntervalMs: 120_000, + autoRestart: true, + }; + private _startTime = 0; + private _uptimeInterval: ReturnType | null = null; + + constructor() { + super(); + this.setMaxListeners(50); + } + + get state() { return this._state; } + get isPaused() { return this._isPaused; } + get isRunning() { return this._isRunning; } + get stats() { return { ...this._stats }; } + get recentLogs() { return this._logs.slice(-400); } + get transactions() { return this._txs; } + get lastJudge() { return this._lastJudge; } + get errors() { return this._errors.slice(-50); } + get queries() { return this._queries; } + get config() { return { ...this._config }; } + + getInitSnapshot(): ViewerEvent { + return { + type: "init", + data: { + state: this._state, + isPaused: this._isPaused, + isRunning: this._isRunning, + stats: this.stats, + logs: this.recentLogs, + config: this.config, + transactions: this._txs, + lastJudge: this._lastJudge, + errors: this.errors, + queries: this._queries, + }, + }; + } + + private _emit(evt: ViewerEvent): void { + this.emit("viewer_event", evt); + } + + private _uid(): string { + return Math.random().toString(36).slice(2, 8); + } + + private _ts(): string { + return new Date().toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } + + setState(s: BotState): void { + this._state = s; + if (s !== "COOLDOWN") this._stats.cooldownRemaining = 0; + this._emit({ + type: "state", + data: { state: s, isPaused: this._isPaused }, + }); + this.broadcastStats(); + } + + setRunning(running: boolean): void { + this._isRunning = running; + if (running) { + this._startTime = Date.now(); + this._uptimeInterval = setInterval(() => { + this._stats.uptime++; + this.broadcastStats(); + }, 1000); + } else { + if (this._uptimeInterval) { + clearInterval(this._uptimeInterval); + this._uptimeInterval = null; + } + } + } + + setPaused(paused: boolean): void { + this._isPaused = paused; + this._emit({ + type: "state", + data: { state: this._state, isPaused: paused }, + }); + } + + setStep(detail: string): void { + this._stats.stepDetail = detail; + this.broadcastStats(); + } + + updateStats(partial: Partial): void { + Object.assign(this._stats, partial); + this.broadcastStats(); + } + + broadcastStats(): void { + this._emit({ type: "stats", data: { ...this._stats } }); + } + + pushLog(level: LogLevel, msg: string): void { + const entry: ViewerLog = { + id: this._uid(), + time: this._ts(), + level, + msg, + }; + this._logs.push(entry); + if (this._logs.length > 600) this._logs = this._logs.slice(-400); + this._emit({ type: "log", data: entry }); + } + + pushError(msg: string): void { + const e = { msg, time: this._ts() }; + this._errors.push(e); + if (this._errors.length > 100) this._errors = this._errors.slice(-50); + this._emit({ type: "error_alert", data: e }); + } + + setQueries(queries: VisibleQuery[]): void { + this._queries = queries; + this._emit({ type: "queries", data: queries }); + } + + setTransactions(txs: ViewerTx[], total?: number): void { + this._txs = txs; + this._emit({ + type: "transactions", + data: { transactions: txs, total: total ?? txs.length }, + }); + } + + setJudgeDetail(judge: JudgeDetail | null): void { + this._lastJudge = judge; + this._emit({ type: "judge_detail", data: judge }); + } + + streamStart(): void { + this._stats.isStreaming = true; + this._stats.thinkingText = ""; + this._stats.tokPerSec = 0; + this._emit({ type: "stream_start", data: {} }); + } + + streamChunk(full: string, tps: number): void { + this._stats.thinkingText = full; + this._stats.tokPerSec = tps; + this._emit({ + type: "think_chunk", + data: { full, tps }, + }); + } + + streamEnd(thinkingText: string, answerText: string, tokPerSec: number): void { + this._stats.isStreaming = false; + this._stats.thinkingText = thinkingText; + this._stats.answerText = answerText; + this._stats.tokPerSec = tokPerSec; + this._emit({ + type: "stream_end", + data: { thinkingText, answerText, tokPerSec }, + }); + this.broadcastStats(); + } + + setConfig(cfg: Partial): void { + Object.assign(this._config, cfg); + this._emit({ type: "config_update", data: { ...this._config } }); + } +} + +const g = globalThis as typeof globalThis & { __viewerBus?: ViewerEventBus }; +if (!g.__viewerBus) g.__viewerBus = new ViewerEventBus(); + +export const viewerBus: ViewerEventBus = g.__viewerBus; diff --git a/src/identity.ts b/src/identity.ts index 53d933a..f9d29ad 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -10,8 +10,8 @@ const MAX_TIEBREAK_ATTEMPTS = 5; export type LogFn = (msg: string) => void; export interface Identity { - agent_id: string; - secret: string; + node_id: string; + node_secret: string; public_key_pem?: string; private_key_pem?: string; } @@ -33,7 +33,10 @@ export function loadIdentity(path: string): Identity | null { if (!existsSync(path)) return null; try { const data = JSON.parse(readFileSync(path, "utf-8")); - if (data.agent_id && data.secret) return data as Identity; + + if (data.agent_id && !data.node_id) data.node_id = data.agent_id; + if (data.secret && !data.node_secret) data.node_secret = data.secret; + if (data.node_id && data.node_secret) return data as Identity; return null; } catch { return null; @@ -76,12 +79,12 @@ async function solveChallenges(challenges: Challenge[], log: LogFn): Promise { let attempt = 0; while (true) { attempt++; - log(`Attempt ${attempt} — registering "${displayName}"...`); + log(`Registering "${displayName}"`); + log(`↳ Attempt ${attempt}`); const { privatePem, publicPem } = generateRsaKeypair(); @@ -153,32 +157,34 @@ export async function registerAgent( log(`~Solving: 0/${challenges.length}`); const responses = await solveChallenges(challenges, log); - log(`Submitting answers (need ${requiredCorrect} correct)...`); + log(`↳ Submitting answers (need ${requiredCorrect} correct)...`); const result = await client.completeRegistration(sessionId, responses); if (!result.passed) { const correct = result.correct_count ?? 0; - log(`Failed: ${correct}/${challenges.length} correct (need ${requiredCorrect}). Retrying...`); + log(`✕ Attempt ${attempt}: ${correct}/${challenges.length} correct (need ${requiredCorrect})`); + log(`↳ Retrying in 2s...`); await sleep(2000); continue; } - const agentId = String(result.agent_id); + const nodeId = String(result.agent_id); const correct = result.correct_count ?? challenges.length; - const secret = result.secret as string; + const node_secret = result.secret as string; const identity: Identity = { - agent_id: agentId, - secret, + node_id: nodeId, + node_secret, public_key_pem: publicPem, private_key_pem: privatePem, }; - saveIdentity(config.get().identity_file, identity); - log(`Passed! ${correct}/${challenges.length} correct — Agent ID: ${agentId}`); + saveIdentity(config.get().node_identity_file, identity); + log(`✓ Passed! ${correct}/${challenges.length} correct — Node ID: ${nodeId}`); return identity; } catch (err) { - log(`Attempt ${attempt} error: ${err}. Retrying in 5s...`); + log(`✕ Attempt ${attempt}: ${err}`); + log(`↳ Retrying in 5s...`); await sleep(5000); } } @@ -186,18 +192,18 @@ export async function registerAgent( export async function reactivateAccount( client: FortyTwoClient, - agentId: string, - secret: string, + nodeId: string, + nodeSecret: string, log: LogFn = console.log, ): Promise { let attempt = 0; while (true) { attempt++; - log(`Reactivation attempt ${attempt}...`); + log(`↳ Reactivation attempt ${attempt}`); try { - const challengeData = await client.startReactivation(agentId, secret); + const challengeData = await client.startReactivation(nodeId, nodeSecret); const sessionId = challengeData.challenge_session_id as string; const challenges = challengeData.challenges as Challenge[]; const requiredCorrect = (challengeData.required_correct as number) ?? 17; @@ -205,20 +211,22 @@ export async function reactivateAccount( log(`~Solving: 0/${challenges.length}`); const responses = await solveChallenges(challenges, log); - log(`Submitting answers (need ${requiredCorrect} correct)...`); + log(`↳ Submitting answers (need ${requiredCorrect} correct)...`); const result = await client.completeReactivation(sessionId, responses); if (!result.passed) { const correct = result.correct_count ?? 0; - log(`Failed: ${correct}/${challenges.length} correct. Retrying...`); + log(`✕ Failed: ${correct}/${challenges.length} correct`); + log(`↳ Retrying in 5s...`); await sleep(5000); continue; } - log(`Reactivation successful! (attempt ${attempt})`); + log(`✓ Reactivation successful! (attempt ${attempt})`); return; } catch (err) { - log(`Reactivation attempt ${attempt} error: ${err}. Retrying in 10s...`); + log(`✕ Reactivation attempt ${attempt}: ${err}`); + log(`↳ Retrying in 10s...`); await sleep(10_000); } } @@ -232,7 +240,7 @@ export async function resetAccount( while (true) { attempt++; - log(`Reset attempt ${attempt}...`); + log(`↳ Reset attempt ${attempt}`); try { const challengeData = await client.startAccountReset(); @@ -244,26 +252,29 @@ export async function resetAccount( log(`~Solving: 0/${challenges.length}`); const responses = await solveChallenges(challenges, log); - log(`Submitting answers (need ${requiredCorrect} correct)...`); + log(`↳ Submitting answers (need ${requiredCorrect} correct)...`); const result = await client.completeAccountReset(sessionId, responses); if (!result.passed) { const correct = result.correct_count ?? 0; const waitTime = Math.max(cooldownMinutes * 60 * 1000, 5000); - log(`Failed: ${correct}/${challenges.length} correct. Waiting...`); + log(`✕ Failed: ${correct}/${challenges.length} correct`); + log(`↳ Waiting...`); await sleep(waitTime); continue; } - log(`Reset successful! (attempt ${attempt})`); + log(`✓ Reset successful! (attempt ${attempt})`); return; } catch (err) { const msg = String(err).toLowerCase(); if (msg.includes("cooldown") || msg.includes("limited")) { - log(`Reset attempt ${attempt} hit cooldown. Waiting 10 min...`); + log(`✕ Reset attempt ${attempt} hit cooldown`); + log(`↳ Waiting 10 min...`); await sleep(600_000); } else { - log(`Reset attempt ${attempt} error: ${err}. Retrying in 10s...`); + log(`✕ Reset attempt ${attempt}: ${err}`); + log(`↳ Retrying in 10s...`); await sleep(10_000); } } diff --git a/src/index.tsx b/src/index.tsx index f9a3500..05a5015 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { render } from "ink"; import App from "./app.js"; +import { startViewerServer } from "./viewer-server.js"; const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")); @@ -9,4 +10,15 @@ if (process.argv.includes("--version")) { process.exit(0); } -render(); +const viewer = startViewerServer(4242); + +console.clear(); +const { unmount } = render(); + +const shutdown = () => { + viewer.close(); + unmount(); + process.exit(0); +}; +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/src/judging.ts b/src/judging.ts index 0fb9bc7..5dffa21 100644 --- a/src/judging.ts +++ b/src/judging.ts @@ -2,6 +2,7 @@ import * as config from "./config.js"; import { log, mapWithConcurrency, pinTask, unpinTask } from "./utils.js"; import { FortyTwoClient } from "./api-client.js"; import * as llm from "./llm.js"; +import { viewerBus, type JudgeDetail } from "./event-bus.js"; const RANKING_LLM_RETRIES = 0; @@ -102,7 +103,7 @@ export async function judgeChallenge( const estimatedTime = estimateLlmTime(answerCountHint); const estimatedTotal = estimatedTime + 30; if (estimatedTotal > remainingSeconds) { - log(`[${tag}] Time budget exceeded: need ~${Math.round(estimatedTotal)}s but only ${Math.round(remainingSeconds)}s remaining. Skipping.`); + log(`[${tag}] ↳ Time budget exceeded: need ~${Math.round(estimatedTotal)}s but only ${Math.round(remainingSeconds)}s left`); return; } } @@ -110,15 +111,15 @@ export async function judgeChallenge( // Step 1: Join the challenge try { const joinResult = await client.joinChallenge(challengeId); - log(`[${tag}] Joined, stake: ${joinResult.stake_amount ?? "?"} FOR`); + log(`[${tag}] ✓ Joined, stake: ${joinResult.stake_amount ?? "?"} FOR`); } catch (err) { const msg = String(err).toLowerCase(); if (msg.includes("maximum") || msg.includes("full") || msg.includes("participants")) { - log(`[${tag}] Challenge full, skipping`); + log(`[${tag}] ↳ Challenge full, skipping`); return; } if (msg.includes("already")) { - log(`[${tag}] Already joined, proceeding`); + log(`[${tag}] ↳ Already joined, proceeding`); } else { throw err; } @@ -134,7 +135,27 @@ export async function judgeChallenge( const answers = (answersResp.answers ?? []) as Answer[]; if (answers.length === 0) throw new Error(`No answers for challenge ${challengeId}`); - log(`[${tag}] Got ${answers.length} answers, evaluating quality...`); + const judgeDetail: JudgeDetail = { + challengeId, + questionText: problem, + answers: answers.map((a) => ({ + id: a.id, + content: a.decrypted_content ?? "", + nodeId: a.agent_id as string | undefined, + })), + comparisons: [], + finalRankings: [], + goodAnswers: [], + phase: "reading_answers", + currentPairA: null, + currentPairB: null, + comparisonIndex: 0, + totalComparisons: 0, + scores: {}, + }; + viewerBus.setJudgeDetail(judgeDetail); + + log(`[${tag}] ↳ Got ${answers.length} answers, evaluating quality...`); pinTask(challengeId, `Judging ${tag}`); try { @@ -153,7 +174,7 @@ export async function judgeChallenge( const isGood = await llm.evaluateGoodEnough(problem, content, RANKING_LLM_RETRIES, AbortSignal.timeout(60_000)); evalDone++; const verdict = isGood ? "good" : "bad"; - log(`[${tag}] Eval ${evalDone}/${evalTotal} → ${verdict}`); + log(`[${tag}] ↳ Eval ${evalDone}/${evalTotal} → ${verdict}`); return [answer, isGood]; }, ); @@ -163,7 +184,8 @@ export async function judgeChallenge( else badAnswers.push(answer); } - log(`[${tag}] Quality: ${goodAnswers.length} good, ${badAnswers.length} bad`); + log(`[${tag}] ✓ Quality: ${goodAnswers.length} good, ${badAnswers.length} bad`); + judgeDetail.goodAnswers = goodAnswers.map((a) => a.id); let answerRankings: string[]; let goodAnswerIds: string[]; @@ -179,7 +201,10 @@ export async function judgeChallenge( const wins: number[][] = Array.from({ length: n }, () => new Array(n).fill(0)); const pairs = buildPairwisePairs(n); - log(`[${tag}] Running ${pairs.length} pairwise comparisons...`); + log(`[${tag}] ↳ Running ${pairs.length} pairwise comparisons...`); + judgeDetail.phase = "comparing"; + judgeDetail.totalComparisons = pairs.length; + viewerBus.setJudgeDetail(judgeDetail); let cmpDone = 0; const cmpTotal = pairs.length; @@ -196,7 +221,16 @@ export async function judgeChallenge( ); cmpDone++; const winner = result === "A" ? `#${aIdx + 1} wins` : result === "B" ? `#${bIdx + 1} wins` : result === "U" ? "tie" : "skip"; - log(`[${tag}] Compare ${cmpDone}/${cmpTotal} (#${aIdx + 1} vs #${bIdx + 1}) → ${winner}`); + log(`[${tag}] ↳ Compare ${cmpDone}/${cmpTotal} (#${aIdx + 1} vs #${bIdx + 1}) → ${winner}`); + judgeDetail.comparisons.push({ + a: goodAnswers[aIdx].id, + b: goodAnswers[bIdx].id, + winner: result ?? "U", + }); + judgeDetail.comparisonIndex = cmpDone; + judgeDetail.currentPairA = goodAnswers[aIdx].id; + judgeDetail.currentPairB = goodAnswers[bIdx].id; + viewerBus.setJudgeDetail(judgeDetail); return [aIdx, bIdx, result]; }, ); @@ -212,13 +246,20 @@ export async function judgeChallenge( } // Step 6: Run local Bradley-Terry + judgeDetail.phase = "ranking_all"; + viewerBus.setJudgeDetail(judgeDetail); + const strengths = computeBradleyTerry(wins); const indexed = strengths .map((s, i) => ({ idx: i, strength: s })) .sort((a, b) => b.strength - a.strength); const ranking = indexed.map((x) => `#${x.idx + 1}:${x.strength.toFixed(2)}`).join(" > "); - log(`[${tag}] BT ranking: ${ranking}`); + log(`[${tag}] ✓ BT ranking: ${ranking}`); + + for (const x of indexed) { + judgeDetail.scores[goodAnswers[x.idx].id] = x.strength; + } const rankedGoodIds = indexed.map((x) => goodAnswers[x.idx].id); const rankedBadIds = badAnswers.map((a) => a.id); @@ -227,9 +268,18 @@ export async function judgeChallenge( } // Step 7: Submit vote - log(`[${tag}] Submitting vote with ${answerRankings.length} ranked answers...`); + judgeDetail.phase = "submitting"; + judgeDetail.finalRankings = answerRankings; + judgeDetail.goodAnswers = goodAnswerIds; + viewerBus.setJudgeDetail(judgeDetail); + + log(`[${tag}] ↳ Submitting vote with ${answerRankings.length} ranked answers...`); const voteResult = await client.submitVote(challengeId, answerRankings, goodAnswerIds); - log(`[${tag}] Vote submitted! vote_id=${voteResult.vote_id ?? "?"}`); + log(`[${tag}] ✓ Vote submitted! vote_id=${voteResult.vote_id ?? "?"}`); + + judgeDetail.phase = "done"; + viewerBus.setJudgeDetail(judgeDetail); + viewerBus.updateStats({ judgments: (viewerBus.stats.judgments || 0) + 1 }); } finally { unpinTask(challengeId); } diff --git a/src/llm.ts b/src/llm.ts index 3c61204..42e4fae 100644 --- a/src/llm.ts +++ b/src/llm.ts @@ -1,7 +1,17 @@ -import OpenAI, { APIConnectionError, APIConnectionTimeoutError, NotFoundError } from "openai"; +import OpenAI, { + APIConnectionError, + APIConnectionTimeoutError, + APIError, + AuthenticationError, + BadRequestError, + NotFoundError, + PermissionDeniedError, + RateLimitError, +} from "openai"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; import * as config from "./config.js"; import { parseLastLetter, verbose } from "./utils.js"; +import { viewerBus } from "./event-bus.js"; const OPENROUTER_BASE = "https://openrouter.ai/api/v1"; @@ -49,7 +59,7 @@ let semaphore: Semaphore | null = null; function getSemaphore(): Semaphore { const cfg = config.get(); if (!semaphore) { - const max = cfg.inference_type === "local" + const max = cfg.inference_type === "self-hosted" ? Math.max(1, Math.floor(cfg.llm_concurrency / 5)) : cfg.llm_concurrency; semaphore = new Semaphore(max); @@ -68,7 +78,7 @@ export function getLlmConcurrency(): { active: number; max: number } { export function resetLlmClient(): void { openaiClient = null; - semaphore = new Semaphore(config.get().llm_concurrency); + semaphore = null; } type LlmPurpose = "ranking" | "generation" | "registration" | "other"; @@ -122,9 +132,9 @@ let openaiClient: OpenAI | null = null; function getClient(): OpenAI { if (openaiClient) return openaiClient; const cfg = config.get(); - const isLocal = cfg.inference_type === "local"; + const isLocal = cfg.inference_type === "self-hosted"; openaiClient = new OpenAI({ - baseURL: isLocal ? cfg.llm_api_base.replace(/\/+$/, "") : OPENROUTER_BASE, + baseURL: isLocal ? cfg.self_hosted_api_base.replace(/\/+$/, "") : OPENROUTER_BASE, apiKey: isLocal ? "EMPTY" : cfg.openrouter_api_key, timeout: cfg.llm_timeout * 1000, maxRetries: 2, @@ -137,6 +147,50 @@ function getClient(): OpenAI { return openaiClient; } +function mapLlmError(err: unknown): Error { + const cfg = config.get(); + const isLocal = cfg.inference_type === "self-hosted"; + + if (isLocal && cfg.self_hosted_api_base) { + const base = cfg.self_hosted_api_base; + if (err instanceof APIConnectionTimeoutError) { + return new Error(`Local LLM at ${base} timed out — is the model loaded? Check your inference server.`); + } + if (err instanceof APIConnectionError) { + return new Error(`Cannot connect to local LLM at ${base} — is the server running? Start LM Studio / Ollama / vLLM and try again.`); + } + if (err instanceof NotFoundError) { + return new Error(`Model "${cfg.model_name}" not found at ${base} — load the model in your inference server first.`); + } + } + + if (!isLocal) { + if (err instanceof RateLimitError) { + return new Error(`OpenRouter rate limit exceeded — too many requests. Wait a moment and try again, or reduce llm_concurrency.`); + } + if (err instanceof AuthenticationError) { + return new Error(`OpenRouter authentication failed — your API key is invalid or expired. Update it with /config set openrouter_api_key .`); + } + if (err instanceof PermissionDeniedError) { + return new Error(`OpenRouter rejected the request — your input was flagged by moderation for model "${cfg.model_name}".`); + } + if (err instanceof BadRequestError) { + return new Error(`OpenRouter bad request — check your model name "${cfg.model_name}" or request parameters.`); + } + if (err instanceof APIError && err.status === 402) { + return new Error(`OpenRouter credits exhausted — add funds at openrouter.ai or switch to a free model.`); + } + if (err instanceof APIError && (err.status === 502 || err.status === 503)) { + return new Error(`OpenRouter: model "${cfg.model_name}" is temporarily unavailable — try again later or switch to another model.`); + } + if (err instanceof APIConnectionTimeoutError) { + return new Error(`OpenRouter request timed out — the model may be overloaded. Try again or increase llm_timeout.`); + } + } + + return err instanceof Error ? err : new Error(String(err)); +} + async function callLlmApi( messages: ChatCompletionMessageParam[], retries = 2, @@ -145,7 +199,7 @@ async function callLlmApi( purpose: LlmPurpose = "other", ): Promise { const cfg = config.get(); - const isLocal = cfg.inference_type === "local"; + const isLocal = cfg.inference_type === "self-hosted"; if (!isLocal && !cfg.openrouter_api_key) { throw new Error("OPENROUTER_API_KEY is not set"); @@ -159,11 +213,11 @@ async function callLlmApi( try { if (signal?.aborted) throw new Error("LLM call aborted"); - verbose(`→ model=${cfg.llm_model} msgs=${messages.length} temp=${temperature}`); + verbose(`→ model=${cfg.model_name} msgs=${messages.length} temp=${temperature}`); const resp = await client.chat.completions.create( { - model: cfg.llm_model, + model: cfg.model_name, messages, temperature, }, @@ -174,32 +228,14 @@ async function callLlmApi( ); const content = (resp.choices[0].message.content ?? "").trim(); - verbose(`← ${cfg.llm_model} (${Date.now() - start}ms) ${content.slice(0, 100)}${content.length > 100 ? "..." : ""}`); + verbose(`← ${cfg.model_name} (${Date.now() - start}ms) ${content.slice(0, 100)}${content.length > 100 ? "..." : ""}`); recordSuccess(purpose, Date.now() - start); return content; } catch (err) { verbose(`✗ failed after ${Date.now() - start}ms: ${err}`); recordError(purpose); if (signal?.aborted) throw new Error("LLM call aborted"); - if (isLocal && cfg.llm_api_base) { - const base = cfg.llm_api_base; - if (err instanceof APIConnectionTimeoutError) { - throw new Error( - `Local LLM at ${base} timed out — is the model loaded? Check your inference server.`, - ); - } - if (err instanceof APIConnectionError) { - throw new Error( - `Cannot connect to local LLM at ${base} — is the server running? Start LM Studio / Ollama / vLLM and try again.`, - ); - } - if (err instanceof NotFoundError) { - throw new Error( - `Model "${cfg.llm_model}" not found at ${base} — load the model in your inference server first.`, - ); - } - } - throw err; + throw mapLlmError(err); } finally { sem.release(); } @@ -243,7 +279,8 @@ export async function compareForRegistration( return 0; } } - } catch { + } catch (err) { + verbose(`✗ Registration comparison failed: ${err}`); return 0; } @@ -318,14 +355,72 @@ export async function generateAnswer( retries = 2, signal?: AbortSignal, ): Promise { - return callLlmApi( - [ - { role: "system", content: systemPrompt }, - { role: "user", content: problem }, - ], - retries, - 0.7, - signal, - "generation", - ); + const cfg = config.get(); + const client = getClient(); + const sem = getSemaphore(); + + await sem.acquire(); + const start = Date.now(); + viewerBus.streamStart(); + + try { + if (signal?.aborted) throw new Error("LLM call aborted"); + + verbose(`→ [stream] model=${cfg.model_name} temp=0.7`); + + const stream = await client.chat.completions.create( + { + model: cfg.model_name, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: problem }, + ], + temperature: 0.7, + stream: true, + }, + { + signal: signal ?? undefined, + maxRetries: retries, + }, + ); + + let fullText = ""; + let tokenCount = 0; + + for await (const chunk of stream) { + if (signal?.aborted) break; + const delta = chunk.choices[0]?.delta?.content ?? ""; + if (delta) { + fullText += delta; + tokenCount++; + const elapsed = (Date.now() - start) / 1000; + const tps = elapsed > 0 ? tokenCount / elapsed : 0; + viewerBus.streamChunk(fullText, Math.round(tps * 10) / 10); + } + } + + const content = fullText.trim(); + const elapsed = (Date.now() - start) / 1000; + const finalTps = elapsed > 0 ? tokenCount / elapsed : 0; + const roundedTps = Math.round(finalTps * 10) / 10; + + verbose(`← [stream] ${cfg.model_name} (${Date.now() - start}ms) ${content.slice(0, 100)}${content.length > 100 ? "..." : ""}`); + recordSuccess("generation", Date.now() - start); + + const thinkMatch = content.match(/^([\s\S]*?)<\/think>\s*([\s\S]*)$/); + const thinkingText = thinkMatch ? thinkMatch[1].trim() : content; + const answerText = thinkMatch ? thinkMatch[2].trim() : content; + + viewerBus.streamEnd(thinkingText, answerText, roundedTps); + + return answerText || content; + } catch (err) { + verbose(`✗ [stream] failed after ${Date.now() - start}ms: ${err}`); + recordError("generation"); + viewerBus.streamEnd("", "", 0); + if (signal?.aborted) throw new Error("LLM call aborted"); + throw mapLlmError(err); + } finally { + sem.release(); + } } diff --git a/src/main.ts b/src/main.ts index beb3fe4..50016a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,59 @@ import { createHash } from "node:crypto"; import * as config from "./config.js"; -import { sleep, secondsUntilDeadline, setVerbose, log } from "./utils.js"; +import { sleep, secondsUntilDeadline, setVerbose, log, getRoleLabel } from "./utils.js"; import { FortyTwoClient } from "./api-client.js"; import { loadIdentity, resetAccount, reactivateAccount } from "./identity.js"; import { judgeChallenge } from "./judging.js"; import { answerQuery } from "./answering.js"; import { isLlmBusy } from "./llm.js"; import { validateModel } from "./setup-logic.js"; +import { viewerBus, type VisibleQuery } from "./event-bus.js"; + +/** Initialize viewer dashboard config and load initial stats from API. */ +export async function initViewerBus( + client: FortyTwoClient, + cfg: ReturnType, + nodeId: string, +): Promise { + viewerBus.setConfig({ + nodeId, + modelName: cfg.model_name, + inferenceType: cfg.inference_type, + provider: cfg.inference_type === "self-hosted" + ? cfg.self_hosted_api_base.replace(/^https?:\/\//, "").replace(/\/.*$/, "") + : "OpenRouter", + cycleIntervalMs: cfg.poll_interval * 1000, + autoRestart: true, + }); + viewerBus.setRunning(true); + + try { + const [rawStats, agentData] = await Promise.all([ + client.getAgentStats().catch(() => null), + client.getAgent().catch(() => null), + ]); + if (rawStats) { + viewerBus.updateStats({ + answersSubmitted: rawStats.answers_submitted ?? 0, + answersWon: rawStats.answers_won ?? 0, + answerWinRate: String(rawStats.answer_win_rate ?? "0"), + judgmentsMade: rawStats.judgments_made ?? 0, + judgmentAccuracy: String(rawStats.judgment_accuracy ?? "0"), + queriesSubmitted: rawStats.queries_submitted ?? 0, + queriesCompleted: rawStats.queries_completed ?? 0, + }); + } + if (agentData) { + const p = agentData.profile ?? agentData; + viewerBus.updateStats({ + rank: String(p.intelligence_score ?? p.intellect_score ?? "—"), + judgeElo: String(p.judging_score ?? p.judge_score ?? "—"), + intelligenceNormalized: String(p.intelligence_normalized ?? "0"), + judgingNormalized: String(p.judging_normalized ?? "0"), + }); + } + } catch { /* stats are optional — don't block startup */ } +} export class InsufficientFundsError extends Error { constructor(message: string) { @@ -15,8 +62,8 @@ export class InsufficientFundsError extends Error { } } -function shouldAnswer(queryId: string, agentId: string): boolean { - const hash = createHash("sha256").update(queryId + agentId).digest("hex"); +function shouldAnswer(queryId: string, nodeId: string): boolean { + const hash = createHash("sha256").update(queryId + nodeId).digest("hex"); return parseInt(hash.slice(-8), 16) % 2 === 0; } @@ -37,6 +84,20 @@ export async function checkBalance(client: FortyTwoClient): Promise { try { const balanceData = await client.getBalance(); const available = parseFloat(balanceData.available ?? "0"); + const staked = parseFloat(balanceData.staked ?? "0"); + const total = parseFloat(balanceData.total ?? "0"); + const weekEarned = parseFloat(balanceData.current_week_earned ?? "0"); + const lifetimeEarned = parseFloat(balanceData.lifetime_earned ?? "0"); + const lifetimeSpent = parseFloat(balanceData.lifetime_spent ?? "0"); + viewerBus.updateStats({ + energy: available, + staked, + total, + weekEarned, + lifetimeEarned, + lifetimeSpent, + forBalance: available.toFixed(2), + }); return available; } catch (err) { log(`Failed to check balance: ${err}`); @@ -66,6 +127,7 @@ function launchTask(id: string, label: string, fn: () => Promise): void { } export async function processChallenges(client: FortyTwoClient, dualMode = false): Promise { + viewerBus.setState("JUDGING"); if (isLlmBusy()) { log(`LLM queue busy, skipping challenge pickup`); return 0; @@ -83,17 +145,17 @@ export async function processChallenges(client: FortyTwoClient, dualMode = false if (ch.has_voted) return false; if (inFlight.has(String(ch.id))) return false; const queryId = String(ch.query_id ?? ""); - if (dualMode && queryId && shouldAnswer(queryId, client.agentId)) return false; + if (dualMode && queryId && shouldAnswer(queryId, client.nodeId)) return false; const effectiveDeadline = (ch.effective_voting_deadline ?? ch.judging_deadline_at ?? "") as string; const remaining = secondsUntilDeadline(effectiveDeadline); if (remaining > 0 && remaining < config.MIN_DEADLINE_SECONDS) { - log(`[${String(ch.id).slice(0, 8)}] Skipping: only ${Math.round(remaining)}s until deadline`); + log(`[${String(ch.id).slice(0, 8)}] ↳ Skipping: only ${Math.round(remaining)}s until deadline`); return false; } return true; }); - log(`Found ${challenges.length} pending challenges (${eligible.length} eligible, ${inFlight.size} in-flight)`); + log(`↳ Found ${challenges.length} pending challenges (${eligible.length} eligible, ${inFlight.size} in-flight)`); for (const ch of eligible) { const challengeId = String(ch.id); @@ -109,9 +171,29 @@ export async function processChallenges(client: FortyTwoClient, dualMode = false } export async function processQueries(client: FortyTwoClient, dualMode = false): Promise { + viewerBus.setState("SCANNING"); const resp = await client.getActiveQueries(1, 50); const queries = (resp.queries ?? []) as Record[]; + const visibleQueries: VisibleQuery[] = queries.map((q) => { + const queryId = String(q.id); + const isInFlight = inFlight.has(queryId); + let status = "available"; + if (isInFlight) status = "active"; + else if (q.has_answered) status = "answered"; + return { + id: queryId, + specialization: String(q.specialization ?? "general"), + stake: parseFloat(q.stake_amount ?? "0"), + minRank: parseFloat(q.min_intelligence_rank ?? "0"), + answerCount: (q.answer_count ?? 0) as number, + status, + questionText: q.decrypted_content as string | undefined, + }; + }); + viewerBus.setQueries(visibleQueries); + viewerBus.updateStats({ questionsAvailable: queries.length }); + if (queries.length === 0) { log("No active queries available"); return 0; @@ -120,7 +202,7 @@ export async function processQueries(client: FortyTwoClient, dualMode = false): const eligible = queries.filter((q) => { const queryId = String(q.id); if (inFlight.has(queryId)) return false; - if (dualMode && !shouldAnswer(queryId, client.agentId)) return false; + if (dualMode && !shouldAnswer(queryId, client.nodeId)) return false; const createdAtStr = (q.created_at ?? "") as string; const decisionDeadlineStr = (q.decision_deadline_at ?? "") as string; const answerRemaining = answerRemainingSeconds(createdAtStr, decisionDeadlineStr); @@ -128,7 +210,7 @@ export async function processQueries(client: FortyTwoClient, dualMode = false): return true; }); - log(`Found ${queries.length} active queries (${eligible.length} eligible, ${inFlight.size} in-flight)`); + log(`↳ Found ${queries.length} active queries (${eligible.length} eligible, ${inFlight.size} in-flight)`); for (const q of eligible) { launchTask(String(q.id), "answering", () => @@ -141,7 +223,7 @@ export async function processQueries(client: FortyTwoClient, dualMode = false): export async function runCycle(client: FortyTwoClient): Promise { const cfg = config.get(); - const role = cfg.bot_role; + const role = cfg.node_role; let total = 0; if (role === "JUDGE") { @@ -155,7 +237,7 @@ export async function runCycle(client: FortyTwoClient): Promise { ]); total += q + c; } else { - log(`Unknown BOT_ROLE: ${role}`); + log(`Unknown NODE_ROLE: ${role}`); } return total; @@ -167,60 +249,66 @@ export async function main(signal?: AbortSignal): Promise { setVerbose(true); } - if (cfg.inference_type !== "local" && !cfg.openrouter_api_key) { + if (cfg.inference_type !== "self-hosted" && !cfg.openrouter_api_key) { log("OPENROUTER_API_KEY not set. Run onboarding first."); process.exit(1); } - const role = cfg.bot_role; + const role = cfg.node_role; if (!["JUDGE", "ANSWERER", "ANSWERER_AND_JUDGE"].includes(role)) { - log(`Invalid BOT_ROLE: ${role}`); + log(`Invalid NODE_ROLE: ${role}`); process.exit(1); } - log(`Bot role: ${role}`); + log(`↳ Node role: ${getRoleLabel(role)}`); const validation = await validateModel({ inference_type: cfg.inference_type, - llm_api_base: cfg.llm_api_base, - openrouter_api_key: cfg.openrouter_api_key, - llm_model: cfg.llm_model, + self_hosted_api_base: cfg.self_hosted_api_base, + fortytwo_api_base: cfg.fortytwo_api_base, + model_name: cfg.model_name, }); if (!validation.ok) { log(`Model check failed: ${validation.error}`); process.exit(1); } - log(`Model OK: ${cfg.llm_model}`); + log(`✓ Model OK: ${cfg.model_name}`); const client = new FortyTwoClient(); try { - const identity = loadIdentity(cfg.identity_file); + const identity = loadIdentity(cfg.node_identity_file); if (!identity) { log("No identity found. Run onboarding first."); process.exit(1); } - await client.login(identity.agent_id, identity.secret); + viewerBus.setState("AUTHENTICATING"); + await client.login(identity.node_id, identity.node_secret); + await initViewerBus(client, cfg, identity.node_id); - log(`Starting polling loop (interval: ${cfg.poll_interval}s)`); + log(`✓ Starting polling loop (interval: ${cfg.poll_interval}s)`); + let cycles = 0; while (!signal?.aborted) { const cycleStart = Date.now(); try { const available = await checkBalance(client); if (available < cfg.min_balance) { throw new InsufficientFundsError( - `Balance ${available.toFixed(2)} FOR is below minimum ${cfg.min_balance.toFixed(2)} FOR`, + `Insufficient FOR balance: ${available.toFixed(2)} available, ${cfg.min_balance.toFixed(2)} required`, ); } const count = await runCycle(client); + cycles++; + viewerBus.updateStats({ cycles }); if (count > 0) log(`Processed ${count} items this cycle`); } catch (err) { if (signal?.aborted) return; if (err instanceof InsufficientFundsError) { log(`${err.message} — resetting account...`); + viewerBus.pushError(err.message); await resetAccount(client); log("Account reset complete!"); continue; @@ -228,19 +316,29 @@ export async function main(signal?: AbortSignal): Promise { const errMsg = (err as Error).message ?? String(err); if (errMsg.toLowerCase().includes("inactive") || errMsg.toLowerCase().includes("deactivated")) { log(`Account deactivated — reactivating...`); - await reactivateAccount(client, identity.agent_id, identity.secret); - await client.login(identity.agent_id, identity.secret); + viewerBus.updateStats({ accountInactive: true }); + await reactivateAccount(client, identity.node_id, identity.node_secret); + await client.login(identity.node_id, identity.node_secret); + viewerBus.updateStats({ accountInactive: false }); log("Reactivation complete!"); continue; } log(`Error in polling cycle: ${errMsg}`); + viewerBus.pushError(errMsg); } if (signal?.aborted) return; + viewerBus.setState("COOLDOWN"); const elapsed = Date.now() - cycleStart; const delay = cfg.poll_interval * 1000 - elapsed; if (delay > 0) { - await sleep(delay, signal); + const totalSec = Math.round(delay / 1000); + for (let rem = totalSec; rem > 0; rem--) { + viewerBus.updateStats({ cooldownRemaining: rem }); + await sleep(1000, signal); + if (signal?.aborted) return; + } + viewerBus.updateStats({ cooldownRemaining: 0 }); } else { log(`Cycle took ${Math.round(elapsed / 1000)}s (> ${cfg.poll_interval}s), starting next immediately`); } @@ -248,6 +346,10 @@ export async function main(signal?: AbortSignal): Promise { } catch (err) { if ((err as Error).name !== "AbortError") { log(`Fatal error: ${err}`); + viewerBus.setState("ERROR"); + viewerBus.pushError(String(err)); } + } finally { + viewerBus.setRunning(false); } } diff --git a/src/onboard.tsx b/src/onboard.tsx index 5e28ba9..6061ba5 100644 --- a/src/onboard.tsx +++ b/src/onboard.tsx @@ -1,29 +1,29 @@ import { useState, useEffect } from "react"; import { Box, Text, useInput } from "ink"; -import { TextInput, Select } from "@inkjs/ui"; +import { TextInput, Select, ThemeProvider, extendTheme, defaultTheme } from "@inkjs/ui"; import { - saveConfig, reloadConfig, get as getConfig, type InferenceType, } from "./config.js"; import { FortyTwoClient } from "./api-client.js"; -import { registerAgent, saveIdentity } from "./identity.js"; -import { validateModel, fetchModels, buildConfig } from "./setup-logic.js"; +import { registerAgent } from "./identity.js"; +import { validateConfig, validateModel, fetchModels, buildConfig } from "./setup-logic.js"; +import { createProfile, sanitizeProfileName } from "./profiles.js"; import { useLoader } from "./loader.js"; - -const COLOR = "rgb(42, 42, 242)"; +import { COLORS, ROLE_OPTIONS } from "./constants.js"; +import { getRoleLabel } from "./utils.js"; type StepId = | "setup_mode" - | "agent_name" - | "agent_id" - | "agent_secret" + | "node_name" + | "node_id" + | "node_secret" | "inference_type" | "openrouter_api_key" - | "llm_api_base" - | "llm_model" - | "bot_role"; + | "self_hosted_api_base" + | "model_name" + | "node_role"; interface StepDef { id: StepId; @@ -34,20 +34,28 @@ interface StepDef { options?: { label: string; value: string }[]; } +const selectTheme = extendTheme(defaultTheme, { + components: { + Select: { + styles: { + focusIndicator: () => ({ color: COLORS.WHITE }), + label: ({ isFocused }: { isFocused: boolean }) => ({ + color: isFocused ? COLORS.BLUE_CONTENT : undefined, + }), + selectedIndicator: () => ({ display: "none" as const }), + }, + }, + }, +}); + const SETUP_MODE_OPTIONS = [ - { label: "Register new agent", value: "new" }, - { label: "Import existing agent", value: "import" }, + { label: "Register new node", value: "new" }, + { label: "Import existing node", value: "import" }, ]; const INFERENCE_OPTIONS = [ { label: "OpenRouter", value: "openrouter" }, - { label: "Local inference", value: "local" }, -]; - -const ROLE_OPTIONS = [ - { label: "ANSWERER_AND_JUDGE — both", value: "ANSWERER_AND_JUDGE" }, - { label: "JUDGE — only judge challenges", value: "JUDGE" }, - { label: "ANSWERER — only answer queries", value: "ANSWERER" }, + { label: "Self-hosted inference", value: "self-hosted" }, ]; function buildSteps(inferenceType?: InferenceType, setupMode?: string): StepDef[] { @@ -57,36 +65,37 @@ function buildSteps(inferenceType?: InferenceType, setupMode?: string): StepDef[ if (setupMode === "import") { steps.push( - { id: "agent_id", label: "Agent ID", type: "text", placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }, - { id: "agent_secret", label: "Agent Secret", type: "text", placeholder: "your-secret", mask: true }, + { id: "node_id", label: "Node ID", type: "text", placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }, + { id: "node_secret", label: "Node Secret", type: "text", placeholder: "your-secret", mask: true }, ); } else { - steps.push({ id: "agent_name", label: "Agent Name", type: "text", placeholder: "JudgeBot" }); + steps.push({ id: "node_name", label: "Node Name", type: "text", placeholder: "JudgeNode" }); } steps.push({ id: "inference_type", label: "Inference Provider", type: "select", options: INFERENCE_OPTIONS }); - if (inferenceType === "local") { + if (inferenceType === "self-hosted") { steps.push( - { id: "llm_api_base", label: "Local API Base URL", type: "text", placeholder: "http://localhost:11434/v1" }, - { id: "llm_model", label: "Model Name", type: "text", placeholder: "llama3" }, + { id: "self_hosted_api_base", label: "Local API Base URL", type: "text", placeholder: "http://localhost:11434/v1" }, + { id: "model_name", label: "Model Name", type: "text", placeholder: "llama3" }, ); } else if (inferenceType === "openrouter") { steps.push( { id: "openrouter_api_key", label: "OpenRouter API Key", type: "text", placeholder: "sk-or-...", mask: true }, - { id: "llm_model", label: "Model Name", type: "text", placeholder: "qwen/qwen3.5-35b-a3b" }, + { id: "model_name", label: "Model Name", type: "text", placeholder: "qwen/qwen3.5-35b-a3b" }, ); } - steps.push({ id: "bot_role", label: "Bot Role", type: "select", options: ROLE_OPTIONS }); + steps.push({ id: "node_role", label: "Node Role", type: "select", options: ROLE_OPTIONS }); return steps; } function displayValue(key: string, value: string): string { - if (key === "openrouter_api_key" || key === "agent_secret") return "***"; + if (key === "openrouter_api_key" || key === "node_secret") return "***"; if (key === "setup_mode") return value === "import" ? "Import existing" : "Register new"; - if (key === "inference_type") return value === "local" ? "Local inference" : "OpenRouter"; + if (key === "inference_type") return value === "self-hosted" ? "Self-hosted inference" : "OpenRouter"; + if (key === "node_role") return getRoleLabel(value, "onboard"); return value; } @@ -95,9 +104,10 @@ type Phase = "input" | "validating" | "validating_creds" | "fetching_models" | " interface OnboardProps { onDone: () => void; skipToRegistration?: boolean; + onCancel?: () => void; } -export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { +export default function Onboard({ onDone, skipToRegistration, onCancel }: OnboardProps) { const [stepIdx, setStepIdx] = useState(0); const [values, setValues] = useState>({}); const [inferenceType, setInferenceType] = useState(); @@ -108,51 +118,49 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { const [regError, setRegError] = useState(null); const [availableModels, setAvailableModels] = useState([]); const [modelFilter, setModelFilter] = useState(""); - const [highlightIdx, setHighlightIdx] = useState(-1); + const [backFocused, setBackFocused] = useState(false); const isLoading = phase !== "input"; const loader = useLoader(isLoading); const steps = buildSteps(inferenceType, setupMode); const step = steps[stepIdx]; + const canGoBack = stepIdx > 0; + + function goBack() { + if (!canGoBack) return; + const prevStep = steps[stepIdx - 1]; + setValidationError(null); + setModelFilter(prevStep.id === "model_name" ? (values["model_name"] ?? "") : ""); + setBackFocused(false); + setStepIdx(stepIdx - 1); + } + + useInput((_input, key) => { + if (phase !== "input" || !canGoBack) return; + // Only handle for TextInput steps — Select steps use "← Back" option + if (step?.type === "select") return; + + if (key.downArrow && !backFocused) { + setBackFocused(true); + } + if (key.upArrow && backFocused) { + setBackFocused(false); + } + if (key.return && backFocused) { + setBackFocused(false); + goBack(); + } + }, { isActive: true }); + const isLast = stepIdx === steps.length - 1; // Autocomplete: filtered models for current query const modelQuery = modelFilter.toLowerCase(); - const filteredModels = step?.id === "llm_model" && modelQuery && availableModels.length > 0 - ? availableModels.filter((m) => m.toLowerCase().includes(modelQuery)) + const filteredModels = step?.id === "model_name" && modelQuery && availableModels.length > 0 + ? availableModels.filter(m => m.toLowerCase().includes(modelQuery.toLowerCase())) : []; - const isModelAutocomplete = phase === "input" && step?.id === "llm_model" && availableModels.length > 0; - - useInput((input, key) => { - if (key.downArrow) { - setHighlightIdx((prev) => Math.min(prev + 1, filteredModels.length - 1)); - } else if (key.upArrow) { - setHighlightIdx((prev) => Math.max(prev - 1, -1)); - } else if (key.return) { - if (highlightIdx >= 0 && highlightIdx < filteredModels.length) { - advance(filteredModels[highlightIdx]); - return; - } - const val = modelFilter; - const exact = availableModels.find((m) => m === val); - if (exact) { advance(val); return; } - if (filteredModels.length === 1) { advance(filteredModels[0]); return; } - if (!val) { - setValidationError("Type a model name to search"); - } else if (filteredModels.length === 0) { - setValidationError(`No models matching "${val}"`); - } else { - setValidationError(`${filteredModels.length} matches — use arrows to select`); - } - } else if (key.backspace || key.delete) { - setModelFilter((prev) => prev.slice(0, -1)); - setHighlightIdx(-1); - } else if (input && !key.ctrl && !key.meta) { - setModelFilter((prev) => prev + input); - setHighlightIdx(-1); - } - }, { isActive: isModelAutocomplete }); + const isModelAutocomplete = phase === "input" && step?.id === "model_name" && availableModels.length > 0; // Credentials validation (import flow) useEffect(() => { @@ -162,30 +170,31 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { (async () => { try { const client = new FortyTwoClient(); - await client.login(values.agent_id, values.agent_secret); + await client.login(values.node_id, values.node_secret); // Fetch display name - let displayName = values.agent_id; + let displayName = values.node_id; try { const agent = await client.getAgent(); displayName = agent?.profile?.display_name || displayName; - } catch { /* keep agent_id as name */ } + } catch { /* keep node_id as name */ } if (cancelled) return; - setValues((prev) => ({ ...prev, _display_name: displayName })); + setValues((prev) => ({ ...prev, _node_display_name: displayName })); setValidationError(null); setPhase("input"); setStepIdx(stepIdx + 1); } catch (err) { if (cancelled) return; - // Return to agent_id step so user can fix either field - const agentIdIdx = steps.findIndex((s) => s.id === "agent_id"); + // Return to node_id step so user can fix either field + const nodeIdIdx = steps.findIndex((s) => s.id === "node_id"); setValues((prev) => { - const { agent_id: _, agent_secret: __, ...rest } = prev; + const { node_id: _, node_secret: __, ...rest } = prev; return rest; }); - setStepIdx(agentIdIdx >= 0 ? agentIdIdx : stepIdx); - setValidationError(`Invalid credentials: ${err}`); + setStepIdx(nodeIdIdx >= 0 ? nodeIdIdx : stepIdx); + const msg = err instanceof Error ? err.message : typeof err === "object" && err !== null && "message" in err ? String((err as Record).message) : String(err); + setValidationError(`✕ Invalid credentials: ${msg}`); setPhase("input"); } })(); @@ -198,19 +207,20 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { if (phase !== "fetching_models") return; let cancelled = false; - const isLocal = values.inference_type === "local"; - const baseUrl = isLocal ? values.llm_api_base : "https://openrouter.ai/api/v1"; + const isLocal = values.inference_type === "self-hosted"; + const baseUrl = isLocal ? values.self_hosted_api_base : "https://openrouter.ai/api/v1"; const apiKey = isLocal ? "EMPTY" : values.openrouter_api_key; fetchModels(baseUrl, apiKey).then((result) => { if (cancelled) return; if (!result.ok) { - setValidationError(result.error ?? "Cannot reach server"); + setValidationError(`✕ ${result.error ?? "Cannot reach server"}`); setPhase("input"); return; } setAvailableModels(result.models); setValidationError(null); + setModelFilter(values["model_name"] ?? ""); setPhase("input"); setStepIdx(stepIdx + 1); }); @@ -230,7 +240,7 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { setPhase("input"); setStepIdx(stepIdx + 1); } else { - setValidationError(result.error ?? "Validation failed"); + setValidationError(`✕ ${result.error ?? "Validation failed"}`); setPhase("input"); } }); @@ -247,15 +257,43 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { (async () => { try { if (!skipToRegistration) { - setRegLog(["Saving config..."]); + setRegLog(["↳ Saving config..."]); const cfg = buildConfig(values); - saveConfig(cfg); + const profileName = sanitizeProfileName(values.node_name || "default"); + createProfile(profileName, cfg); reloadConfig(); } const cfg = getConfig(); + + // Show inference info + const isLocal = cfg.inference_type === "self-hosted"; + const finalLines = [ + `Role: ${cfg.node_role}`, + `Inference: ${cfg.inference_type}`, + ...(isLocal ? [`Host: ${cfg.self_hosted_api_base}`] : []), + `Model: ${cfg.model_name}`, + "", + ]; + setRegLog((prev) => [...prev, ...finalLines]); + + // Validate config fields + const cfgCheck = validateConfig(cfg as unknown as Record); + if (cfgCheck.ok) { + setRegLog((prev) => [...prev, "Validating model..."]); + const modelCheck = await validateModel(cfg as unknown as Record); + if (!modelCheck.ok) { + setRegError(`Config error: ${modelCheck.error}`); + return; + } + setRegLog((prev) => [...prev, "✓ Model validated"]); + } else { + setRegError(`Config error: ${cfgCheck.error}`); + return; + } + const client = new FortyTwoClient(); - const displayName = cfg.display_name || values.agent_name || "JudgeBot"; + const displayName = cfg.node_display_name || values.node_name || "JudgeNode"; await registerAgent(client, displayName, (msg) => { if (cancelled) return; if (msg.startsWith("~")) { @@ -283,7 +321,7 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { return () => { cancelled = true; }; }, [phase === "registering"]); - // Import existing agent + // Import existing node useEffect(() => { if (phase !== "importing") return; @@ -291,18 +329,17 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { (async () => { try { - setRegLog(["Saving config..."]); + setRegLog(["↳ Saving config..."]); const cfg = buildConfig(values); - saveConfig(cfg); - reloadConfig(); - - saveIdentity(getConfig().identity_file, { - agent_id: values.agent_id, - secret: values.agent_secret, + const profileName = sanitizeProfileName(values._node_display_name || values.node_id || "default"); + createProfile(profileName, cfg, { + node_id: values.node_id, + node_secret: values.node_secret, }); + reloadConfig(); - const name = values._display_name || values.agent_id; - setRegLog((prev) => [...prev, `Agent "${name}" (${values.agent_id}) imported!`]); + const name = values._node_display_name || values.node_id; + setRegLog((prev) => [...prev, `✓ Node "${name}" (${values.node_id}) imported!`]); onDone(); } catch (err) { if (cancelled) return; @@ -314,7 +351,33 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { }, [phase === "importing"]); function advance(value: string) { + if (value === "__cancel__" && onCancel) { + onCancel(); + return; + } + if (value === "__back__") { + goBack(); + return; + } const next = { ...values, [step!.id]: value }; + const previousValue = values[step!.id]; + + // Branch change: clear dependent values when branching select changes + if (step!.id === "setup_mode" && previousValue !== undefined && previousValue !== value) { + delete next["node_name"]; + delete next["node_id"]; + delete next["node_secret"]; + delete next["_node_display_name"]; + } + + if (step!.id === "inference_type" && previousValue !== undefined && previousValue !== value) { + delete next["openrouter_api_key"]; + delete next["self_hosted_api_base"]; + delete next["model_name"]; + delete next["node_role"]; + setAvailableModels([]); + } + setValues(next); if (step!.id === "setup_mode") { @@ -325,19 +388,19 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { setInferenceType(value as InferenceType); } - if (step!.id === "agent_secret") { + if (step!.id === "node_secret") { setValidationError(null); setPhase("validating_creds"); return; } - if (step!.id === "llm_api_base" || step!.id === "openrouter_api_key") { + if (step!.id === "self_hosted_api_base" || step!.id === "openrouter_api_key") { setValidationError(null); setPhase("fetching_models"); return; } - if (step!.id === "llm_model") { + if (step!.id === "model_name") { setValidationError(null); setPhase("validating"); return; @@ -355,10 +418,10 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { if (phase === "validating_creds") { return ( - - Setup ({stepIdx + 1}/{steps.length}) + + STEP {stepIdx + 1}/{steps.length}: {step!.label.toUpperCase()} - {loader} Checking credentials... + {loader} Checking credentials... ); } @@ -366,10 +429,10 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { if (phase === "fetching_models") { return ( - - Setup ({stepIdx + 1}/{steps.length}) + + STEP {stepIdx + 1}/{steps.length}: {step!.label.toUpperCase()} - {loader} Checking connection and fetching models... + {loader} Checking connection and fetching models... ); } @@ -377,10 +440,10 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { if (phase === "validating") { return ( - - Setup ({stepIdx + 1}/{steps.length}) + + STEP {stepIdx + 1}/{steps.length}: {step!.label.toUpperCase()} - {loader} Checking model... + {loader} Checking model... ); } @@ -388,94 +451,142 @@ export default function Onboard({ onDone, skipToRegistration }: OnboardProps) { if (phase === "registering" || phase === "importing") { const displayLine = (line: string) => line.replace(/^\[progress]/, ""); const last = regLog.length - 1; - const header = phase === "importing" ? "Import Agent" : "Registration"; + const header = phase === "importing" ? "IMPORT NODE" : "REGISTRATION"; return ( - {header} - {regLog.length === 0 && {loader} Starting registration...} + ▒▓░ {header} ░▓▒ + {regLog.length === 0 && {loader} ⎔ Registering Node...} {regLog.map((line, i) => { const isCurrent = i === last; const text = displayLine(line); return ( - - {isCurrent ? `${loader} ` : " "}{text} + + {isCurrent ? {loader} : " "}{text} ); })} - {regError && {regError}} + {regError && ✕ ERROR: {regError}} ); } return ( - - Setup ({stepIdx + 1}/{steps.length}) + + STEP {stepIdx + 1}/{steps.length} {validationError && ( - {validationError} + {validationError} )} {step!.label} - {step!.placeholder ? ({step!.placeholder}) : null} + {step!.placeholder ? ({step!.placeholder}) : null} {isModelAutocomplete ? (() => { const MAX_SHOWN = 5; - const clampedIdx = Math.min(highlightIdx, filteredModels.length - 1); - const windowStart = Math.max(0, Math.min(clampedIdx - Math.floor(MAX_SHOWN / 2), filteredModels.length - MAX_SHOWN)); - const visible = filteredModels.slice(windowStart, windowStart + MAX_SHOWN); + const visible = filteredModels.slice(0, MAX_SHOWN); return ( <> - {modelFilter} - + {backFocused ? " " : "❯ "} + { + if (val === modelFilter) return; + setModelFilter(val); + setValidationError(null); + }} + onSubmit={(val) => { + const exact = availableModels.find((m) => m === val); + if (exact) { advance(val); return; } + const query = val.toLowerCase(); + const matches = query ? availableModels.filter((m) => m.toLowerCase().includes(query)) : []; + if (matches.length === 1) { advance(matches[0]); return; } + if (!val) { + setValidationError("Type a model name to search"); + } else if (matches.length === 0) { + setValidationError(`No models matching "${val}"`); + } else { + setValidationError(`${matches.length} matches — narrow your search`); + } + }} + /> {modelQuery && filteredModels.length > 0 && ( - {windowStart > 0 && ↑ more} - {visible.map((m, i) => { - const realIdx = windowStart + i; - const selected = realIdx === clampedIdx; - return ( - - {selected ? "▸ " : " "}{m} - - ); - })} - {windowStart + MAX_SHOWN < filteredModels.length && ( - ↓ +{filteredModels.length - windowStart - MAX_SHOWN} more + {visible.map((m) => ( + {m} + ))} + {filteredModels.length > MAX_SHOWN && ( + +{filteredModels.length - MAX_SHOWN} more )} )} {modelQuery && filteredModels.length === 0 && ( - No matches + No matches )} {!modelQuery && ( - {availableModels.length} models available — type to search + {availableModels.length} models available — type to search + )} + {canGoBack && ( + + {backFocused ? "❯" : " "} ← Back + )} ); })() : step!.type === "select" && step!.options ? ( - { + const savedValue = values[step!.id]; + const base = step!.options!; + const ordered = savedValue + ? [...base.filter(o => o.value === savedValue), ...base.filter(o => o.value !== savedValue)] + : base; + if (canGoBack) return [...ordered, { label: "Back", value: "__back__" }]; + if (onCancel && stepIdx === 0) return [...ordered, { label: "Back", value: "__cancel__" }]; + return ordered; + })()} + onChange={(val) => advance(val)} + /> + ) : ( - advance(val)} - /> + <> + + {backFocused ? " " : "❯ "} + advance(val)} + /> + + {canGoBack && ( + + {backFocused ? "❯" : " "} ← Back + + )} + )} {Object.keys(values).length > 0 && ( - ─── configured ─── + ─── configured ─── {Object.entries(values).map(([k, v]) => ( - + {k}: {displayValue(k, v)} ))} diff --git a/src/profiles.ts b/src/profiles.ts new file mode 100644 index 0000000..19d5956 --- /dev/null +++ b/src/profiles.ts @@ -0,0 +1,195 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { CONFIG_DIR, setConfigDir, reloadConfig } from "./config.js"; +import { loadIdentity, type Identity } from "./identity.js"; +import type { UserConfig } from "./config.js"; + +export const PROFILES_DIR = join(CONFIG_DIR, "profiles"); +export const PROFILES_META = join(CONFIG_DIR, "profiles.json"); + +export interface ProfilesMeta { + active: string; + profiles: string[]; +} + +export interface ProfileInfo { + name: string; + active: boolean; + agentName: string; + nodeId: string; +} + +let _profileOverride: string | undefined; + +export function setProfileOverride(name: string | undefined): void { + _profileOverride = name; +} + +export function sanitizeProfileName(name: string): string { + return (name + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9_-]/g, "") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 60)) + || "default"; +} + +export function loadProfilesMeta(): ProfilesMeta | null { + if (!existsSync(PROFILES_META)) return null; + try { + return JSON.parse(readFileSync(PROFILES_META, "utf-8")) as ProfilesMeta; + } catch { + return null; + } +} + +export function saveProfilesMeta(meta: ProfilesMeta): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(PROFILES_META, JSON.stringify(meta, null, 2)); +} + +export function getActiveProfileName(): string { + if (_profileOverride) return _profileOverride; + + const env = process.env.FORTYTWO_PROFILE; + if (env) return env; + + const meta = loadProfilesMeta(); + return meta?.active ?? "default"; +} + +export function getProfileDir(name?: string): string { + const profileName = name ?? getActiveProfileName(); + return join(PROFILES_DIR, profileName); +} + +export function initProfiles(): void { + migrateIfNeeded(); + const dir = getProfileDir(); + setConfigDir(dir); + reloadConfig(); +} + +export function profileExists(name: string): boolean { + const dir = getProfileDir(name); + return existsSync(join(dir, "config.json")); +} + +export function listProfiles(): ProfileInfo[] { + const meta = loadProfilesMeta(); + if (!meta || meta.profiles.length === 0) return []; + + const activeName = getActiveProfileName(); + + return meta.profiles.map((name) => { + const dir = getProfileDir(name); + let agentName = name; + let nodeId = ""; + + try { + const cfg = JSON.parse(readFileSync(join(dir, "config.json"), "utf-8")); + agentName = cfg.node_name || cfg.node_display_name || name; + } catch {} + + try { + const id = loadIdentity(join(dir, "identity.json")); + if (id) nodeId = id.node_id; + } catch {} + + return { name, active: name === activeName, agentName, nodeId }; + }); +} + +export function createProfile(name: string, cfg: UserConfig, identity?: Identity): void { + const dir = getProfileDir(name); + mkdirSync(dir, { recursive: true }); + + const profileCfg = { ...cfg, node_identity_file: join(dir, "identity.json") }; + writeFileSync(join(dir, "config.json"), JSON.stringify(profileCfg, null, 2)); + + if (identity) { + writeFileSync(join(dir, "identity.json"), JSON.stringify(identity, null, 2)); + } + + const meta = loadProfilesMeta() ?? { active: name, profiles: [] }; + if (!meta.profiles.includes(name)) { + meta.profiles.push(name); + } + meta.active = name; + saveProfilesMeta(meta); + + setConfigDir(dir); + reloadConfig(); +} + +export function deleteProfile(name: string): void { + const meta = loadProfilesMeta(); + if (!meta) throw new Error("No profiles found."); + if (meta.active === name) throw new Error("Cannot delete the active profile. Switch first."); + if (!meta.profiles.includes(name)) throw new Error(`Profile "${name}" not found.`); + + const dir = getProfileDir(name); + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + + meta.profiles = meta.profiles.filter((p) => p !== name); + saveProfilesMeta(meta); +} + +export function switchProfile(name: string): void { + const meta = loadProfilesMeta(); + if (!meta) throw new Error("No profiles found."); + if (!meta.profiles.includes(name)) { + const available = meta.profiles.join(", "); + throw new Error(`Profile "${name}" not found. Available: ${available}`); + } + + meta.active = name; + saveProfilesMeta(meta); + + setConfigDir(getProfileDir(name)); + reloadConfig(); +} + +const LEGACY_CONFIG = join(CONFIG_DIR, "config.json"); +const LEGACY_IDENTITY = join(CONFIG_DIR, "identity.json"); + +export function migrateIfNeeded(): void { + if (existsSync(PROFILES_META)) return; + + if (!existsSync(LEGACY_CONFIG)) { + saveProfilesMeta({ active: "default", profiles: [] }); + return; + } + + let cfg: Record; + try { + cfg = JSON.parse(readFileSync(LEGACY_CONFIG, "utf-8")); + } catch { + saveProfilesMeta({ active: "default", profiles: [] }); + return; + } + + const agentName = (cfg.node_name as string) || (cfg.agent_name as string) || (cfg.node_display_name as string) || ""; + const profileName = sanitizeProfileName(agentName) || "default"; + const dir = join(PROFILES_DIR, profileName); + mkdirSync(dir, { recursive: true }); + + const profileCfg = { ...cfg, node_identity_file: join(dir, "identity.json") }; + writeFileSync(join(dir, "config.json"), JSON.stringify(profileCfg, null, 2)); + + const legacyIdentityPath = (cfg.node_identity_file as string) || LEGACY_IDENTITY; + if (existsSync(legacyIdentityPath)) { + try { + const identityData = readFileSync(legacyIdentityPath, "utf-8"); + writeFileSync(join(dir, "identity.json"), identityData); + } catch (err) { + console.warn(`Warning: could not migrate identity from ${legacyIdentityPath}: ${err instanceof Error ? err.message : err}`); + } + } + + saveProfilesMeta({ active: profileName, profiles: [profileName] }); +} diff --git a/src/setup-logic.ts b/src/setup-logic.ts index 6d19610..88e40c4 100644 --- a/src/setup-logic.ts +++ b/src/setup-logic.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { type UserConfig, type InferenceType, - CONFIG_DIR, + getConfigDir, } from "./config.js"; export const OPENROUTER_BASE = "https://openrouter.ai/api/v1"; @@ -47,13 +47,32 @@ export async function fetchModels(baseUrl: string, apiKey: string): Promise): ValidateResult { + const inferenceType = values.inference_type; + if (inferenceType !== "openrouter" && inferenceType !== "self-hosted") { + return { ok: false, error: `inference_type is invalid: "${inferenceType}". Options: "openrouter" | "self-hosted"` }; + } + + if (inferenceType === "openrouter" && !values.openrouter_api_key) { + return { ok: false, error: `openrouter_api_key is required for OpenRouter inference. Use /config set openrouter_api_key ` }; + } + if (inferenceType === "self-hosted" && !values.self_hosted_api_base) { + return { ok: false, error: `self_hosted_api_base is not set for local inference. Use /config set self_hosted_api_base ` }; + } + + if (!values.model_name) { + return { ok: false, error: `model_name is not set. Use /config set model_name ` }; + } + return { ok: true }; +} + export async function validateModel(values: Record): Promise { - const isLocal = values.inference_type === "local"; + const isLocal = values.inference_type === "self-hosted"; const baseUrl = isLocal - ? values.llm_api_base?.replace(/\/+$/, "") + ? values.self_hosted_api_base?.replace(/\/+$/, "") : OPENROUTER_BASE; const apiKey = isLocal ? "EMPTY" : values.openrouter_api_key; - const model = values.llm_model; + const model = values.model_name; const url = `${baseUrl}/models`; const headers: Record = { @@ -86,7 +105,7 @@ export async function validateModel(values: Record): Promise 5 ? ` (+${models.length - 5} more)` : ""}` }; + return { ok: false, error: `Model "${model}" not found. Choose correct one and restart the client.` }; } } catch { return { ok: true }; @@ -96,21 +115,21 @@ export async function validateModel(values: Record): Promise): UserConfig { - const isLocal = values.inference_type === "local"; + const isLocal = values.inference_type === "self-hosted"; return { - agent_name: values.agent_name || values._display_name || values.agent_id || "", - display_name: values.agent_name || values._display_name || values.agent_id || "", - inference_type: isLocal ? "local" : "openrouter", + node_name: values.node_name || values.node_display_name || values.node_id || "", + node_display_name: values.node_name || values.node_display_name || values.node_id || "", + inference_type: isLocal ? "self-hosted" : "openrouter", openrouter_api_key: values.openrouter_api_key ?? "", - llm_api_base: values.llm_api_base ?? "", - fortytwo_api_base: "https://app.fortytwo.network/api", - identity_file: join(CONFIG_DIR, "identity.json"), - poll_interval: 120, - llm_model: values.llm_model || (isLocal ? "" : "qwen/qwen3.5-35b-a3b"), + self_hosted_api_base: values.self_hosted_api_base ?? "", + fortytwo_api_base: values.fortytwo_api_base ?? "https://app.fortytwo.network/api", + node_identity_file: values.node_identity_file ?? join(getConfigDir(), "identity.json"), + poll_interval: Number(values.poll_interval) || 120, + model_name: values.model_name || (isLocal ? "" : "qwen/qwen3.5-35b-a3b"), llm_concurrency: 40, llm_timeout: 120, min_balance: 5.0, - bot_role: values.bot_role || "JUDGE", + node_role: values.node_role || "JUDGE", answerer_system_prompt: "You are a helpful assistant.", }; } diff --git a/src/update-check.ts b/src/update-check.ts new file mode 100644 index 0000000..53c4279 --- /dev/null +++ b/src/update-check.ts @@ -0,0 +1,93 @@ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "./config.js"; +import pkg from "../package.json" with { type: "json" }; + +export const UPDATE_COMMAND = "npm install -g @fortytwo-network/fortytwo-cli@latest"; + +const REGISTRY_URL = + "https://registry.npmjs.org/@fortytwo-network/fortytwo-cli/latest"; +const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours +const FETCH_TIMEOUT = 5000; + +export interface UpdateInfo { + currentVersion: string; + latestVersion: string; + updateAvailable: boolean; +} + +interface CacheData { + lastCheck: number; + latestVersion: string; +} + +function cachePath(): string { + return join(getConfigDir(), "update-check.json"); +} + +function readCache(): CacheData | null { + try { + return JSON.parse(readFileSync(cachePath(), "utf-8")); + } catch { + return null; + } +} + +function writeCache(data: CacheData): void { + try { + mkdirSync(getConfigDir(), { recursive: true }); + writeFileSync(cachePath(), JSON.stringify(data)); + } catch { + // ignore write errors + } +} + +export function isNewerVersion(latest: string, current: string): boolean { + const l = latest.split(".").map(Number); + const c = current.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if ((l[i] ?? 0) > (c[i] ?? 0)) return true; + if ((l[i] ?? 0) < (c[i] ?? 0)) return false; + } + return false; +} + +async function fetchLatestVersion(): Promise { + const res = await fetch(REGISTRY_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + const data = (await res.json()) as { version: string }; + return data.version; +} + +function buildInfo(latestVersion: string): UpdateInfo { + return { + currentVersion: pkg.version, + latestVersion, + updateAvailable: isNewerVersion(latestVersion, pkg.version), + }; +} + +export async function checkForUpdate(): Promise { + try { + const cache = readCache(); + if (cache && Date.now() - cache.lastCheck < CHECK_INTERVAL) { + return buildInfo(cache.latestVersion); + } + const latestVersion = await fetchLatestVersion(); + writeCache({ lastCheck: Date.now(), latestVersion }); + return buildInfo(latestVersion); + } catch { + return null; + } +} + +export function getCachedUpdate(): UpdateInfo | null { + try { + const cache = readCache(); + if (!cache) return null; + return buildInfo(cache.latestVersion); + } catch { + return null; + } +} diff --git a/src/utils.ts b/src/utils.ts index fd7a10a..5709e6d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,69 @@ +import { COLORS, ROLE_OPTIONS } from "./constants.js"; +import { viewerBus } from "./event-bus.js"; + +export { COLORS, ROLE_OPTIONS }; + +export function getRoleLabel(value: string, context: "onboard" | "bot" = "bot"): string { + const opt = ROLE_OPTIONS.find((opt) => opt.value === value); + if (!opt) return value; + return context === "onboard" ? opt.label : opt.display; +} + +// ── Number formatting ──────────────────────────────────────── + +function truncateDecimals(value: number, decimals: number): string { + if (decimals <= 0) return String(Math.floor(value)); + const str = value.toFixed(20); + const dotIdx = str.indexOf('.'); + const intPart = str.slice(0, dotIdx); + const decPart = str.slice(dotIdx + 1, dotIdx + 1 + decimals).padEnd(decimals, '0'); + return `${intPart}.${decPart}`; +} + +function stripTrailingZeros(str: string): string { + if (!str.includes('.')) return str; + return str.replace(/\.?0+$/, ''); +} + +export function formatNumber(value: number | string, digits?: number): string { + const num = typeof value === 'string' ? parseFloat(value) : value; + if (Number.isNaN(num)) return '0'; + + const sign = num < 0 ? '-' : ''; + const abs = Math.abs(num); + + const withSuffix = (divisor: number, suffix: string): string => { + const divided = abs / divisor; + const intLen = Math.floor(divided).toString().length; + const decimalPlaces = digits ?? Math.max(0, 4 - intLen); + return `${sign}${stripTrailingZeros(truncateDecimals(divided, decimalPlaces))}${suffix}`; + }; + + if (abs >= 1_000_000_000) return withSuffix(1_000_000_000, 'B'); + if (abs >= 1_000_000) return withSuffix(1_000_000, 'M'); + if (abs >= 100_000) return withSuffix(1_000, 'K'); + + if (abs >= 1_000) { + const decimalPlaces = digits ?? 0; + const truncated = truncateDecimals(abs, decimalPlaces); + const stripped = stripTrailingZeros(truncated); + const [intPart, decPart] = stripped.split('.'); + const intWithCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return sign + (decPart ? `${intWithCommas}.${decPart}` : intWithCommas); + } + + const intLen = Math.floor(abs).toString().length; + const decimalPlaces = digits ?? (5 - intLen); + return `${sign}${stripTrailingZeros(truncateDecimals(abs, decimalPlaces))}`; +} + +// ── Name truncation ────────────────────────────────────────── + +export function truncateName(name: string, max = 33): string { + if (name.length <= max) return name; + return name.slice(0, max) + "..."; +} + // ── Global log ──────────────────────────────────────────────── let _logFn: (msg: string) => void = console.log; @@ -7,6 +73,7 @@ export function setLogFn(fn: (msg: string) => void): void { export function log(msg: string): void { _logFn(msg); + viewerBus.pushLog("info", msg); } // ── Verbose logging ─────────────────────────────────────────── @@ -17,7 +84,10 @@ export function setVerbose(on: boolean): void { } export function verbose(msg: string): void { - if (_verbose) _logFn(`[verbose] ${msg}`); + if (_verbose) { + _logFn(`[verbose] ${msg}`); + viewerBus.pushLog("dim", msg); + } } // ── Pinned tasks (shown as active tasks section) ───────────── @@ -64,7 +134,7 @@ export function parseLastLetter(text: string, valid: Set): string | null const lines = text .trim() .split("\n") - .map((l) => l.trim()) + .map((l) => l.trim().replace(/[^\w]/g, " ").trim()) .filter(Boolean); for (let i = lines.length - 1; i >= 0; i--) { diff --git a/src/viewer-server.ts b/src/viewer-server.ts new file mode 100644 index 0000000..f4793a4 --- /dev/null +++ b/src/viewer-server.ts @@ -0,0 +1,165 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { join, extname, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { viewerBus, type ViewerEvent } from "./event-bus.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const MIME_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".txt": "text/plain; charset=utf-8", +}; + +function setCorsHeaders(res: ServerResponse): void { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); +} + +const sseClients = new Set(); + +function handleSSE(req: IncomingMessage, res: ServerResponse): void { + setCorsHeaders(res); + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + sseClients.add(res); + + const initEvent = viewerBus.getInitSnapshot(); + res.write(`data: ${JSON.stringify(initEvent)}\n\n`); + + const onEvent = (evt: ViewerEvent) => { + try { + res.write(`data: ${JSON.stringify(evt)}\n\n`); + } catch {} + }; + + viewerBus.on("viewer_event", onEvent); + + const heartbeat = setInterval(() => { + try { + res.write(`:heartbeat\n\n`); + } catch { + cleanup(); + } + }, 15_000); + + const cleanup = () => { + sseClients.delete(res); + viewerBus.off("viewer_event", onEvent); + clearInterval(heartbeat); + }; + + req.on("close", cleanup); + req.on("error", cleanup); +} + +function handleStatus(_req: IncomingMessage, res: ServerResponse): void { + setCorsHeaders(res); + const snapshot = viewerBus.getInitSnapshot(); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(snapshot.data)); +} + +function handleStatic(req: IncomingMessage, res: ServerResponse): void { + setCorsHeaders(res); + + let staticDir = join(__dirname, "viewer"); + if (!existsSync(join(staticDir, "index.html"))) { + staticDir = join(__dirname, "..", "dist", "viewer"); + } + + let urlPath = (req.url ?? "/").split("?")[0]; + if (urlPath === "/") urlPath = "/index.html"; + + const filePath = join(staticDir, urlPath); + + if (!filePath.startsWith(staticDir)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + const indexPath = join(staticDir, "index.html"); + if (existsSync(indexPath)) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + createReadStream(indexPath).pipe(res); + return; + } + res.writeHead(404); + res.end("Not Found"); + return; + } + + const ext = extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; + res.writeHead(200, { "Content-Type": contentType }); + createReadStream(filePath).pipe(res); +} + + +export function startViewerServer(port = 4242): { port: number; close: () => void } { + const server = createServer((req, res) => { + const url = req.url ?? "/"; + + if (req.method === "OPTIONS") { + setCorsHeaders(res); + res.writeHead(204); + res.end(); + return; + } + + if (url === "/api/stream") { + handleSSE(req, res); + } else if (url === "/api/status") { + handleStatus(req, res); + } else { + handleStatic(req, res); + } + }); + + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + server.close(); + const nextPort = port + 1; + const result = startViewerServer(nextPort); + Object.assign(serverInfo, result); + } + }); + + const serverInfo = { + port, + close: () => { + for (const res of sseClients) { + try { res.end(); } catch {} + } + sseClients.clear(); + server.close(); + }, + }; + + server.listen(port, "127.0.0.1", () => { + serverInfo.port = port; + }); + + server.unref(); + + return serverInfo; +} diff --git a/tests/api-client.test.ts b/tests/api-client.test.ts index eebcc07..ae1c8bf 100644 --- a/tests/api-client.test.ts +++ b/tests/api-client.test.ts @@ -55,7 +55,7 @@ describe("FortyTwoClient", () => { const client = new FortyTwoClient("https://api.test.com"); const data = await client.login("agent-1", "secret-1"); expect(data.tokens.access_token).toBe("at-123"); - expect(client.agentId).toBe("agent-1"); + expect(client.nodeId).toBe("agent-1"); }); it("adds auth header for authenticated requests", async () => { @@ -300,8 +300,8 @@ describe("FortyTwoClient", () => { ); const client = new FortyTwoClient("https://api.test.com"); // Set agentId and secret without calling login (no access token) - (client as any).agentId = "agent-1"; - (client as any).secret = "secret-1"; + (client as any).nodeId = "agent-1"; + (client as any).nodeSecret = "secret-1"; const data = await client.request("GET", "/test"); expect(data.ok).toBe(true); expect(fn).toHaveBeenCalledTimes(2); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 8a72ea1..662a3dc 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -7,28 +7,31 @@ vi.mock("../src/config.js", () => ({ fortytwo_api_base: "https://api.test.com", identity_file: "/tmp/identity.json", poll_interval: 60, - llm_model: "test-model", + model_name: "test-model", llm_concurrency: 5, llm_timeout: 10, min_balance: 5.0, - bot_role: "JUDGE", + node_role: "JUDGE", answerer_system_prompt: "You are a helpful assistant.", }), + CONFIG_DIR: "/tmp/.fortytwo", configExists: vi.fn().mockReturnValue(true), saveConfig: vi.fn(), reloadConfig: vi.fn(), + setConfigDir: vi.fn(), + getConfigDir: vi.fn().mockReturnValue("/tmp/.fortytwo"), })); const mockClient = { - agentId: "agent-1", + nodeId: "agent-1", login: vi.fn().mockResolvedValue({}), - getAgent: vi.fn().mockResolvedValue({ profile: { display_name: "Bot" } }), + getAgent: vi.fn().mockResolvedValue({ profile: { node_display_name: "Bot" } }), createQuery: vi.fn().mockResolvedValue({ id: "q-1" }), }; vi.mock("../src/api-client.js", () => { class MockFortyTwoClient { - agentId = mockClient.agentId; + nodeId = mockClient.nodeId; login = mockClient.login; getAgent = mockClient.getAgent; createQuery = mockClient.createQuery; @@ -37,9 +40,9 @@ vi.mock("../src/api-client.js", () => { }); vi.mock("../src/identity.js", () => ({ - loadIdentity: vi.fn().mockReturnValue({ agent_id: "agent-1", secret: "sec" }), + loadIdentity: vi.fn().mockReturnValue({ node_id: "agent-1", node_secret: "sec" }), saveIdentity: vi.fn(), - registerAgent: vi.fn().mockResolvedValue({ agent_id: "new-agent", secret: "new-sec" }), + registerAgent: vi.fn().mockResolvedValue({ node_id: "new-agent", node_secret: "new-sec" }), })); vi.mock("../src/main.js", () => ({ @@ -47,25 +50,25 @@ vi.mock("../src/main.js", () => ({ })); vi.mock("../src/commands.js", () => ({ - executeCommand: vi.fn().mockReturnValue(["Config:", " bot_role: JUDGE"]), + executeCommand: vi.fn().mockReturnValue(["Config:", " node_role: JUDGE"]), })); vi.mock("../src/setup-logic.js", () => ({ validateModel: vi.fn().mockResolvedValue({ ok: true }), buildConfig: vi.fn().mockReturnValue({ - agent_name: "Bot", - display_name: "Bot", + node_name: "Bot", + node_display_name: "Bot", inference_type: "openrouter", openrouter_api_key: "key", - llm_api_base: "", + self_hosted_api_base: "", fortytwo_api_base: "https://app.fortytwo.network/api", identity_file: "/tmp/identity.json", poll_interval: 120, - llm_model: "test", + model_name: "test", llm_concurrency: 40, llm_timeout: 120, min_balance: 5.0, - bot_role: "JUDGE", + node_role: "JUDGE", answerer_system_prompt: "You are a helpful assistant.", }), })); @@ -75,6 +78,18 @@ vi.mock("../src/utils.js", () => ({ log: vi.fn(), })); +vi.mock("../src/profiles.js", () => ({ + initProfiles: vi.fn(), + setProfileOverride: vi.fn(), + listProfiles: vi.fn().mockReturnValue([]), + switchProfile: vi.fn(), + deleteProfile: vi.fn(), + createProfile: vi.fn(), + sanitizeProfileName: vi.fn((name: string) => name.toLowerCase().replace(/\s+/g, "-")), + getProfileDir: vi.fn().mockReturnValue("/tmp/.fortytwo/profiles/default"), + profileExists: vi.fn().mockReturnValue(false), +})); + vi.mock("../src/index.js", () => ({})); const origArgv = process.argv; @@ -97,10 +112,10 @@ describe("cli", () => { const { loadIdentity } = await import("../src/identity.js"); const { validateModel } = await import("../src/setup-logic.js"); vi.mocked(configExists).mockReturnValue(true); - vi.mocked(loadIdentity).mockReturnValue({ agent_id: "agent-1", secret: "sec" }); + vi.mocked(loadIdentity).mockReturnValue({ node_id: "agent-1", node_secret: "sec" }); vi.mocked(validateModel).mockResolvedValue({ ok: true }); mockClient.login.mockResolvedValue({}); - mockClient.getAgent.mockResolvedValue({ profile: { display_name: "Bot" } }); + mockClient.getAgent.mockResolvedValue({ profile: { node_display_name: "Bot" } }); consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); @@ -142,8 +157,8 @@ describe("cli", () => { it("config set calls executeCommand", async () => { const { executeCommand } = await import("../src/commands.js"); - await runCli(["config", "set", "bot_role", "ANSWERER"]); - expect(executeCommand).toHaveBeenCalledWith("/config set bot_role ANSWERER"); + await runCli(["config", "set", "node_role", "ANSWERER"]); + expect(executeCommand).toHaveBeenCalledWith("/config set node_role ANSWERER"); }); it("config set without key/value exits", async () => { @@ -159,19 +174,19 @@ describe("cli", () => { describe("setup", () => { const setupFlags = [ - "--name", "TestBot", + "--node-name", "TestBot", "--inference-type", "openrouter", "--api-key", "sk-or-xxx", - "--model", "test-model", - "--role", "JUDGE", + "--model-name", "test-model", + "--node-role", "JUDGE", "--skip-validation", ]; it("completes full setup flow", async () => { - const { saveConfig } = await import("../src/config.js"); + const { createProfile } = await import("../src/profiles.js"); const { registerAgent } = await import("../src/identity.js"); await runCli(["setup", ...setupFlags]); - expect(saveConfig).toHaveBeenCalled(); + expect(createProfile).toHaveBeenCalled(); expect(registerAgent).toHaveBeenCalled(); const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); expect(output).toContain("Setup complete"); @@ -179,61 +194,59 @@ describe("cli", () => { it("validates model when no --skip-validation", async () => { const { validateModel } = await import("../src/setup-logic.js"); - await runCli(["setup", "--name", "B", "--inference-type", "openrouter", "--api-key", "k", "--model", "m", "--role", "JUDGE"]); + await runCli(["setup", "--node-name", "B", "--inference-type", "openrouter", "--openrouter-api-key", "k", "--model-name", "m", "--node-role", "JUDGE"]); expect(validateModel).toHaveBeenCalled(); }); it("exits on validation failure", async () => { const { validateModel } = await import("../src/setup-logic.js"); vi.mocked(validateModel).mockResolvedValue({ ok: false, error: "not found" }); - await runCli(["setup", "--name", "B", "--inference-type", "openrouter", "--api-key", "k", "--model", "m", "--role", "JUDGE"]); + await runCli(["setup", "--node-name", "B", "--inference-type", "openrouter", "--openrouter-api-key", "k", "--model-name", "m", "--node-role", "JUDGE"]); expect(exitSpy).toHaveBeenCalledWith(1); }); it("exits on missing --name flag", async () => { - await runCli(["setup", "--inference-type", "openrouter", "--model", "m", "--role", "JUDGE"]); + await runCli(["setup", "--inference-type", "openrouter", "--model-name", "m", "--node-role", "JUDGE"]); expect(exitSpy).toHaveBeenCalledWith(1); }); it("exits on invalid inference type", async () => { - await runCli(["setup", "--name", "B", "--inference-type", "invalid", "--model", "m", "--role", "JUDGE"]); + await runCli(["setup", "--node-name", "B", "--inference-type", "invalid", "--model-name", "m", "--node-role", "JUDGE"]); expect(exitSpy).toHaveBeenCalledWith(1); }); it("exits on invalid role", async () => { - await runCli(["setup", "--name", "B", "--inference-type", "openrouter", "--api-key", "k", "--model", "m", "--role", "INVALID"]); + await runCli(["setup", "--node-name", "B", "--inference-type", "openrouter", "--openrouter-api-key", "k", "--model-name", "m", "--node-role", "INVALID"]); expect(exitSpy).toHaveBeenCalledWith(1); }); - it("requires llm-api-base for local inference", async () => { - await runCli(["setup", "--name", "B", "--inference-type", "local", "--model", "m", "--role", "JUDGE"]); + it("requires self-hosted-api-base for local inference", async () => { + await runCli(["setup", "--node-name", "B", "--inference-type", "self-hosted", "--model-name", "m", "--node-role", "JUDGE"]); expect(exitSpy).toHaveBeenCalledWith(1); }); it("works with local inference", async () => { - await runCli(["setup", "--name", "B", "--inference-type", "local", "--llm-api-base", "http://localhost:11434/v1", "--model", "m", "--role", "JUDGE", "--skip-validation"]); - const { saveConfig } = await import("../src/config.js"); - expect(saveConfig).toHaveBeenCalled(); + await runCli(["setup", "--node-name", "B", "--inference-type", "self-hosted", "--self-hosted-api-base", "http://localhost:11434/v1", "--model-name", "m", "--node-role", "JUDGE", "--skip-validation"]); + const { createProfile } = await import("../src/profiles.js"); + expect(createProfile).toHaveBeenCalled(); }); }); describe("import", () => { const importFlags = [ - "--agent-id", "uuid-123", - "--secret", "sec-456", + "--node-id", "uuid-123", + "--node-secret", "sec-456", "--inference-type", "openrouter", - "--api-key", "sk-or-xxx", - "--model", "test-model", - "--role", "JUDGE", + "--openrouter-api-key", "sk-or-xxx", + "--model-name", "test-model", + "--node-role", "JUDGE", "--skip-validation", ]; it("completes full import flow", async () => { - const { saveConfig } = await import("../src/config.js"); - const { saveIdentity } = await import("../src/identity.js"); + const { createProfile } = await import("../src/profiles.js"); await runCli(["import", ...importFlags]); - expect(saveConfig).toHaveBeenCalled(); - expect(saveIdentity).toHaveBeenCalled(); + expect(createProfile).toHaveBeenCalled(); expect(mockClient.login).toHaveBeenCalledWith("uuid-123", "sec-456"); }); @@ -244,18 +257,18 @@ describe("cli", () => { }); it("exits on invalid inference type", async () => { - await runCli(["import", "--agent-id", "a", "--secret", "s", "--inference-type", "bad", "--model", "m", "--role", "JUDGE"]); + await runCli(["import", "--node-id", "a", "--node-secret", "s", "--inference-type", "bad", "--model-name", "m", "--node-role", "JUDGE"]); expect(exitSpy).toHaveBeenCalledWith(1); }); it("exits on invalid role", async () => { - await runCli(["import", "--agent-id", "a", "--secret", "s", "--inference-type", "openrouter", "--api-key", "k", "--model", "m", "--role", "BAD"]); + await runCli(["import", "--node-id", "a", "--secret", "s", "--inference-type", "openrouter", "--api-key", "k", "--model", "m", "--role", "BAD"]); expect(exitSpy).toHaveBeenCalledWith(1); }); it("validates model when no --skip-validation", async () => { const { validateModel } = await import("../src/setup-logic.js"); - await runCli(["import", "--agent-id", "a", "--secret", "s", "--inference-type", "openrouter", "--api-key", "k", "--model", "m", "--role", "JUDGE"]); + await runCli(["import", "--node-id", "a", "--secret", "s", "--inference-type", "openrouter", "--api-key", "k", "--model", "m", "--role", "JUDGE"]); expect(validateModel).toHaveBeenCalled(); }); @@ -269,14 +282,14 @@ describe("cli", () => { it("exits on import validation failure", async () => { const { validateModel } = await import("../src/setup-logic.js"); vi.mocked(validateModel).mockResolvedValue({ ok: false, error: "bad model" }); - await runCli(["import", "--agent-id", "a", "--secret", "s", "--inference-type", "openrouter", "--api-key", "k", "--model", "m", "--role", "JUDGE"]); + await runCli(["import", "--node-id", "a", "--secret", "s", "--inference-type", "openrouter", "--api-key", "k", "--model", "m", "--role", "JUDGE"]); expect(exitSpy).toHaveBeenCalledWith(1); }); it("works with local inference", async () => { - await runCli(["import", "--agent-id", "a", "--secret", "s", "--inference-type", "local", "--llm-api-base", "http://localhost:11434/v1", "--model", "m", "--role", "JUDGE", "--skip-validation"]); - const { saveConfig } = await import("../src/config.js"); - expect(saveConfig).toHaveBeenCalled(); + await runCli(["import", "--node-id", "a", "--secret", "s", "--inference-type", "self-hosted", "--self-hosted-api-base", "http://localhost:11434/v1", "--model", "m", "--role", "JUDGE", "--skip-validation"]); + const { createProfile } = await import("../src/profiles.js"); + expect(createProfile).toHaveBeenCalled(); }); }); diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 248b6d4..145f6c6 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -1,19 +1,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mockConfig = { - agent_name: "testbot", - display_name: "TestBot", + node_name: "testbot", + node_display_name: "TestBot", inference_type: "openrouter" as const, openrouter_api_key: "sk-or-v1-abcdef1234567890", - llm_api_base: "", + self_hosted_api_base: "", fortytwo_api_base: "https://app.fortytwo.network/api", identity_file: "/tmp/identity.json", poll_interval: 120, - llm_model: "qwen/qwen3.5-35b-a3b", + model_name: "qwen/qwen3.5-35b-a3b", llm_concurrency: 40, llm_timeout: 120, min_balance: 5.0, - bot_role: "JUDGE", + node_role: "JUDGE", answerer_system_prompt: "You are a helpful assistant.", }; @@ -27,8 +27,8 @@ vi.mock("../src/config.js", () => ({ vi.mock("../src/identity.js", () => ({ loadIdentity: (path: string) => ({ - agent_id: "test-agent-id", - secret: "test-secret-key", + node_id: "test-agent-id", + node_secret: "test-secret-key", }), })); @@ -40,6 +40,13 @@ vi.mock("../src/llm.js", () => ({ resetLlmClient: vi.fn(), })); +vi.mock("../src/profiles.js", () => ({ + listProfiles: vi.fn().mockReturnValue([ + { name: "default", active: true, agentName: "testbot", nodeId: "test-agent-id" }, + ]), + switchProfile: vi.fn(), +})); + import { executeCommand } from "../src/commands.js"; import { setVerbose } from "../src/utils.js"; import { resetLlmClient } from "../src/llm.js"; @@ -66,12 +73,12 @@ describe("executeCommand", () => { expect(result[0]).toBe("Commands:"); }); - it("/identity shows agent_id and secret", () => { + it("/identity shows node_id and secret", () => { const result = executeCommand("/identity"); expect(result).toEqual([ "Identity:", - " agent_id: test-agent-id", - " secret: test-secret-key", + " node_id: test-agent-id", + " node_secret: test-secret-key", ]); }); @@ -85,19 +92,19 @@ describe("executeCommand", () => { }); it("/config set saves and reloads", () => { - const result = executeCommand("/config set llm_model gpt-4"); + const result = executeCommand("/config set model_name gpt-4"); expect(savedConfig).not.toBeNull(); - expect(savedConfig.llm_model).toBe("gpt-4"); - expect(result[0]).toContain("llm_model"); + expect(savedConfig.model_name).toBe("gpt-4"); + expect(result[0]).toContain("model_name"); }); it("/config set LLM key resets client", () => { - executeCommand("/config set llm_model test"); + executeCommand("/config set model_name test"); expect(resetLlmClient).toHaveBeenCalled(); }); it("/config set non-LLM key does not reset client", () => { - executeCommand("/config set bot_role ANSWERER"); + executeCommand("/config set node_role ANSWERER"); expect(resetLlmClient).not.toHaveBeenCalled(); }); @@ -148,4 +155,74 @@ describe("executeCommand", () => { const result = executeCommand("/config"); expect(result[0]).toContain("Usage:"); }); + + describe("/profile", () => { + it("/profile list shows profiles", async () => { + const { listProfiles } = await import("../src/profiles.js"); + vi.mocked(listProfiles).mockReturnValue([ + { name: "my-judge", active: true, agentName: "MyJudge", nodeId: "aaaa-bbbb-cccc" }, + { name: "answerer", active: false, agentName: "Answerer", nodeId: "dddd-eeee-ffff" }, + ]); + const result = executeCommand("/profile list"); + expect(result[0]).toBe("Profiles:"); + expect(result[1]).toContain("my-judge"); + expect(result[1]).toContain("(active)"); + expect(result[2]).toContain("answerer"); + expect(result[2]).not.toContain("(active)"); + }); + + it("/profile without subcommand shows list", async () => { + const { listProfiles } = await import("../src/profiles.js"); + vi.mocked(listProfiles).mockReturnValue([ + { name: "default", active: true, agentName: "Bot", nodeId: "id-1" }, + ]); + const result = executeCommand("/profile"); + expect(result[0]).toBe("Profiles:"); + expect(result[1]).toContain("default"); + }); + + it("/profile list returns message when no profiles", async () => { + const { listProfiles } = await import("../src/profiles.js"); + vi.mocked(listProfiles).mockReturnValue([]); + const result = executeCommand("/profile list"); + expect(result[0]).toContain("No profiles"); + }); + + it("/profile switch changes profile and returns marker", async () => { + const { switchProfile } = await import("../src/profiles.js"); + const result = executeCommand("/profile switch my-judge"); + expect(switchProfile).toHaveBeenCalledWith("my-judge"); + expect(resetLlmClient).toHaveBeenCalled(); + expect(result[0]).toContain("__SWITCH_PROFILE__:my-judge"); + expect(result[1]).toContain("Switched to profile"); + }); + + it("/profile switch without name shows usage", async () => { + const { listProfiles } = await import("../src/profiles.js"); + vi.mocked(listProfiles).mockReturnValue([ + { name: "default", active: true, agentName: "Bot", nodeId: "id-1" }, + ]); + const result = executeCommand("/profile switch"); + expect(result[0]).toContain("Usage:"); + expect(result).toEqual(expect.arrayContaining([expect.stringContaining("Available profiles")])); + }); + + it("/profile switch returns error for unknown profile", async () => { + const { switchProfile } = await import("../src/profiles.js"); + vi.mocked(switchProfile).mockImplementation(() => { throw new Error('Profile "nope" not found'); }); + const result = executeCommand("/profile switch nope"); + expect(result[0]).toContain("not found"); + }); + + it("/profile create returns create marker", () => { + const result = executeCommand("/profile create"); + expect(result[0]).toBe("__CREATE_PROFILE__"); + expect(result[1]).toContain("Starting profile creation"); + }); + + it("/profile unknown subcommand shows profile help", () => { + const result = executeCommand("/profile unknown"); + expect(result[0]).toContain("Profile commands:"); + }); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index f14c241..ce8b746 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -19,15 +19,15 @@ describe("config", () => { it("exports default values when config file does not exist", async () => { const config = await import("../src/config.js"); const cfg = config.get(); - expect(cfg.agent_name).toBe(""); - expect(cfg.display_name).toBe(""); + expect(cfg.node_name).toBe(""); + expect(cfg.node_display_name).toBe(""); expect(cfg.inference_type).toBe("openrouter"); expect(cfg.fortytwo_api_base).toBe("https://app.fortytwo.network/api"); expect(cfg.poll_interval).toBe(120); - expect(cfg.llm_model).toBe("qwen/qwen3.5-35b-a3b"); + expect(cfg.model_name).toBe("qwen/qwen3.5-35b-a3b"); expect(cfg.llm_concurrency).toBe(40); expect(cfg.min_balance).toBe(5.0); - expect(cfg.bot_role).toBe("JUDGE"); + expect(cfg.node_role).toBe("JUDGE"); }); it("has correct hardcoded constants", async () => { @@ -50,12 +50,12 @@ describe("config", () => { it("loadConfig merges with defaults when file exists", async () => { vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ agent_name: "MyBot", poll_interval: 60 })); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ node_name: "MyBot", poll_interval: 60 })); const config = await import("../src/config.js"); const cfg = config.loadConfig(); - expect(cfg.agent_name).toBe("MyBot"); + expect(cfg.node_name).toBe("MyBot"); expect(cfg.poll_interval).toBe(60); - expect(cfg.bot_role).toBe("JUDGE"); + expect(cfg.node_role).toBe("JUDGE"); }); it("loadConfig returns defaults on parse error", async () => { @@ -63,8 +63,8 @@ describe("config", () => { vi.mocked(readFileSync).mockReturnValue("not json"); const config = await import("../src/config.js"); const cfg = config.loadConfig(); - expect(cfg.agent_name).toBe(""); - expect(cfg.bot_role).toBe("JUDGE"); + expect(cfg.node_name).toBe(""); + expect(cfg.node_role).toBe("JUDGE"); }); it("saveConfig creates dir and writes file", async () => { @@ -74,16 +74,16 @@ describe("config", () => { expect(mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true }); expect(writeFileSync).toHaveBeenCalled(); const written = vi.mocked(writeFileSync).mock.calls[0][1] as string; - expect(JSON.parse(written).bot_role).toBe("JUDGE"); + expect(JSON.parse(written).node_role).toBe("JUDGE"); }); it("reloadConfig updates live config", async () => { vi.mocked(existsSync).mockReturnValue(false); const config = await import("../src/config.js"); - expect(config.get().agent_name).toBe(""); + expect(config.get().node_name).toBe(""); vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ agent_name: "Reloaded" })); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ node_name: "Reloaded" })); config.reloadConfig(); - expect(config.get().agent_name).toBe("Reloaded"); + expect(config.get().node_name).toBe("Reloaded"); }); }); diff --git a/tests/identity.test.ts b/tests/identity.test.ts index 2ab39f5..f44059f 100644 --- a/tests/identity.test.ts +++ b/tests/identity.test.ts @@ -59,9 +59,9 @@ describe("identity", () => { it("returns identity when file has valid data", () => { vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ agent_id: "a1", secret: "s1" })); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ node_id: "a1", node_secret: "s1" })); const id = loadIdentity("id.json"); - expect(id!.agent_id).toBe("a1"); + expect(id!.node_id).toBe("a1"); }); it("returns null when missing required fields", () => { @@ -79,7 +79,7 @@ describe("identity", () => { describe("saveIdentity", () => { it("writes identity as formatted JSON", () => { - saveIdentity("out.json", { agent_id: "a1", secret: "s1" }); + saveIdentity("out.json", { node_id: "a1", node_secret: "s1" }); expect(writeFileSync).toHaveBeenCalledWith("out.json", expect.any(String)); }); }); @@ -99,8 +99,8 @@ describe("identity", () => { vi.mocked(llm.compareForRegistration).mockResolvedValue(1); const identity = await registerAgent(client, "TestBot", vi.fn()); - expect(identity.agent_id).toBe("new-agent"); - expect(identity.secret).toBe("new-secret"); + expect(identity.node_id).toBe("new-agent"); + expect(identity.node_secret).toBe("new-secret"); expect(writeFileSync).toHaveBeenCalled(); }); @@ -128,7 +128,7 @@ describe("identity", () => { .mockResolvedValueOnce(1); // c2 inverse const identity = await registerAgent(client, "Bot", vi.fn()); - expect(identity.agent_id).toBe("a"); + expect(identity.node_id).toBe("a"); }); it("handles challenge timeout (compareForRegistration throws)", async () => { @@ -149,7 +149,7 @@ describe("identity", () => { .mockRejectedValueOnce(new Error("timeout")) .mockResolvedValue(1); // tiebreak succeeds → net becomes non-zero const identity = await registerAgent(client, "Bot", vi.fn()); - expect(identity.agent_id).toBe("a"); + expect(identity.node_id).toBe("a"); }); it("retries when registration fails", async () => { @@ -169,7 +169,7 @@ describe("identity", () => { vi.mocked(llm.compareForRegistration).mockResolvedValue(1); const identity = await registerAgent(client, "Bot", vi.fn()); - expect(identity.agent_id).toBe("a"); + expect(identity.node_id).toBe("a"); expect(client.completeRegistration).toHaveBeenCalledTimes(2); }); @@ -192,7 +192,7 @@ describe("identity", () => { vi.mocked(llm.compareForRegistration).mockResolvedValue(1); const identity = await registerAgent(client, "Bot", vi.fn()); - expect(identity.agent_id).toBe("a"); + expect(identity.node_id).toBe("a"); expect(sleep).toHaveBeenCalled(); }); }); diff --git a/tests/llm.test.ts b/tests/llm.test.ts index 912499e..0818af3 100644 --- a/tests/llm.test.ts +++ b/tests/llm.test.ts @@ -1,20 +1,43 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const mockCreate = vi.fn(); vi.mock("openai", () => { + class APIError extends Error { + status: number; + constructor(status: number, msg = "") { + super(msg); + this.status = status; + } + } + class RateLimitError extends APIError { constructor(m = "") { super(429, m); } } + class AuthenticationError extends APIError { constructor(m = "") { super(401, m); } } + class PermissionDeniedError extends APIError { constructor(m = "") { super(403, m); } } + class BadRequestError extends APIError { constructor(m = "") { super(400, m); } } + class NotFoundError extends APIError { constructor(m = "") { super(404, m); } } + class APIConnectionError extends Error {} + class APIConnectionTimeoutError extends APIConnectionError {} + return { default: class { chat = { completions: { create: mockCreate } }; constructor() {} }, + APIError, + RateLimitError, + AuthenticationError, + PermissionDeniedError, + BadRequestError, + NotFoundError, + APIConnectionError, + APIConnectionTimeoutError, }; }); const mockLlmCfg: Record = { inference_type: "openrouter", openrouter_api_key: "test-key", - llm_model: "test-model", + model_name: "test-model", llm_concurrency: 2, llm_timeout: 10, }; @@ -45,6 +68,17 @@ describe("llm", () => { }); } + function mockStreamResponse(content: string) { + const chunks = content.split("").map((ch) => ({ + choices: [{ delta: { content: ch } }], + })); + mockCreate.mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + for (const chunk of chunks) yield chunk; + }, + }); + } + describe("callLlm", () => { it("sends prompt and returns response content", async () => { mockResponse("Hello World"); @@ -131,7 +165,7 @@ describe("llm", () => { describe("generateAnswer", () => { it("sends system + user messages", async () => { - mockResponse("The answer is 42"); + mockStreamResponse("The answer is 42"); const result = await generateAnswer("Be helpful", "What is 6*7?"); expect(result).toBe("The answer is 42"); const call = mockCreate.mock.calls[0]; @@ -207,4 +241,102 @@ describe("llm", () => { mockLlmCfg.openrouter_api_key = origKey; }); }); + + describe("OpenRouter error messages", () => { + let RateLimitError: any; + let AuthenticationError: any; + let PermissionDeniedError: any; + let BadRequestError: any; + let APIError: any; + let APIConnectionTimeoutError: any; + + beforeEach(async () => { + const mod = await import("openai"); + RateLimitError = (mod as any).RateLimitError; + AuthenticationError = (mod as any).AuthenticationError; + PermissionDeniedError = (mod as any).PermissionDeniedError; + BadRequestError = (mod as any).BadRequestError; + APIError = (mod as any).APIError; + APIConnectionTimeoutError = (mod as any).APIConnectionTimeoutError; + mockLlmCfg.inference_type = "openrouter"; + resetLlmClient(); + }); + + it("rate limit (429) shows OpenRouter message", async () => { + mockCreate.mockRejectedValue(new RateLimitError()); + await expect(callLlm("test")).rejects.toThrow("OpenRouter rate limit exceeded"); + }); + + it("authentication (401) shows OpenRouter message", async () => { + mockCreate.mockRejectedValue(new AuthenticationError()); + await expect(callLlm("test")).rejects.toThrow("OpenRouter authentication failed"); + }); + + it("permission denied (403) shows moderation message", async () => { + mockCreate.mockRejectedValue(new PermissionDeniedError()); + await expect(callLlm("test")).rejects.toThrow("OpenRouter rejected the request"); + }); + + it("bad request (400) shows OpenRouter message", async () => { + mockCreate.mockRejectedValue(new BadRequestError()); + await expect(callLlm("test")).rejects.toThrow("OpenRouter bad request"); + }); + + it("payment required (402) shows credits message", async () => { + mockCreate.mockRejectedValue(new APIError(402, "insufficient credits")); + await expect(callLlm("test")).rejects.toThrow("OpenRouter credits exhausted"); + }); + + it("bad gateway (502) shows unavailable message", async () => { + mockCreate.mockRejectedValue(new APIError(502, "bad gateway")); + await expect(callLlm("test")).rejects.toThrow("temporarily unavailable"); + }); + + it("service unavailable (503) shows unavailable message", async () => { + mockCreate.mockRejectedValue(new APIError(503, "no provider")); + await expect(callLlm("test")).rejects.toThrow("temporarily unavailable"); + }); + + it("timeout shows OpenRouter timeout message", async () => { + mockCreate.mockRejectedValue(new APIConnectionTimeoutError()); + await expect(callLlm("test")).rejects.toThrow("OpenRouter request timed out"); + }); + }); + + describe("local LLM error messages", () => { + let APIConnectionError: any; + let APIConnectionTimeoutError: any; + let NotFoundError: any; + + beforeEach(async () => { + const mod = await import("openai"); + APIConnectionError = (mod as any).APIConnectionError; + APIConnectionTimeoutError = (mod as any).APIConnectionTimeoutError; + NotFoundError = (mod as any).NotFoundError; + mockLlmCfg.inference_type = "self-hosted"; + mockLlmCfg.self_hosted_api_base = "http://localhost:11434/v1"; + resetLlmClient(); + }); + + afterEach(() => { + mockLlmCfg.inference_type = "openrouter"; + delete mockLlmCfg.self_hosted_api_base; + resetLlmClient(); + }); + + it("timeout shows local LLM message", async () => { + mockCreate.mockRejectedValue(new APIConnectionTimeoutError()); + await expect(callLlm("test")).rejects.toThrow("Local LLM at http://localhost:11434/v1 timed out"); + }); + + it("connection error shows local LLM message", async () => { + mockCreate.mockRejectedValue(new APIConnectionError()); + await expect(callLlm("test")).rejects.toThrow("Cannot connect to local LLM"); + }); + + it("not found shows model message", async () => { + mockCreate.mockRejectedValue(new NotFoundError()); + await expect(callLlm("test")).rejects.toThrow('Model "test-model" not found'); + }); + }); }); diff --git a/tests/main.test.ts b/tests/main.test.ts index be51833..5e64486 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -6,11 +6,11 @@ const mockCfg: Record = { fortytwo_api_base: "https://api.test.com", identity_file: "identity.json", poll_interval: 1, - llm_model: "test-model", + model_name: "test-model", llm_concurrency: 5, llm_timeout: 10, min_balance: 5.0, - bot_role: "JUDGE", + node_role: "JUDGE", answerer_system_prompt: "You are a helpful assistant.", }; @@ -20,7 +20,7 @@ vi.mock("../src/config.js", () => ({ })); const mockClient = { - agentId: "agent-1", + nodeId: "agent-1", login: vi.fn().mockResolvedValue({}), getPendingChallenges: vi.fn().mockResolvedValue({ challenges: [] }), getActiveQueries: vi.fn().mockResolvedValue({ queries: [] }), @@ -29,7 +29,7 @@ const mockClient = { vi.mock("../src/api-client.js", () => { class MockFortyTwoClient { - agentId = mockClient.agentId; + nodeId = mockClient.nodeId; login = mockClient.login; getPendingChallenges = mockClient.getPendingChallenges; getActiveQueries = mockClient.getActiveQueries; @@ -39,7 +39,7 @@ vi.mock("../src/api-client.js", () => { }); vi.mock("../src/identity.js", () => ({ - loadIdentity: vi.fn().mockReturnValue({ agent_id: "agent-1", secret: "sec" }), + loadIdentity: vi.fn().mockReturnValue({ node_id: "agent-1", secret: "sec" }), resetAccount: vi.fn().mockResolvedValue(undefined), })); @@ -64,6 +64,7 @@ vi.mock("../src/utils.js", () => ({ secondsUntilDeadline: vi.fn().mockReturnValue(600), setVerbose: vi.fn(), log: vi.fn(), + getRoleLabel: vi.fn((v) => v), })); import { @@ -225,7 +226,7 @@ describe("processQueries", () => { describe("runCycle", () => { beforeEach(() => { vi.clearAllMocks(); - mockCfg.bot_role = "JUDGE"; + mockCfg.node_role = "JUDGE"; }); it("processes challenges for JUDGE role", async () => { @@ -238,7 +239,7 @@ describe("runCycle", () => { }); it("processes queries for ANSWERER role", async () => { - mockCfg.bot_role = "ANSWERER"; + mockCfg.node_role = "ANSWERER"; mockClient.getActiveQueries.mockResolvedValue({ queries: [] }); const count = await runCycle(mockClient as any); expect(count).toBe(0); @@ -247,7 +248,7 @@ describe("runCycle", () => { }); it("processes both for ANSWERER_AND_JUDGE role", async () => { - mockCfg.bot_role = "ANSWERER_AND_JUDGE"; + mockCfg.node_role = "ANSWERER_AND_JUDGE"; vi.mocked(isLlmBusy).mockReturnValue(false); mockClient.getActiveQueries.mockResolvedValue({ queries: [] }); mockClient.getPendingChallenges.mockResolvedValue({ challenges: [] }); @@ -258,20 +259,20 @@ describe("runCycle", () => { }); it("logs warning for unknown role", async () => { - mockCfg.bot_role = "UNKNOWN"; + mockCfg.node_role = "UNKNOWN"; const count = await runCycle(mockClient as any); expect(count).toBe(0); - expect(log).toHaveBeenCalledWith(expect.stringContaining("Unknown BOT_ROLE")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Unknown NODE_ROLE")); }); }); describe("main", () => { beforeEach(() => { vi.clearAllMocks(); - mockCfg.bot_role = "JUDGE"; + mockCfg.node_role = "JUDGE"; mockCfg.inference_type = "openrouter"; mockCfg.openrouter_api_key = "test-key"; - vi.mocked(loadIdentity).mockReturnValue({ agent_id: "agent-1", secret: "sec" }); + vi.mocked(loadIdentity).mockReturnValue({ node_id: "agent-1", secret: "sec" }); }); it("runs one cycle then stops on abort", async () => { @@ -306,13 +307,13 @@ describe("main", () => { mockCfg.openrouter_api_key = "test-key"; }); - it("exits on invalid bot_role", async () => { - mockCfg.bot_role = "BAD_ROLE"; + it("exits on invalid node_role", async () => { + mockCfg.node_role = "BAD_ROLE"; const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); }); await expect(main()).rejects.toThrow("exit"); expect(exitSpy).toHaveBeenCalledWith(1); exitSpy.mockRestore(); - mockCfg.bot_role = "JUDGE"; + mockCfg.node_role = "JUDGE"; }); it("resets account on InsufficientFundsError", async () => { @@ -352,7 +353,7 @@ describe("main", () => { }); it("skips API key check for local inference", async () => { - mockCfg.inference_type = "local"; + mockCfg.inference_type = "self-hosted"; mockCfg.openrouter_api_key = ""; mockClient.login.mockResolvedValue({}); vi.mocked(isLlmBusy).mockReturnValue(false); diff --git a/tests/profiles.test.ts b/tests/profiles.test.ts new file mode 100644 index 0000000..e7354ba --- /dev/null +++ b/tests/profiles.test.ts @@ -0,0 +1,492 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + existsSync: vi.fn().mockReturnValue(false), + rmSync: vi.fn(), +})); + +vi.mock("../src/config.js", () => ({ + CONFIG_DIR: "/tmp/.fortytwo", + setConfigDir: vi.fn(), + reloadConfig: vi.fn(), +})); + +vi.mock("../src/identity.js", () => ({ + loadIdentity: vi.fn().mockReturnValue(null), +})); + +import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs"; +import { loadIdentity } from "../src/identity.js"; +import { setConfigDir, reloadConfig } from "../src/config.js"; + +describe("profiles", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.mocked(existsSync).mockReturnValue(false); + + delete process.env.FORTYTWO_PROFILE; + }); + + describe("sanitizeProfileName", () => { + it("lowercases and replaces spaces with hyphens", async () => { + const { sanitizeProfileName } = await import("../src/profiles.js"); + expect(sanitizeProfileName("My Bot")).toBe("my-bot"); + }); + + it("strips special characters", async () => { + const { sanitizeProfileName } = await import("../src/profiles.js"); + expect(sanitizeProfileName("bot@v2!")).toBe("botv2"); + }); + + it("collapses multiple hyphens", async () => { + const { sanitizeProfileName } = await import("../src/profiles.js"); + expect(sanitizeProfileName("my--bot---name")).toBe("my-bot-name"); + }); + + it("trims leading/trailing hyphens", async () => { + const { sanitizeProfileName } = await import("../src/profiles.js"); + expect(sanitizeProfileName("-bot-")).toBe("bot"); + }); + + it("returns 'default' for empty result", async () => { + const { sanitizeProfileName } = await import("../src/profiles.js"); + expect(sanitizeProfileName("")).toBe("default"); + expect(sanitizeProfileName("!!!")).toBe("default"); + }); + + it("preserves underscores and digits", async () => { + const { sanitizeProfileName } = await import("../src/profiles.js"); + expect(sanitizeProfileName("bot_v2_test")).toBe("bot_v2_test"); + }); + }); + + describe("loadProfilesMeta", () => { + it("returns null when file does not exist", async () => { + const { loadProfilesMeta } = await import("../src/profiles.js"); + expect(loadProfilesMeta()).toBeNull(); + }); + + it("parses valid JSON", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "bot", profiles: ["bot"] }), + ); + const { loadProfilesMeta } = await import("../src/profiles.js"); + expect(loadProfilesMeta()).toEqual({ active: "bot", profiles: ["bot"] }); + }); + + it("returns null on invalid JSON", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("not json"); + const { loadProfilesMeta } = await import("../src/profiles.js"); + expect(loadProfilesMeta()).toBeNull(); + }); + }); + + describe("saveProfilesMeta", () => { + it("creates dir and writes JSON", async () => { + const { saveProfilesMeta } = await import("../src/profiles.js"); + saveProfilesMeta({ active: "test", profiles: ["test"] }); + expect(mkdirSync).toHaveBeenCalledWith("/tmp/.fortytwo", { recursive: true }); + expect(writeFileSync).toHaveBeenCalled(); + const written = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(JSON.parse(written)).toEqual({ active: "test", profiles: ["test"] }); + }); + }); + + describe("getActiveProfileName", () => { + it("returns meta active when no override or env", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "my-bot", profiles: ["my-bot"] }), + ); + const { getActiveProfileName } = await import("../src/profiles.js"); + expect(getActiveProfileName()).toBe("my-bot"); + }); + + it("returns 'default' when no meta exists", async () => { + const { getActiveProfileName } = await import("../src/profiles.js"); + expect(getActiveProfileName()).toBe("default"); + }); + + it("env var overrides meta", async () => { + process.env.FORTYTWO_PROFILE = "env-bot"; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "meta-bot", profiles: ["meta-bot"] }), + ); + const { getActiveProfileName } = await import("../src/profiles.js"); + expect(getActiveProfileName()).toBe("env-bot"); + }); + + it("setProfileOverride overrides env and meta", async () => { + process.env.FORTYTWO_PROFILE = "env-bot"; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "meta-bot", profiles: ["meta-bot"] }), + ); + const { getActiveProfileName, setProfileOverride } = await import("../src/profiles.js"); + setProfileOverride("flag-bot"); + expect(getActiveProfileName()).toBe("flag-bot"); + + setProfileOverride(undefined); + }); + }); + + describe("createProfile", () => { + it("creates dir, writes config and identity, updates meta", async () => { + vi.mocked(existsSync).mockReturnValue(false); + + const { createProfile } = await import("../src/profiles.js"); + const cfg = { + node_name: "TestBot", + node_display_name: "TestBot", + inference_type: "openrouter" as const, + openrouter_api_key: "key", + llm_api_base: "", + fortytwo_api_base: "https://app.fortytwo.network/", + self_hosted_api_base: "", + node_identity_file: "", + poll_interval: 120, + model_name: "test", + llm_concurrency: 40, + llm_timeout: 120, + min_balance: 5.0, + node_role: "JUDGE", + answerer_system_prompt: "You are a helpful assistant.", + }; + + createProfile("testbot", cfg, { node_id: "agent-1", node_secret: "sec" }); + + expect(mkdirSync).toHaveBeenCalledWith( + expect.stringContaining("profiles/testbot"), + { recursive: true }, + ); + + const configCall = vi.mocked(writeFileSync).mock.calls.find( + (c) => (c[0] as string).endsWith("profiles/testbot/config.json"), + ); + expect(configCall).toBeDefined(); + const writtenCfg = JSON.parse(configCall![1] as string); + expect(writtenCfg.node_identity_file).toContain("profiles/testbot/identity.json"); + expect(writtenCfg.node_name).toBe("TestBot"); + + const idCall = vi.mocked(writeFileSync).mock.calls.find( + (c) => (c[0] as string).endsWith("profiles/testbot/identity.json"), + ); + expect(idCall).toBeDefined(); + const writtenId = JSON.parse(idCall![1] as string); + expect(writtenId.node_id).toBe("agent-1"); + + const metaCall = vi.mocked(writeFileSync).mock.calls.find( + (c) => (c[0] as string).endsWith("profiles.json"), + ); + expect(metaCall).toBeDefined(); + const writtenMeta = JSON.parse(metaCall![1] as string); + expect(writtenMeta.active).toBe("testbot"); + expect(writtenMeta.profiles).toContain("testbot"); + + expect(setConfigDir).toHaveBeenCalledWith(expect.stringContaining("profiles/testbot")); + expect(reloadConfig).toHaveBeenCalled(); + }); + + it("does not write identity file when no identity provided", async () => { + vi.mocked(existsSync).mockReturnValue(false); + const { createProfile } = await import("../src/profiles.js"); + const cfg = { + node_name: "Bot", node_display_name: "Bot", inference_type: "openrouter" as const, + openrouter_api_key: "", self_hosted_api_base: "", fortytwo_api_base: "", + node_identity_file: "", poll_interval: 120, model_name: "", llm_concurrency: 40, + llm_timeout: 120, min_balance: 5, node_role: "JUDGE", answerer_system_prompt: "", + }; + createProfile("bot", cfg); + + const idCall = vi.mocked(writeFileSync).mock.calls.find( + (c) => (c[0] as string).endsWith("identity.json") && !(c[0] as string).endsWith("profiles.json"), + ); + const allPaths = vi.mocked(writeFileSync).mock.calls.map((c) => c[0] as string); + expect(allPaths.some((p) => p.endsWith("bot/identity.json"))).toBe(false); + }); + + it("does not duplicate profile in meta", async () => { + vi.mocked(existsSync).mockImplementation((path) => { + return (path as string).endsWith("profiles.json"); + }); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "bot", profiles: ["bot"] }), + ); + + const { createProfile } = await import("../src/profiles.js"); + const cfg = { + node_name: "Bot", node_display_name: "Bot", inference_type: "openrouter" as const, + openrouter_api_key: "", self_hosted_api_base: "", fortytwo_api_base: "", + node_identity_file: "", poll_interval: 120, model_name: "", llm_concurrency: 40, + llm_timeout: 120, min_balance: 5, node_role: "JUDGE", answerer_system_prompt: "", + }; + createProfile("bot", cfg); + + const metaCall = vi.mocked(writeFileSync).mock.calls.find( + (c) => (c[0] as string).endsWith("profiles.json"), + ); + const writtenMeta = JSON.parse(metaCall![1] as string); + expect(writtenMeta.profiles.filter((p: string) => p === "bot").length).toBe(1); + }); + }); + + describe("deleteProfile", () => { + it("throws when no profiles exist", async () => { + const { deleteProfile } = await import("../src/profiles.js"); + expect(() => deleteProfile("bot")).toThrow("No profiles found"); + }); + + it("throws when deleting active profile", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "bot", profiles: ["bot", "other"] }), + ); + const { deleteProfile } = await import("../src/profiles.js"); + expect(() => deleteProfile("bot")).toThrow("Cannot delete the active profile"); + }); + + it("throws when profile not found", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "bot", profiles: ["bot"] }), + ); + const { deleteProfile } = await import("../src/profiles.js"); + expect(() => deleteProfile("nonexistent")).toThrow('not found'); + }); + + it("removes profile dir and updates meta", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "bot", profiles: ["bot", "other"] }), + ); + const { deleteProfile } = await import("../src/profiles.js"); + deleteProfile("other"); + + expect(rmSync).toHaveBeenCalledWith( + expect.stringContaining("profiles/other"), + { recursive: true, force: true }, + ); + + const metaCall = vi.mocked(writeFileSync).mock.calls.find( + (c) => (c[0] as string).endsWith("profiles.json"), + ); + const writtenMeta = JSON.parse(metaCall![1] as string); + expect(writtenMeta.profiles).toEqual(["bot"]); + expect(writtenMeta.active).toBe("bot"); + }); + }); + + describe("switchProfile", () => { + it("throws when no profiles exist", async () => { + const { switchProfile } = await import("../src/profiles.js"); + expect(() => switchProfile("bot")).toThrow("No profiles found"); + }); + + it("throws when profile not found", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "bot", profiles: ["bot"] }), + ); + const { switchProfile } = await import("../src/profiles.js"); + expect(() => switchProfile("nonexistent")).toThrow("not found"); + }); + + it("updates meta and calls setConfigDir + reloadConfig", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "bot", profiles: ["bot", "other"] }), + ); + const { switchProfile } = await import("../src/profiles.js"); + switchProfile("other"); + + const metaCall = vi.mocked(writeFileSync).mock.calls.find( + (c) => (c[0] as string).endsWith("profiles.json"), + ); + const writtenMeta = JSON.parse(metaCall![1] as string); + expect(writtenMeta.active).toBe("other"); + + expect(setConfigDir).toHaveBeenCalledWith(expect.stringContaining("profiles/other")); + expect(reloadConfig).toHaveBeenCalled(); + }); + }); + + describe("listProfiles", () => { + it("returns empty array when no meta", async () => { + const { listProfiles } = await import("../src/profiles.js"); + expect(listProfiles()).toEqual([]); + }); + + it("returns empty array when no profiles in meta", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ active: "default", profiles: [] }), + ); + const { listProfiles } = await import("../src/profiles.js"); + expect(listProfiles()).toEqual([]); + }); + + it("returns profile info with active marker", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = path as string; + if (p.endsWith("profiles.json")) { + return JSON.stringify({ active: "bot-a", profiles: ["bot-a", "bot-b"] }); + } + if (p.includes("bot-a") && p.endsWith("config.json")) { + return JSON.stringify({ node_name: "BotA" }); + } + if (p.includes("bot-b") && p.endsWith("config.json")) { + return JSON.stringify({ node_name: "BotB" }); + } + return "{}"; + }); + vi.mocked(loadIdentity).mockImplementation((path) => { + if (path.includes("bot-a")) return { node_id: "id-aaa-bbb", node_secret: "s" }; + return null; + }); + + const { listProfiles } = await import("../src/profiles.js"); + const profiles = listProfiles(); + + expect(profiles).toHaveLength(2); + expect(profiles[0]).toEqual({ + name: "bot-a", + active: true, + agentName: "BotA", + nodeId: "id-aaa-bbb", + }); + expect(profiles[1]).toEqual({ + name: "bot-b", + active: false, + agentName: "BotB", + nodeId: "", + }); + }); + }); + + describe("migrateIfNeeded", () => { + it("does nothing when profiles.json already exists", async () => { + vi.mocked(existsSync).mockImplementation((path) => { + return (path as string).endsWith("profiles.json"); + }); + const { migrateIfNeeded } = await import("../src/profiles.js"); + migrateIfNeeded(); + expect(writeFileSync).not.toHaveBeenCalled(); + }); + + it("creates empty meta for fresh install (no legacy config)", async () => { + vi.mocked(existsSync).mockReturnValue(false); + const { migrateIfNeeded } = await import("../src/profiles.js"); + migrateIfNeeded(); + + expect(writeFileSync).toHaveBeenCalledTimes(1); + const metaCall = vi.mocked(writeFileSync).mock.calls[0]; + const written = JSON.parse(metaCall[1] as string); + expect(written).toEqual({ active: "default", profiles: [] }); + }); + + it("migrates legacy config and identity", async () => { + const legacyCfg = { + agent_name: "My Judge Bot", + inference_type: "openrouter", + node_identity_file: "/tmp/.fortytwo/identity.json", + }; + const legacyIdentity = JSON.stringify({ agent_id: "old-id", node_secret: "old-sec" }); + + vi.mocked(existsSync).mockImplementation((path) => { + const p = path as string; + if (p.endsWith("profiles.json")) return false; + if (p.endsWith(".fortytwo/config.json")) return true; + if (p.endsWith(".fortytwo/identity.json")) return true; + return false; + }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = path as string; + if (p.endsWith(".fortytwo/config.json")) return JSON.stringify(legacyCfg); + if (p.endsWith(".fortytwo/identity.json")) return legacyIdentity; + throw new Error(`Unexpected read: ${p}`); + }); + + const { migrateIfNeeded } = await import("../src/profiles.js"); + migrateIfNeeded(); + + expect(mkdirSync).toHaveBeenCalledWith( + expect.stringContaining("profiles/my-judge-bot"), + { recursive: true }, + ); + + const cfgCall = vi.mocked(writeFileSync).mock.calls.find( + (c) => (c[0] as string).includes("profiles/my-judge-bot/config.json"), + ); + expect(cfgCall).toBeDefined(); + const writtenCfg = JSON.parse(cfgCall![1] as string); + expect(writtenCfg.node_identity_file).toContain("profiles/my-judge-bot/identity.json"); + expect(writtenCfg.agent_name).toBe("My Judge Bot"); + + const idCall = vi.mocked(writeFileSync).mock.calls.find( + (c) => (c[0] as string).includes("profiles/my-judge-bot/identity.json"), + ); + expect(idCall).toBeDefined(); + expect(idCall![1]).toBe(legacyIdentity); + + const metaCall = vi.mocked(writeFileSync).mock.calls.find( + (c) => (c[0] as string).endsWith("profiles.json"), + ); + expect(metaCall).toBeDefined(); + const writtenMeta = JSON.parse(metaCall![1] as string); + expect(writtenMeta.active).toBe("my-judge-bot"); + expect(writtenMeta.profiles).toEqual(["my-judge-bot"]); + }); + + it("handles corrupt legacy config gracefully", async () => { + vi.mocked(existsSync).mockImplementation((path) => { + const p = path as string; + if (p.endsWith("profiles.json")) return false; + if (p.endsWith(".fortytwo/config.json")) return true; + return false; + }); + vi.mocked(readFileSync).mockReturnValue("not valid json"); + + const { migrateIfNeeded } = await import("../src/profiles.js"); + migrateIfNeeded(); + + const metaCall = vi.mocked(writeFileSync).mock.calls[0]; + const written = JSON.parse(metaCall[1] as string); + expect(written).toEqual({ active: "default", profiles: [] }); + }); + }); + + describe("initProfiles", () => { + it("calls migrateIfNeeded, setConfigDir and reloadConfig", async () => { + + vi.mocked(existsSync).mockReturnValue(false); + const { initProfiles } = await import("../src/profiles.js"); + initProfiles(); + + expect(setConfigDir).toHaveBeenCalledWith(expect.stringContaining("profiles/")); + expect(reloadConfig).toHaveBeenCalled(); + }); + }); + + describe("profileExists", () => { + it("returns false when config.json does not exist", async () => { + vi.mocked(existsSync).mockReturnValue(false); + const { profileExists } = await import("../src/profiles.js"); + expect(profileExists("bot")).toBe(false); + }); + + it("returns true when config.json exists", async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const { profileExists } = await import("../src/profiles.js"); + expect(profileExists("bot")).toBe(true); + }); + }); +}); diff --git a/tests/setup-logic.test.ts b/tests/setup-logic.test.ts index d37a4c1..f73dbf5 100644 --- a/tests/setup-logic.test.ts +++ b/tests/setup-logic.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("../src/config.js", () => ({ CONFIG_DIR: "/tmp/.fortytwo", + getConfigDir: () => "/tmp/.fortytwo", })); import { validateModel, buildConfig, OPENROUTER_BASE } from "../src/setup-logic.js"; @@ -28,7 +29,7 @@ describe("setup-logic", () => { const result = await validateModel({ inference_type: "openrouter", openrouter_api_key: "key", - llm_model: "test-model", + model_name: "test-model", }); expect(result.ok).toBe(true); }); @@ -41,7 +42,7 @@ describe("setup-logic", () => { const result = await validateModel({ inference_type: "openrouter", openrouter_api_key: "key", - llm_model: "missing-model", + model_name: "missing-model", }); expect(result.ok).toBe(false); expect(result.error).toContain("not found"); @@ -53,9 +54,9 @@ describe("setup-logic", () => { json: async () => ({ data: [] }), }); const result = await validateModel({ - inference_type: "local", - llm_api_base: "http://localhost:11434/v1", - llm_model: "llama3", + inference_type: "self-hosted", + self_hosted_api_base: "http://localhost:11434/v1", + model_name: "llama3", }); expect(result.ok).toBe(true); }); @@ -63,9 +64,9 @@ describe("setup-logic", () => { it("returns error on network failure", async () => { globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); const result = await validateModel({ - inference_type: "local", - llm_api_base: "http://localhost:11434/v1", - llm_model: "llama3", + inference_type: "self-hosted", + self_hosted_api_base: "http://localhost:11434/v1", + model_name: "llama3", }); expect(result.ok).toBe(false); expect(result.error).toContain("Cannot reach"); @@ -79,7 +80,7 @@ describe("setup-logic", () => { const result = await validateModel({ inference_type: "openrouter", openrouter_api_key: "bad-key", - llm_model: "test", + model_name: "test", }); expect(result.ok).toBe(false); expect(result.error).toContain("Invalid API key"); @@ -91,9 +92,9 @@ describe("setup-logic", () => { status: 401, }); const result = await validateModel({ - inference_type: "local", - llm_api_base: "http://localhost:11434/v1", - llm_model: "test", + inference_type: "self-hosted", + self_hosted_api_base: "http://localhost:11434/v1", + model_name: "test", }); expect(result.ok).toBe(false); expect(result.error).toContain("Auth rejected"); @@ -107,7 +108,7 @@ describe("setup-logic", () => { const result = await validateModel({ inference_type: "openrouter", openrouter_api_key: "key", - llm_model: "test", + model_name: "test", }); expect(result.ok).toBe(false); expect(result.error).toContain("API returned 500"); @@ -121,7 +122,7 @@ describe("setup-logic", () => { const result = await validateModel({ inference_type: "openrouter", openrouter_api_key: "key", - llm_model: "test", + model_name: "test", }); expect(result.ok).toBe(true); }); @@ -133,9 +134,9 @@ describe("setup-logic", () => { }); globalThis.fetch = fetchMock; await validateModel({ - inference_type: "local", - llm_api_base: "http://localhost:11434/v1/", - llm_model: "llama3", + inference_type: "self-hosted", + self_hosted_api_base: "http://localhost:11434/v1/", + model_name: "llama3", }); expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/v1/models"); }); @@ -152,70 +153,70 @@ describe("setup-logic", () => { const result = await validateModel({ inference_type: "openrouter", openrouter_api_key: "key", - llm_model: "missing", + model_name: "missing", }); expect(result.ok).toBe(false); - expect(result.error).toContain("+1 more"); + expect(result.error).toContain("Model \"missing\" not found. Choose correct one and restart the client."); }); }); describe("buildConfig", () => { it("builds openrouter config", () => { const cfg = buildConfig({ - agent_name: "Bot", + node_name: "Bot", inference_type: "openrouter", openrouter_api_key: "sk-or-xxx", - llm_model: "test-model", - bot_role: "JUDGE", + model_name: "test-model", + node_role: "JUDGE", }); - expect(cfg.agent_name).toBe("Bot"); + expect(cfg.node_name).toBe("Bot"); expect(cfg.inference_type).toBe("openrouter"); expect(cfg.openrouter_api_key).toBe("sk-or-xxx"); - expect(cfg.llm_model).toBe("test-model"); - expect(cfg.bot_role).toBe("JUDGE"); + expect(cfg.model_name).toBe("test-model"); + expect(cfg.node_role).toBe("JUDGE"); expect(cfg.poll_interval).toBe(120); - expect(cfg.identity_file).toContain("identity.json"); + expect(cfg.node_identity_file).toContain("identity.json"); }); it("builds local config", () => { const cfg = buildConfig({ - agent_name: "LocalBot", - inference_type: "local", - llm_api_base: "http://localhost:11434/v1", - llm_model: "llama3", - bot_role: "ANSWERER", + node_name: "LocalBot", + inference_type: "self-hosted", + self_hosted_api_base: "http://localhost:11434/v1", + model_name: "llama3", + node_role: "ANSWERER", }); - expect(cfg.inference_type).toBe("local"); - expect(cfg.llm_api_base).toBe("http://localhost:11434/v1"); + expect(cfg.inference_type).toBe("self-hosted"); + expect(cfg.self_hosted_api_base).toBe("http://localhost:11434/v1"); }); - it("uses _display_name as fallback for agent_name", () => { + it("uses node_display_name as fallback for node_name", () => { const cfg = buildConfig({ - _display_name: "ServerName", + node_display_name: "ServerName", inference_type: "openrouter", - llm_model: "m", + model_name: "m", }); - expect(cfg.agent_name).toBe("ServerName"); - expect(cfg.display_name).toBe("ServerName"); + expect(cfg.node_name).toBe("ServerName"); + expect(cfg.node_display_name).toBe("ServerName"); }); - it("uses agent_id as fallback when no name", () => { + it("uses node_id as fallback when no name", () => { const cfg = buildConfig({ - agent_id: "uuid-123", + node_id: "uuid-123", inference_type: "openrouter", - llm_model: "m", + model_name: "m", }); - expect(cfg.agent_name).toBe("uuid-123"); + expect(cfg.node_name).toBe("uuid-123"); }); - it("defaults bot_role to JUDGE", () => { - const cfg = buildConfig({ inference_type: "openrouter", llm_model: "m" }); - expect(cfg.bot_role).toBe("JUDGE"); + it("defaults node_role to JUDGE", () => { + const cfg = buildConfig({ inference_type: "openrouter", model_name: "m" }); + expect(cfg.node_role).toBe("JUDGE"); }); - it("defaults llm_model for openrouter", () => { + it("defaults model_name for openrouter", () => { const cfg = buildConfig({ inference_type: "openrouter" }); - expect(cfg.llm_model).toBe("qwen/qwen3.5-35b-a3b"); + expect(cfg.model_name).toBe("qwen/qwen3.5-35b-a3b"); }); }); }); diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts new file mode 100644 index 0000000..d6724a3 --- /dev/null +++ b/tests/update-check.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { isNewerVersion } from "../src/update-check.js"; + +// Mock config module +vi.mock("../src/config.js", () => ({ + getConfigDir: () => "/tmp/test-fortytwo", +})); + +// Mock fs to control cache behavior +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(() => { throw new Error("no cache"); }), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +describe("isNewerVersion", () => { + it("detects newer major", () => { + expect(isNewerVersion("1.0.0", "0.9.9")).toBe(true); + }); + + it("detects newer minor", () => { + expect(isNewerVersion("0.2.0", "0.1.4")).toBe(true); + }); + + it("detects newer patch", () => { + expect(isNewerVersion("0.1.5", "0.1.4")).toBe(true); + }); + + it("returns false for same version", () => { + expect(isNewerVersion("0.1.4", "0.1.4")).toBe(false); + }); + + it("returns false for older version", () => { + expect(isNewerVersion("0.1.3", "0.1.4")).toBe(false); + }); + + it("handles major difference correctly", () => { + expect(isNewerVersion("2.0.0", "1.9.9")).toBe(true); + }); +}); + +describe("checkForUpdate", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + vi.resetModules(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("fetches latest version from registry", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ version: "99.0.0" }), + }) as any; + + const { checkForUpdate } = await import("../src/update-check.js"); + const result = await checkForUpdate(); + + expect(result).not.toBeNull(); + expect(result!.latestVersion).toBe("99.0.0"); + expect(result!.updateAvailable).toBe(true); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining("registry.npmjs.org"), + expect.any(Object), + ); + }); + + it("returns null on network failure", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("network error")) as any; + + const { checkForUpdate } = await import("../src/update-check.js"); + const result = await checkForUpdate(); + + expect(result).toBeNull(); + }); + + it("uses cache when fresh", async () => { + const fs = await import("node:fs"); + const now = Date.now(); + (fs.readFileSync as any).mockReturnValue( + JSON.stringify({ lastCheck: now, latestVersion: "0.2.0" }), + ); + + globalThis.fetch = vi.fn() as any; + + const { checkForUpdate } = await import("../src/update-check.js"); + const result = await checkForUpdate(); + + expect(result).not.toBeNull(); + expect(result!.latestVersion).toBe("0.2.0"); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); +}); + +describe("getCachedUpdate", () => { + it("returns null when no cache exists", async () => { + const fs = await import("node:fs"); + (fs.readFileSync as any).mockImplementation(() => { throw new Error("no file"); }); + + const { getCachedUpdate } = await import("../src/update-check.js"); + const result = getCachedUpdate(); + + expect(result).toBeNull(); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 81953af..14efe6f 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -11,6 +11,9 @@ import { unpinTask, getPinnedTasks, mapWithConcurrency, + getRoleLabel, + formatNumber, + truncateName, } from "../src/utils.js"; describe("secondsUntilDeadline", () => { @@ -188,3 +191,72 @@ describe("mapWithConcurrency", () => { expect(results).toEqual([1, 2]); }); }); + +describe("getRoleLabel", () => { + it("returns human-readable label for onboard context", () => { + expect(getRoleLabel("ANSWERER_AND_JUDGE", "onboard")).toBe("ANSWERER & JUDGE — both"); + expect(getRoleLabel("JUDGE", "onboard")).toBe("JUDGE — only judge challenges"); + expect(getRoleLabel("ANSWERER", "onboard")).toBe("ANSWERER — only answer queries"); + }); + + it("returns short display name for bot context", () => { + expect(getRoleLabel("ANSWERER_AND_JUDGE", "bot")).toBe("ANSWERER & JUDGE"); + expect(getRoleLabel("JUDGE", "bot")).toBe("JUDGE"); + expect(getRoleLabel("ANSWERER", "bot")).toBe("ANSWERER"); + }); + + it("defaults to bot context", () => { + expect(getRoleLabel("JUDGE")).toBe("JUDGE"); + }); + + it("returns identity if value not found", () => { + expect(getRoleLabel("UNKNOWN")).toBe("UNKNOWN"); + }); +}); + +describe("formatNumber", () => { + it("formats small numbers correctly", () => { + expect(formatNumber(123)).toBe("123"); + expect(formatNumber(123.45)).toBe("123.45"); + expect(formatNumber(123.45678, 2)).toBe("123.45"); + }); + + it("adds commas for thousands", () => { + expect(formatNumber(1234)).toBe("1,234"); + expect(formatNumber(1234567)).toBe("1.234M"); + }); + + it("uses suffixes for large numbers", () => { + expect(formatNumber(1000000)).toBe("1M"); + expect(formatNumber(1500000)).toBe("1.5M"); + expect(formatNumber(1000000000)).toBe("1B"); + }); + + it("handles negative numbers", () => { + expect(formatNumber(-123.45)).toBe("-123.45"); + expect(formatNumber(-1000000)).toBe("-1M"); + }); + + it("handles string input", () => { + expect(formatNumber("123.45")).toBe("123.45"); + }); + + it("returns '0' for invalid input", () => { + expect(formatNumber("abc")).toBe("0"); + }); +}); + +describe("truncateName", () => { + it("does not truncate short names", () => { + expect(truncateName("Short Name")).toBe("Short Name"); + }); + + it("truncates long names", () => { + expect(truncateName("This is a very long name that should be truncated", 10)).toBe("This is a ..."); + }); + + it("uses default limit of 33", () => { + const longName = "A".repeat(40); + expect(truncateName(longName)).toBe("A".repeat(33) + "..."); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9e40716..1605299 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "rootDir": "src", "declaration": true, "sourceMap": true, - "skipLibCheck": true + "skipLibCheck": true, + "resolveJsonModule": true }, "include": ["src"] } diff --git a/viewer/.gitignore b/viewer/.gitignore new file mode 100644 index 0000000..7c8ed23 --- /dev/null +++ b/viewer/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.next/ +out/ diff --git a/viewer/index.html b/viewer/index.html new file mode 100644 index 0000000..e9da695 --- /dev/null +++ b/viewer/index.html @@ -0,0 +1,13 @@ + + + + + + Node Vision — Fortytwo + + + +
+ + + diff --git a/viewer/package-lock.json b/viewer/package-lock.json new file mode 100644 index 0000000..6e5c59e --- /dev/null +++ b/viewer/package-lock.json @@ -0,0 +1,2627 @@ +{ + "name": "fortytwo-node-vision", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fortytwo-node-vision", + "version": "1.0.0", + "dependencies": { + "framer-motion": "^12.34.3", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.5.2", + "autoprefixer": "^10.4.27", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "typescript": "^5.7.0", + "vite": "^6.3.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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 + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.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" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "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 + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "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.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "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, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/viewer/package.json b/viewer/package.json new file mode 100644 index 0000000..e5a3186 --- /dev/null +++ b/viewer/package.json @@ -0,0 +1,26 @@ +{ + "name": "fortytwo-node-vision", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "framer-motion": "^12.34.3", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.5.2", + "autoprefixer": "^10.4.27", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "typescript": "^5.7.0", + "vite": "^6.3.5" + } +} diff --git a/viewer/postcss.config.cjs b/viewer/postcss.config.cjs new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/viewer/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/viewer/public/fonts/.gitkeep b/viewer/public/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/viewer/public/fonts/HafferXH-TRIAL-Black.woff2 b/viewer/public/fonts/HafferXH-TRIAL-Black.woff2 new file mode 100644 index 0000000..18c08e3 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-Black.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-BlackItalic.woff2 b/viewer/public/fonts/HafferXH-TRIAL-BlackItalic.woff2 new file mode 100644 index 0000000..3c47b3b Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-BlackItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-Bold.woff2 b/viewer/public/fonts/HafferXH-TRIAL-Bold.woff2 new file mode 100644 index 0000000..8938ec4 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-Bold.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-BoldItalic.woff2 b/viewer/public/fonts/HafferXH-TRIAL-BoldItalic.woff2 new file mode 100644 index 0000000..6d898d4 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-BoldItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-Heavy.woff2 b/viewer/public/fonts/HafferXH-TRIAL-Heavy.woff2 new file mode 100644 index 0000000..f9c19f1 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-Heavy.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-HeavyItalic.woff2 b/viewer/public/fonts/HafferXH-TRIAL-HeavyItalic.woff2 new file mode 100644 index 0000000..4c3f6fa Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-HeavyItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-Light.woff2 b/viewer/public/fonts/HafferXH-TRIAL-Light.woff2 new file mode 100644 index 0000000..e8f4229 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-Light.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-LightItalic.woff2 b/viewer/public/fonts/HafferXH-TRIAL-LightItalic.woff2 new file mode 100644 index 0000000..65d7a12 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-LightItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-Medium.woff2 b/viewer/public/fonts/HafferXH-TRIAL-Medium.woff2 new file mode 100644 index 0000000..631313e Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-Medium.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-MediumItalic.woff2 b/viewer/public/fonts/HafferXH-TRIAL-MediumItalic.woff2 new file mode 100644 index 0000000..cb5e5b7 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-MediumItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-Regular.woff2 b/viewer/public/fonts/HafferXH-TRIAL-Regular.woff2 new file mode 100644 index 0000000..2907c53 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-Regular.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-RegularItalic.woff2 b/viewer/public/fonts/HafferXH-TRIAL-RegularItalic.woff2 new file mode 100644 index 0000000..10d4eda Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-RegularItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-SemiBold.woff2 b/viewer/public/fonts/HafferXH-TRIAL-SemiBold.woff2 new file mode 100644 index 0000000..106bd07 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-SemiBold.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-SemiBoldItalic.woff2 b/viewer/public/fonts/HafferXH-TRIAL-SemiBoldItalic.woff2 new file mode 100644 index 0000000..c54a47f Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-SemiBoldItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-Thin.woff2 b/viewer/public/fonts/HafferXH-TRIAL-Thin.woff2 new file mode 100644 index 0000000..6283691 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-Thin.woff2 differ diff --git a/viewer/public/fonts/HafferXH-TRIAL-ThinItalic.woff2 b/viewer/public/fonts/HafferXH-TRIAL-ThinItalic.woff2 new file mode 100644 index 0000000..c9d91f2 Binary files /dev/null and b/viewer/public/fonts/HafferXH-TRIAL-ThinItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-Bold.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-Bold.woff2 new file mode 100644 index 0000000..41a6bcf Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-Bold.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-BoldItalic.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-BoldItalic.woff2 new file mode 100644 index 0000000..dabf9d7 Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-BoldItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-Heavy.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-Heavy.woff2 new file mode 100644 index 0000000..604cc0a Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-Heavy.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-HeavyItalic.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-HeavyItalic.woff2 new file mode 100644 index 0000000..269a36f Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-HeavyItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-Light.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-Light.woff2 new file mode 100644 index 0000000..468ec34 Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-Light.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-LightItalic.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-LightItalic.woff2 new file mode 100644 index 0000000..69917d1 Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-LightItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-Medium.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-Medium.woff2 new file mode 100644 index 0000000..b8594e6 Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-Medium.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-MediumItalic.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-MediumItalic.woff2 new file mode 100644 index 0000000..322ed66 Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-MediumItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-Regular.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-Regular.woff2 new file mode 100644 index 0000000..3939211 Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-Regular.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-RegularItalic.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-RegularItalic.woff2 new file mode 100644 index 0000000..8192b09 Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-RegularItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-SemiBold.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-SemiBold.woff2 new file mode 100644 index 0000000..7a83cbe Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-SemiBold.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-SemiBoldItalic.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-SemiBoldItalic.woff2 new file mode 100644 index 0000000..e70eadf Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-SemiBoldItalic.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-Thin.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-Thin.woff2 new file mode 100644 index 0000000..7bc2a0a Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-Thin.woff2 differ diff --git a/viewer/public/fonts/HafferXHMono-TRIAL-ThinItalic.woff2 b/viewer/public/fonts/HafferXHMono-TRIAL-ThinItalic.woff2 new file mode 100644 index 0000000..9c08fa8 Binary files /dev/null and b/viewer/public/fonts/HafferXHMono-TRIAL-ThinItalic.woff2 differ diff --git a/viewer/public/fortytwo.svg b/viewer/public/fortytwo.svg new file mode 100644 index 0000000..06edade --- /dev/null +++ b/viewer/public/fortytwo.svg @@ -0,0 +1 @@ + diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx new file mode 100644 index 0000000..58a0395 --- /dev/null +++ b/viewer/src/App.tsx @@ -0,0 +1,943 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +type LogLevel = "info" | "success" | "warn" | "error" | "dim" | "api"; +interface Log { id: string; time: string; level: LogLevel; msg: string } +interface Tx { id: string; amount: string; transaction_type: string; description: string; created_at: string } +interface JudgeDetail { + challengeId: string; questionText: string; + answers: { id: string; content: string; agentId?: string }[]; + comparisons: { a: string; b: string; winner: string }[]; + finalRankings: string[]; goodAnswers: string[]; + phase: string; currentPairA: string | null; currentPairB: string | null; + comparisonIndex: number; totalComparisons: number; scores: Record; +} +interface VQ { + id: string; specialization: string; stake: number; minRank: number; + answerCount: number; status: string; questionText?: string; errorMsg?: string; +} +interface Stats { + answers: number; judgments: number; energy: number; staked: number; total: number; + weekEarned: number; lifetimeEarned: number; lifetimeSpent: number; + cycles: number; uptime: number; rank: string; judgeElo: string; accuracy: string; + wins: number; matches: number; + activeQueryId: string | null; activeQuestionText: string | null; activeQuestionCat: string | null; + questionsAvailable: number; cooldownRemaining: number; + thinkingText: string; answerText: string; isStreaming: boolean; tokPerSec: number; + stepDetail: string; accountInactive: boolean; + answersSubmitted: number; answersWon: number; answerWinRate: string; + judgmentsMade: number; judgmentAccuracy: string; + queriesSubmitted: number; queriesCompleted: number; + likesGiven: number; likesReceived: number; + forBalance: string; intelligenceNormalized: string; judgingNormalized: string; +} +interface Config { + agentId: string; modelName: string; inferenceType: string; + provider: string; cycleIntervalMs: number; autoRestart: boolean; +} + +const PIPE_STATES = ["IDLE", "AUTHENTICATING", "SCANNING", "JOINING", "THINKING", "SUBMITTING", "JUDGING", "COOLDOWN"]; + +const PHASE_LABELS: Record = { + loading: "Loading challenge...", + reading_answers: "Reading answers...", + comparing: "Comparing answers...", + ranking_all: "Ranking all answers...", + submitting: "Submitting judgment...", + done: "Judgment complete", +}; + +const LOG_COLORS: Record = { + info: "text-white/60", success: "text-white/60", warn: "text-white/60", + error: "text-white/60", dim: "text-white/60", api: "text-white/60", +}; + +const LOG_FILTER = ["No challenges", "200", "No transactions", "No errors", "detail"]; + +const LOG_ICON_COLORS: Record = { + info: "rgba(255,255,255,0.6)", success: "rgba(255,255,255,0.6)", warn: "rgba(255,255,255,0.6)", + error: "rgba(255,255,255,0.6)", dim: "rgba(255,255,255,0.6)", api: "rgba(255,255,255,0.6)", +}; + +function renderLogMsg(msg: string, level: string) { + if (!msg.includes("FOR")) return msg; + const color = LOG_ICON_COLORS[level] || "rgba(255,255,255,0.6)"; + const parts = msg.split(/\bFOR\b/); + return parts.map((part, i) => ( + + {i > 0 && } + {part} + + )); +} + +function truncateDecimals(value: number, decimals: number): string { + if (decimals <= 0) return String(Math.floor(value)); + const str = value.toFixed(20); + const dotIdx = str.indexOf('.'); + const intPart = str.slice(0, dotIdx); + const decPart = str.slice(dotIdx + 1, dotIdx + 1 + decimals).padEnd(decimals, '0'); + return `${intPart}.${decPart}`; +} + +function stripTrailingZeros(str: string): string { + if (!str.includes('.')) return str; + return str.replace(/\.?0+$/, ''); +} + +function formatNumber(value: number | string, digits?: number): string { + const num = typeof value === 'string' ? parseFloat(value) : value; + if (Number.isNaN(num)) return '0'; + + const sign = num < 0 ? '-' : ''; + const abs = Math.abs(num); + + const withSuffix = (divisor: number, suffix: string): string => { + const divided = abs / divisor; + const intLen = Math.floor(divided).toString().length; + const decimalPlaces = digits ?? Math.max(0, 4 - intLen); + return `${sign}${stripTrailingZeros(truncateDecimals(divided, decimalPlaces))}${suffix}`; + }; + + if (abs >= 1_000_000_000) return withSuffix(1_000_000_000, 'B'); + if (abs >= 1_000_000) return withSuffix(1_000_000, 'M'); + if (abs >= 100_000) return withSuffix(1_000, 'K'); + + if (abs >= 1_000) { + const decimalPlaces = digits ?? 0; + const truncated = truncateDecimals(abs, decimalPlaces); + const stripped = stripTrailingZeros(truncated); + const [intPart, decPart] = stripped.split('.'); + const intWithCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return sign + (decPart ? `${intWithCommas}.${decPart}` : intWithCommas); + } + + const intLen = Math.floor(abs).toString().length; + const decimalPlaces = digits ?? (5 - intLen); + return `${sign}${stripTrailingZeros(truncateDecimals(abs, decimalPlaces))}`; +} + +function parseTxDesc(d: string): string { + try { + const p = JSON.parse(d); + return p.label || p.type || "—"; + } catch { + return d.substring(0, 60); + } +} + +function fmtUptime(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +function getQInfo(q: VQ): { color: string; label: string; dim: boolean; dotColor: string | null } { + const s = q.status; + if (s === "active") return { color: "text-white", label: "Answering", dim: false, dotColor: "#00DAF7" }; + if (s === "checking") return { color: "text-white/60", label: "Scanning...", dim: false, dotColor: "rgba(0,218,247,0.6)" }; + if (s === "answered" || s === "joined") return { color: "text-white/40", label: "Answered", dim: true, dotColor: "#0D0" }; + if (s === "skipped_rank") return { + color: "text-white/40", + label: `Rank too low${q.minRank ? ` (${q.minRank})` : q.errorMsg ? ` — ${q.errorMsg.substring(0, 30)}` : ""}`, + dim: true, + dotColor: "#7D7D7D", + }; + if (s === "skipped_own") return { color: "text-white/40", label: "Your query", dim: true, dotColor: "#7D7D7D" }; + if (s === "error") return { color: "text-white/40", label: q.errorMsg?.substring(0, 30) || "Error", dim: true, dotColor: "#7D7D7D" }; + return { color: "text-white/40", label: "Available", dim: false, dotColor: null }; +} + +function Logo() { + return ( + + + + + + + + + ); +} + +function ForIcon({ size = 16, color = "white" }: { size?: number; color?: string }) { + return ( + + + + + + + + + + + ); +} + +export default function AgenticVision() { + const [connected, setConnected] = useState(false); + const [state, setState] = useState("IDLE"); + const [stats, setStats] = useState({ + answers: 0, judgments: 0, energy: 0, staked: 0, total: 0, + weekEarned: 0, lifetimeEarned: 0, lifetimeSpent: 0, + cycles: 0, uptime: 0, rank: "—", judgeElo: "—", accuracy: "—", + wins: 0, matches: 0, + activeQueryId: null, activeQuestionText: null, activeQuestionCat: null, + questionsAvailable: 0, cooldownRemaining: 0, + thinkingText: "", answerText: "", isStreaming: false, tokPerSec: 0, + stepDetail: "", accountInactive: false, + answersSubmitted: 0, answersWon: 0, answerWinRate: "0", + judgmentsMade: 0, judgmentAccuracy: "0", + queriesSubmitted: 0, queriesCompleted: 0, + likesGiven: 0, likesReceived: 0, + forBalance: "0", intelligenceNormalized: "0", judgingNormalized: "0", + }); + const [logs, setLogs] = useState([]); + const [txs, setTxs] = useState([]); + const [txTotal, setTxTotal] = useState(0); + const [errors, setErrors] = useState<{ msg: string; time: string }[]>([]); + const [queries, setQueries] = useState([]); + const [judge, setJudge] = useState(null); + const [config, setConfig] = useState(null); + const [rTab, setRTab] = useState<"log" | "txs" | "errors">("log"); + + const [streamText, setStreamText] = useState(""); + const [streamTps, setStreamTps] = useState(0); + const [isStreaming, setIsStreaming] = useState(false); + const [streamAnswer, setStreamAnswer] = useState(""); + const [botThinking, setBotThinking] = useState(false); + + const [justSubmitted, setJustSubmitted] = useState(false); + const [submittedQueryId, setSubmittedQueryId] = useState(null); + const activeQueryRef = useRef(null); + const submitTimer = useRef | null>(null); + + const logRef = useRef(null); + const thinkRef = useRef(null); + + useEffect(() => { + let es: EventSource | null = null; + let retryTimer: ReturnType; + + const connect = () => { + es = new EventSource("/api/stream"); + + es.onopen = () => setConnected(true); + + es.onmessage = (e) => { + try { + const ev = JSON.parse(e.data); + switch (ev.type) { + case "init": + setState(ev.data.state || "IDLE"); + setStats(ev.data.stats || {}); + setLogs(ev.data.logs || []); + setConfig(ev.data.config || null); + setTxs(ev.data.transactions || []); + setErrors(ev.data.errors || []); + setQueries(ev.data.queries || []); + if (ev.data.lastJudge) setJudge(ev.data.lastJudge); + if (ev.data.stats?.isStreaming) { + setIsStreaming(true); + setStreamText(ev.data.stats.thinkingText || ""); + setStreamTps(ev.data.stats.tokPerSec || 0); + } + activeQueryRef.current = ev.data.stats?.activeQueryId || null; + break; + case "state": + setState(ev.data.state); + break; + case "stats": + setStats((prev) => { + const next = { ...prev, ...ev.data }; + const nextId = next.activeQueryId || null; + if (activeQueryRef.current && !nextId) { + setSubmittedQueryId(activeQueryRef.current); + setJustSubmitted(true); + setBotThinking(false); + if (submitTimer.current) clearTimeout(submitTimer.current); + submitTimer.current = setTimeout(() => { + setJustSubmitted(false); + setSubmittedQueryId(null); + }, 3000); + } + if (activeQueryRef.current && nextId && nextId !== activeQueryRef.current) { + setStreamText(""); + setStreamAnswer(""); + setIsStreaming(false); + setBotThinking(false); + } + activeQueryRef.current = nextId; + return next; + }); + break; + case "log": + setLogs((prev) => { + const next = [...prev, ev.data]; + return next.length > 400 ? next.slice(-300) : next; + }); + break; + case "error_alert": + setErrors((prev) => [...prev.slice(-49), ev.data]); + break; + case "config_update": + setConfig(ev.data); + break; + case "queries": + setQueries(ev.data); + if (!ev.data || ev.data.length === 0) { + setJustSubmitted(false); + setSubmittedQueryId(null); + activeQueryRef.current = null; + if (submitTimer.current) clearTimeout(submitTimer.current); + } + break; + case "transactions": + setTxs(ev.data.transactions || []); + setTxTotal(ev.data.total || 0); + break; + case "judge_detail": + setJudge(ev.data); + break; + case "stream_start": + setBotThinking(true); + setIsStreaming(true); + setStreamText(""); + setStreamTps(0); + setStreamAnswer(""); + setJustSubmitted(false); + setSubmittedQueryId(null); + break; + case "think_chunk": + setBotThinking(false); + setStreamText(ev.data.full || ""); + setStreamTps(ev.data.tps || 0); + setIsStreaming(true); + break; + case "stream_end": + setIsStreaming(false); + setBotThinking(false); + setStreamText(ev.data.thinkingText || ""); + setStreamTps(ev.data.tokPerSec || 0); + setStreamAnswer(ev.data.answerText || ""); + break; + } + } catch {} + }; + + es.onerror = () => { + setConnected(false); + es?.close(); + retryTimer = setTimeout(connect, 3000); + }; + }; + + connect(); + return () => { + es?.close(); + clearTimeout(retryTimer); + }; + }, []); + + useEffect(() => { + logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); + }, [logs, txs, errors, rTab]); + + useEffect(() => { + thinkRef.current?.scrollTo({ top: thinkRef.current.scrollHeight }); + }, [streamText]); + + const activeQuery = queries.find((q) => q.status === "active"); + const winRate = stats.answersSubmitted > 0 + ? ((stats.answersWon / stats.answersSubmitted) * 100).toFixed(1) + : stats.answerWinRate || "0"; + + const isCooldown = state === "COOLDOWN"; + const isJudging = state === "JUDGING"; + const isThinkingWait = (state === "THINKING" || botThinking) && !streamText && !streamAnswer; + const hasActiveQuestion = !!stats.activeQuestionText; + + const renderJudge = useCallback((j: JudgeDetail) => { + const phase = j.phase || "loading"; + const isComparing = phase === "comparing" && j.currentPairA && j.currentPairB; + const isDone = phase === "done" || phase === "submitting"; + const maxScore = Math.max(1, ...Object.values(j.scores || {})); + const goodPercent = j.answers.length > 0 ? Math.round((j.goodAnswers.length / j.answers.length) * 100) : 0; + const goodColor = goodPercent >= 50 ? "text-ft-green" : "text-red-500"; + + return ( +
+
+ Judging + {j.challengeId.slice(0, 12)} + + {PHASE_LABELS[phase] || phase} + +
+ + {phase === "comparing" && j.totalComparisons > 0 && ( +
+
+ Comparisons + + {j.comparisonIndex}/{j.totalComparisons} + +
+
+
+
+
+ )} + +
+
Question
+

+ {j.questionText.substring(0, 500)} +

+
+ +
+ + Answers ({j.answers.length}) + + {j.answers.map((a) => { + const isInPair = j.currentPairA === a.id || j.currentPairB === a.id; + const pairLabel = j.currentPairA === a.id ? "A" : j.currentPairB === a.id ? "B" : null; + const rk = j.finalRankings.indexOf(a.id); + const good = j.goodAnswers.includes(a.id); + const score = j.scores?.[a.id] || 0; + const barW = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0; + + return ( +
+ {rk === 0 &&
} +
+ {!isDone && isInPair && pairLabel && ( + + {pairLabel} + + )} + + {a.id.slice(0, 10)} + + {a.agentId && ( + + Node:{a.agentId.slice(0, 8)} + + )} + {good && ( + + {goodPercent}% GOOD + + )} +
+

+ {a.content} +

+
+ ); + })} +
+ + {(streamText || isStreaming) && ( +
+
+ + {isComparing + ? `Comparing ${j.currentPairA?.slice(0, 6)} vs ${j.currentPairB?.slice(0, 6)}` + : phase === "ranking_all" ? "Ranking all answers" : "Reasoning"} + + {isStreaming && } + {isStreaming && {formatNumber(streamTps, 1)} tok/s} +
+
+

+ {streamText} + {isStreaming && } +

+
+
+ )} + + {j.comparisons.length > 0 && ( +
+ + Comparisons ({j.comparisons.length}{j.totalComparisons > 0 ? `/${j.totalComparisons}` : ""}) + + {j.comparisons.map((c, i) => ( +
+ #{i + 1} + + {c.a.slice(0, 8)} + + vs + + {c.b.slice(0, 8)} + +
+ ))} +
+ )} + + {j.finalRankings.length > 0 && ( +
+ + Final Ranking {isDone ? "✓" : ""} + + {j.finalRankings.map((id, i) => ( +
+ + #{i + 1} + + {id.slice(0, 12)} + {(j.scores?.[id] || 0) > 0 && ( + {j.scores[id]} wins + )} + {j.goodAnswers.includes(id) && ( + + {goodPercent}% GOOD + + )} +
+ ))} +
+ )} +
+ ); + }, [streamText, isStreaming, streamTps]); + + return ( +
+ {!connected && ( +
+
+
Node disconnected
+
Reconnecting...
+
+
+ )} + +
+
+ + Node Vision +
+
+ {state === "IDLE" ? "Idle" : state.charAt(0) + state.slice(1).toLowerCase()} + {stats.questionsAvailable || queries.length} Questions + {queries.filter((q) => q.status === "available").length} Available + {stats.stepDetail && ( + {stats.stepDetail} + )} + {isStreaming ? ( + Generating response... + ) : botThinking ? ( + Thinking... + ) : justSubmitted ? ( + Submitted ✓ + ) : null} + {stats.accountInactive && ( + Account inactive + )} + {errors.length > 0 && ( + setRTab("errors")} + className="cursor-pointer bg-red-500 text-white text-[12px] px-1.5 py-0.5 min-w-[20px] text-center" + > + {errors.length} + + )} +
+
+ +
+
+ + +
+
+
+ Answers + {formatNumber(stats.answersSubmitted || 0)} +
+ {formatNumber(stats.answersWon || 0)} wins
{winRate}% win rate +
+
+
+ Judgments + {formatNumber(stats.judgmentsMade || 0)} +
+ {formatNumber(stats.accuracy || 0)}% accuracy +
+
+
+ +
+ Economy +
+
+
+ + {formatNumber(stats.energy)} +
+
+ + {formatNumber(stats.staked)} Staked +
+
+
+
+ + + +{formatNumber(stats.weekEarned)} + / Day + +
+
+ + + -{formatNumber(stats.lifetimeSpent)} + / Week + +
+
+
+
+ +
+ Current State +
+ {PIPE_STATES.map((s) => { + const isActive = state === s; + return ( +
+ {isActive && } + + {s.charAt(0) + s.slice(1).toLowerCase()} + +
+ ); + })} +
+
+ +
+ Rank ELO / Normalized +
+
+ Intelligence + {stats.rank || "—"} / {stats.intelligenceNormalized || "—"} +
+
+ Judgment + {stats.judgeElo || "—"} / {stats.judgingNormalized || "—"} +
+
+
+
+
+ +
+ {isCooldown && ( +
+
+
+ {stats.cooldownRemaining} +
+
+
+
COOLDOWN
+ {stats.stepDetail && ( +
{stats.stepDetail}
+ )} +
+ )} + + {isJudging && judge && !isCooldown && ( +
+ {renderJudge(judge)} +
+ )} + +
+ {connected && !isCooldown && !isJudging && queries.length === 0 && !hasActiveQuestion && state !== "IDLE" && ( +
+
+
+ + {state} + +
+
+ {stats.stepDetail || "Working..."} +
+
+ )} + + {queries.length > 0 && ( +
+ {queries.map((q) => { + const info = getQInfo(q); + const isActive = stats.activeQueryId === q.id; + const isChecking = q.status === "checking"; + const wasJustSubmitted = submittedQueryId === q.id && justSubmitted; + const questionText = q.questionText || (isActive ? stats.activeQuestionText : null); + + return ( +
+
+ + {q.specialization} + +
+
+ {info.dotColor && ( +
+ )} + + {isActive ? "Answering" + : isChecking ? "Scanning..." + : wasJustSubmitted ? "Submitted ✓" + : info.label} + +
+ +
+ + {formatNumber(q.stake)} +
+ + {q.answerCount}/10 +
+
+ + {isActive && ( +
+ {questionText && ( +
+

+ {questionText} +

+
+ )} + + {isThinkingWait && !streamAnswer && ( +
+
+
Node is thinking...
+
+ Preparing {config?.modelName || "LLM"} response +
+
+
+ {[85, 72, 90, 60].map((w, i) => ( +
+
+
+ ))} +
+
+ )} + + {streamAnswer && !isStreaming && ( +
+
+ Answer +
+
+

+ {streamAnswer.substring(0, 2000)} +

+
+
+ )} + + {(isStreaming || streamText) && !isThinkingWait && ( +
+
+
+ + Reasoning +
+ {isStreaming && {formatNumber(streamTps, 1)} tok/s} +
+
+

+ {streamText} + {isStreaming && } +

+
+
+ )} + + {!streamText && !isStreaming && !streamAnswer && !isThinkingWait && ( +
+
+
+ + {stats.stepDetail || "Processing..."} + +
+
+ )} +
+ )} +
+ ); + })} +
+ )} +
+
+ +
+
+ {(["log", "txs", "errors"] as const).map((tab) => ( + + ))} +
+ +
+ {rTab === "log" && logs + .filter((l) => !LOG_FILTER.some((f) => l.msg.includes(f))) + .map((l) => ( +
+ {l.time} + + {renderLogMsg(l.msg, l.level)} + +
+ ))} + {rTab === "txs" && (txs.length > 0 ? txs.map((t) => { + const amt = parseFloat(t.amount); + const pos = amt > 0; + return ( +
+ + {new Date(t.created_at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit" })} + +
+ + {pos ? "+" : ""}{formatNumber(t.amount)} + + {parseTxDesc(t.description)} +
+
+ ); + }) : ( +
No transactions
+ ))} + {rTab === "errors" && (errors.length > 0 ? errors.slice().reverse() + .filter((e) => !e.msg.includes("detail")) + .map((e, i) => ( +
+ {e.time} + {renderLogMsg(e.msg, "error")} +
+ )) : ( +
No errors
+ ))} +
+ + {rTab === "txs" && txTotal > 0 && ( +
+ {txTotal.toLocaleString()} total +
+ )} +
+
+ +
+
+ Co-defined with + + Novee + Novee + +
+
+ {formatNumber(stats.tokPerSec || streamTps || 0, 1)} tok/s + {config?.provider || "—"} + {config?.modelName || "—"} + {fmtUptime(stats.uptime || 0)} +
+
+
+ ); +} diff --git a/viewer/src/globals.css b/viewer/src/globals.css new file mode 100644 index 0000000..79a266b --- /dev/null +++ b/viewer/src/globals.css @@ -0,0 +1,126 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@font-face { + font-family: "Haffer XH"; + src: url("/fonts/HafferXH-TRIAL-Light.woff2") format("woff2"); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Haffer XH"; + src: url("/fonts/HafferXH-TRIAL-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Haffer XH"; + src: url("/fonts/HafferXH-TRIAL-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Haffer XH"; + src: url("/fonts/HafferXH-TRIAL-SemiBold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Haffer XH"; + src: url("/fonts/HafferXH-TRIAL-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Haffer XH"; + src: url("/fonts/HafferXH-TRIAL-Heavy.woff2") format("woff2"); + font-weight: 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Haffer XH Mono"; + src: url("/fonts/HafferXHMono-TRIAL-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Haffer XH Mono"; + src: url("/fonts/HafferXHMono-TRIAL-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@layer base { + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + ::-webkit-scrollbar { + display: none !important; + width: 0 !important; + height: 0 !important; + background: transparent !important; + } + + * { + scrollbar-width: none !important; + -ms-overflow-style: none !important; + } +} + +@layer utilities { + .log-entry { + animation: log-slide 0.25s ease-out both; + } + @keyframes log-slide { + from { opacity: 0; transform: translateX(-8px); } + to { opacity: 1; transform: translateX(0); } + } + + .think-block { + animation: think-slide-in 0.25s ease-out both; + } + @keyframes think-slide-in { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } + } + + .shimmer-line { + position: relative; + overflow: hidden; + } + .shimmer-slide { + animation: shimmer-move 1.5s ease-in-out infinite; + } + @keyframes shimmer-move { + from { transform: translateX(-100%); } + to { transform: translateX(350%); } + } + + .thinking-spinner { + border-radius: 50% !important; + animation: spin 2.5s linear infinite; + } + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } +} diff --git a/viewer/src/main.tsx b/viewer/src/main.tsx new file mode 100644 index 0000000..9b0bd19 --- /dev/null +++ b/viewer/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./globals.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/viewer/tailwind.config.cjs b/viewer/tailwind.config.cjs new file mode 100644 index 0000000..551f34f --- /dev/null +++ b/viewer/tailwind.config.cjs @@ -0,0 +1,44 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + fontFamily: { + sans: ["'Haffer XH'", "Inter", "system-ui", "sans-serif"], + mono: ["'Haffer XH Mono'", "ui-monospace", "monospace"], + }, + colors: { + ft: { + blue: "#2D2DFF", + cyan: "#00DAF7", + green: "#00DD00", + red: "#EE0000", + orange: "#CC9900", + base: "#000000", + surface: "#050505", + card: "rgba(255,255,255,0.07)", + border: "rgba(255,255,255,0.1)", + }, + }, + keyframes: { + "ft-pulse": { + "0%, 100%": { opacity: "1" }, + "50%": { opacity: "0.3" }, + }, + "ft-blink": { + "0%, 100%": { opacity: "1" }, + "50%": { opacity: "0" }, + }, + }, + animation: { + "ft-pulse": "ft-pulse 2s infinite", + "ft-pulse-fast": "ft-pulse 0.6s infinite", + "ft-blink": "ft-blink 1s step-end infinite", + }, + }, + }, + plugins: [], +}; diff --git a/viewer/tsconfig.json b/viewer/tsconfig.json new file mode 100644 index 0000000..9e465fd --- /dev/null +++ b/viewer/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/viewer/vite.config.ts b/viewer/vite.config.ts new file mode 100644 index 0000000..5559416 --- /dev/null +++ b/viewer/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: "../dist/viewer", + emptyOutDir: true, + }, + server: { + port: 3000, + proxy: { + "/api": "http://127.0.0.1:4242", + }, + }, +});