diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6b2c2..b83b6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Node Tier model (Challenger / Capable) and Capability rank (0–42) +- Capability Challenge worker — Challengers auto-answer Foundation Pool rounds +- `challenge_locked` wallet, tier badge, capability progress bar, dead-lock banner +- CLI: `capability [history]`, `reset --yes`, `challenge list|answer` +- TUI: `/capability [history]`, `/challenge list` + +### Changed +- Registration: 2-step challenge quiz → 1-step; no LLM key needed to onboard +- `reset` is one-shot, requires `--yes` +- `min_balance` gates Capable only; Challengers stake from `challenge_locked` + +### Removed +- Reactivation flow, 2-step register, challenge-based reset endpoints +- `compareForRegistration` LLM helper +- Auto-reset on `InsufficientFundsError` +- Command `/ask ` + ## [0.1.6] - 13.04.2026 ### Features diff --git a/README.md b/README.md index 6b7c212..4a3ac8c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A client app for connecting to the Fortytwo Network — the first collective superintelligence owned by its participants. Use your own inference (OpenRouter or self-hosted) to earn rewards by answering network queries, and spend them when you need the network's intelligence to solve your own requests. No API fees, no subscriptions. -Requires an account on [app.fortytwo.network](https://app.fortytwo.network/) — registration and sign-in are available directly within the tool. Run it in your terminal in interactive or headless mode, or invoke it via CLI commands for agentic workflows. This tool is also used as the underlying client when participating in the Fortytwo Network through an AI agent such as OpenClaw. +Requires an account on [node.fortytwo.network](https://node.fortytwo.network/) — registration and sign-in are available directly within the tool. Run it in your terminal in interactive or headless mode, or invoke it via CLI commands for agentic workflows. This tool is also used as the underlying client when participating in the Fortytwo Network through an AI agent such as OpenClaw. ## Installation @@ -76,7 +76,9 @@ Runs with UI layout: | Command | Description | |---------|-------------| | `/help` | Show available commands | -| `/ask ` | Submit a question to the network | +| `/capability` | Show current capability rank and tier | +| `/capability history` | Show recent capability rank changes | +| `/challenge list` | List active Capability Challenge rounds | | `/identity` | Show node_id and node_secret | | `/profile list` | List all profiles | | `/profile create` | Create a new profile (interactive wizard) | @@ -102,7 +104,10 @@ fortytwo Launch Interactive UI fortytwo setup [flags] Register new agent (non-interactive) fortytwo import [flags] Import existing agent (non-interactive) fortytwo run [-v] Run agent headless -fortytwo ask Submit a question to the network +fortytwo capability [history] Show capability rank / tier (or history) +fortytwo reset --yes Reset capability to 0 (+250 FOR locked) +fortytwo challenge list List active Capability Challenge rounds +fortytwo challenge answer Submit a manual answer to a round fortytwo config show Show current config fortytwo config set Update a config value fortytwo identity Show node credentials @@ -159,32 +164,32 @@ Same flags as `setup`, plus: | `--node-id` | yes | Agent UUID | | `--secret` | yes | Agent secret | -### `ask` +### `capability` -Submit a question to the Fortytwo Network. +Show the node's current capability rank, tier, and rank-change history. ```bash -fortytwo ask "What is the meaning of life?" +fortytwo capability # show tier + rank (e.g. "Capable 42/42") +fortytwo capability history # show last rank changes (±3 / reset events) ``` -### `profile` +### `reset` -Manage multiple agent profiles. Each profile has its own config and identity. +Reset capability rank back to 0 and receive a 250 FOR drop into `challenge_locked`. This is a one-shot operation (no challenge quiz). The node will rejoin the Capability Challenge as a fresh Challenger. ```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) +fortytwo reset --yes ``` -### `version` +Without `--yes` the command prints a confirmation prompt and does nothing. Reset is required when the node enters a *dead lock* state (all FOR locked, nothing available). -Show current version. +### `challenge` + +Inspect or manually answer active Capability Challenge rounds. The headless/TUI worker participates automatically when the node is a Challenger — these commands are for manual operation and debugging. ```bash -fortytwo version +fortytwo challenge list # show active rounds +fortytwo challenge answer Yes # submit a manual answer ``` ### `profile` @@ -199,6 +204,14 @@ fortytwo profile delete # delete a profile fortytwo profile show [name] # show profile config (defaults to active) ``` +### `version` + +Show current version. + +```bash +fortytwo version +``` + ### Global Flags | Flag | Description | @@ -218,14 +231,14 @@ All configuration is stored in `config.json`. It's created automatically during | `inference_type` | `openrouter` | `openrouter` or `self-hosted` | | `openrouter_api_key` | | OpenRouter API key | | `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 | +| `fortytwo_api_base` | `https://node.fortytwo.network/api` | Fortytwo API endpoint | +| `node_identity_file` | `~/.fortytwo/profiles//identity.json` | Path to identity/credentials file | | `poll_interval` | `120` | Polling interval in seconds | | `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 | -| `node_role` | `ANSWERER_AND_JUDGE` | `ANSWERER_AND_JUDGE`, `ANSWERER`, or `JUDGE` | +| `min_balance` | `5.0` | Minimum `available` FOR for a Capable node to run a cycle (below this the worker idles). Does not apply to Challenger nodes — they are funded by `challenge_locked`. | +| `node_role` | `ANSWERER_AND_JUDGE` | `ANSWERER_AND_JUDGE`, `ANSWERER`, or `JUDGE` — only applies once the node is **Capable** | | `answerer_system_prompt` | `You are a helpful assistant.` | System prompt for answer generation | You can update any value at runtime. For example: @@ -269,8 +282,33 @@ fortytwo identity /identity ``` +## Node Tiers + +Every agent has a **tier** and a **Capability rank** (0–42) returned by the server: + +| Tier | Capability rank | What the worker does | +|------|-----------------|----------------------| +| **Challenger** | 0–41 | Participates in **Capability Challenge** rounds (Foundation Pool puzzles). Each answer stakes 10 FOR from `challenge_locked`; a correct answer grants +3 rank, an incorrect one −2. ELO is **frozen** and regular `node_role` work is skipped until rank reaches 42. | +| **Capable** | 42 | Runs according to the configured `node_role` (answering queries, judging, or both). | + +New agents start as Challengers at rank 0 with 250 FOR in `challenge_locked`. Reaching Capability 42 promotes the node to Capable and unlocks everything. + +### Reset & Dead Lock + +If the `challenge_locked` pool is fully staked on unresolved answers and `available` is empty, the server reports `is_dead_locked: true`. The worker detects this, logs a warning, and **does not auto-reset** — you decide: + +```bash +fortytwo reset --yes +``` + +This resets rank back to 0 and drops another 250 FOR into `challenge_locked`, so the node rejoins the Capability Challenge as a fresh Challenger. + +Low `available` balance (`< min_balance`) puts only **Capable** nodes into idle mode (they need FOR to stake on queries/judgments). Challengers keep working as long as `challenge_locked > 0` and `is_dead_locked` is false. + ## Roles +Roles apply **only to Capable nodes**. Challenger nodes follow the Capability Challenge path regardless of `node_role`. + | Role | Behavior | |------|----------| | `ANSWERER_AND_JUDGE` | Generates answers to network queries via attached inference, and evaluates and ranks answers to questions | diff --git a/package.json b/package.json index 8038985..9a1356d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fortytwo-network/fortytwo-cli", - "version": "0.1.6", - "description": "Fortytwo Network Client — CLI for running AI agents on app.fortytwo.network", + "version": "0.1.7", + "description": "Fortytwo Network Client — CLI for running AI agents on node.fortytwo.network", "type": "module", "bin": { "fortytwo": "./dist/cli.js" diff --git a/src/api-client.ts b/src/api-client.ts index e43f86c..bbb5a42 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -1,5 +1,15 @@ import * as config from "./config.js"; import { sleep, verbose } from "./utils.js"; +import type { + CapabilityInfo, + CapabilityHistoryEntry, + ChallengeAnswer, + ChallengeRound, + JoinRoundResponse, + PaginatedResponse, + RegistrationResponse, + ResetResponse, +} from "./api-types.js"; export class FortyTwoClient { private baseUrl: string; @@ -44,17 +54,13 @@ export class FortyTwoClient { this.tokenExpiresAt = Date.now() + expiresIn * 1000; } - // ── Registration ────────────────────────────────────────────── + // ── Registration ──────────────────────── - async register(publicKeyPem: string, displayName?: string): Promise> { + 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, timeout: 60_000 }); - } - - async completeRegistration(sessionId: string, responses: Record[]): Promise> { - return this.request("POST", "/auth/register/complete", { - body: { challenge_session_id: sessionId, responses }, + return this.request("POST", "/auth/register", { + body: payload, auth: false, timeout: 60_000, }); @@ -92,12 +98,6 @@ export class FortyTwoClient { // ── Queries & Answers ──────────────────────────────────────── - async createQuery(encryptedContent: string, specialization: string): Promise> { - return this.request("POST", "/queries", { - body: { encrypted_content: encryptedContent, specialization }, - }); - } - async getActiveQueries(page = 1, pageSize = 50): Promise> { return this.request("GET", "/queries/active", { params: { page, page_size: pageSize }, @@ -118,31 +118,61 @@ export class FortyTwoClient { }); } - // ── Account Reset & Reactivation ───────────────────────────── + // ── Capability ──────────────────────────────────────────────── - async startAccountReset(): Promise> { - return this.request("POST", "/auth/reset/start"); + async getCapability(agentId?: string): Promise { + const id = agentId ?? this.nodeId; + return this.request("GET", `/capability/${id}`); } - async completeAccountReset(sessionId: string, responses: Record[]): Promise> { - return this.request("POST", "/auth/reset/complete", { - body: { challenge_session_id: sessionId, responses }, - }); + async getCapabilityHistory( + agentId?: string, + page = 1, + pageSize = 20, + ): Promise> { + const id = agentId ?? this.nodeId; + return this.request>( + "GET", + `/capability/${id}/history`, + { params: { page, page_size: pageSize } }, + ); } - 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 resetCapability(agentId?: string): Promise { + const id = agentId ?? this.nodeId; + return this.request("POST", `/capability/${id}/reset`); } - async completeReactivation(sessionId: string, responses: Record[]): Promise> { - return this.request("POST", "/auth/reactivate/complete", { - body: { challenge_session_id: sessionId, responses }, - auth: false, - }); + // ── Foundation Pool / Capability Challenge ─────────────────── + + async listActiveChallengeRounds( + page = 1, + pageSize = 20, + ): Promise> { + return this.request>( + "GET", + "/foundation-pool/rounds", + { params: { page, page_size: pageSize } }, + ); + } + + async getChallengeRound(roundId: string): Promise { + return this.request("GET", `/foundation-pool/rounds/${roundId}`); + } + + async joinChallengeRound(roundId: string): Promise { + return this.request( + "POST", + `/foundation-pool/rounds/${roundId}/join`, + ); + } + + async submitChallengeAnswer(roundId: string, content: string): Promise { + return this.request( + "POST", + `/foundation-pool/rounds/${roundId}/answers`, + { body: { content } }, + ); } // ── Economy ─────────────────────────────────────────────────── @@ -183,7 +213,7 @@ export class FortyTwoClient { } } - async request( + async request>( method: string, path: string, opts: { @@ -193,7 +223,7 @@ export class FortyTwoClient { maxRetries?: number; timeout?: number; } = {}, - ): Promise> { + ): Promise { const { body, params, auth = true, maxRetries = 3, timeout = 30_000 } = opts; let url = `${this.baseUrl}${path}`; @@ -271,12 +301,25 @@ export class FortyTwoClient { 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)}`); + const err = new ApiError( + resp.status, + detail || `API error ${resp.status} on ${method} ${path}: ${text.slice(0, 500)}`, + ); + throw err; } - return (await resp.json()) as Record; + return (await resp.json()) as T; } throw new Error(`Request to ${method} ${path} failed after ${maxRetries + 1} attempts`); } } + +export class ApiError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} diff --git a/src/api-types.ts b/src/api-types.ts new file mode 100644 index 0000000..4884215 --- /dev/null +++ b/src/api-types.ts @@ -0,0 +1,132 @@ + +export type NodeTier = "challenger" | "capable"; + +export interface RegistrationResponse { + agent_id: string; + secret: string; + capability_rank: number; + node_tier: NodeTier; + message?: string; +} + +export interface FortBalance { + agent_id: string; + available: string; + challenge_locked: string; + staked: string; + total: string; + lifetime_earned: string; + lifetime_spent: string; + current_week_earned: string; + week_start_at: string; +} + +export interface Agent { + id: string; + status: "active" | "deactivated" | "pending"; + capability_rank: number; + node_tier: NodeTier; + bad_attendance_counter?: number; + bad_attendance_stake_multiplier?: string; + profile: Record | null; + created_at: string; + last_active_at: string | null; +} + +export interface CapabilityInfo { + agent_id: string; + capability_rank: number; + node_tier: NodeTier; + is_dead_locked: boolean; +} + +export type CapabilityHistoryReason = + | "challenge_correct" + | "challenge_incorrect" + | "challenge_cancelled" + | "reset" + | "migration"; + +export interface CapabilityHistoryEntry { + id: string; + agent_id: string; + delta: number; + rank_before: number; + rank_after: number; + reason: CapabilityHistoryReason; + reference_id: string | null; + created_at: string; +} + +export interface ChallengeRound { + id: string; + foundation_pool_id: string; + content?: string; // hidden in listing; present after join + expected_answer?: string | null; + status: "active" | "settled" | "cancelled"; + starts_at: string; + ends_at: string; + for_budget_total: string; + max_participants: number; + joined_count: number; + slots_remaining: number; + has_joined?: boolean; + has_answered?: boolean; + settled_at: string | null; + winners_count: number; + reward_per_winner: string; + created_at: string; + answer_count?: number; +} + +export interface JoinRoundResponse extends ChallengeRound { + content: string; // guaranteed after join + stake_amount: string; + participant_id: string; +} + +export interface ChallengeAnswer { + id: string; + round_id: string; + agent_id: string; + content: string; + is_correct: boolean | null; + capability_delta: number; + staked_amount: string; + reward_amount: string; + submitted_at: string; + validated_at: string | null; +} + +export interface ResetResponse { + agent_id: string; + capability_rank: 0; + rank_before: number; + challenge_locked: string; + drop_amount: string; +} + +export interface Query { + id: string; + status: string; + specialization: string; + query_tier: "capable" | "challenger"; + stake_amount?: string; + min_intelligence_rank?: string; + answer_count?: number; + has_answered?: boolean; + has_joined?: boolean; + decrypted_content?: string; + created_at: string; + answer_deadline_at?: string; + decision_deadline_at?: string; + answering_grace_ends_at?: string; + extra_completion_duration_answers_seconds?: number; +} + +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + page_size: number; +} diff --git a/src/app.tsx b/src/app.tsx index 6e80840..df9ad5c 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,29 +1,50 @@ +import { hostname, userInfo } from "node:os"; import { useState, useCallback, useRef } from "react"; -import { Box, Text, Static } from "ink"; +import { Box, Text } from "ink"; +import { Select, ThemeProvider, extendTheme, defaultTheme } from "@inkjs/ui"; 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"; +import { LogoMark } from "./logo-mark.js"; +import { listProfiles, switchProfile } from "./profiles.js"; +import type { ProfileInfo } from "./profiles.js"; -const LOGO = [ - " ▒█████░ ▒█████░ █████████░ █████████░", - " ▓███████▓ ▓███████▓ █████████░ █████████░", - " ░█████████░ ░█████████░ █████████░ █████████░", - " ▓███████▓ ▓███████▓ █████████░ █████████░", - " ▒█████░ ▒█████░ █████████░ █████████░", - " █████████░ █████████░", - " ▒█████░ ▒█████░ █████████░ █████████░", - " ▓███████▓ ▓███████▓ █████████░ █████████░", - " ░█████████░ ░█████████░ █████████░ █████████░", - " ▓███████▓ ▓███████▓ █████████░ █████████░", - " ▒█████░ ▒█████░ █████████░ █████████░", -]; - -type Screen = "onboard" | "register" | "running"; +type Screen = "profile_select" | "onboard" | "register" | "running"; + +const PROFILE_REGISTER_VALUE = "__register__"; +const PROFILE_IMPORT_VALUE = "__import__"; + +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 }), + }, + }, + }, +}); + +function getShellPrompt(): string { + try { + const user = userInfo().username || "user"; + const host = hostname().split(".")[0] || "localhost"; + return `${user}@${host} ~ % fortytwo`; + } catch { + return "fortytwo"; + } +} + +const SHELL_PROMPT = getShellPrompt(); function getInitialScreen(): Screen { + if (listProfiles().length > 0) return "profile_select"; if (!configExists()) return "onboard"; const cfg = getConfig(); if (!cfg.node_identity_file || !loadIdentity(cfg.node_identity_file)) return "register"; @@ -33,59 +54,127 @@ function getInitialScreen(): Screen { export default function App() { const [screen, setScreen] = useState(getInitialScreen); const [botKey, setBotKey] = useState(0); + const [onboardStep, setOnboardStep] = useState<{ current: number; total: number; label: string } | null>(null); + const [onboardMode, setOnboardMode] = useState<"new" | "import" | undefined>(); + const [profileError, setProfileError] = useState(null); + const [profileList, setProfileList] = useState(() => listProfiles()); 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; + setOnboardMode(undefined); setScreen("onboard"); }, []); const handleCancelCreate = useCallback(() => { fromCreateRef.current = false; + setOnboardMode(undefined); + setOnboardStep(null); setScreen("running"); }, []); const handleOnboardDone = useCallback(() => { fromCreateRef.current = false; + setOnboardMode(undefined); + setOnboardStep(null); reloadConfig(); resetLlmClient(); + setProfileList(listProfiles()); setBotKey((k) => k + 1); setScreen("running"); }, []); + const handleProfileSelect = useCallback((value: string) => { + setProfileError(null); + if (value === PROFILE_REGISTER_VALUE) { + setOnboardMode("new"); + setScreen("onboard"); + return; + } + if (value === PROFILE_IMPORT_VALUE) { + setOnboardMode("import"); + setScreen("onboard"); + return; + } + if (!value.startsWith("profile:")) return; + + const profileName = value.slice("profile:".length); + try { + switchProfile(profileName); + reloadConfig(); + resetLlmClient(); + setBotKey((k) => k + 1); + setScreen("running"); + } catch (err) { + setProfileError(err instanceof Error ? err.message : String(err)); + } + }, []); + + const profileOptions = [ + { label: "Register new node", value: PROFILE_REGISTER_VALUE }, + { label: "Import existing node", value: PROFILE_IMPORT_VALUE }, + ...profileList.map((p) => { + const name = p.agentName || p.nodeId || p.name; + const marker = p.active ? " ◀ last used" : ""; + return { label: `[••] ${name}${marker}`, value: `profile:${p.name}` }; + }), + ]; + const titleText = screen === "profile_select" ? "SELECT PROFILE" : "ONBOARDING"; + const logoHeight = screen === "profile_select" ? 4 : 5; + return ( - - {() => ( - - ╔═════════ WELCOME TO FORTYTWO, NETWORK NODE - - {LOGO.map((line, i) => ( - {line} - ))} - - ╚═════════ ONBOARDING + {screen !== "running" && ( + + {SHELL_PROMPT} + + + + + WELCOME TO + FORTYTWO + + NODERUNNER + + {titleText} + {screen !== "profile_select" && onboardStep ? ( + + STEP {onboardStep.current}/{onboardStep.total}: {onboardStep.label} + + ) : null} + + + + + )} + + + {screen === "profile_select" && ( + + {profileError ? {profileError} : null} + +