diff --git a/.gitignore b/.gitignore index fa370a0f..120b2b24 100755 --- a/.gitignore +++ b/.gitignore @@ -152,6 +152,13 @@ demo/memcell_outputs/ # macOS .DS_Store + +# Office temporary lock files +~$* + +# AI agent tooling (local agent skills/config, not part of the project) +.agents/ +AGENTS.md *.duckdb diff --git a/README.md b/README.md index 14dc1e42..b49e6ca3 100644 --- a/README.md +++ b/README.md @@ -111,14 +111,20 @@ Before configuring a provider or starting the server, run: everos demo ``` -The command asks for one memory and one recall question, then opens a -full-screen terminal visualizer. It is hardcoded and local to the CLI: it does -not need an API key, start or call the EverOS server, or change anything in the -real memory workflow below. - -

- Animated EverOS demo preview showing the memory sphere moving through recall and confetti states -

+The full-screen terminal UI has an input box: type something EverOS should +remember, then ask a question that recalls it. No API key or server setup is +needed. Each round lets you watch the memory move through the real lifecycle: +ingest -> extract -> index -> recall. + +The particle sphere changes with each stage, then bursts across the memory +field and fades away. A small core of yellow and white particles keeps moving +at the center, then expands smoothly into the next round. See +[docs/everos-demo.md](docs/everos-demo.md) for the complete experience. Type +`/` to see the commands (`/replay`, `/live`, `/quit`); `ctrl+c` exits anytime. +After a few rounds the demo points you at configuring your own keys (`everos +init`, then `everos demo --live`). + + Press `r` to replay and `q` to quit. For a non-interactive preview, use `everos demo --plain`; for the looping showroom view, use diff --git a/README.zh-CN.md b/README.zh-CN.md index d3f10208..9fb0d58f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -111,13 +111,17 @@ uv pip install everos everos demo ``` -这个命令会询问一条记忆和一个召回问题,然后打开全屏 terminal visualizer。 -它是 hardcoded 的本地 CLI 演示:不需要 API Key,不会启动或连接 EverOS -server,也不会修改下面真实记忆流程的任何数据。 +全屏 terminal UI 里带有一个输入框:先输入一件希望 EverOS 记住的事情,再提出 +一个能够召回它的问题。不需要 API key,也不需要提前启动 server。每一轮都能 +直观看到记忆经历完整流程:ingest -> extract -> index -> recall。 -

- Animated EverOS demo preview showing the memory sphere moving through recall and confetti states -

+粒子球会随四个阶段持续变化,随后散落到 memory field 并淡出;中心会先留下少量 +黄白粒子攒动,再由中心向外扩展成完整球体,进入下一轮。完整体验见 +[docs/everos-demo.md](docs/everos-demo.md)。输入 `/` 可以 +查看可用命令(`/replay`、`/live`、`/quit`);`ctrl+c` 随时退出。几轮之后,demo +会引导你配置自己的 key(`everos init`,然后 `everos demo --live`)。 + + 按 `r` replay,按 `q` 退出。非交互式预览可以使用 `everos demo --plain`; 循环 showroom view 可以使用 `everos demo --cinematic`。Visualizer 的范围见 diff --git a/deploy/netlify_relay/README.md b/deploy/netlify_relay/README.md new file mode 100644 index 00000000..472c0d1f --- /dev/null +++ b/deploy/netlify_relay/README.md @@ -0,0 +1,52 @@ +# EverOS demo relay on Netlify + +This project deploys the public `everos demo` relay as a Netlify Function. The +client sends no credentials. The function accepts only the demo API surface, +injects the platform key stored in Netlify, applies distributed per-IP quotas +with Upstash Redis, and forwards the request to EverOS Cloud. + +## Required Netlify environment variables + +Configure these under **Project configuration -> Environment variables**. Never +put their values in this repository. + +| Variable | Required | Default | +| --- | --- | --- | +| `EVEROS_CLOUD_API_KEY` | yes | none | +| `UPSTASH_REDIS_REST_URL` | yes | none | +| `UPSTASH_REDIS_REST_TOKEN` | yes | none | +| `EVEROS_CLOUD_UPSTREAM` | no | `https://api.evermind.ai` | +| `RELAY_RATE_PER_MIN` | no | `30` | +| `RELAY_DAILY_ROUNDS` | no | `3` | +| `RELAY_UPSTREAM_TIMEOUT_MS` | no | `20000` | +| `RELAY_MAX_BODY_BYTES` | no | `1000000` | + +The per-minute limit counts every relay request. The daily limit counts only +`POST /api/v2/memory/add`, which starts a demo round; flush and search requests +do not spend additional rounds. The quota service fails closed: +demo API requests return `503` when Redis is not configured or unavailable. +`/healthz` remains available for diagnostics and reports only whether secrets +are configured, never their values. + +## Deploy from GitHub + +1. In Netlify, choose **Add new project -> Import an existing project -> GitHub**. +2. Select the EverOS fork and the `feat/demo-cloud-interactive` branch. +3. Set **Base directory** to `deploy/netlify_relay`. +4. Leave the build command empty. `netlify.toml` supplies the publish and + functions directories. +5. Add the required environment variables and deploy. +6. Verify `https://.netlify.app/healthz` reports both configuration flags + as `true`. +7. Test the client with + `EVEROS_CLOUD_DEMO_URL=https://.netlify.app everos demo`. +8. After validation, add the production custom domain. + +## Local checks + +```bash +cd deploy/netlify_relay +npm run check +``` + +The test suite is credential-free and mocks both Redis and EverOS Cloud. diff --git a/deploy/netlify_relay/netlify.toml b/deploy/netlify_relay/netlify.toml new file mode 100644 index 00000000..a40163d0 --- /dev/null +++ b/deploy/netlify_relay/netlify.toml @@ -0,0 +1,13 @@ +[build] + publish = "public" + functions = "netlify/functions" + +[build.environment] + NODE_VERSION = "22" + +[[headers]] + for = "/*" + [headers.values] + X-Content-Type-Options = "nosniff" + Referrer-Policy = "no-referrer" + X-Frame-Options = "DENY" diff --git a/deploy/netlify_relay/netlify/functions/relay.mjs b/deploy/netlify_relay/netlify/functions/relay.mjs new file mode 100644 index 00000000..fcad816d --- /dev/null +++ b/deploy/netlify_relay/netlify/functions/relay.mjs @@ -0,0 +1,199 @@ +const DEFAULT_UPSTREAM = "https://api.evermind.ai"; +const DEFAULT_RATE_PER_MINUTE = 30; +const DEFAULT_DAILY_ROUNDS = 3; +const DEFAULT_TIMEOUT_MS = 20_000; +const DEFAULT_MAX_BODY_BYTES = 1_000_000; + +const ALLOWED_EXACT = new Set([ + "POST /api/v2/memory/add", + "POST /api/v2/memory/flush", + "POST /api/v2/memory/search", +]); + +function readPositiveInteger(name, fallback) { + const value = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); +} + +export function isAllowed(method, path) { + return ALLOWED_EXACT.has(`${method} ${path}`); +} + +function clientIp(request, context) { + return context?.ip || request.headers.get("x-nf-client-connection-ip") || "unknown"; +} + +async function hashClientIp(ip) { + const bytes = new TextEncoder().encode(ip); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ) + .join("") + .slice(0, 24); +} + +async function checkQuota(ip, countRound, now = Date.now()) { + const redisUrl = (process.env.UPSTASH_REDIS_REST_URL ?? "").replace(/\/$/, ""); + const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN ?? ""; + if (!redisUrl || !redisToken) { + throw new Error("quota store is not configured"); + } + + const fingerprint = await hashClientIp(ip); + const minute = Math.floor(now / 60_000); + const day = new Date(now).toISOString().slice(0, 10); + const minuteKey = `everos-demo:minute:${fingerprint}:${minute}`; + const commands = [ + ["INCR", minuteKey], + ["EXPIRE", minuteKey, 120], + ]; + if (countRound) { + const dailyKey = `everos-demo:rounds:${fingerprint}:${day}`; + commands.push(["INCR", dailyKey], ["EXPIRE", dailyKey, 172_800]); + } + + const response = await fetch(`${redisUrl}/pipeline`, { + method: "POST", + headers: { + authorization: `Bearer ${redisToken}`, + "content-type": "application/json", + }, + body: JSON.stringify(commands), + }); + if (!response.ok) { + throw new Error("quota store request failed"); + } + + const results = await response.json(); + if (!Array.isArray(results) || results.length !== commands.length) { + throw new Error("quota store returned an invalid response"); + } + if (results.some((result) => result?.error)) { + throw new Error("quota store command failed"); + } + + const minuteCount = Number(results[0]?.result); + const dailyCount = countRound ? Number(results[2]?.result) : 0; + if (!Number.isFinite(minuteCount) || !Number.isFinite(dailyCount)) { + throw new Error("quota store returned invalid counters"); + } + + return { + minuteCount, + dailyCount, + minuteLimit: readPositiveInteger( + "RELAY_RATE_PER_MIN", + DEFAULT_RATE_PER_MINUTE, + ), + dailyLimit: readPositiveInteger( + "RELAY_DAILY_ROUNDS", + DEFAULT_DAILY_ROUNDS, + ), + }; +} + +async function forwardRequest(request, path, context) { + const apiKey = process.env.EVEROS_CLOUD_API_KEY ?? ""; + if (!apiKey) { + return jsonResponse({ error: "relay API key is not configured" }, 500); + } + + let quota; + try { + const countRound = + request.method === "POST" && path === "/api/v2/memory/add"; + quota = await checkQuota(clientIp(request, context), countRound); + } catch { + return jsonResponse({ error: "relay quota service is unavailable" }, 503); + } + if (quota.minuteCount > quota.minuteLimit) { + return jsonResponse({ error: "rate limit exceeded, slow down" }, 429); + } + if (quota.dailyCount > quota.dailyLimit) { + return jsonResponse( + { error: "daily demo round limit reached, configure your own key" }, + 429, + ); + } + + const maxBodyBytes = readPositiveInteger( + "RELAY_MAX_BODY_BYTES", + DEFAULT_MAX_BODY_BYTES, + ); + const body = request.method === "GET" ? null : await request.arrayBuffer(); + if (body && body.byteLength > maxBodyBytes) { + return jsonResponse({ error: "request body is too large" }, 413); + } + + const upstream = (process.env.EVEROS_CLOUD_UPSTREAM ?? DEFAULT_UPSTREAM).replace( + /\/$/, + "", + ); + const incomingUrl = new URL(request.url); + const upstreamUrl = `${upstream}${path}${incomingUrl.search}`; + const timeoutMs = readPositiveInteger( + "RELAY_UPSTREAM_TIMEOUT_MS", + DEFAULT_TIMEOUT_MS, + ); + + let response; + try { + response = await fetch(upstreamUrl, { + method: request.method, + headers: { + accept: "application/json", + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + return jsonResponse({ error: "upstream unreachable" }, 502); + } + + const headers = new Headers(); + headers.set( + "content-type", + response.headers.get("content-type") ?? "application/json", + ); + const retryAfter = response.headers.get("retry-after"); + if (retryAfter) { + headers.set("retry-after", retryAfter); + } + return new Response(await response.arrayBuffer(), { + status: response.status, + headers, + }); +} + +export default async function relay(request, context) { + const path = new URL(request.url).pathname; + if (path === "/healthz") { + return jsonResponse({ + ok: true, + upstream: process.env.EVEROS_CLOUD_UPSTREAM ?? DEFAULT_UPSTREAM, + key_configured: Boolean(process.env.EVEROS_CLOUD_API_KEY), + quota_configured: Boolean( + process.env.UPSTASH_REDIS_REST_URL && + process.env.UPSTASH_REDIS_REST_TOKEN, + ), + }); + } + if (!isAllowed(request.method, path)) { + return jsonResponse({ error: "endpoint not allowed" }, 403); + } + return forwardRequest(request, path, context); +} + +export const config = { + path: ["/healthz", "/api/v2/*"], +}; diff --git a/deploy/netlify_relay/package-lock.json b/deploy/netlify_relay/package-lock.json new file mode 100644 index 00000000..10b7cf92 --- /dev/null +++ b/deploy/netlify_relay/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "everos-demo-netlify-relay", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "everos-demo-netlify-relay", + "version": "1.0.0", + "engines": { + "node": ">=22" + } + } + } +} diff --git a/deploy/netlify_relay/package.json b/deploy/netlify_relay/package.json new file mode 100644 index 00000000..df6cb2f6 --- /dev/null +++ b/deploy/netlify_relay/package.json @@ -0,0 +1,13 @@ +{ + "name": "everos-demo-netlify-relay", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "check": "node --check netlify/functions/relay.mjs && node --test tests/*.test.mjs", + "test": "node --test tests/*.test.mjs" + }, + "engines": { + "node": ">=22" + } +} diff --git a/deploy/netlify_relay/public/index.html b/deploy/netlify_relay/public/index.html new file mode 100644 index 00000000..0af8b08e --- /dev/null +++ b/deploy/netlify_relay/public/index.html @@ -0,0 +1,16 @@ + + + + + + + EverOS Demo Relay + + +
+

EverOS Demo Relay

+

This service powers the public everos demo experience.

+

Service health

+
+ + diff --git a/deploy/netlify_relay/tests/relay.test.mjs b/deploy/netlify_relay/tests/relay.test.mjs new file mode 100644 index 00000000..5f103a34 --- /dev/null +++ b/deploy/netlify_relay/tests/relay.test.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import relay, { isAllowed } from "../netlify/functions/relay.mjs"; + +const ORIGINAL_FETCH = globalThis.fetch; +const ENV_NAMES = [ + "EVEROS_CLOUD_API_KEY", + "EVEROS_CLOUD_UPSTREAM", + "UPSTASH_REDIS_REST_URL", + "UPSTASH_REDIS_REST_TOKEN", + "RELAY_RATE_PER_MIN", + "RELAY_DAILY_ROUNDS", + "RELAY_UPSTREAM_TIMEOUT_MS", + "RELAY_MAX_BODY_BYTES", +]; +const ORIGINAL_ENV = Object.fromEntries( + ENV_NAMES.map((name) => [name, process.env[name]]), +); + +function configureEnvironment() { + process.env.EVEROS_CLOUD_API_KEY = "server-secret"; + process.env.EVEROS_CLOUD_UPSTREAM = "https://api.test"; + process.env.UPSTASH_REDIS_REST_URL = "https://redis.test"; + process.env.UPSTASH_REDIS_REST_TOKEN = "redis-secret"; + process.env.RELAY_RATE_PER_MIN = "30"; + process.env.RELAY_DAILY_ROUNDS = "3"; +} + +function restoreEnvironment() { + globalThis.fetch = ORIGINAL_FETCH; + for (const name of ENV_NAMES) { + if (ORIGINAL_ENV[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = ORIGINAL_ENV[name]; + } + } +} + +function quotaResponse(minuteCount = 1, dailyCount = 1) { + return new Response( + JSON.stringify([ + { result: minuteCount }, + { result: 1 }, + { result: dailyCount }, + { result: 1 }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +test.afterEach(restoreEnvironment); + +test("allows only the demo API surface", () => { + assert.equal(isAllowed("POST", "/api/v2/memory/add"), true); + assert.equal(isAllowed("POST", "/api/v2/memory/flush"), true); + assert.equal(isAllowed("POST", "/api/v2/memory/search"), true); + assert.equal(isAllowed("GET", "/api/v1/tasks/task-123"), false); + assert.equal(isAllowed("DELETE", "/api/v2/memory/add"), false); + assert.equal(isAllowed("GET", "/api/v2/memory/get"), false); +}); + +test("reports deployment readiness without exposing secrets", async () => { + configureEnvironment(); + const response = await relay( + new Request("https://demo.test/healthz"), + { ip: "192.0.2.1" }, + ); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + ok: true, + upstream: "https://api.test", + key_configured: true, + quota_configured: true, + }); +}); + +test("rejects non-demo endpoints", async () => { + const response = await relay( + new Request("https://demo.test/api/v2/memory/get"), + { ip: "192.0.2.1" }, + ); + assert.equal(response.status, 403); +}); + +test("injects the server key and never forwards client authorization", async () => { + configureEnvironment(); + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url: String(url), options }); + if (String(url).startsWith("https://redis.test")) { + return quotaResponse(); + } + return new Response(JSON.stringify({ data: { status: "queued" } }), { + status: 202, + headers: { "content-type": "application/json" }, + }); + }; + + const response = await relay( + new Request("https://demo.test/api/v2/memory/add", { + method: "POST", + headers: { + authorization: "Bearer client-secret", + "content-type": "application/json", + }, + body: JSON.stringify({ messages: [] }), + }), + { ip: "192.0.2.1" }, + ); + + assert.equal(response.status, 202); + assert.equal(calls.length, 2); + assert.equal(calls[1].url, "https://api.test/api/v2/memory/add"); + assert.equal(calls[1].options.headers.authorization, "Bearer server-secret"); + assert.notEqual( + calls[1].options.headers.authorization, + "Bearer client-secret", + ); +}); + +test("enforces the distributed per-minute quota", async () => { + configureEnvironment(); + globalThis.fetch = async () => quotaResponse(31, 31); + + const response = await relay( + new Request("https://demo.test/api/v2/memory/add", { method: "POST" }), + { ip: "192.0.2.1" }, + ); + assert.equal(response.status, 429); + assert.match((await response.json()).error, /rate limit/); +}); + +test("enforces three demo rounds per day", async () => { + configureEnvironment(); + globalThis.fetch = async () => quotaResponse(1, 4); + + const response = await relay( + new Request("https://demo.test/api/v2/memory/add", { method: "POST" }), + { ip: "192.0.2.1" }, + ); + assert.equal(response.status, 429); + assert.match((await response.json()).error, /daily demo round limit/); +}); + +test("flush does not spend another demo round", async () => { + configureEnvironment(); + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url: String(url), options }); + if (String(url).startsWith("https://redis.test")) { + const commands = JSON.parse(options.body); + assert.equal(commands.length, 2); + return new Response(JSON.stringify([{ result: 1 }, { result: 1 }]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ data: { status: "success" } })); + }; + + const response = await relay( + new Request("https://demo.test/api/v2/memory/flush", { method: "POST" }), + { ip: "192.0.2.1" }, + ); + assert.equal(response.status, 200); + assert.equal(calls.length, 2); +}); + +test("fails closed when the server API key is missing", async () => { + configureEnvironment(); + delete process.env.EVEROS_CLOUD_API_KEY; + globalThis.fetch = async () => { + throw new Error("fetch must not be called without a server key"); + }; + + const response = await relay( + new Request("https://demo.test/api/v2/memory/flush", { method: "POST" }), + { ip: "192.0.2.1" }, + ); + assert.equal(response.status, 500); + assert.match((await response.json()).error, /API key/); +}); + +test("fails closed when the quota store is unavailable", async () => { + configureEnvironment(); + globalThis.fetch = async () => { + throw new Error("offline"); + }; + + const response = await relay( + new Request("https://demo.test/api/v2/memory/flush", { method: "POST" }), + { ip: "192.0.2.1" }, + ); + assert.equal(response.status, 503); +}); diff --git a/docs/everos-demo.md b/docs/everos-demo.md index ab55ebbc..101510ad 100644 --- a/docs/everos-demo.md +++ b/docs/everos-demo.md @@ -1,8 +1,8 @@ # EverOS Demo -`everos demo` is a local educational TUI. It helps new users feel the memory -lifecycle before they configure API keys, start the server, or write real -memory through the API. +`everos demo` is an interactive TUI that lets new users feel the memory +lifecycle — type a memory, ask for it back, watch EverOS recall it — before they +configure their own API keys. ## Run It @@ -10,48 +10,51 @@ memory through the API. everos demo ``` -The command asks for one memory and one recall question, then opens a -full-screen terminal UI. The visual flow is deterministic and local to the CLI: -conversation -> memory sphere -> recall -> source proof -> confetti. +This opens a full-screen terminal UI with an input box. Type something EverOS +should remember, then ask a question that recalls it. No API key or server setup +is needed for the default demo. -For non-interactive shells or a copyable preview, use: +Each round runs the memory lifecycle and visualizes its four stages: -```bash -everos demo --plain -``` +1. **Ingest** receives what you want EverOS to remember. +2. **Extract** identifies the useful memory inside the conversation. +3. **Index** prepares that memory for retrieval. +4. **Recall** finds it again when you ask a related question. -For the looping showroom view used by README media, use: +The same particle sphere flows continuously across all four stages. At the end, +the particles burst across the memory field and fade away. A small core of +yellow and white particles keeps moving at the center, then expands smoothly as +the next ingest cycle begins. -```bash -everos demo --cinematic -``` +If the demo service is temporarily unavailable or the trial limit is reached, +the UI explains what happened and points you toward running with your own key. +It never fabricates a memory result. -## Run It Against A Server +## Run It With Your Own Cloud Key -After `everos init` and `everos server start`, run: +Get a key from , then: ```bash +export EVEROS_CLOUD_API_KEY= everos demo --live ``` -Live mode keeps the same TUI, but the memory lifecycle is backed by real -server calls: +`--live` bypasses the relay and runs the same flow directly against the platform +with your own key. -1. `GET /health` -2. `POST /api/v2/memory/add` -3. `POST /api/v2/memory/flush` -4. `POST /api/v2/memory/search` +## Static Previews -If your server is not running on `http://127.0.0.1:8000`, pass -`--server-url `. +For non-interactive shells or a copyable preview (no input box, no network): -## What It Does Not Do +```bash +everos demo --plain +``` + +For the looping showroom view used by README media: -By default, `everos demo` does not connect to the EverOS server, call LLM -providers, or write production memory files. It is intentionally hardcoded so -users can try the experience before configuring the full runtime. Use -`everos demo --live` when you want the same visual flow backed by a running -server. +```bash +everos demo --cinematic +``` ## Source Layout @@ -60,8 +63,9 @@ because the public command is still `everos demo`. The TUI implementation lives under `src/everos/entrypoints/tui/demo/`: -- `app.py` renders the Textual app. -- `data.py` builds the deterministic demo story. +- `app.py` renders the Textual app and drives the interactive rounds. +- `cloud.py` runs the demo memory requests (`add -> flush -> search`). +- `data.py` holds the static showcase story for `--plain` / `--cinematic`. - `widgets/sphere.py` builds the memory sphere frames. - `readme_media.py` renders README media. diff --git a/src/everos/component/tokenizer/jieba_provider.py b/src/everos/component/tokenizer/jieba_provider.py index ab083aa9..d01a80bf 100644 --- a/src/everos/component/tokenizer/jieba_provider.py +++ b/src/everos/component/tokenizer/jieba_provider.py @@ -18,10 +18,19 @@ from __future__ import annotations +import warnings from collections.abc import Sequence from typing import Final -import jieba +with warnings.catch_warnings(): + # jieba 0.42.1 (its last release; unmaintained) ships regex string + # literals with invalid escape sequences. Python >= 3.12 flags these as + # SyntaxWarning at first-import compile time (before the .pyc is cached), + # leaking noise on a user's first run. The warnings are harmless and out + # of our control, so we suppress them at the single jieba import site. + # See https://github.com/EverMind-AI/EverOS/issues/304. + warnings.simplefilter("ignore", SyntaxWarning) + import jieba # Small bilingual stopword set. Intentionally tight (not a full # Chinese stopword list) so the behaviour is predictable; callers diff --git a/src/everos/entrypoints/cli/commands/demo.py b/src/everos/entrypoints/cli/commands/demo.py index 61e7c966..2afa9502 100644 --- a/src/everos/entrypoints/cli/commands/demo.py +++ b/src/everos/entrypoints/cli/commands/demo.py @@ -1,28 +1,26 @@ -"""``everos demo`` — first-run memory sphere demo.""" +"""``everos demo`` — first-run memory sphere demo. + +The default command launches an interactive Textual TUI: the user types memories +and recall questions directly in the UI, and each round runs the *real* memory +pipeline against a hosted EverOS server (keys live server-side; see +:mod:`everos.entrypoints.tui.demo.cloud`). ``--plain`` / ``--cinematic`` are +static, no-network renderings for non-interactive shells and README media. +""" from __future__ import annotations -import json +import getpass +import os +import subprocess import sys -import time -import urllib.error -import urllib.request -from collections.abc import Callable -from typing import Any import typer from rich.console import Console from rich.panel import Panel -from everos.component.utils.datetime import get_utc_now from everos.entrypoints.cli._log_setup import configure_cli_logging -from everos.entrypoints.tui.demo.data import ( - DEFAULT_MEMORY_SEED, - DEFAULT_QUERY, - DemoStory, - build_demo_story, - default_demo_story, -) +from everos.entrypoints.tui.demo import cloud +from everos.entrypoints.tui.demo.data import DemoStory, default_demo_story from everos.entrypoints.tui.demo.widgets.sphere import ( EVEROS_GREEN, EVEROS_YELLOW, @@ -30,14 +28,7 @@ render_dot_sphere_text, ) -LIVE_DEMO_SERVER_URL = "http://127.0.0.1:8000" -LIVE_DEMO_SESSION_ID = "everos-demo-live" -LIVE_DEMO_USER_ID = "everos_demo_user" -LIVE_DEMO_APP_ID = "default" -LIVE_DEMO_PROJECT_ID = "default" -LIVE_DEMO_TIMEOUT_SECONDS = 10.0 -LIVE_DEMO_SEARCH_ATTEMPTS = 6 -LIVE_DEMO_SEARCH_INTERVAL_SECONDS = 0.5 +TEXTUAL_DISABLE_KITTY_KEY_ENV = "TEXTUAL_DISABLE_KITTY_KEY" def register(parent: typer.Typer) -> None: @@ -53,17 +44,22 @@ def demo( cinematic: bool = typer.Option( False, "--cinematic", - help="Skip prompts and launch the looping README-style demo.", + help="Launch the looping README-style showcase (no input box).", ), live: bool = typer.Option( False, "--live", - help="Connect to a running EverOS server and run add/flush/search.", + help="Use your own EverOS Cloud API key (env EVEROS_CLOUD_API_KEY).", + ), + cloud_mode: bool = typer.Option( + False, + "--cloud", + help="Run against EverOS Cloud with the demo key (this is the default).", ), server_url: str = typer.Option( - LIVE_DEMO_SERVER_URL, + cloud.LIVE_DEMO_SERVER_URL, "--server-url", - help="EverOS server URL used by --live.", + help="Override the EverOS Cloud API base URL.", ), verbose: bool = typer.Option( False, @@ -74,42 +70,78 @@ def demo( ) -> None: """Launch the EverOS first-memory Textual TUI.""" configure_cli_logging(verbose=verbose) - if live: - _run_live_demo( - cinematic=cinematic, - plain=plain or not sys.stdout.isatty(), - base_url=server_url, - ) - return - if plain or not sys.stdout.isatty(): _print_plain_demo() return - _run_interactive_demo(cinematic=cinematic) + user_label = _resolve_local_user() + if cinematic: + _load_run_demo_tui()(user_label=user_label) + return + _launch_interactive_demo( + live=live, server_url=server_url, user_label=user_label + ) + + +def _launch_interactive_demo( + *, live: bool, server_url: str, user_label: str = "you" +) -> None: + """Launch the cloud-platform interactive TUI. + + The default mode talks to the credential-injecting public relay. ``--live`` + bypasses the relay and uses the user's own platform key directly. + """ -def _run_interactive_demo(*, cinematic: bool) -> None: run_demo_tui = _load_run_demo_tui() - story = None if cinematic else _collect_playable_story() - run_demo_tui(story=story) + base_url = ( + cloud.resolve_live_base_url(server_url) + if live + else cloud.resolve_cloud_base_url(server_url) + ) + session_id, user_id = cloud.new_demo_identity() + api_key = cloud.resolve_user_key() if live else cloud.resolve_demo_key() + run_demo_tui( + interactive=True, + base_url=base_url, + session_id=session_id, + user_id=user_id, + api_key=api_key, + user_label=user_label, + ) -def _run_live_demo(*, cinematic: bool, plain: bool, base_url: str) -> None: - run_demo_tui = None if plain else _load_run_demo_tui() - story = default_demo_story() if cinematic or plain else _collect_playable_story() - live_story = _run_live_demo_flow(story, base_url=base_url) - if plain: - _print_plain_demo(live_story) - return +def _resolve_local_user() -> str: + """Local-first display name: the clone's git identity, else the OS user.""" - if run_demo_tui is None: # pragma: no cover - guarded by plain branch. - raise typer.Exit(code=1) - run_demo_tui(story=live_story) + try: + result = subprocess.run( + ["git", "config", "user.name"], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + name = result.stdout.strip() + except (OSError, subprocess.SubprocessError): + name = "" + if name: + return name + try: + return getpass.getuser() + except Exception: + return "you" def _load_run_demo_tui(): + # Textual's Kitty extended-key parser conflicts with macOS Chinese IMEs in + # some terminals: the pinyin pre-edit is delivered as ordinary key presses + # before the selected Han characters are committed. Configure Textual + # before its first import so `everos demo` accepts composed Chinese input. + # Keep an explicit user override intact for terminals that need the + # extended-key protocol. + os.environ.setdefault(TEXTUAL_DISABLE_KITTY_KEY_ENV, "1") try: from everos.entrypoints.tui.demo.app import run_demo_tui except ModuleNotFoundError as exc: @@ -126,178 +158,6 @@ def _load_run_demo_tui(): return run_demo_tui -def _collect_playable_story() -> DemoStory: - Console().print( - f"[bold {EVEROS_YELLOW}]EverOS demo[/] " - "Give it one memory, then ask for it back." - ) - memory = typer.prompt( - "Give EverOS one thing to remember", - default=DEFAULT_MEMORY_SEED, - ) - query = typer.prompt( - "Ask EverOS to recall it", - default=DEFAULT_QUERY, - ) - return build_demo_story(memory, query) - - -def _run_live_demo_flow( - story: DemoStory, - *, - base_url: str, - request_json: Callable[..., dict[str, Any]] | None = None, - timeout_seconds: float = LIVE_DEMO_TIMEOUT_SECONDS, - search_attempts: int = LIVE_DEMO_SEARCH_ATTEMPTS, - search_interval_seconds: float = LIVE_DEMO_SEARCH_INTERVAL_SECONDS, -) -> DemoStory: - """Run the educational demo story through a live EverOS server.""" - - request = request_json or _request_json - health = request( - "GET", - "/health", - base_url=base_url, - timeout_seconds=timeout_seconds, - ) - if health.get("status") != "ok": - raise typer.BadParameter( - f"EverOS server at {base_url} did not return healthy status" - ) - - timestamp_ms = int(get_utc_now().timestamp() * 1000) - request( - "POST", - "/api/v2/memory/add", - base_url=base_url, - json_body={ - "session_id": LIVE_DEMO_SESSION_ID, - "app_id": LIVE_DEMO_APP_ID, - "project_id": LIVE_DEMO_PROJECT_ID, - "messages": [ - { - "sender_id": LIVE_DEMO_USER_ID, - "role": "user", - "timestamp": timestamp_ms, - "content": story.memory, - } - ], - }, - timeout_seconds=timeout_seconds, - ) - request( - "POST", - "/api/v2/memory/flush", - base_url=base_url, - json_body={ - "session_id": LIVE_DEMO_SESSION_ID, - "app_id": LIVE_DEMO_APP_ID, - "project_id": LIVE_DEMO_PROJECT_ID, - }, - timeout_seconds=timeout_seconds, - ) - - search_payload = { - "user_id": LIVE_DEMO_USER_ID, - "app_id": LIVE_DEMO_APP_ID, - "project_id": LIVE_DEMO_PROJECT_ID, - "query": story.query, - "top_k": 5, - } - for attempt in range(search_attempts): - search = request( - "POST", - "/api/v2/memory/search", - base_url=base_url, - json_body=search_payload, - timeout_seconds=timeout_seconds, - ) - episode = _first_live_episode(search) - if episode is not None: - return _story_from_live_episode(story, episode) - if attempt < search_attempts - 1: - time.sleep(search_interval_seconds) - - raise typer.BadParameter( - "EverOS server accepted the memory, but search did not return it yet. " - "Try `everos demo --live` again after indexing catches up." - ) - - -def _request_json( - method: str, - path: str, - *, - base_url: str, - json_body: dict[str, object] | None = None, - timeout_seconds: float, -) -> dict[str, Any]: - url = f"{base_url.rstrip('/')}{path}" - data = None if json_body is None else json.dumps(json_body).encode("utf-8") - request = urllib.request.Request( - url, - data=data, - method=method, - headers={"Content-Type": "application/json"}, - ) - try: - with urllib.request.urlopen(request, timeout=timeout_seconds) as response: - raw = response.read().decode("utf-8") - except urllib.error.URLError as exc: - raise typer.BadParameter( - f"Could not reach EverOS server at {base_url}. " - "Start it with `everos server start` and try again." - ) from exc - if not raw: - return {} - parsed = json.loads(raw) - if not isinstance(parsed, dict): - raise typer.BadParameter(f"EverOS server returned non-object JSON: {url}") - return parsed - - -def _first_live_episode(payload: dict[str, Any]) -> dict[str, Any] | None: - data = payload.get("data") - if not isinstance(data, dict): - return None - episodes = data.get("episodes") - if not isinstance(episodes, list) or not episodes: - return None - first = episodes[0] - return first if isinstance(first, dict) else None - - -def _story_from_live_episode(story: DemoStory, episode: dict[str, Any]) -> DemoStory: - facts = episode.get("atomic_facts") - first_fact = facts[0] if isinstance(facts, list) and facts else None - fact_id = _string_field(first_fact, "id") if isinstance(first_fact, dict) else "" - answer = ( - _string_field(first_fact, "content") if isinstance(first_fact, dict) else "" - ) - if not answer: - answer = ( - _string_field(episode, "summary") - or _string_field(episode, "episode") - or story.answer - ) - episode_id = _string_field(episode, "id") or "live" - return DemoStory( - owner=LIVE_DEMO_USER_ID, - memory=story.memory, - query=story.query, - answer=answer, - source_filename=f"episode:{episode_id}", - fact_filename=f"fact:{fact_id or 'live'}", - ) - - -def _string_field(payload: dict[str, Any] | None, key: str) -> str: - if payload is None: - return "" - value = payload.get(key) - return value if isinstance(value, str) else "" - - def _print_plain_demo(story: DemoStory | None = None) -> None: story = story or default_demo_story() console = Console() diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py index b1eabafc..20d3aedb 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -2,13 +2,24 @@ from __future__ import annotations +from functools import partial + +import anyio from rich.text import Text +from textual import on from textual.app import App, ComposeResult -from textual.containers import Horizontal, Vertical +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.message import Message from textual.timer import Timer -from textual.widgets import Footer, Static +from textual.widgets import Footer, Input, Static -from everos.entrypoints.tui.demo.data import DemoStory, default_demo_story +from everos.component.utils.datetime import today_with_timezone +from everos.entrypoints.tui.demo import cloud +from everos.entrypoints.tui.demo.data import ( + DemoStory, + default_demo_story, +) from everos.entrypoints.tui.demo.widgets.sphere import ( EVEROS_AMBER, EVEROS_AMBER_DIM, @@ -17,6 +28,7 @@ EVEROS_ORANGE, EVEROS_YELLOW, EVEROS_YELLOW_SOFT, + blend_dot_sphere_frames, build_dot_sphere, render_dot_sphere_text, ) @@ -31,6 +43,72 @@ SPHERE_FRAME_HEIGHT = 17 TERMINAL_CELL_HEIGHT_RATIO = 2.0 SIGNAL_RAIL_SOURCE_WIDTH = 18 +# Offline default demo: how many memory -> recall rounds a user plays before the +# TUI nudges them toward the real pipeline (`--cloud` / `--live`). +DEFAULT_DEMO_ROUNDS = 3 + +# Sphere animation cadence. Each named state (and its highlighted trace word) +# dwells for SPHERE_STAGE_SECONDS so a viewer can read the stage it represents. +SPHERE_FPS = 24 +SPHERE_STAGE_SECONDS = 3.0 +SPHERE_STAGE_TICKS = round(SPHERE_FPS * SPHERE_STAGE_SECONDS) +SPHERE_TRANSITION_SECONDS = 0.35 +SPHERE_TRANSITION_TICKS = round(SPHERE_FPS * SPHERE_TRANSITION_SECONDS) +SPHERE_SUPERNOVA_CYCLE_SECONDS = 8.0 +SPHERE_SUPERNOVA_CYCLE_TICKS = round(SPHERE_FPS * SPHERE_SUPERNOVA_CYCLE_SECONDS) + +# The four pipeline stages shown in the trace header. They line up with the four +# core sphere states, so the active word can highlight in sync with the sphere. +TRACE_STAGES = ("ingest", "extract", "index", "recall") +SPHERE_IDLE_STATES = ( + "booting", + "ingesting", + "extracting", + "indexing", + "recalling", + "celebrating", +) + +# Words a user can type in the input box to quit back to the terminal. +QUIT_COMMANDS = frozenset({"quit", "exit", ":q", "/quit", "/exit"}) +_STATE_TO_STAGE = { + "ingesting": 0, + "extracting": 1, + "indexing": 2, + "recalling": 3, + "remembered": 3, + "source": 3, +} + + +def _state_to_stage(state_key: str) -> int: + """Map a sphere state to its trace-stage index (-1 = no stage highlighted).""" + + return _STATE_TO_STAGE.get(state_key, -1) + + +def _sphere_state_phase(state: str, state_tick: int) -> float: + """Return a one-shot phase for stages and a seamless loop for celebration.""" + + if state == "celebrating": + return (state_tick % SPHERE_SUPERNOVA_CYCLE_TICKS) / ( + SPHERE_SUPERNOVA_CYCLE_TICKS - 1 + ) + return min(1.0, state_tick / SPHERE_STAGE_TICKS) + + +def _idle_sphere_state(tick: int) -> str: + """Start with Working, then loop ingest through the full celebration.""" + + if tick < SPHERE_STAGE_TICKS: + return SPHERE_IDLE_STATES[0] + pipeline_state_count = len(SPHERE_IDLE_STATES) - 2 + pipeline_ticks = pipeline_state_count * SPHERE_STAGE_TICKS + cycle_ticks = pipeline_ticks + SPHERE_SUPERNOVA_CYCLE_TICKS + cycle_tick = (tick - SPHERE_STAGE_TICKS) % cycle_ticks + if cycle_tick < pipeline_ticks: + return SPHERE_IDLE_STATES[1 + cycle_tick // SPHERE_STAGE_TICKS] + return SPHERE_IDLE_STATES[-1] class DotSphereWidget(Static): @@ -43,42 +121,174 @@ class DotSphereWidget(Static): } """ - STATES = ( - "booting", - "ingesting", - "extracting", - "indexing", - "recalling", - "remembered", - "source", - "celebrating", - ) + STATES = SPHERE_IDLE_STATES + + class StageChanged(Message): + """Posted when the sphere enters a different trace stage.""" + + def __init__(self, stage: int) -> None: + self.stage = stage + super().__init__() def __init__(self) -> None: super().__init__() self._phase = 0.0 self._tick = 0 + self._last_stage = -2 + self._rendered_state: str | None = None + self._transition_from_state: str | None = None + self._transition_tick = 0 + self._state_tick = 0 + self._celebration_source_phase = 0.0 self._animation_timer: Timer | None = None + # When set, the sphere is pinned to a pipeline state (synced to the + # signal rail during a round). When None it free-runs the idle loop. + self._driven_state: str | None = None + + def drive_state(self, state: str | None) -> None: + """Pin the sphere to a pipeline state, or None to resume the idle loop.""" + + self._driven_state = state + self._advance() # reflect the change without waiting for the next tick def on_mount(self) -> None: - self._animation_timer = self.set_interval(1 / 12, self._advance) + self._animation_timer = self.set_interval(1 / SPHERE_FPS, self._advance) self._advance() def pause_animation(self) -> None: if self._animation_timer is not None: self._animation_timer.pause() + def _frame_size(self) -> tuple[int, int]: + """Size the sphere to its actual box so it never clips and stays round. + + Terminal cells are ~2x taller than wide, so a round sphere needs + ``width ≈ 2 * height``. We fill whichever dimension is the constraint. + Before the first layout the widget reports a 0x0 size, so fall back to + the default frame. + """ + + width, height = self.size.width, self.size.height + if width <= 0 or height <= 0: + return SPHERE_FRAME_WIDTH, SPHERE_FRAME_HEIGHT + frame_height = height + frame_width = round(height * TERMINAL_CELL_HEIGHT_RATIO) + 3 + if frame_width > width: + frame_width = width + frame_height = round((width - 3) / TERMINAL_CELL_HEIGHT_RATIO) + # The builder needs at least 13x7; clamp up even on a tiny box (it just + # clips a little) rather than crash. + return max(13, frame_width), max(7, frame_height) + def _advance(self) -> None: - self._phase = (self._phase + 0.025) % 1.0 + # Keep time monotonic. Wrapping at 1.0 made the non-integer wave + # frequencies jump to a different shape every few seconds. + self._phase += 0.3 / SPHERE_FPS self._tick += 1 - state = self.STATES[(self._tick // 36) % len(self.STATES)] + if ( + self._driven_state == "celebrating" + and self._state_tick >= SPHERE_SUPERNOVA_CYCLE_TICKS + ): + self._driven_state = None + self._tick = SPHERE_STAGE_TICKS + if self._driven_state is not None: + state = self._driven_state + else: + state = _idle_sphere_state(self._tick) + if self._rendered_state is None: + self._rendered_state = state + elif state != self._rendered_state: + self._transition_from_state = self._rendered_state + self._rendered_state = state + self._transition_tick = 0 + self._state_tick = 0 + if state == "celebrating": + self._celebration_source_phase = self._phase + frame_width, frame_height = self._frame_size() + state_phase = _sphere_state_phase(state, self._state_tick) + render_phase = ( + self._celebration_source_phase if state == "celebrating" else self._phase + ) frame = build_dot_sphere( - width=SPHERE_FRAME_WIDTH, - height=SPHERE_FRAME_HEIGHT, - phase=self._phase, + width=frame_width, + height=frame_height, + phase=render_phase, state_key=state, + state_phase=state_phase, ) + if self._transition_from_state is not None: + previous_phase = ( + self._celebration_source_phase + if self._transition_from_state == "celebrating" + else self._phase + ) + previous_frame = build_dot_sphere( + width=frame_width, + height=frame_height, + phase=previous_phase, + state_key=self._transition_from_state, + state_phase=( + 1.0 if self._transition_from_state == "celebrating" else None + ), + ) + raw_progress = min( + 1.0, + (self._transition_tick + 1) / SPHERE_TRANSITION_TICKS, + ) + eased_progress = raw_progress * raw_progress * (3 - 2 * raw_progress) + frame = blend_dot_sphere_frames( + previous_frame, + frame, + eased_progress, + background=EVEROS_SURFACE, + ) + self._transition_tick += 1 + if self._transition_tick >= SPHERE_TRANSITION_TICKS: + self._transition_from_state = None self.update(render_dot_sphere_text(frame)) + self._state_tick += 1 + + stage = _state_to_stage(state) + if stage != self._last_stage: + self._last_stage = stage + self.post_message(self.StageChanged(stage)) + + +class QueryAnswerBar(Static): + """Query <-> Answer bar with a marker that propagates back and forth.""" + + TRACK_WIDTH = 11 + + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) + self._pos = 0 + self._dir = 1 + self._timer: Timer | None = None + + def on_mount(self) -> None: + self._timer = self.set_interval(0.1, self._advance) + + def _advance(self) -> None: + self._pos += self._dir + if self._pos >= self.TRACK_WIDTH - 1: + self._pos = self.TRACK_WIDTH - 1 + self._dir = -1 + elif self._pos <= 0: + self._pos = 0 + self._dir = 1 + self.refresh() + + def render(self) -> Text: + glyph = "▶" if self._dir > 0 else "◀" + left = "·" * self._pos + right = "·" * (self.TRACK_WIDTH - 1 - self._pos) + return Text.assemble( + ("Query ", f"bold {EVEROS_CYAN}"), + (f" {left}", EVEROS_AMBER), + (glyph, f"bold {EVEROS_YELLOW}"), + (f"{right} ", EVEROS_AMBER), + ("Answer", f"bold {EVEROS_GREEN}"), + ) class EverOSDemoApp(App[None]): @@ -86,7 +296,11 @@ class EverOSDemoApp(App[None]): TITLE = "EverOS Memory Core" SUB_TITLE = "dot sphere demo" + # ctrl+c / ctrl+q are priority bindings so they quit even while the input + # box has focus (where a bare "q" would just be typed into the field). BINDINGS = [ + Binding("ctrl+c", "quit", "Quit", priority=True, show=False), + Binding("ctrl+q", "quit", "Quit", priority=True), ("q", "quit", "Quit"), ("r", "replay", "Replay"), ] @@ -105,7 +319,7 @@ class EverOSDemoApp(App[None]): }} #command-strip {{ - height: 2; + height: 1; padding: 0 1; color: {EVEROS_INK}; content-align: left middle; @@ -113,7 +327,7 @@ class EverOSDemoApp(App[None]): #main {{ height: 1fr; - margin-top: 1; + margin-top: 0; }} #memory-field {{ @@ -133,12 +347,29 @@ class EverOSDemoApp(App[None]): border-top: hkey {EVEROS_AMBER_DIM}; background: {EVEROS_SURFACE_RAISED}; padding: 0 1; + content-align: center middle; }} - #signal-rail {{ + #right-rail {{ width: 48; height: 100%; margin-left: 1; + }} + + #capabilities {{ + height: 9; + border: panel {EVEROS_YELLOW}; + border-title-color: {EVEROS_BLACK}; + border-title-background: {EVEROS_YELLOW}; + border-title-style: bold; + background: {EVEROS_SURFACE_RAISED}; + padding: 0 2; + margin-bottom: 1; + }} + + #signal-rail {{ + width: 100%; + height: 1fr; border: round {EVEROS_AMBER}; background: {EVEROS_SURFACE}; padding: 1 2; @@ -151,6 +382,7 @@ class EverOSDemoApp(App[None]): #source-lock {{ width: 1fr; + height: 100%; border: round {EVEROS_CYAN}; background: {EVEROS_SURFACE}; padding: 0 2; @@ -159,19 +391,45 @@ class EverOSDemoApp(App[None]): #recall-lock {{ width: 54; + height: 100%; border: round {EVEROS_GREEN}; background: {EVEROS_SURFACE}; padding: 0 2; }} - #payoff {{ - height: 2; - border-top: hkey {EVEROS_YELLOW}; + #conversation {{ + height: 6; + overflow-y: auto; + scrollbar-size-vertical: 1; + scrollbar-color: {EVEROS_AMBER}; + scrollbar-background: {EVEROS_SURFACE}; + border: round {EVEROS_YELLOW}; background: {EVEROS_SURFACE}; color: {EVEROS_INK}; padding: 0 1; margin-top: 1; - content-align: left middle; + }} + + #conversation-log {{ + height: auto; + width: 1fr; + background: {EVEROS_SURFACE}; + color: {EVEROS_INK}; + }} + + #console {{ + height: auto; + margin-top: 1; + }} + + #console-prompt {{ + height: auto; + padding: 0 1; + }} + + #console-input {{ + border: round {EVEROS_AMBER}; + background: {EVEROS_SURFACE}; }} Footer {{ @@ -195,9 +453,41 @@ class EverOSDemoApp(App[None]): }} """ - def __init__(self, *, story: DemoStory | None = None) -> None: + def __init__( + self, + *, + story: DemoStory | None = None, + interactive: bool = False, + base_url: str = cloud.CLOUD_API_BASE_URL, + session_id: str = cloud.LIVE_DEMO_SESSION_ID, + user_id: str = cloud.LIVE_DEMO_USER_ID, + api_key: str = "", + user_label: str = "you", + max_rounds: int = DEFAULT_DEMO_ROUNDS, + ) -> None: super().__init__() self._story = story or default_demo_story() + self._interactive = interactive + self._base_url = base_url + self._session_id = session_id + self._user_id = user_id + self._api_key = api_key + self._user_label = user_label + self._max_rounds = max_rounds + self._active_stage = -1 + # Each round auto-alternates two steps with no mode toggle: + # "memory" -> tell EverOS one thing (stored, no answer) + # "query" -> ask one question (recalls -> an answer) + # the "*ing" variants mean a cloud call is in flight; "done" -> cap hit. + self._conversation_phase = "memory" + self._current_memory = "" + self._stored_memories: list[str] = [] + self._round = 0 + self._lights = _initial_lights() + self._log: list[tuple[str, str]] = [] + self._history_chars = 0 + self._saved_pct: int | None = None + self._recall_celebration_timer: Timer | None = None def compose(self) -> ComposeResult: with Vertical(id="shell"): @@ -206,22 +496,363 @@ def compose(self) -> ComposeResult: memory_field = Vertical(id="memory-field") memory_field.border_title = "memory field" with memory_field: - yield Static(_field_header_text(self._story), id="field-header") + yield Static( + _field_header_text( + user_label=self._user_label, + active_stage=self._active_stage, + ), + id="field-header", + ) yield DotSphereWidget() - yield Static(_sphere_caption(self._story), id="field-answer") - signal_rail = Static(_signal_rail_text(self._story), id="signal-rail") - signal_rail.border_title = "signal rail" - yield signal_rail + yield QueryAnswerBar(id="field-answer") + with Vertical(id="right-rail"): + capabilities = Static(_capabilities_text(), id="capabilities") + capabilities.border_title = "EverOS strengths" + yield capabilities + signal_rail = Static( + _signal_rail_text(self._lights), id="signal-rail" + ) + signal_rail.border_title = "signal rail" + yield signal_rail with Horizontal(id="provenance-strip"): - source_lock = Static(_source_tree_text(self._story), id="source-lock") + source_lock = Static(_source_tree_text(), id="source-lock") source_lock.border_title = "source lock" yield source_lock - recall_lock = Static(_recall_proof_text(self._story), id="recall-lock") + recall_lock = Static( + _recall_proof_text(self._story, user_label=self._user_label), + id="recall-lock", + ) recall_lock.border_title = "recall lock" yield recall_lock - yield Static(_payoff_text(self._story), id="payoff") + # A real scroll container (not a bare Static): a Static clips but + # never scrolls, so older turns would be unreachable once the log + # grows past the panel height. + conversation = VerticalScroll(id="conversation") + conversation.border_title = "conversation" + with conversation: + yield Static(_conversation_text(self._log), id="conversation-log") + if self._interactive: + with Vertical(id="console"): + yield Static( + _prompt_memory_text(self._round, self._max_rounds), + id="console-prompt", + ) + yield Input( + placeholder=( + "tell EverOS something & enter · /live · /quit" + ), + id="console-input", + ) yield Footer(show_command_palette=False) + def on_mount(self) -> None: + if self._interactive: + self.query_one("#console-input", Input).focus() + + @on(DotSphereWidget.StageChanged) + def _on_stage_changed(self, event: DotSphereWidget.StageChanged) -> None: + self._active_stage = event.stage + self.query_one("#field-header", Static).update( + _field_header_text( + user_label=self._user_label, + active_stage=self._active_stage, + ) + ) + + def on_input_submitted(self, event: Input.Submitted) -> None: + if not self._interactive: + return + value = event.value.strip() + # Quit works in any phase, even mid-round. + if value.lower() in QUIT_COMMANDS: + self.exit() + return + if self._conversation_phase in {"storing", "recalling"}: + return # a cloud call is in flight; ignore further input + if value.startswith("/"): + self._run_slash_command(value.lower()) + return + prompt = self.query_one("#console-prompt", Static) + field = self.query_one("#console-input", Input) + if self._conversation_phase == "done": + # Free rounds used up, but keep the input usable: re-show the nudge. + field.value = "" + prompt.update(_quota_guidance_text()) + return + if not value: + return # ignore empty submissions; never substitute canned content + + if self._conversation_phase == "memory": + # Step 1: store the line. No answer here — just remember it. + self._record_line("you", value) + self._conversation_phase = "storing" + field.value = "" + field.disabled = True + prompt.update(_storing_text()) + self.run_worker(self._store(value), group="round", exclusive=True) + return + + # Step 2: a question. Echo it, then recall against everything stored. + self._record_line("ask", value) + self._conversation_phase = "recalling" + field.value = "" + field.disabled = True + prompt.update(_recalling_text()) + self.run_worker(self._ask(value), group="round", exclusive=True) + + def on_input_changed(self, event: Input.Changed) -> None: + # Live slash-command panel: as soon as the user types "/", surface the + # available commands; restore the phase prompt once they type real text. + if not self._interactive or self._conversation_phase in { + "storing", + "recalling", + }: + return + prompt = self.query_one("#console-prompt", Static) + if event.value.startswith("/"): + prompt.update(_commands_text()) + elif event.value: + prompt.update(self._phase_prompt()) + + def _run_slash_command(self, command: str) -> None: + prompt = self.query_one("#console-prompt", Static) + self.query_one("#console-input", Input).value = "" + if command == "/live": + prompt.update(_live_guidance_text()) + elif command == "/replay": + self.action_replay() + prompt.update(self._phase_prompt()) + elif command == "/clear": + self._log.clear() + self.query_one("#conversation-log", Static).update( + _conversation_text(self._log) + ) + prompt.update(self._phase_prompt()) + else: + prompt.update(_unknown_command_text(command)) + + def _phase_prompt(self) -> Text: + if self._conversation_phase == "done": + return _quota_guidance_text() + if self._conversation_phase == "query": + return _prompt_query_text() + return _prompt_memory_text(self._round, self._max_rounds) + + async def _store(self, memory: str) -> None: + # Step 1 of a round: store the memory. Light the pipeline as each real + # step (add -> flush) completes. No recall happens here. + self._reset_round_lights() + base_url, session_id, user_id, api_key = ( + self._base_url, + self._session_id, + self._user_id, + self._api_key, + ) + try: + await anyio.to_thread.run_sync( + partial( + cloud.add_memory, + memory, + base_url=base_url, + session_id=session_id, + user_id=user_id, + api_key=api_key, + ) + ) + # A successful add means the key authenticated and the memory landed. + self._set_light("core", "ready") + self._set_light("conversation", "captured") + await anyio.to_thread.run_sync( + partial( + cloud.flush_memory, + base_url=base_url, + session_id=session_id, + api_key=api_key, + ) + ) + self._set_light("facts", "live") + self._set_light("index", "synced") + except cloud.CloudQuotaError: + self._enter_done(_quota_guidance_text()) + return + except cloud.CloudAuthError: + self._set_light("core", "error") + self._show_round_error( + "demo authentication is temporarily unavailable", "memory" + ) + return + except cloud.CloudDemoError: + self._set_light("core", "error") + self._show_round_error("could not reach EverOS Cloud", "memory") + return + + # Stored. Move to step 2 and invite the question — still no answer yet. + # The sphere stays pinned at the last stored stage (indexing) until a + # question actually recalls; it is not reset here on purpose. + self._current_memory = memory + self._stored_memories.append(memory) + self.action_replay() + self._conversation_phase = "query" + self.query_one("#console-prompt", Static).update(_prompt_query_text()) + self._reenable_input() + + async def _ask(self, query: str) -> None: + # Step 2 of a round: recall against everything stored so far. + self._reset_recall_light() + base_url, session_id, user_id, api_key = ( + self._base_url, + self._session_id, + self._user_id, + self._api_key, + ) + try: + story = await anyio.to_thread.run_sync( + partial( + cloud.search_recall, + self._current_memory, + query, + stored_memories=self._stored_memories.copy(), + base_url=base_url, + session_id=session_id, + user_id=user_id, + api_key=api_key, + ) + ) + except cloud.CloudQuotaError: + self._enter_done(_quota_guidance_text()) + return + except cloud.CloudAuthError: + self._set_light("core", "error") + self._show_round_error( + "demo authentication is temporarily unavailable", "query" + ) + return + except cloud.CloudDemoError: + self._set_light("core", "error") + self._show_round_error("could not reach EverOS Cloud", "query") + return + + if story is None: + self._set_light("recall", "miss") + answer = "(no matching memory found)" + self._record_line("everos", answer) + story = DemoStory( + owner=user_id, + memory="", + query=query, + answer=answer, + source_filename="", + fact_filename="", + ) + else: + self._set_light("recall", "hit") + self._record_line("everos", story.answer) + self._update_savings(query, story.answer) + self._finish_round(story) + + def _update_savings(self, query: str, answer: str) -> None: + # Estimate (not measured): carrying the whole conversation as LLM context + # vs. EverOS handing back only the compact recalled answer. Char counts + # are a token proxy; the ratio is what matters, so the /4 cancels out. + self._history_chars += len(query) + len(answer) + if self._history_chars: + ratio = 1 - len(answer) / self._history_chars + self._saved_pct = max(0, min(99, round(100 * ratio))) + + def _finish_round(self, story: DemoStory) -> None: + self._story = story + self.query_one("#recall-lock", Static).update( + _recall_proof_text( + story, user_label=self._user_label, saved_pct=self._saved_pct + ) + ) + self.action_replay() + # Hold the white recall targets long enough to read, then celebrate. + # There is no intermediate yellow remembered/source state. + if self._lights.get("recall") == "hit": + self._recall_celebration_timer = self.set_timer( + SPHERE_STAGE_SECONDS, + self._celebrate_recall, + ) + self._round += 1 + if self._round >= self._max_rounds: + self._enter_done(_quota_guidance_text()) + return + self._conversation_phase = "memory" + self.query_one("#console-prompt", Static).update( + _prompt_memory_text(self._round, self._max_rounds) + ) + self._reenable_input() + + def _reset_recall_light(self) -> None: + self._lights["recall"] = "idle" + self.query_one("#signal-rail", Static).update(_signal_rail_text(self._lights)) + self._sync_sphere_to_rail() + + def _reset_round_lights(self) -> None: + if self._recall_celebration_timer is not None: + self._recall_celebration_timer.stop() + self._recall_celebration_timer = None + self._lights.update( + conversation="idle", facts="idle", index="idle", recall="idle" + ) + self.query_one("#signal-rail", Static).update(_signal_rail_text(self._lights)) + self._sync_sphere_to_rail() + + def _celebrate_recall(self) -> None: + self._recall_celebration_timer = None + if self._lights.get("recall") == "hit": + self.query_one(DotSphereWidget).drive_state("celebrating") + + def _set_light(self, key: str, state: str) -> None: + self._lights[key] = state + self.query_one("#signal-rail", Static).update(_signal_rail_text(self._lights)) + self._sync_sphere_to_rail() + + def _sync_sphere_to_rail(self) -> None: + """Pin the sphere to the furthest lit pipeline stage on the rail. + + It holds there (does not advance) until the next real step lights up — + e.g. after storing it rests at ``indexing`` and only reaches + ``recalling`` once a question actually recalls. With no stage lit it + free-runs the idle loop. + """ + + for key, sphere_state in _RAIL_STAGE_ORDER: + if self._lights.get(key) in _LIGHT_YELLOW: + self.query_one(DotSphereWidget).drive_state(sphere_state) + return + self.query_one(DotSphereWidget).drive_state(None) + + def _record_line(self, speaker: str, text: str) -> None: + self._log.append((speaker, text)) + self.query_one("#conversation-log", Static).update( + _conversation_text(self._log) + ) + # Keep the newest line in view as the log grows past the panel height. + self.query_one("#conversation", VerticalScroll).scroll_end(animate=False) + + def _enter_done(self, message: Text) -> None: + # Cap reached: keep the input usable (so /live, /quit still work and the + # user is never locked out) and show the upgrade nudge. + self._conversation_phase = "done" + self._sync_sphere_to_rail() + self.query_one("#console-prompt", Static).update(message) + self._reenable_input() + + def _show_round_error(self, message: str, phase: str) -> None: + # A step failed (server unreachable, unhealthy, or slow). Surface the + # reason honestly and let the user retry from the same step. + self._conversation_phase = phase + self._sync_sphere_to_rail() + self.query_one("#console-prompt", Static).update(_recall_error_text(message)) + self._reenable_input() + + def _reenable_input(self) -> None: + field = self.query_one("#console-input", Input) + field.disabled = False + field.focus() + def action_replay(self) -> None: widget = self.query_one(DotSphereWidget) widget._tick = 0 @@ -229,117 +860,267 @@ def action_replay(self) -> None: widget._advance() -def run_demo_tui(*, story: DemoStory | None = None) -> None: - EverOSDemoApp(story=story).run() +def run_demo_tui( + *, + story: DemoStory | None = None, + interactive: bool = False, + base_url: str = cloud.CLOUD_API_BASE_URL, + session_id: str = cloud.LIVE_DEMO_SESSION_ID, + user_id: str = cloud.LIVE_DEMO_USER_ID, + api_key: str = "", + user_label: str = "you", +) -> None: + EverOSDemoApp( + story=story, + interactive=interactive, + base_url=base_url, + session_id=session_id, + user_id=user_id, + api_key=api_key, + user_label=user_label, + ).run() -def _hero_text() -> Text: +def _prompt_memory_text(round_index: int, total_rounds: int) -> Text: return Text.assemble( - (" everos demo ", f"bold black on {EVEROS_YELLOW}"), - (" memory core ", f"bold {EVEROS_YELLOW}"), - ("online", EVEROS_MUTED), + (f"round {round_index + 1}/{total_rounds} ", EVEROS_MUTED), + ("① tell EverOS something to remember", f"bold {EVEROS_YELLOW}"), ) -def _field_header_text(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() +def _prompt_query_text() -> Text: return Text.assemble( - (f"user={story.owner}", f"bold {EVEROS_INK}"), - (" scope=local-first", f"bold {EVEROS_YELLOW_SOFT}"), - (" trace ", EVEROS_MUTED), - ("conversation -> facts -> index", f"bold {EVEROS_YELLOW}"), - (" live", f"bold {EVEROS_ORANGE}"), + ("② now ask EverOS a question", f"bold {EVEROS_CYAN}"), + (" · it recalls what you stored", EVEROS_MUTED), ) -def _sphere_caption(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() +def _storing_text() -> Text: + return Text("remembering...", style=f"bold {EVEROS_ORANGE}") + + +def _recalling_text() -> Text: + return Text("recalling from EverOS...", style=f"bold {EVEROS_ORANGE}") + + +def _recall_error_text(message: str) -> Text: return Text.assemble( - ("query ", f"bold {EVEROS_CYAN}"), - (f"{story.query} ", EVEROS_INK), - ("-> ", EVEROS_MUTED), - ("answer ", f"bold {EVEROS_GREEN}"), - (story.answer, f"bold {EVEROS_GREEN}"), + (f"{message} ", f"bold {EVEROS_ORANGE}"), + ("· type to retry", EVEROS_MUTED), ) -def _signal_rail_text(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() +def _commands_text() -> Text: + return Text.assemble( + ("commands ", f"bold {EVEROS_YELLOW}"), + ("/live", f"bold {EVEROS_GREEN}"), + (" use your key ", EVEROS_MUTED), + ("/replay", f"bold {EVEROS_GREEN}"), + (" re-run ", EVEROS_MUTED), + ("/clear", f"bold {EVEROS_GREEN}"), + (" wipe log ", EVEROS_MUTED), + ("/quit", f"bold {EVEROS_GREEN}"), + (" exit", EVEROS_MUTED), + ) + + +def _live_guidance_text() -> Text: return Text.assemble( - ("● ", f"bold {EVEROS_GREEN}"), - ("memory core ", EVEROS_INK), - ("ready\n", f"bold {EVEROS_GREEN}"), - ("● ", f"bold {EVEROS_YELLOW_SOFT}"), - ("conversation ", EVEROS_INK), - ("captured\n", f"bold {EVEROS_YELLOW_SOFT}"), - ("● ", f"bold {EVEROS_ORANGE}"), - ("episode -> facts ", EVEROS_INK), - ("live\n", f"bold {EVEROS_ORANGE}"), - ("● ", f"bold {EVEROS_CYAN}"), - ("SQLite + LanceDB ", EVEROS_INK), - ("synced\n", f"bold {EVEROS_CYAN}"), - ("● ", f"bold {EVEROS_GREEN}"), - ("memory recall ", EVEROS_INK), - ("hit\n", f"bold {EVEROS_GREEN}"), - ("\nsource route\n", EVEROS_MUTED), - (_rail_cell(story.source_filename), EVEROS_INK), - (" attached\n", f"bold {EVEROS_YELLOW_SOFT}"), - (_rail_cell(story.fact_filename), EVEROS_INK), - (" 7 nodes\n", f"bold {EVEROS_ORANGE}"), - ("lancedb orbit ", EVEROS_INK), - ("synced\n", f"bold {EVEROS_CYAN}"), - ("\nrecall proof\n", EVEROS_MUTED), - ("score ", EVEROS_INK), - ("0.628\n", f"bold {EVEROS_GREEN}"), - ("source ", EVEROS_INK), - (f"{story.source_filename}\n", f"bold {EVEROS_CYAN}"), - ("field integrity\n", EVEROS_MUTED), - ("█████████░ 92%\n", f"bold {EVEROS_YELLOW}"), - ("latency ", EVEROS_MUTED), - ("42 ms\n", f"bold {EVEROS_GREEN}"), - ("mode ", EVEROS_MUTED), - ("local-first", f"bold {EVEROS_INK}"), + ("use your own key ", f"bold {EVEROS_YELLOW}"), + ("everos init", f"bold {EVEROS_GREEN}"), + (" then ", EVEROS_MUTED), + ("everos demo --live", f"bold {EVEROS_GREEN}"), ) +def _unknown_command_text(command: str) -> Text: + return Text.assemble( + (f"unknown command {command} ", f"bold {EVEROS_ORANGE}"), + ("available: /live /replay /clear /quit", EVEROS_INK), + ) + + +def _quota_guidance_text() -> Text: + return Text.assemble( + ("free demo rounds used up ", f"bold {EVEROS_YELLOW}"), + ("configure your own key -> ", EVEROS_INK), + ("everos init", f"bold {EVEROS_GREEN}"), + (" then ", EVEROS_MUTED), + ("everos demo --live", f"bold {EVEROS_GREEN}"), + ) + + +def _hero_text() -> Text: + return Text.assemble( + (" everos demo ", f"bold black on {EVEROS_YELLOW}"), + (" memory core ", f"bold {EVEROS_YELLOW}"), + ("online", EVEROS_MUTED), + ) + + +def _field_header_text(*, user_label: str = "you", active_stage: int = -1) -> Text: + parts: list[tuple[str, str]] = [ + (f"user={user_label}", f"bold {EVEROS_INK}"), + (" scope=local-first", f"bold {EVEROS_YELLOW_SOFT}"), + (" trace ", EVEROS_MUTED), + ] + for index, stage in enumerate(TRACE_STAGES): + if index: + parts.append((" · ", EVEROS_MUTED)) + if index == active_stage: + parts.append((stage, f"bold {EVEROS_YELLOW}")) + else: + parts.append((stage, EVEROS_AMBER)) + return Text.assemble(*parts) + + +def _initial_lights() -> dict[str, str]: + """Default signal-rail state before any round runs.""" + + return { + "core": "not_ready", + "conversation": "idle", + "facts": "idle", + "index": "idle", + "recall": "idle", + } + + +# White = not ready / idle / miss; yellow = ready / active / hit; black = error. +_LIGHT_YELLOW = frozenset({"ready", "captured", "live", "synced", "hit"}) + +# The sphere is a progress indicator bound to the signal rail: it shows the +# *furthest* pipeline stage currently lit. Checked in furthest-first order; if +# none of these are lit the sphere free-runs its idle loop. (``core`` is just an +# "online" lamp, not a pipeline stage, so it does not drive the sphere — that is +# why an idle session keeps looping after the core comes up.) +_RAIL_STAGE_ORDER = ( + ("recall", "recalling"), + ("index", "indexing"), + ("facts", "extracting"), + ("conversation", "ingesting"), +) + + +def _light_color(state: str) -> str: + if state in _LIGHT_YELLOW: + return EVEROS_YELLOW + if state == "error": + return EVEROS_BLACK + return EVEROS_INK + + +def _light_label(state: str) -> str: + return "not ready" if state == "not_ready" else state + + +_SIGNAL_ROWS = ( + ("core", "memory core "), + ("conversation", "conversation "), + ("facts", "episode -> facts "), + ("index", "SQLite + LanceDB "), + ("recall", "memory recall "), +) + + +def _signal_rail_text(lights: dict[str, str] | None = None) -> Text: + lights = lights or _initial_lights() + parts: list[tuple[str, str]] = [] + for key, label in _SIGNAL_ROWS: + state = lights.get(key, "idle") + color = _light_color(state) + parts.append(("● ", f"bold {color}")) + parts.append((label, EVEROS_INK)) + parts.append((f"{_light_label(state)}\n", f"bold {color}")) + parts.append(("\nsource route\n", EVEROS_MUTED)) + parts.append((_rail_cell(_demo_episode_name()), EVEROS_INK)) + parts.append((" attached\n", f"bold {EVEROS_YELLOW_SOFT}")) + parts.append((_rail_cell(_demo_fact_name()), EVEROS_INK)) + parts.append((" stored", f"bold {EVEROS_ORANGE}")) + return Text.assemble(*parts) + + def _rail_cell(value: str, *, width: int = SIGNAL_RAIL_SOURCE_WIDTH) -> str: if len(value) > width: return f"{value[: width - 3]}..." return f"{value:<{width}}" -def _source_tree_text(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() +def _demo_episode_name() -> str: + """Date-stamped episode filename reflecting when the demo is used.""" + + return f"episode-{today_with_timezone().isoformat()}.md" + + +def _demo_fact_name() -> str: + return f"atomic_fact-{today_with_timezone().isoformat()}.md" + + +def _capabilities_text() -> Text: + # Real highlights from evermind.ai: the token-efficiency claim, one headline + # SOTA benchmark, and core capabilities. No fabricated figures. (local-first + # is dropped here because the field header already shows scope=local-first.) + rows = ( + ("token efficiency ", "1/10 of full context", EVEROS_YELLOW), + ("LoCoMo ", "93.05% (SOTA)", EVEROS_GREEN), + ("context window ", "unlimited", EVEROS_CYAN), + ("hybrid retrieval ", "BM25 + vector", EVEROS_ORANGE), + ("agentic rerank ", "on", EVEROS_YELLOW_SOFT), + ("multimodal ", "pdf / image / docs", EVEROS_INK), + ("self-evolving ", "cases -> skills", EVEROS_GREEN), + ) + parts: list[tuple[str, str]] = [] + for label, value, color in rows: + parts.append((label, EVEROS_MUTED)) + parts.append((f"{value}\n", f"bold {color}")) + return Text.assemble(*parts) + + +def _source_tree_text() -> Text: return Text.assemble( ("episode ", EVEROS_MUTED), - (f"{story.source_filename}\n", f"bold {EVEROS_YELLOW_SOFT}"), + (f"{_demo_episode_name()}\n", f"bold {EVEROS_YELLOW_SOFT}"), ("facts ", EVEROS_MUTED), - (f"{story.fact_filename}\n", f"bold {EVEROS_ORANGE}"), + (f"{_demo_fact_name()}\n", f"bold {EVEROS_ORANGE}"), ("index ", EVEROS_MUTED), ("sqlite/system.db + lancedb/*.lance\n", EVEROS_CYAN), ("root ", EVEROS_MUTED), - ("~/.everos/default_app/default_project", EVEROS_INK), + ("~/.everos/default_app/demo", EVEROS_INK), ) -def _recall_proof_text(story: DemoStory | None = None) -> Text: +def _recall_proof_text( + story: DemoStory | None = None, + *, + user_label: str = "you", + saved_pct: int | None = None, +) -> Text: story = story or default_demo_story() + score = f"{story.score:.3f}" if story.score else "—" + saved = f"~{saved_pct}% tokens (est)" if saved_pct is not None else "—" return Text.assemble( ("score ", EVEROS_MUTED), - ("0.628\n", f"bold {EVEROS_GREEN}"), + (f"{score}\n", f"bold {EVEROS_GREEN}"), + ("saved ", EVEROS_MUTED), + (f"{saved}\n", f"bold {EVEROS_YELLOW}"), ("scope ", EVEROS_MUTED), - (f"user={story.owner} project=default\n", EVEROS_INK), - ("answer ", EVEROS_MUTED), - (story.answer, f"bold {EVEROS_YELLOW}"), + (f"user={user_label} project=demo", EVEROS_INK), ) -def _payoff_text(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() - return Text.assemble( - ("memory formed: ", f"bold {EVEROS_YELLOW}"), - ( - f"EverOS recalled {story.answer} and kept the source attached.", - f"bold {EVEROS_INK}", - ), - ) +_SPEAKER_COLORS = { + "you": EVEROS_CYAN, # the memory you stored + "ask": EVEROS_YELLOW_SOFT, # the question you asked + "everos": EVEROS_GREEN, # the recalled answer +} + + +def _conversation_text(log: list[tuple[str, str]]) -> Text: + if not log: + return Text("your input and EverOS output will appear here", style=EVEROS_MUTED) + parts: list[tuple[str, str]] = [] + for speaker, text in log: + color = _SPEAKER_COLORS.get(speaker, EVEROS_INK) + parts.append((f"{speaker:<7}", f"bold {color}")) + parts.append((f"{text}\n", EVEROS_INK)) + return Text.assemble(*parts) diff --git a/src/everos/entrypoints/tui/demo/cloud.py b/src/everos/entrypoints/tui/demo/cloud.py new file mode 100644 index 00000000..03e8669d --- /dev/null +++ b/src/everos/entrypoints/tui/demo/cloud.py @@ -0,0 +1,651 @@ +"""Cloud-platform HTTP client for ``everos demo``. + +The interactive demo runs the *real* memory pipeline through the public EverOS +demo relay. The relay holds the shared platform key server-side, so the default +demo sends no credentials. ``--live`` bypasses the relay and talks directly to +EverOS Cloud with the user's own key (env ``EVEROS_CLOUD_API_KEY``). + +One round is: synchronously ``add`` the message -> ``flush`` (force extraction) +-> poll ``search``. Each run uses a fresh +``(session_id, user_id)`` pair so demo visitors never read each other's memory. + +The functions here are typer-free on purpose: they are called from the Textual +TUI worker. Failures raise :class:`CloudDemoError` (or the more specific +:class:`CloudQuotaError` / :class:`CloudAuthError`); callers decide how to +surface them. +""" + +from __future__ import annotations + +import json +import os +import re +import time +import urllib.error +import urllib.request +import uuid +from collections.abc import Callable, Sequence +from typing import Any + +from everos.component.utils.datetime import get_utc_now +from everos.entrypoints.tui.demo.data import DemoStory + +# Sentinel default for the --server-url option; a different value means the user +# explicitly pointed the demo somewhere else. +LIVE_DEMO_SERVER_URL = "http://127.0.0.1:8000" +LIVE_DEMO_SESSION_ID = "everos-demo-live" +LIVE_DEMO_USER_ID = "everos_demo_user" + +CLOUD_PLATFORM_API_BASE_URL = "https://api.evermind.ai" +CLOUD_API_BASE_URL = "https://everosdemo.com" +CLOUD_DEMO_SERVER_URL_ENV = "EVEROS_CLOUD_DEMO_URL" +CLOUD_DEMO_KEY_ENV = "EVEROS_CLOUD_DEMO_KEY" +CLOUD_USER_KEY_ENV = "EVEROS_CLOUD_API_KEY" +# The public demo authenticates at the relay. Never ship its platform key in the +# client. The environment override remains useful for testing a direct endpoint. +DEFAULT_CLOUD_DEMO_KEY = "" + +TIMEOUT_SECONDS = 15.0 +SEARCH_ATTEMPTS = 8 +SEARCH_INTERVAL_SECONDS = 1.5 +# How far ahead an episode must score to beat a concise profile answer. Profiles +# read as a direct one-liner; episodes are verbose summaries. A small bias keeps +# answers concise on ties and near-ties without hiding a clearly-better episode. +PROFILE_SCORE_BIAS = 0.08 +# Relevance floor. The platform always returns its best candidate, even for an +# unrelated query (short texts get ~0.4 similarity to everything), so without a +# cutoff "am I a programmer?" would surface whatever single memory exists. Below +# this score we report an honest miss instead of an absurd answer. Tuned from +# observed scores: clearly-irrelevant queries top out ~0.48, real hits >= 0.50. +MIN_RELEVANCE_SCORE = 0.5 +CURRENT_MEMORY_MATCH_THRESHOLD = 0.28 +CURRENT_MEMORY_PREFERENCE_MARGIN = 0.08 +# The just-flushed memory needs a moment to land in the index. Searching +# immediately returns a stale ranking (older memories that are already indexed), +# which is why a "store X then recall X" round could come back with an unrelated +# earlier memory. Let indexing settle before the first search. +SEARCH_SETTLE_SECONDS = 2.0 + + +class CloudDemoError(Exception): + """A cloud demo round could not be completed.""" + + +class CloudQuotaError(CloudDemoError): + """The platform hit a rate/quota limit (HTTP 429).""" + + +class CloudAuthError(CloudDemoError): + """The platform rejected the API key (HTTP 401/403).""" + + +def resolve_cloud_base_url(server_url: str) -> str: + """Pick the API endpoint: explicit --server-url wins, then env, then default.""" + + if server_url != LIVE_DEMO_SERVER_URL: + return server_url + return os.environ.get(CLOUD_DEMO_SERVER_URL_ENV, CLOUD_API_BASE_URL) + + +def resolve_live_base_url(server_url: str) -> str: + """Use the platform for ``--live`` unless the user supplied an override.""" + + if server_url != LIVE_DEMO_SERVER_URL: + return server_url + return CLOUD_PLATFORM_API_BASE_URL + + +def resolve_demo_key() -> str: + """Return an optional direct-test key; the public relay needs no client key.""" + + return os.environ.get(CLOUD_DEMO_KEY_ENV, DEFAULT_CLOUD_DEMO_KEY) + + +def resolve_user_key() -> str: + """The user's own platform key for --live (env only).""" + + return os.environ.get(CLOUD_USER_KEY_ENV, "") + + +def new_demo_identity() -> tuple[str, str]: + """Generate a unique ``(session_id, user_id)`` pair for one demo run.""" + + token = uuid.uuid4().hex[:12] + return f"everos-demo-{token}", f"everos_demo_{token}" + + +def add_memory( + memory: str, + *, + base_url: str, + session_id: str, + user_id: str, + api_key: str, + request_json: Callable[..., dict[str, Any]] | None = None, + timeout_seconds: float = TIMEOUT_SECONDS, +) -> None: + """Write one user message to the v2 session buffer. Blocking.""" + + request = request_json or _request_json + timestamp_ms = int(get_utc_now().timestamp() * 1000) + response = request( + "POST", + "/api/v2/memory/add", + base_url=base_url, + api_key=api_key, + json_body={ + "session_id": session_id, + # Complete the write before forcing extraction. v2 extraction itself + # is flush-triggered and remains asynchronous internally. + "async_mode": False, + "messages": [ + { + "sender_id": user_id, + "role": "user", + "timestamp": timestamp_ms, + "content": memory, + } + ], + }, + timeout_seconds=timeout_seconds, + ) + if not isinstance(response.get("data"), dict): + raise CloudDemoError("EverOS Cloud returned an invalid add response") + + +def flush_memory( + *, + base_url: str, + session_id: str, + api_key: str, + request_json: Callable[..., dict[str, Any]] | None = None, + timeout_seconds: float = TIMEOUT_SECONDS, +) -> None: + """Force extraction of the session into episodes/facts. Blocking.""" + + request = request_json or _request_json + response = request( + "POST", + "/api/v2/memory/flush", + base_url=base_url, + api_key=api_key, + json_body={"session_id": session_id}, + timeout_seconds=timeout_seconds, + ) + if not isinstance(response.get("data"), dict): + raise CloudDemoError("EverOS Cloud returned an invalid flush response") + + +def search_recall( + memory: str, + query: str, + *, + stored_memories: Sequence[str] | None = None, + base_url: str, + session_id: str, + user_id: str, + api_key: str, + request_json: Callable[..., dict[str, Any]] | None = None, + search_attempts: int = SEARCH_ATTEMPTS, + search_interval_seconds: float = SEARCH_INTERVAL_SECONDS, + settle_seconds: float = SEARCH_SETTLE_SECONDS, + min_relevance_score: float = MIN_RELEVANCE_SCORE, + timeout_seconds: float = TIMEOUT_SECONDS, +) -> DemoStory | None: + """Search the query, polling while indexing catches up. + + Returns a :class:`DemoStory` (with the real recall score) on a hit, or + ``None`` on a miss. A miss means either the platform returned nothing or the + best candidate scored below ``min_relevance_score`` — an honest "no match" + beats surfacing an unrelated memory for an off-topic question. Blocking. + + The just-flushed memory takes a moment to index, so we settle first and then + keep the best-scored result across attempts rather than returning the first + (possibly stale) hit — otherwise "store X, recall X" can return an unrelated + older memory that was already indexed. + + We pool the response's *profiles* and *episodes*: profiles are concise, + answer-shaped facts that score well on natural-language questions, while + episodes are the raw recalled memories. The highest-scored candidate wins. + """ + + request = request_json or _request_json + payload = { + "query": query, + "user_id": user_id, + # Pin this demo session so v2 can also expose its in-flight tail while + # the newly extracted episode is settling into the search index. + "filters": {"session_id": session_id}, + "method": "hybrid", + "top_k": 5, + "include_profile": True, + } + best: DemoStory | None = None + buffered: DemoStory | None = None + for attempt in range(search_attempts): + if attempt == 0 and settle_seconds: + time.sleep(settle_seconds) + search = request( + "POST", + "/api/v2/memory/search", + base_url=base_url, + api_key=api_key, + json_body=payload, + timeout_seconds=timeout_seconds, + ) + story = _best_recall_story(memory, query, search, user_id=user_id) + in_flight = _buffered_recall_story(memory, query, search, user_id=user_id) + if in_flight is not None: + buffered = in_flight + if story is not None and ( + best is None + or _story_priority(story, memory) > _story_priority(best, memory) + ): + best = story + # An older memory can already have a positive score while the memory + # flushed in this round is still entering the index. Stop only once the + # answer actually resembles the current memory; otherwise keep polling. + if ( + best is not None + and best.score > 0.0 + and _is_current_recall(best, memory) + and ( + best.score >= min_relevance_score + or _is_direct_current_recall(best, memory, query) + ) + ): + break + if attempt < search_attempts - 1: + time.sleep(search_interval_seconds) + if best is not None and ( + best.score >= min_relevance_score + or _is_direct_current_recall(best, memory, query) + ): + return best + # v2 extraction remains asynchronous even after a successful flush. If the + # index did not catch up within the polling window, the session-pinned + # search response still exposes this round's raw message. Use it only as a + # final fallback so a processed episode/profile always wins. + if buffered is not None: + return buffered + return _stored_memory_recall_story( + memory, + query, + stored_memories=stored_memories, + user_id=user_id, + ) + + +def _request_json( + method: str, + path: str, + *, + base_url: str, + api_key: str | None = None, + json_body: dict[str, object] | None = None, + timeout_seconds: float, +) -> dict[str, Any]: + url = f"{base_url.rstrip('/')}{path}" + data = None if json_body is None else json.dumps(json_body).encode("utf-8") + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + request = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + raw = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + raise CloudAuthError( + "EverOS Cloud rejected the API key (set EVEROS_CLOUD_DEMO_KEY)." + ) from exc + if exc.code == 429: + raise CloudQuotaError(base_url) from exc + raise CloudDemoError( + f"EverOS Cloud at {base_url} returned HTTP {exc.code}." + ) from exc + except urllib.error.URLError as exc: + raise CloudDemoError(f"Could not reach EverOS Cloud at {base_url}.") from exc + if not raw: + return {} + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise CloudDemoError(f"EverOS Cloud returned non-object JSON: {url}") + return parsed + + +def _best_recall_story( + memory: str, + query: str, + payload: dict[str, Any], + *, + user_id: str, +) -> DemoStory | None: + """Pick the single highest-scored recall candidate from a search response. + + Pools *profiles* (concise answer-shaped facts) and *episodes* (raw recalled + memories); the platform does not pre-sort them, so we score every candidate + and keep the best. Returns ``None`` when the response carries no candidates. + """ + + data = payload.get("data") + if not isinstance(data, dict): + return None + + candidates: list[tuple[str, str, str, float, float, bool]] = [] + for profile in _as_dicts(data.get("profiles")): + profile_data = profile.get("profile_data") + text = _string_field( + profile_data if isinstance(profile_data, dict) else None, "embed_text" + ) + if not text: + continue + score = _float_field(profile, "score") + answer = _clean_profile_text(text) + candidates.append( + ( + answer, + f"profile:{_string_field(profile, 'id')[:12] or 'live'}", + "", + score, + _memory_match_score(memory, answer), + True, + ) + ) + + for episode in _as_dicts(data.get("episodes")): + answer, episode_id, fact_id = _episode_answer(episode, memory) + score = _episode_score(episode) + candidates.append( + ( + answer, + f"episode:{episode_id}", + f"fact:{fact_id}", + score, + _memory_match_score(memory, answer), + False, + ) + ) + + if not candidates: + return None + + # Keep the existing concise-profile bias as the default ranking, but let a + # candidate that clearly matches this round's memory override a stale hit. + default = max( + candidates, + key=lambda candidate: ( + candidate[3] + (PROFILE_SCORE_BIAS if candidate[5] else 0.0) + ), + ) + current = max(candidates, key=lambda candidate: (candidate[4], candidate[3])) + selected = default + if ( + current[4] >= CURRENT_MEMORY_MATCH_THRESHOLD + and current[4] >= default[4] + CURRENT_MEMORY_PREFERENCE_MARGIN + ): + selected = current + best_answer, best_source, best_fact = selected[:3] + # Preserve the strongest platform relevance signal for the existing floor. + best_score = max(candidate[3] for candidate in candidates) + + return DemoStory( + owner=user_id, + memory=memory, + query=query, + answer=_humanize_answer(best_answer, user_id), + source_filename=best_source, + fact_filename=best_fact, + score=best_score, + ) + + +def _story_priority(story: DemoStory, memory: str) -> tuple[float, float]: + """Prefer a result tied to this round before comparing platform scores.""" + + return _memory_match_score(memory, story.answer), story.score + + +def _is_current_recall(story: DemoStory, memory: str) -> bool: + """Return whether polling has likely reached this round's indexed memory.""" + + if _memory_match_score(memory, story.answer) >= CURRENT_MEMORY_MATCH_THRESHOLD: + return True + # Episode text may be translated or paraphrased beyond cheap lexical + # matching. It is still safe to accept unless its polarity contradicts the + # current memory; stale profiles remain the main reason to keep polling. + return story.source_filename.startswith("episode:") and _has_negation( + memory + ) == _has_negation(story.answer) + + +def _is_direct_current_recall(story: DemoStory, memory: str, query: str) -> bool: + """Accept a low-scored hit only when it clearly answers this demo round. + + The global relevance floor protects against the platform's weak best-match + candidates. v2 can assign a just-written, short memory a score just below + that floor, though, so require lexical agreement with both the stored + memory and the user's question before treating it as a safe current hit. + """ + + return ( + _memory_match_score(memory, story.answer) >= CURRENT_MEMORY_MATCH_THRESHOLD + and _memory_match_score(query, story.answer) >= CURRENT_MEMORY_MATCH_THRESHOLD + ) + + +def _buffered_recall_story( + memory: str, + query: str, + payload: dict[str, Any], + *, + user_id: str, +) -> DemoStory | None: + """Build a final v2 fallback from this session's in-flight messages.""" + + data = payload.get("data") + if not isinstance(data, dict): + return None + + candidates: list[tuple[float, str, str]] = [] + for message in _as_dicts(data.get("unprocessed_messages")): + content = _string_field(message, "content") + if not content: + continue + memory_match = _memory_match_score(memory, content) + query_match = _memory_match_score(query, content) + if ( + memory_match < CURRENT_MEMORY_MATCH_THRESHOLD + or query_match < CURRENT_MEMORY_MATCH_THRESHOLD + ): + continue + candidates.append( + (memory_match + query_match, content, _string_field(message, "id")) + ) + + if not candidates: + return None + _, answer, message_id = max(candidates, key=lambda candidate: candidate[0]) + return DemoStory( + owner=user_id, + memory=memory, + query=query, + answer=_humanize_answer(answer, user_id), + source_filename=f"buffer:{message_id[:12] or 'live'}", + fact_filename="", + # In-flight messages are intentionally unranked in v2, so do not invent + # a similarity score for the UI. + score=0.0, + ) + + +def _stored_memory_recall_story( + current_memory: str, + query: str, + *, + stored_memories: Sequence[str] | None, + user_id: str, +) -> DemoStory | None: + """Use a successfully written demo memory when v2 indexing lags. + + The demo has already completed add and flush before search starts. If the + user's question clearly overlaps one of the memories from this run, + returning the original text is safer than turning a translated, low-scored + v2 candidate into a false miss. Ties prefer the most recent memory and + off-topic questions still return ``None``. + """ + + memories = list(stored_memories or ()) + if not memories or memories[-1] != current_memory: + memories.append(current_memory) + matches = [ + (_memory_match_score(candidate, query), index, candidate) + for index, candidate in enumerate(memories) + if candidate + ] + if not matches: + return None + match_score, index, answer = max(matches) + if match_score < CURRENT_MEMORY_MATCH_THRESHOLD: + return None + is_current = index == len(memories) - 1 and answer == current_memory + return DemoStory( + owner=user_id, + memory=answer, + query=query, + answer=answer, + source_filename="buffer:current" if is_current else "buffer:history", + fact_filename="", + score=0.0, + ) + + +def _memory_match_score(memory: str, answer: str) -> float: + """Estimate whether a recalled answer belongs to the just-stored memory.""" + + memory_normalized = _normalize_match_text(memory) + answer_normalized = _normalize_match_text(answer) + if not memory_normalized or not answer_normalized: + return 0.0 + if len(answer_normalized) >= 2 and answer_normalized in memory_normalized: + return 1.0 + if len(memory_normalized) >= 2 and memory_normalized in answer_normalized: + return 1.0 + + memory_features = _match_features(memory) + answer_features = _match_features(answer) + if not memory_features or not answer_features: + return 0.0 + overlap = len(memory_features & answer_features) / len(memory_features) + if _has_negation(memory) != _has_negation(answer): + overlap *= 0.35 + return overlap + + +def _normalize_match_text(text: str) -> str: + return "".join(re.findall(r"[a-z0-9\u4e00-\u9fff]+", text.lower())) + + +def _match_features(text: str) -> set[str]: + features = { + word + for word in re.findall(r"[a-z0-9]+", text.lower()) + if len(word) > 2 and word not in {"the", "and", "that", "this", "you", "user"} + } + for sequence in re.findall(r"[\u4e00-\u9fff]+", text): + if len(sequence) == 1: + features.add(sequence) + else: + features.update( + sequence[index : index + 2] for index in range(len(sequence) - 1) + ) + if _has_negation(text): + features.add("__negation__") + return features + + +def _has_negation(text: str) -> bool: + lowered = text.lower() + return any( + marker in lowered + for marker in ("不", "没", "讨厌", "not ", "n't", "dislike", "hate") + ) + + +def _as_dicts(value: object) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _episode_score(episode: dict[str, Any]) -> float: + """Relevance score for ranking: episode score, else its top fact's score.""" + + score = _float_field(episode, "score") + if score: + return score + facts = episode.get("atomic_facts") + first_fact = facts[0] if isinstance(facts, list) and facts else None + return _float_field(first_fact if isinstance(first_fact, dict) else None, "score") + + +def _episode_answer(episode: dict[str, Any], memory: str) -> tuple[str, str, str]: + """Return ``(answer, episode_id, fact_id)`` for an episode candidate. + + Cloud puts the recalled content in ``atomic_fact`` (concise) and falls back + to the episode summary; ``memory`` is the last resort. + """ + + facts = episode.get("atomic_facts") + first_fact = facts[0] if isinstance(facts, list) and facts else None + fact = first_fact if isinstance(first_fact, dict) else None + answer = ( + _string_field(fact, "content") + or _string_field(fact, "atomic_fact") + or ( + _string_field(episode, "summary") + or _string_field(episode, "episode") + or memory + ) + ) + episode_id = _string_field(episode, "id") or "live" + return answer, episode_id, _string_field(fact, "id") or "live" + + +def _clean_profile_text(text: str) -> str: + """Tidy a profile ``embed_text`` for display. + + Profiles arrive as ``": "``. The category is metadata that + reads as noise next to the recalled value, so drop a short leading label + (half- or full-width colon) and keep the value. + """ + + for separator in (": ", "\uff1a"): + head, sep, tail = text.partition(separator) + if sep and tail.strip() and len(head.split()) <= 3: + return tail.strip() + return text.strip() + + +def _humanize_answer(answer: str, user_id: str) -> str: + """Strip the synthetic demo user_id out of platform-generated summaries. + + The platform phrases summaries like "everos_demo_ab12 said ...". The raw id + is noise in a demo, so swap it for "you". + """ + + return answer.replace(user_id, "you") + + +def _string_field(payload: dict[str, Any] | None, key: str) -> str: + if payload is None: + return "" + value = payload.get(key) + return value if isinstance(value, str) else "" + + +def _float_field(payload: dict[str, Any] | None, key: str) -> float: + if payload is None: + return 0.0 + value = payload.get(key) + return float(value) if isinstance(value, int | float) else 0.0 diff --git a/src/everos/entrypoints/tui/demo/data.py b/src/everos/entrypoints/tui/demo/data.py index c5e23305..89132384 100644 --- a/src/everos/entrypoints/tui/demo/data.py +++ b/src/everos/entrypoints/tui/demo/data.py @@ -18,10 +18,16 @@ class DemoStory: answer: str source_filename: str fact_filename: str + score: float = 0.0 def default_demo_story() -> DemoStory: - """Return the cinematic story used by README media and no-prompt previews.""" + """Return the cinematic story used by README media and no-prompt previews. + + This is only the static showcase content for ``--plain`` / ``--cinematic``. + The interactive demo builds its story from real server recall (see + :func:`everos.entrypoints.tui.demo.cloud.search_recall`). + """ return DemoStory( owner="alice", @@ -31,45 +37,3 @@ def default_demo_story() -> DemoStory: source_filename="episode-2026-06-20.md", fact_filename="atomic_fact-2026-06-20.md", ) - - -def build_demo_story( - memory_seed: str | None = None, - query: str | None = None, -) -> DemoStory: - """Build a playable demo story from one user memory and one recall query.""" - - memory = _clean(memory_seed, DEFAULT_MEMORY_SEED) - recall_query = _clean(query, DEFAULT_QUERY) - return DemoStory( - owner="you", - memory=memory, - query=recall_query, - answer=_derive_demo_answer(memory), - source_filename="episode-demo.md", - fact_filename="atomic_fact-demo.md", - ) - - -def _clean(value: str | None, fallback: str) -> str: - if value is None: - return fallback - stripped = value.strip() - return stripped or fallback - - -def _derive_demo_answer(memory: str) -> str: - """Keep the demo deterministic without pretending to run the server.""" - - lower_memory = memory.lower() - if "yosemite" in lower_memory: - if "spring" in lower_memory: - return "Yosemite every spring" - return "Yosemite" - return _compact(memory) - - -def _compact(text: str, *, limit: int = 66) -> str: - if len(text) <= limit: - return text - return f"{text[: limit - 3].rstrip()}..." diff --git a/src/everos/entrypoints/tui/demo/readme_media.py b/src/everos/entrypoints/tui/demo/readme_media.py index 36ad8908..846e9923 100644 --- a/src/everos/entrypoints/tui/demo/readme_media.py +++ b/src/everos/entrypoints/tui/demo/readme_media.py @@ -110,12 +110,12 @@ async def render_media(out_dir: Path) -> tuple[Path, Path]: await anyio.Path(out_dir).mkdir(parents=True, exist_ok=True) screenshot = out_dir / "everos-demo-tui-screenshot.svg" - remembered_index = DotSphereWidget.STATES.index("remembered") - remembered = FramePlan( - state=DotSphereWidget.STATES[remembered_index], - phase=remembered_index / len(DotSphereWidget.STATES), + recall_index = DotSphereWidget.STATES.index("recalling") + recalled = FramePlan( + state=DotSphereWidget.STATES[recall_index], + phase=recall_index / len(DotSphereWidget.STATES), ) - await _export_frame(screenshot, remembered, terminal_size=TERMINAL_SIZE) + await _export_frame(screenshot, recalled, terminal_size=TERMINAL_SIZE) plan = build_frame_plan(DotSphereWidget.STATES) frame_paths: list[Path] = [] diff --git a/src/everos/entrypoints/tui/demo/widgets/sphere.py b/src/everos/entrypoints/tui/demo/widgets/sphere.py index 8c15c34d..f7fd1bbc 100644 --- a/src/everos/entrypoints/tui/demo/widgets/sphere.py +++ b/src/everos/entrypoints/tui/demo/widgets/sphere.py @@ -13,27 +13,44 @@ EVEROS_YELLOW = "#F9B91C" EVEROS_YELLOW_SOFT = "#F6C23B" +EVEROS_YELLOW_PALE = "#FFD267" EVEROS_AMBER_DIM = "#4A3D20" EVEROS_AMBER = "#8B763F" +EVEROS_GOLD_SHADOW = "#61522F" +EVEROS_GOLD_DEEP = "#76612F" +EVEROS_GOLD_DARK = "#8C6D2B" +EVEROS_GOLD_MID = "#A97D25" +EVEROS_GOLD_WARM = "#C48E20" +EVEROS_GOLD_LIGHT = "#DDA21E" EVEROS_CYAN = "#F5EDDC" EVEROS_GREEN = "#D8CDAF" EVEROS_ORANGE = "#C09525" +EVEROS_FIELD_BACKGROUND = "#24231E" BRAILLE_BASE = 0x2800 BRAILLE_DOT_BITS = ( (0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80), ) -SPHERE_POINT_COUNT = 1300 -CONFETTI_POINT_COUNT = 150 -CONFETTI_GLYPHS = (".", "+", "*", "x") -CONFETTI_STYLES = ( - EVEROS_YELLOW, - EVEROS_YELLOW_SOFT, - EVEROS_CYAN, - EVEROS_ORANGE, - EVEROS_AMBER, -) +WORKING_ORBITS_PER_RADIUS = 0.55 +WORKING_SAMPLES_PER_RADIUS = 1.6 +WORKING_MIN_ORBITS = 14 +WORKING_MIN_SAMPLES = 52 +WORKING_PARTICLES_PER_ORBIT = 3 +SOLVING_BACKGROUND_DENSITY = 0.11 +SOLVING_SIGNAL_COUNT = 9 +SOLVING_SIGNAL_TRAIL_STEPS = 3 +SOLVING_SIGNAL_TRAIL_GAP = 0.06 +EXTRACT_BRANCH_COUNT = 7 +EXTRACT_TRAIL_STEPS = 3 +EXTRACT_TRAIL_GAP = 0.055 +SHARED_EDGE_INNER_RADIUS = 0.72 +SHARED_EDGE_DENSITY = 0.29 +STAGE_INTERIOR_RADIUS = 0.69 GOLDEN_ANGLE = math.pi * (3 - math.sqrt(5)) +SUPERNOVA_CORE_START = 0.31 +SUPERNOVA_REFORM_START = 0.66 +SUPERNOVA_CORE_RADIUS = 0.1 +SUPERNOVA_REFORM_END = 0.94 @dataclass(frozen=True) @@ -74,12 +91,12 @@ def caption(self) -> str: SPHERE_STATES: dict[str, SphereState] = { "booting": SphereState( key="booting", - caption="forming local memory field", + caption="working...", accent=EVEROS_YELLOW, ), "ingesting": SphereState( key="ingesting", - caption="ingesting conversation dots", + caption="capturing conversation into memory", accent=EVEROS_CYAN, ), "extracting": SphereState( @@ -89,7 +106,7 @@ def caption(self) -> str: ), "indexing": SphereState( key="indexing", - caption="syncing SQLite + LanceDB orbit", + caption="organizing memory for fast recall", accent=EVEROS_CYAN, ), "recalling": SphereState( @@ -99,7 +116,7 @@ def caption(self) -> str: ), "remembered": SphereState( key="remembered", - caption="remembered Yosemite preference", + caption="found the matching memory", accent=EVEROS_YELLOW, ), "source": SphereState( @@ -116,7 +133,12 @@ def caption(self) -> str: def build_dot_sphere( - *, width: int, height: int, phase: float, state_key: str + *, + width: int, + height: int, + phase: float, + state_key: str, + state_phase: float | None = None, ) -> DotSphereFrame: """Build one dot-sphere animation frame.""" if width < 13 or height < 7: @@ -127,64 +149,248 @@ def build_dot_sphere( raise ValueError(f"unknown sphere state: {state_key}") from exc if state.key == "celebrating": - return _build_confetti_burst( + return _build_soft_supernova( + width=width, + height=height, + phase=phase, + state=state, + progress=( + _state_local_phase(phase, state.key) + if state_phase is None + else state_phase + ), + ) + if state.key in { + "booting", + "ingesting", + "extracting", + "indexing", + "recalling", + "remembered", + "source", + }: + return _build_working_cloud( width=width, height=height, phase=phase, state=state, ) - sub_width = width * 2 - sub_height = height * 4 - sub_center_x = (sub_width - 1) / 2 - sub_center_y = (sub_height - 1) / 2 - radius_x = max(1.0, sub_center_x - 5) - radius_y = max(1.0, sub_center_y - 3) - rotation = phase * math.tau - active_target = _highlight_target(width, height) + raise AssertionError(f"unhandled sphere state: {state.key}") + + +def _build_working_cloud( + *, width: int, height: int, phase: float, state: SphereState +) -> DotSphereFrame: + """Render a full orbital sphere with state-specific white particles.""" + + sub_width, sub_height, center_x, center_y, radius_x, radius_y = _sphere_geometry( + width, height + ) + animation_time = phase * math.tau + orbit_count = max( + WORKING_MIN_ORBITS, + round(radius_x * WORKING_ORBITS_PER_RADIUS), + ) + samples_per_orbit = max( + WORKING_MIN_SAMPLES, + round(radius_x * WORKING_SAMPLES_PER_RADIUS), + ) + global_yaw = animation_time * 0.08 + camera_tilt = 0.18 + vertical_axis = (0.0, 1.0, 0.0) masks: dict[tuple[int, int], int] = {} depths: dict[tuple[int, int], float] = {} - highlighted_positions: set[tuple[int, int]] = set() - for index in range(SPHERE_POINT_COUNT): - y3 = 1 - 2 * ((index + 0.5) / SPHERE_POINT_COUNT) - ring_radius = math.sqrt(max(0.0, 1.0 - y3 * y3)) - theta = index * GOLDEN_ANGLE + rotation - x3 = ring_radius * math.cos(theta) - z3 = ring_radius * math.sin(theta) - sub_x = round(sub_center_x + x3 * radius_x) - sub_y = round(sub_center_y + y3 * radius_y) - if not (0 <= sub_x < sub_width and 0 <= sub_y < sub_height): - continue - _add_braille_dot( + active_depths: dict[tuple[int, int], float] = {} + for orbit in range(orbit_count): + if orbit < 3: + normal = ((0.0, 1.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0))[orbit] + else: + normal_y = 1 - 2 * ((orbit + 0.5) / orbit_count) + normal_radius = math.sqrt(max(0.0, 1.0 - normal_y * normal_y)) + normal_theta = orbit * GOLDEN_ANGLE + normal = ( + normal_radius * math.cos(normal_theta), + normal_y, + normal_radius * math.sin(normal_theta), + ) + reference = (0.0, 0.0, 1.0) if abs(normal[2]) < 0.9 else vertical_axis + basis_u = _normalize_3d(*_cross_3d(normal, reference)) + basis_v = _cross_3d(normal, basis_u) + orbit_radius = ( + 0.98 + if orbit < 3 or orbit % 4 == 0 + else 0.52 + 0.44 * _stable_hash(orbit, 2.7) + ) + + for sample in range(samples_per_orbit): + angle = (sample / samples_per_orbit) * math.tau + if orbit == 0: + sub_x = round(center_x + math.cos(angle) * radius_x) + sub_y = round(center_y - math.sin(angle) * radius_y) + normalized_depth = math.sin(angle + global_yaw) * 0.35 + else: + point = _point_on_orbit( + basis_u, + basis_v, + orbit_radius, + angle, + global_yaw, + camera_tilt, + ) + sub_x = round(center_x + point[0] * radius_x) + sub_y = round(center_y - point[1] * radius_y) + normalized_depth = point[2] / orbit_radius + if 0 <= sub_x < sub_width and 0 <= sub_y < sub_height: + _add_braille_dot( + masks=masks, + depths=depths, + sub_x=sub_x, + sub_y=sub_y, + z=normalized_depth, + ) + + direction = 1 if orbit % 2 == 0 else -1 + speed = direction * (0.18 + 0.12 * _stable_hash(orbit, 7.3)) + for particle in range(WORKING_PARTICLES_PER_ORBIT): + point = _point_on_orbit( + basis_u, + basis_v, + orbit_radius, + animation_time * speed + + (particle / WORKING_PARTICLES_PER_ORBIT) * math.tau + + _stable_hash(orbit, 5.1) * math.tau, + global_yaw, + camera_tilt, + ) + sub_x = round(center_x + point[0] * radius_x) + sub_y = round(center_y - point[1] * radius_y) + normalized_depth = point[2] / orbit_radius + if not (0 <= sub_x < sub_width and 0 <= sub_y < sub_height): + continue + for offset_x, offset_y in _particle_offsets_for_depth(normalized_depth): + particle_x = sub_x + offset_x + particle_y = sub_y + offset_y + if not _inside_sphere_projection( + particle_x, + particle_y, + center_x, + center_y, + radius_x, + radius_y, + ): + continue + _add_braille_dot( + masks=masks, + depths=depths, + sub_x=particle_x, + sub_y=particle_y, + z=normalized_depth, + ) + position = (particle_x // 2, particle_y // 4) + active_depths[position] = max( + normalized_depth, + active_depths.get(position, -1.0), + ) + + shared_edge_positions = _replace_with_shared_outer_shell( + masks=masks, + depths=depths, + layer_maps=(active_depths,), + animation_time=animation_time, + center_x=center_x, + center_y=center_y, + radius_x=radius_x, + radius_y=radius_y, + ) + + network_edge_depths: dict[tuple[int, int], float] = {} + network_edge_visibilities: dict[tuple[int, int], float] = {} + network_node_depths: dict[tuple[int, int], float] = {} + network_signal_depths: dict[tuple[int, int], float] = {} + if state.key == "extracting": + ( + network_edge_depths, + network_edge_visibilities, + network_node_depths, + network_signal_depths, + ) = _network_layers_on_particle_field( masks=masks, depths=depths, - sub_x=sub_x, - sub_y=sub_y, - z=z3, + shared_edge_positions=shared_edge_positions, + animation_time=animation_time, + center_x=center_x, + center_y=center_y, + radius_x=radius_x, + radius_y=radius_y, ) + highlighted_positions: set[tuple[int, int]] = set() if state.key in {"recalling", "remembered", "source"}: - highlighted_positions.add(active_target) - target_sub_x = active_target[0] * 2 + 1 - target_sub_y = active_target[1] * 4 + 1 - _add_braille_dot( - masks=masks, - depths=depths, - sub_x=target_sub_x, - sub_y=target_sub_y, - z=1.0, + target_ratios = ( + ((0.72, 0.28), (0.63, 0.37), (0.78, 0.44), (0.57, 0.24)) + if state.key == "recalling" + else ((0.72, 0.28),) ) + for target_x, target_y in target_ratios: + available = ( + position + for position in masks + if position not in highlighted_positions + and position not in shared_edge_positions + and depths[position] > -0.15 + ) + highlighted_positions.add( + min( + available, + key=lambda position: ( + (position[0] - (width - 1) * target_x) ** 2 + + (position[1] - (height - 1) * target_y) ** 2 + ), + ) + ) cells = [] for (x, y), mask in masks.items(): - highlighted = (x, y) in highlighted_positions - if highlighted and state.key == "recalling": + position = (x, y) + active_depth = active_depths.get((x, y)) + target_highlighted = (x, y) in highlighted_positions + network_signal_depth = network_signal_depths.get(position) + network_node_depth = network_node_depths.get(position) + network_edge_depth = network_edge_depths.get(position) + if position in shared_edge_positions: + style = _style_for_shared_outer_shell(depths[(x, y)]) + elif network_signal_depth is not None: + style = _style_for_network_signal(depths[position]) + elif network_node_depth is not None: + style = _style_for_network_node(network_node_depth) + elif network_edge_depth is not None: + style = _style_for_network_edge( + network_edge_depth, + network_edge_visibilities[position], + ) + elif target_highlighted and state.key == "recalling": style = EVEROS_CYAN - elif highlighted: + elif target_highlighted: style = EVEROS_YELLOW + elif state.key == "indexing" and depths[(x, y)] > 0.3: + # Preserve the old Index behavior: the organized front layer turns + # white, now projected onto the complete orbital sphere. + style = EVEROS_CYAN + elif active_depth is not None and state.key == "ingesting": + style = _style_for_active_particle(active_depth, allow_white=True) + elif state.key == "extracting": + style = _style_for_network_surface(depths[position]) + elif active_depth is not None: + style = _style_for_active_particle(active_depth, allow_white=False) else: - style = _style_for_depth(depths[(x, y)], state) + style = _style_for_ghost_depth(depths[(x, y)]) + highlighted = ( + target_highlighted + or network_signal_depth is not None + or style == EVEROS_CYAN + ) cells.append( DotCell( x=x, @@ -204,54 +410,781 @@ def build_dot_sphere( ) -def _build_confetti_burst( +def _network_layers_on_particle_field( + *, + masks: dict[tuple[int, int], int], + depths: dict[tuple[int, int], float], + shared_edge_positions: set[tuple[int, int]], + animation_time: float, + center_x: float, + center_y: float, + radius_x: float, + radius_y: float, +) -> tuple[ + dict[tuple[int, int], float], + dict[tuple[int, int], float], + dict[tuple[int, int], float], + dict[tuple[int, int], float], +]: + """Color a moving network onto the shared particle field. + + Extract used to build an independent sphere, so entering the stage replaced + most of the center in one frame. This layer snaps its graph, nodes, and + moving packets to particles that already exist in every stage. The shape + therefore remains continuous while the color animation still reads as a + connected extraction network. + """ + + candidates = tuple( + position for position in masks if position not in shared_edge_positions + ) + candidate_set = set(candidates) + edge_depths: dict[tuple[int, int], float] = {} + edge_visibilities: dict[tuple[int, int], float] = {} + node_depths: dict[tuple[int, int], float] = {} + signal_depths: dict[tuple[int, int], float] = {} + if not candidates: + return edge_depths, edge_visibilities, node_depths, signal_depths + + def nearest_particle( + sub_x: float, + sub_y: float, + depth: float, + ) -> tuple[int, int]: + depth_scale = min(radius_x, radius_y) * 0.22 + target_x = round((sub_x - 0.5) / 2) + target_y = round((sub_y - 1.5) / 4) + local_candidates = tuple( + position + for y in range(target_y - 2, target_y + 3) + for x in range(target_x - 3, target_x + 4) + if (position := (x, y)) in candidate_set + ) + return min( + local_candidates or candidates, + key=lambda position: ( + (position[0] * 2 + 0.5 - sub_x) ** 2 + + (position[1] * 4 + 1.5 - sub_y) ** 2 + + ((depths[position] - depth) * depth_scale) ** 2 + ), + ) + + branch_rotation = 0.07 * math.sin(animation_time * 0.18) + + def branch_point( + branch: int, + progress: float, + ) -> tuple[float, float, float]: + base_angle = branch * math.tau / EXTRACT_BRANCH_COUNT - math.pi / 2 + bend = (1 if branch % 2 == 0 else -1) * 0.24 + angle = base_angle + branch_rotation + bend * math.sin(progress * math.pi) + branch_length = 0.58 + 0.07 * _stable_hash(branch, 71.3) + radial = 0.06 + branch_length * progress + depth_limit = math.sqrt(max(0.0, 1 - radial * radial)) + depth = ( + math.cos(base_angle + animation_time * 0.1) + * depth_limit + * (0.35 + 0.55 * progress) + ) + return ( + center_x + math.cos(angle) * radius_x * radial, + center_y - math.sin(angle) * radius_y * radial, + depth, + ) + + samples_per_branch = max(12, round(radius_x * 0.55)) + for branch in range(EXTRACT_BRANCH_COUNT): + for sample in range(samples_per_branch + 1): + progress = sample / samples_per_branch + sub_x, sub_y, depth = branch_point(branch, progress) + position = nearest_particle(sub_x, sub_y, depth) + edge_depths[position] = max(depth, edge_depths.get(position, -1.0)) + edge_visibilities[position] = max( + 0.2 + 0.28 * progress, + edge_visibilities.get(position, 0.0), + ) + + for node_progress in (0.06, 0.5, 1.0): + sub_x, sub_y, depth = branch_point(branch, node_progress) + position = nearest_particle(sub_x, sub_y, depth) + node_depths[position] = max(depth, node_depths.get(position, -1.0)) + + head_progress = (animation_time * 0.16 + branch / EXTRACT_BRANCH_COUNT) % 1.0 + for trail_step in range(EXTRACT_TRAIL_STEPS): + progress = head_progress - trail_step * EXTRACT_TRAIL_GAP + if progress < 0: + continue + sub_x, sub_y, depth = branch_point(branch, progress) + position = nearest_particle(sub_x, sub_y, depth) + signal_depths[position] = max( + depth, + signal_depths.get(position, -1.0), + ) + + source = nearest_particle(center_x, center_y, 0.85) + node_depths[source] = 0.85 + + return edge_depths, edge_visibilities, node_depths, signal_depths + + +def _build_solving_network( *, width: int, height: int, phase: float, state: SphereState ) -> DotSphereFrame: - center_x = (width - 1) / 2 - center_y = (height - 1) / 2 - radius_x = max(1.0, center_x - 3) - radius_y = max(1.0, center_y - 2) - local_phase = _state_local_phase(phase, state.key) - bloom = 0.62 + 0.58 * math.sin(local_phase * math.pi) - rotation = phase * math.tau * 1.4 - - cells_by_position: dict[tuple[int, int], DotCell] = {} - for index in range(CONFETTI_POINT_COUNT): - shell = 0.55 + 0.45 * ((index % 17) / 16) - angle = index * GOLDEN_ANGLE + rotation - drift = math.sin(phase * math.tau * 2 + index * 0.23) - x = round(center_x + math.cos(angle) * radius_x * shell * bloom) - y = round( - center_y - + math.sin(angle) * radius_y * shell * bloom - + drift * 0.75 * local_phase + """Render Extract as a dense memory web with packets following its edges.""" + + sub_width, sub_height, center_x, center_y, radius_x, radius_y = _sphere_geometry( + width, height + ) + animation_time = phase * math.tau + yaw = animation_time * 0.12 + tilt = 0.32 + sin_tilt, cos_tilt = math.sin(tilt), math.cos(tilt) + + def project_at_yaw( + x3: float, + y3: float, + z3: float, + sample_yaw: float, + ) -> tuple[int, int, float]: + sample_sin_yaw = math.sin(sample_yaw) + sample_cos_yaw = math.cos(sample_yaw) + x_rotated = x3 * sample_cos_yaw + z3 * sample_sin_yaw + z_rotated = -x3 * sample_sin_yaw + z3 * sample_cos_yaw + y_projected = y3 * cos_tilt - z_rotated * sin_tilt + depth = y3 * sin_tilt + z_rotated * cos_tilt + return ( + round(center_x + x_rotated * radius_x * STAGE_INTERIOR_RADIUS), + round(center_y - y_projected * radius_y * STAGE_INTERIOR_RADIUS), + depth, ) - if not (0 <= x < width and 0 <= y < height): - continue - - z = math.cos(angle - rotation) * shell - glyph = CONFETTI_GLYPHS[(index + int(local_phase * 10)) % len(CONFETTI_GLYPHS)] - style = CONFETTI_STYLES[ - (index * 3 + int(local_phase * 7)) % len(CONFETTI_STYLES) - ] - position = (x, y) - existing = cells_by_position.get(position) - if existing is None or z > existing.z: - cells_by_position[position] = DotCell( + + def project(x3: float, y3: float, z3: float) -> tuple[int, int, float]: + return project_at_yaw(x3, y3, z3, yaw) + + surface_area = math.pi * radius_x * radius_y + background_count = max( + 140, + round(surface_area * SOLVING_BACKGROUND_DENSITY * STAGE_INTERIOR_RADIUS**2), + ) + node_count = max(28, round(radius_x * 1.05)) + masks: dict[tuple[int, int], int] = {} + depths: dict[tuple[int, int], float] = {} + background_depths: dict[tuple[int, int], float] = {} + signal_depths: dict[tuple[int, int], float] = {} + node_depths: dict[tuple[int, int], float] = {} + edge_depths: dict[tuple[int, int], float] = {} + edge_visibilities: dict[tuple[int, int], float] = {} + + # A dense spherical field preserves the particle density of the other + # stages. Each sample follows a small surface flow rather than remaining + # fixed, while the zero-mean motion keeps the sphere centered. + for index in range(background_count): + base_y = 1 - 2 * ((index + 0.5) / background_count) + base_latitude = math.asin(base_y) + flow_speed = 0.12 + 0.08 * _stable_hash(index, 4.3) + latitude = base_latitude + 0.04 * math.sin( + animation_time * (0.4 + 0.15 * _stable_hash(index, 8.1)) + + index * GOLDEN_ANGLE * 0.37 + ) + latitude = max(-math.pi / 2, min(math.pi / 2, latitude)) + y3 = math.sin(latitude) + latitude_radius = math.cos(latitude) + theta = ( + index * GOLDEN_ANGLE + + animation_time * flow_speed + + 0.025 * math.sin(animation_time * 0.55 + index * 0.19) + ) + x3 = latitude_radius * math.cos(theta) + z3 = latitude_radius * math.sin(theta) + sub_x, sub_y, depth = project(x3, y3, z3) + if 0 <= sub_x < sub_width and 0 <= sub_y < sub_height: + position = (sub_x // 2, sub_y // 4) + _add_braille_dot( + masks=masks, + depths=depths, + sub_x=sub_x, + sub_y=sub_y, + z=depth, + ) + background_depths[position] = max( + depth, + background_depths.get(position, -1.0), + ) + + base_nodes: list[tuple[float, float, float]] = [] + nodes: list[tuple[float, float, float]] = [] + for index in range(node_count): + base_y = 1 - 2 * ((index + 0.5) / node_count) + latitude_radius = math.sqrt(max(0.0, 1.0 - base_y * base_y)) + theta = index * GOLDEN_ANGLE + base_x = latitude_radius * math.cos(theta) + base_z = latitude_radius * math.sin(theta) + base_nodes.append((base_x, base_y, base_z)) + x3 = base_x + 0.12 * math.sin(animation_time * 0.72 + index * 0.31 + 9) + y3 = base_y + 0.12 * math.sin(animation_time * 0.63 + index * 0.53 + 27) + z3 = base_z + 0.12 * math.sin(animation_time * 0.81 + index * 0.77 + 55) + nodes.append(_normalize_3d(x3, y3, z3)) + + projected_nodes = [project(*node) for node in nodes] + edge_threshold = 0.72 + edges: list[tuple[int, int, float]] = [] + adjacency: list[list[int]] = [[] for _ in range(node_count)] + for start_index in range(node_count): + for end_index in range(start_index + 1, node_count): + distance = math.sqrt( + sum( + (a - b) ** 2 + for a, b in zip( + base_nodes[start_index], + base_nodes[end_index], + strict=True, + ) + ) + ) + if distance < edge_threshold: + edges.append((start_index, end_index, distance)) + adjacency[start_index].append(end_index) + adjacency[end_index].append(start_index) + + for start_index, end_index, distance in edges: + start_x, start_y, start_z = projected_nodes[start_index] + end_x, end_y, end_z = projected_nodes[end_index] + line_depth = (start_z + end_z) / 2 + depth_factor = 0.3 + 0.55 * ((line_depth + 1) / 2) + visibility = (1 - distance / edge_threshold) * depth_factor + steps = max(1, max(abs(end_x - start_x), abs(end_y - start_y))) + for step in range(0, steps + 1, 2): + progress = step / steps + sub_x = round(start_x + (end_x - start_x) * progress) + sub_y = round(start_y + (end_y - start_y) * progress) + edge_depth = start_z + (end_z - start_z) * progress + _add_braille_dot( + masks=masks, + depths=depths, + sub_x=sub_x, + sub_y=sub_y, + z=edge_depth, + ) + position = (sub_x // 2, sub_y // 4) + edge_depths[position] = max( + edge_depth, + edge_depths.get(position, -1.0), + ) + edge_visibilities[position] = max( + visibility, + edge_visibilities.get(position, 0.0), + ) + + for sub_x, sub_y, depth in projected_nodes: + offsets = _particle_offsets_for_depth(depth) + for offset_x, offset_y in offsets: + node_x = sub_x + offset_x + node_y = sub_y + offset_y + if not _inside_sphere_projection( + node_x, + node_y, + center_x, + center_y, + radius_x, + radius_y, + ): + continue + _add_braille_dot( + masks=masks, + depths=depths, + sub_x=node_x, + sub_y=node_y, + z=depth, + ) + position = (node_x // 2, node_y // 4) + node_depths[position] = max( + depth, + node_depths.get(position, -1.0), + ) + + # Each bright packet follows one continuous walk through the connected + # graph. Reaching a node therefore leads into the next edge instead of + # respawning elsewhere, matching the reference's surface traversal. + for signal in range(SOLVING_SIGNAL_COUNT): + head_clock = animation_time * 0.46 + signal / SOLVING_SIGNAL_COUNT + seed = round((signal + 0.5) * node_count / SOLVING_SIGNAL_COUNT) % node_count + for trail_step in range(SOLVING_SIGNAL_TRAIL_STEPS): + signal_clock = max( + 0.0, + head_clock - trail_step * SOLVING_SIGNAL_TRAIL_GAP, + ) + segment = math.floor(signal_clock) + route = _signal_route_edge( + adjacency=adjacency, + seed=seed, + segment=segment, + signal=signal, + ) + if route is None: + continue + start_index, end_index = route + progress = signal_clock - math.floor(signal_clock) + start_x, start_y, start_z = projected_nodes[start_index] + end_x, end_y, end_z = projected_nodes[end_index] + sub_x = round(start_x + (end_x - start_x) * progress) + sub_y = round(start_y + (end_y - start_y) * progress) + depth = start_z + (end_z - start_z) * progress + for offset_x, offset_y in _particle_offsets_for_depth( + depth, + pulse=trail_step == 0, + ): + signal_x = sub_x + offset_x + signal_y = sub_y + offset_y + if not _inside_sphere_projection( + signal_x, + signal_y, + center_x, + center_y, + radius_x, + radius_y, + ): + continue + _add_braille_dot( + masks=masks, + depths=depths, + sub_x=signal_x, + sub_y=signal_y, + z=depth, + ) + position = (signal_x // 2, signal_y // 4) + signal_depths[position] = max( + depth, + signal_depths.get(position, -1.0), + ) + + shared_edge_positions = _replace_with_shared_outer_shell( + masks=masks, + depths=depths, + layer_maps=( + background_depths, + signal_depths, + node_depths, + edge_depths, + edge_visibilities, + ), + animation_time=animation_time, + center_x=center_x, + center_y=center_y, + radius_x=radius_x, + radius_y=radius_y, + ) + + cells = [] + for (x, y), mask in masks.items(): + signal_depth = signal_depths.get((x, y)) + node_depth = node_depths.get((x, y)) + edge_depth = edge_depths.get((x, y)) + background_depth = background_depths.get((x, y), -1.0) + if (x, y) in shared_edge_positions: + style = _style_for_shared_outer_shell(depths[(x, y)]) + elif signal_depth is not None: + style = _style_for_network_signal(signal_depth) + elif node_depth is not None and node_depth >= background_depth - 0.05: + style = _style_for_network_node(node_depth) + elif edge_depth is not None and edge_depth >= background_depth - 0.05: + style = _style_for_network_edge( + edge_depth, + edge_visibilities[(x, y)], + ) + else: + style = _style_for_network_surface(background_depth) + highlighted = signal_depth is not None + cells.append( + DotCell( x=x, y=y, - z=z, - glyph=glyph, + z=depths[(x, y)], + glyph=chr(BRAILLE_BASE + mask), style=style, + highlighted=highlighted, ) + ) return DotSphereFrame( width=width, height=height, state=state, - cells=tuple( - sorted(cells_by_position.values(), key=lambda cell: (cell.y, cell.x)) - ), + cells=tuple(sorted(cells, key=lambda cell: (cell.y, cell.x))), + ) + + +def _build_soft_supernova( + *, + width: int, + height: int, + phase: float, + state: SphereState, + progress: float, +) -> DotSphereFrame: + """Burst the recalled sphere into a bright Braille-particle supernova.""" + + progress = max(0.0, min(1.0, progress)) + if progress < SUPERNOVA_CORE_START: + # Freeze the recalled source only while it explodes, so particle + # identities cannot change in mid-flight. + source = _build_working_cloud( + width=width, + height=height, + phase=phase, + state=SPHERE_STATES["recalling"], + ) + else: + # During the blank/reappearance section, use the *moving* Working + # field at the phase the next idle frame will inherit. The last + # celebration frame therefore joins the next loop without a jump. + source = _build_working_cloud( + width=width, + height=height, + phase=phase + progress * 2.4, + state=SPHERE_STATES["ingesting"], + ) + sub_width, sub_height, center_x, center_y, radius_x, radius_y = _sphere_geometry( + width, + height, + ) + animation_time = progress * math.tau * 2.4 + + contraction = _smoothstep(min(1.0, progress / 0.035)) + contraction_release = 1 - _smoothstep( + max(0.0, min(1.0, (progress - 0.035) / 0.035)) + ) + contraction *= contraction_release + source_scale_x = 1 - 0.085 * contraction + source_scale_y = 1 - 0.065 * contraction + + flash = max(0.0, 1 - abs(progress - 0.047) / 0.03) + wave_progress = max(0.0, min(1.0, (progress - 0.035) / 0.14)) + wave_radius = 0.08 + 1.02 * (1 - (1 - wave_progress) ** 2) + wave_strength = 1 - _smoothstep(max(0.0, (progress - 0.17) / 0.07)) + + masks: dict[tuple[int, int], int] = {} + depths: dict[tuple[int, int], float] = {} + source_styles: dict[tuple[int, int], str] = {} + highlighted_positions: set[tuple[int, int]] = set() + + def edge_visibility(sub_x: int, sub_y: int, particle_id: int) -> float: + """Feather particles before the rectangular terminal crop is visible.""" + + if sub_x // 2 in {0, width - 1} or sub_y // 4 in {0, height - 1}: + return 0.0 + distance = min( + sub_x + 0.5, + sub_width - 0.5 - sub_x, + sub_y + 0.5, + sub_height - 0.5 - sub_y, + ) + feather_width = 2.5 + 5.5 * _stable_hash(particle_id, 17.3) + return _smoothstep(max(0.0, min(1.0, distance / feather_width))) + + def add_particle( + *, + sub_x: int, + sub_y: int, + depth: float, + style: str, + highlighted: bool, + ) -> None: + if not (0 <= sub_x < sub_width and 0 <= sub_y < sub_height): + return + position = (sub_x // 2, sub_y // 4) + if position not in depths or depth >= depths[position]: + source_styles[position] = style + if highlighted: + highlighted_positions.add(position) + else: + highlighted_positions.discard(position) + _add_braille_dot( + masks=masks, + depths=depths, + sub_x=sub_x, + sub_y=sub_y, + z=depth, + ) + + for cell in source.cells: + mask = ord(cell.glyph) - BRAILLE_BASE + for local_x in range(2): + for local_y in range(4): + if not mask & BRAILLE_DOT_BITS[local_x][local_y]: + continue + original_x = cell.x * 2 + local_x + original_y = cell.y * 4 + local_y + delta_x = original_x - center_x + delta_y = original_y - center_y + radial = math.sqrt( + (delta_x / radius_x) ** 2 + (delta_y / radius_y) ** 2 + ) + particle_id = original_y * sub_width + original_x + if progress >= SUPERNOVA_CORE_START: + if progress < SUPERNOVA_REFORM_START: + core_growth = _smoothstep( + max( + 0.0, + min( + 1.0, + (progress - SUPERNOVA_CORE_START) / 0.11, + ), + ) + ) + reveal_radius = SUPERNOVA_CORE_RADIUS * core_growth + else: + expansion = _smoothstep( + max( + 0.0, + min( + 1.0, + (progress - SUPERNOVA_REFORM_START) + / (SUPERNOVA_REFORM_END - SUPERNOVA_REFORM_START), + ), + ) + ) + reveal_radius = SUPERNOVA_CORE_RADIUS + expansion + # A soft radial edge makes the same moving ingest field + # appear first at its origin, hold there, and then expand + # continuously to the full sphere. No separate seed layer + # is swapped out when the rest of the particles arrive. + reveal_feather = 0.04 + appearance = _smoothstep( + max( + 0.0, + min( + 1.0, + (reveal_radius - radial + reveal_feather) + / reveal_feather, + ), + ) + ) + if appearance <= 0.12: + continue + style = _blend_hex_color( + EVEROS_FIELD_BACKGROUND, + cell.style, + appearance, + ) + add_particle( + sub_x=original_x, + sub_y=original_y, + depth=cell.z, + style=style, + highlighted=cell.highlighted and appearance > 0.45, + ) + continue + + speed_hash = _stable_hash(particle_id, 44.1) + spark_hash = _stable_hash(particle_id, 19.7) + source_x = center_x + delta_x * source_scale_x + source_y = center_y + delta_y * source_scale_y + launch_start = 0.025 + 0.025 * speed_hash + launch_duration = 0.03 + 0.055 * _stable_hash( + particle_id, + 71.4, + ) + launch_raw = max( + 0.0, + min(1.0, (progress - launch_start) / launch_duration), + ) + launch = 1 - (1 - launch_raw) ** 3 + + # Decouple the launch direction from the particle's original + # place on the sphere. A radial correlation turns the burst + # into a larger circular shell instead of a chaotic release. + scatter_angle = math.tau * _stable_hash(particle_id, 31.2) + scatter_distance = math.sqrt(_stable_hash(particle_id, 57.8)) + field_radius = min(sub_width, sub_height) * 0.62 + irregular_envelope = ( + 0.78 + + 0.14 * math.sin(scatter_angle * 3 + 1.1) + + 0.1 * math.sin(scatter_angle * 7 + 2.3) + + 0.08 * (_stable_hash(particle_id, 68.4) - 0.5) + ) + target_radius = field_radius * scatter_distance * irregular_envelope + target_x = center_x + math.cos(scatter_angle) * target_radius + target_y = center_y + math.sin(scatter_angle) * target_radius + launch_end = launch_start + launch_duration + coast_elapsed = max(0.0, progress - launch_end) + coast = _smoothstep(min(1.0, coast_elapsed / 0.08)) + travel_x = target_x - source_x + travel_y = target_y - source_y + travel_length = max(1.0, math.hypot(travel_x, travel_y)) + bend = ( + (_stable_hash(particle_id, 83.7) - 0.5) + * min(sub_width, sub_height) + * 0.28 + ) + curve = math.sin(math.pi * launch) + curve_x = -travel_y / travel_length * bend * curve + curve_y = travel_x / travel_length * bend * curve + drift_strength = launch * coast + drift_angle = ( + scatter_angle + + (_stable_hash(particle_id, 26.3) - 0.5) * math.pi * 0.75 + ) + coast_speed = 0.2 + 0.26 * _stable_hash(particle_id, 38.9) + ballistic_x = ( + math.cos(drift_angle) + * min(sub_width, sub_height) + * coast_elapsed + * coast_speed + * drift_strength + ) + ballistic_y = ( + math.sin(drift_angle) + * min(sub_width, sub_height) + * coast_elapsed + * coast_speed + * drift_strength + ) + turbulence_x = ( + math.sin(animation_time * 0.22 + particle_id * 0.13) + * 2.1 + * drift_strength + ) + turbulence_y = ( + math.cos(animation_time * 0.19 + particle_id * 0.17) + * 1.7 + * drift_strength + ) + sub_x = round( + source_x + travel_x * launch + curve_x + ballistic_x + turbulence_x + ) + sub_y = round( + source_y + travel_y * launch + curve_y + ballistic_y + turbulence_y + ) + + fade_start = 0.14 + 0.04 * _stable_hash( + particle_id, + 91.6, + ) + fade_duration = 0.1 + 0.04 * speed_hash + fade_raw = max( + 0.0, + min(1.0, (progress - fade_start) / fade_duration), + ) + visibility = 1 - _smoothstep(fade_raw) + if not (0 <= sub_x < sub_width and 0 <= sub_y < sub_height): + continue + visibility *= edge_visibility(sub_x, sub_y, particle_id) + if visibility <= 0.12: + continue + wave = ( + max(0.0, 1 - abs(radial - wave_radius) / 0.09) * wave_strength + if progress > 0.045 + else 0.0 + ) + twinkle = ( + launch + * visibility + * max( + 0.0, + math.sin(animation_time * 0.72 + particle_id * 0.41), + ) + ) + glow = min(0.92, flash * 0.82 + wave * 0.58 + twinkle * 0.22) + glow_target = ( + EVEROS_CYAN + if cell.highlighted or (flash > 0 and radial < 0.32) + else EVEROS_YELLOW_PALE + ) + style = _blend_hex_color(cell.style, glow_target, glow) + style = _blend_hex_color( + style, + EVEROS_FIELD_BACKGROUND, + 1 - visibility, + ) + add_particle( + sub_x=sub_x, + sub_y=sub_y, + depth=cell.z, + style=style, + highlighted=cell.highlighted and visibility > 0.45, + ) + + trails_visible = ( + math.sin(math.pi * launch_raw) * visibility + if 0 < launch_raw < 1 + else 0.0 + ) + if spark_hash <= 0.54 or trails_visible <= 1e-6: + continue + if launch_duration < 0.09 and spark_hash > 0.72: + trail_count = 3 + elif spark_hash > 0.76: + trail_count = 2 + else: + trail_count = 1 + tangent_x = travel_x + ( + -travel_y + / travel_length + * bend + * math.pi + * math.cos(math.pi * launch) + ) + tangent_y = travel_y + ( + travel_x + / travel_length + * bend + * math.pi + * math.cos(math.pi * launch) + ) + travel_angle = math.atan2(tangent_y, tangent_x) + for trail_step in range(1, trail_count + 1): + distance = trail_step * (1.0 + trails_visible * 2.35) + trail_x = round(sub_x - math.cos(travel_angle) * distance) + trail_y = round(sub_y - math.sin(travel_angle) * distance) + if not (0 <= trail_x < sub_width and 0 <= trail_y < sub_height): + continue + trail_edge_visibility = edge_visibility( + trail_x, + trail_y, + particle_id, + ) + if trail_edge_visibility <= 0.12: + continue + trail_style = _blend_hex_color( + style, + EVEROS_FIELD_BACKGROUND, + min( + 0.94, + 0.22 + trail_step * 0.18 + (1 - trails_visible) * 0.46, + ), + ) + trail_style = _blend_hex_color( + trail_style, + EVEROS_FIELD_BACKGROUND, + 1 - trail_edge_visibility, + ) + add_particle( + sub_x=trail_x, + sub_y=trail_y, + depth=cell.z - trail_step * 0.02, + style=trail_style, + highlighted=False, + ) + + cells = tuple( + DotCell( + x=x, + y=y, + z=depths[(x, y)], + glyph=chr(BRAILLE_BASE + mask), + style=source_styles[(x, y)], + highlighted=(x, y) in highlighted_positions, + ) + for (x, y), mask in sorted( + masks.items(), + key=lambda item: (item[0][1], item[0][0]), + ) + ) + return DotSphereFrame( + width=width, + height=height, + state=state, + cells=cells, ) @@ -282,6 +1215,71 @@ def render_dot_sphere_text(frame: DotSphereFrame) -> Text: return text +def blend_dot_sphere_frames( + previous: DotSphereFrame, + current: DotSphereFrame, + progress: float, + *, + background: str = "#1D1C18", +) -> DotSphereFrame: + """Ease between states without replacing the whole particle field at once.""" + + if (previous.width, previous.height) != (current.width, current.height): + raise ValueError("dot sphere frames must have matching dimensions") + progress = max(0.0, min(1.0, progress)) + previous_cells = {(cell.x, cell.y): cell for cell in previous.cells} + current_cells = {(cell.x, cell.y): cell for cell in current.cells} + cells = [] + positions = previous_cells.keys() | current_cells.keys() + for position in sorted(positions, key=lambda item: (item[1], item[0])): + old = previous_cells.get(position) + new = current_cells.get(position) + if old is not None and new is not None: + glyph = old.glyph if old.glyph == new.glyph or progress < 0.5 else new.glyph + style = _blend_hex_color(old.style, new.style, progress) + z = old.z + (new.z - old.z) * progress + highlighted = new.highlighted if progress >= 0.5 else old.highlighted + elif old is not None: + glyph = old.glyph + style = _blend_hex_color(old.style, background, progress) + z = old.z + highlighted = old.highlighted and progress < 0.5 + else: + assert new is not None + glyph = new.glyph + style = _blend_hex_color(background, new.style, progress) + z = new.z + highlighted = new.highlighted and progress >= 0.5 + cells.append( + DotCell( + x=position[0], + y=position[1], + z=z, + glyph=glyph, + style=style, + highlighted=highlighted, + ) + ) + + return DotSphereFrame( + width=current.width, + height=current.height, + state=current.state, + cells=tuple(cells), + ) + + +def _blend_hex_color(start: str, end: str, progress: float) -> str: + """Blend the plain RGB styles used by the particle renderer.""" + + start_rgb = tuple(int(start[index : index + 2], 16) for index in (1, 3, 5)) + end_rgb = tuple(int(end[index : index + 2], 16) for index in (1, 3, 5)) + channels = tuple( + round(a + (b - a) * progress) for a, b in zip(start_rgb, end_rgb, strict=True) + ) + return "#" + "".join(f"{channel:02X}" for channel in channels) + + def _add_braille_dot( *, masks: dict[tuple[int, int], int], @@ -299,22 +1297,424 @@ def _add_braille_dot( depths[position] = max(z, depths.get(position, -1.0)) +def _sphere_geometry( + width: int, + height: int, +) -> tuple[int, int, float, float, float, float]: + """Return a physically round Braille projection for the available space.""" + + sub_width = width * 2 + sub_height = height * 4 + center_x = (sub_width - 1) / 2 + center_y = (sub_height - 1) / 2 + 1 + radius_x = max(1.0, (center_x - 6) * 0.9) + radius_y = max(1.0, (center_y - 5) * 0.9) + return sub_width, sub_height, center_x, center_y, radius_x, radius_y + + +def _inside_sphere_projection( + sub_x: int, + sub_y: int, + center_x: float, + center_y: float, + radius_x: float, + radius_y: float, +) -> bool: + normalized = ((sub_x - center_x) / radius_x) ** 2 + ( + (sub_y - center_y) / radius_y + ) ** 2 + return normalized <= 1.0 + + +def _replace_with_shared_outer_shell( + *, + masks: dict[tuple[int, int], int], + depths: dict[tuple[int, int], float], + layer_maps: tuple[dict[tuple[int, int], float], ...], + animation_time: float, + center_x: float, + center_y: float, + radius_x: float, + radius_y: float, +) -> set[tuple[int, int]]: + """Give every processing state one identical, stable particle edge. + + Stage renderers are free to animate the center of the sphere. The outer + band is replaced after that work so a change in network or orbit density + cannot make the silhouette appear to jump between stages. + """ + + shared_masks: dict[tuple[int, int], int] = {} + shared_depths: dict[tuple[int, int], float] = {} + surface_area = math.pi * radius_x * radius_y + band_ratio = 1 - SHARED_EDGE_INNER_RADIUS**2 + particle_count = max( + 120, + round(surface_area * band_ratio * SHARED_EDGE_DENSITY), + ) + + for index in range(particle_count): + angle = ( + index * GOLDEN_ANGLE + + animation_time * 0.055 + + 0.028 + * math.sin( + animation_time * (0.16 + 0.05 * _stable_hash(index, 29.4)) + + index * 0.43 + ) + ) + radial_hash = _stable_hash(index, 41.7) + base_radius = math.sqrt(SHARED_EDGE_INNER_RADIUS**2 + band_ratio * radial_hash) + radius = base_radius + 0.014 * math.sin( + animation_time * (0.22 + 0.08 * _stable_hash(index, 63.1)) + index * 0.37 + ) + radius = max( + SHARED_EDGE_INNER_RADIUS + 0.01, + min(0.985, radius), + ) + sub_x = round(center_x + math.cos(angle) * radius_x * radius) + sub_y = round(center_y - math.sin(angle) * radius_y * radius) + hemisphere = 1.0 if _stable_hash(index, 17.9) >= 0.38 else -1.0 + depth = hemisphere * math.sqrt(max(0.0, 1 - radius * radius)) + _add_braille_dot( + masks=shared_masks, + depths=shared_depths, + sub_x=sub_x, + sub_y=sub_y, + z=depth, + ) + + # Symmetric pairs drift around each cardinal direction. Their small + # tangential motion keeps the silhouette alive while preserving its exact + # adaptive width and height through the whole animation cycle. + boundary_wobble = 0.018 + 0.012 * (0.5 + 0.5 * math.sin(animation_time * 0.34)) + for cardinal in range(4): + cardinal_angle = cardinal * math.pi / 2 + for direction in (-1, 1): + angle = cardinal_angle + direction * boundary_wobble + sub_x = round(center_x + math.cos(angle) * radius_x * 0.998) + sub_y = round(center_y - math.sin(angle) * radius_y * 0.998) + _add_braille_dot( + masks=shared_masks, + depths=shared_depths, + sub_x=sub_x, + sub_y=sub_y, + z=0.0, + ) + + # Braille cells are larger than their sub-dots. Keep only cells whose + # visual center belongs to the outer band so the moving shell never masks + # a stage-specific packet travelling through the middle. + for position in tuple(shared_masks): + if ( + _cell_projection_radius( + position, + center_x=center_x, + center_y=center_y, + radius_x=radius_x, + radius_y=radius_y, + ) + < SHARED_EDGE_INNER_RADIUS + ): + shared_masks.pop(position) + shared_depths.pop(position) + + replace_positions = set(shared_masks) + replace_positions.update( + position + for position in masks + if _cell_projection_radius( + position, + center_x=center_x, + center_y=center_y, + radius_x=radius_x, + radius_y=radius_y, + ) + >= SHARED_EDGE_INNER_RADIUS + ) + for position in replace_positions: + masks.pop(position, None) + depths.pop(position, None) + for layer_map in layer_maps: + layer_map.pop(position, None) + + masks.update(shared_masks) + depths.update(shared_depths) + return set(shared_masks) + + +def _cell_projection_radius( + position: tuple[int, int], + *, + center_x: float, + center_y: float, + radius_x: float, + radius_y: float, +) -> float: + """Return a terminal cell's radial position in the Braille projection.""" + + cell_x, cell_y = position + sub_x = cell_x * 2 + 0.5 + sub_y = cell_y * 4 + 1.5 + return math.sqrt( + ((sub_x - center_x) / radius_x) ** 2 + ((sub_y - center_y) / radius_y) ** 2 + ) + + +def _rotate_around_axis( + point: tuple[float, float, float], + axis: tuple[float, float, float], + angle: float, +) -> tuple[float, float, float]: + """Rotate a particle around one stable flow axis using Rodrigues' formula.""" + + cos_angle = math.cos(angle) + sin_angle = math.sin(angle) + cross = _cross_3d(axis, point) + dot = sum(a * b for a, b in zip(axis, point, strict=True)) + return tuple( + point[index] * cos_angle + + cross[index] * sin_angle + + axis[index] * dot * (1 - cos_angle) + for index in range(3) + ) + + +def _point_on_orbit( + basis_u: tuple[float, float, float], + basis_v: tuple[float, float, float], + radius: float, + angle: float, + yaw: float, + tilt: float, +) -> tuple[float, float, float]: + point = tuple( + (basis_u[index] * math.cos(angle) + basis_v[index] * math.sin(angle)) * radius + for index in range(3) + ) + point = _rotate_around_axis(point, (0.0, 1.0, 0.0), yaw) + return _rotate_around_axis(point, (1.0, 0.0, 0.0), tilt) + + +def _particle_offsets_for_depth( + depth: float, + *, + pulse: bool = False, +) -> tuple[tuple[int, int], ...]: + """Make near particles physically larger, mirroring the Canvas reference.""" + + offsets = [(0, 0)] + if depth > -0.45: + offsets.append((1, 0)) + if depth > 0.1: + offsets.append((0, 1)) + if depth > 0.55: + offsets.append((1, 1)) + if pulse and depth > 0.15: + offsets.append((-1, 0)) + return tuple(offsets) + + +def _style_for_active_particle(depth: float, *, allow_white: bool) -> str: + depth_ratio = (depth + 1) / 2 + if allow_white and depth_ratio > 0.5: + return EVEROS_CYAN + if depth_ratio > 0.86: + return EVEROS_YELLOW_PALE + if depth_ratio > 0.7: + return EVEROS_YELLOW + if depth_ratio > 0.52: + return EVEROS_GOLD_LIGHT + if depth_ratio > 0.34: + return EVEROS_GOLD_WARM + if depth_ratio > 0.16: + return EVEROS_GOLD_MID + return EVEROS_GOLD_DARK + + +def _style_for_ghost_depth(depth: float) -> str: + """Approximate the reference ghost-path alpha using dark gold steps.""" + + depth_ratio = (depth + 1) / 2 + if depth_ratio > 0.82: + return EVEROS_GOLD_MID + if depth_ratio > 0.55: + return EVEROS_GOLD_DARK + if depth_ratio > 0.28: + return EVEROS_GOLD_DEEP + return EVEROS_GOLD_SHADOW + + +def _style_for_shared_outer_shell(depth: float) -> str: + """Keep edge contrast calm and identical while the center tells the story.""" + + if depth > 0.48: + return EVEROS_GOLD_MID + if depth > 0.18: + return EVEROS_GOLD_DARK + if depth > -0.18: + return EVEROS_GOLD_DEEP + return EVEROS_GOLD_SHADOW + + +def _style_for_network_node(depth: float) -> str: + if depth > 0.72: + return EVEROS_YELLOW_PALE + if depth > 0.42: + return EVEROS_YELLOW + if depth > 0.12: + return EVEROS_GOLD_LIGHT + if depth > -0.18: + return EVEROS_GOLD_WARM + if depth > -0.5: + return EVEROS_GOLD_MID + if depth > -0.78: + return EVEROS_GOLD_DEEP + return EVEROS_GOLD_SHADOW + + +def _style_for_network_surface(depth: float) -> str: + """Give Extract a bright front hemisphere and a dim visible back.""" + + if depth > 0.65: + return EVEROS_YELLOW + if depth > 0.3: + return EVEROS_GOLD_LIGHT + if depth > 0.0: + return EVEROS_GOLD_WARM + if depth > -0.35: + return EVEROS_GOLD_MID + if depth > -0.68: + return EVEROS_GOLD_DEEP + return EVEROS_GOLD_SHADOW + + +def _style_for_network_signal(depth: float) -> str: + """Keep packets white across the front and side, dimming only at the back.""" + + if depth > -0.32: + return EVEROS_CYAN + if depth > -0.62: + return EVEROS_GOLD_LIGHT + return EVEROS_GOLD_DARK + + +def _style_for_network_edge(depth: float, visibility: float) -> str: + if depth < -0.62: + return EVEROS_GOLD_SHADOW + if depth < -0.28: + return EVEROS_GOLD_DEEP + + depth_ratio = (depth + 1) / 2 + ink = visibility * (0.55 + 0.45 * depth_ratio) + if depth > 0.42 and ink > 0.17: + return EVEROS_YELLOW + if ink > 0.27: + return EVEROS_GOLD_LIGHT + if ink > 0.17: + return EVEROS_GOLD_WARM + if ink > 0.09: + return EVEROS_GOLD_MID + if ink > 0.04: + return EVEROS_GOLD_DARK + return EVEROS_GOLD_DEEP + + +def _normalize_3d(x: float, y: float, z: float) -> tuple[float, float, float]: + length = max(1e-6, math.sqrt(x * x + y * y + z * z)) + return x / length, y / length, z / length + + +def _cross_3d( + left: tuple[float, float, float], + right: tuple[float, float, float], +) -> tuple[float, float, float]: + return ( + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ) + + +def _signal_route_edge( + *, + adjacency: list[list[int]], + seed: int, + segment: int, + signal: int, +) -> tuple[int, int] | None: + """Return one edge in a deterministic, continuous graph walk.""" + + if not adjacency or not adjacency[seed]: + return None + segment = max(0, segment) + + def next_node(previous: int, current: int) -> int: + choices = [node for node in adjacency[current] if node != previous] + if not choices: + choices = adjacency[current] + selector = _stable_hash( + current * 131 + max(previous, 0) * 17, + signal * 5.3 + 1.9, + ) + return choices[min(len(choices) - 1, math.floor(selector * len(choices)))] + + # The next hop depends only on (previous, current), so the finite graph + # eventually cycles. Detect that cycle to keep long-running demos cheap. + states: list[tuple[int, int]] = [] + seen: dict[tuple[int, int], int] = {} + state = (-1, seed) + while state not in seen: + seen[state] = len(states) + states.append(state) + previous, current = state + state = (current, next_node(previous, current)) + + if segment < len(states): + state_index = segment + else: + cycle_start = seen[state] + cycle_length = len(states) - cycle_start + state_index = cycle_start + (segment - cycle_start) % cycle_length + + previous, current = states[state_index] + return current, next_node(previous, current) + + +def _stable_hash(value: int, salt: float) -> float: + """Return a deterministic pseudo-random value in [0, 1).""" + + hashed = math.sin((value + 1) * 12.9898 + salt * 78.233) * 43758.5453 + return hashed - math.floor(hashed) + + +def _smoothstep(value: float) -> float: + value = max(0.0, min(1.0, value)) + return value * value * (3 - 2 * value) + + def _style_for_depth(z: float, state: SphereState) -> str: if state.key == "extracting" and z > 0.38: return EVEROS_ORANGE - if state.key == "indexing" and z > 0.45: + if state.key == "indexing" and z > 0.3: return EVEROS_CYAN - if state.key == "ingesting" and z > 0.5: + if state.key == "ingesting" and z > 0.68: return EVEROS_CYAN - if z > 0.58: - return EVEROS_YELLOW - if z > 0.05: + if z > 0.78: + return EVEROS_YELLOW_PALE + if z > 0.68: return EVEROS_YELLOW - return EVEROS_AMBER - - -def _highlight_target(width: int, height: int) -> tuple[int, int]: - return (round((width - 1) * 0.66), round((height - 1) * 0.42)) + if z > 0.56: + return EVEROS_GOLD_LIGHT + if z > 0.44: + return EVEROS_GOLD_WARM + if z > 0.25: + return EVEROS_GOLD_MID + if z > 0: + return EVEROS_GOLD_DARK + if z > -0.4: + return EVEROS_GOLD_DEEP + return EVEROS_GOLD_SHADOW def _state_local_phase(phase: float, state_key: str) -> float: diff --git a/tests/unit/test_entrypoints/test_cli/test_demo_command.py b/tests/unit/test_entrypoints/test_cli/test_demo_command.py index 4af9fd0e..eaf842b2 100644 --- a/tests/unit/test_entrypoints/test_cli/test_demo_command.py +++ b/tests/unit/test_entrypoints/test_cli/test_demo_command.py @@ -2,76 +2,45 @@ from __future__ import annotations +import os import re -import pytest import typer from rich.panel import Panel from typer.testing import CliRunner from everos.entrypoints.cli.commands import demo as demo_command -from everos.entrypoints.tui.demo.data import build_demo_story +from everos.entrypoints.tui.demo import cloud +from everos.entrypoints.tui.demo.data import DemoStory -def test_demo_help_exposes_cinematic_mode() -> None: +def test_demo_help_exposes_all_modes() -> None: app = typer.Typer() demo_command.register(app) result = CliRunner().invoke(app, ["demo", "--help"], terminal_width=120) + help_text = _strip_ansi(result.stdout) assert result.exit_code == 0 - assert "--cinematic" in _strip_ansi(result.stdout) + for flag in ("--cinematic", "--live", "--cloud", "--server-url", "--verbose"): + assert flag in help_text -def test_demo_help_exposes_live_mode() -> None: +def test_demo_configures_requested_log_level(monkeypatch) -> None: + configured: list[bool] = [] + monkeypatch.setattr( + demo_command, + "configure_cli_logging", + lambda *, verbose: configured.append(verbose), + ) + monkeypatch.setattr(demo_command, "_print_plain_demo", lambda: None) app = typer.Typer() demo_command.register(app) - result = CliRunner().invoke(app, ["demo", "--help"], terminal_width=120) + result = CliRunner().invoke(app, ["--plain", "--verbose"]) - help_text = _strip_ansi(result.stdout) assert result.exit_code == 0 - assert "--live" in help_text - assert "--server-url" in help_text - - -def test_collect_playable_story_prompts_for_memory_then_query(monkeypatch) -> None: - prompts: list[tuple[str, str]] = [] - replies = iter( - [ - "I keep my Monday design review notes in Notion.", - "Where are my Monday review notes?", - ] - ) - - def fake_prompt(label: str, *, default: str) -> str: - prompts.append((label, default)) - return next(replies) - - monkeypatch.setattr(demo_command.typer, "prompt", fake_prompt) - - story = demo_command._collect_playable_story() - - assert [label for label, _ in prompts] == [ - "Give EverOS one thing to remember", - "Ask EverOS to recall it", - ] - assert story.memory == "I keep my Monday design review notes in Notion." - assert story.query == "Where are my Monday review notes?" - - -def test_interactive_demo_checks_textual_before_prompt(monkeypatch) -> None: - def fail_load_tui() -> object: - raise typer.Exit(code=1) - - def fail_prompt(*_: object, **__: object) -> str: - pytest.fail("demo prompted before checking TUI availability") - - monkeypatch.setattr(demo_command, "_load_run_demo_tui", fail_load_tui) - monkeypatch.setattr(demo_command.typer, "prompt", fail_prompt) - - with pytest.raises(typer.Exit): - demo_command._run_interactive_demo(cinematic=False) + assert configured == [True] def test_plain_demo_uses_poster_gold_brand_primary(monkeypatch) -> None: @@ -102,9 +71,13 @@ def print(self, *renderables: object, **_: object) -> None: monkeypatch.setattr(demo_command, "Console", FakeConsole) demo_command._print_plain_demo( - build_demo_story( - "I keep my Monday design review notes in Notion.", - "Where are my Monday review notes?", + DemoStory( + owner="you", + memory="I keep my Monday design review notes in Notion.", + query="Where are my Monday review notes?", + answer="In Notion.", + source_filename="episode-demo.md", + fact_filename="atomic_fact-demo.md", ) ) @@ -114,74 +87,65 @@ def print(self, *renderables: object, **_: object) -> None: assert "episode-demo.md" in printed_text -def test_live_demo_flow_calls_server_and_builds_story() -> None: - story = build_demo_story( - "I love climbing in Yosemite every spring.", - "Where do I like to climb?", +def test_launch_interactive_defaults_to_cloud_with_unique_identity(monkeypatch) -> None: + captured: dict[str, object] = {} + + monkeypatch.setattr( + demo_command, + "_load_run_demo_tui", + lambda: lambda **kwargs: captured.update(kwargs), ) - calls: list[tuple[str, str, dict[str, object] | None]] = [] - - def fake_request( - method: str, - path: str, - *, - base_url: str, - json_body: dict[str, object] | None = None, - timeout_seconds: float, - ) -> dict[str, object]: - calls.append((method, path, json_body)) - assert base_url == "http://server.test" - assert timeout_seconds == 3.0 - if path == "/health": - return {"status": "ok"} - if path == "/api/v2/memory/add": - return {"message_count": 1, "status": "accumulated"} - if path == "/api/v2/memory/flush": - return {"message_count": 1, "status": "extracted"} - if path == "/api/v2/memory/search": - return { - "data": { - "episodes": [ - { - "id": "alice_ep_20260623_0001", - "episode": "Alice loves climbing in Yosemite every spring.", - "summary": "Alice climbs in Yosemite every spring.", - "subject": "Yosemite climbing", - "score": 0.82, - "atomic_facts": [ - { - "id": "alice_af_20260623_0001", - "content": ( - "Alice loves climbing in Yosemite every spring." - ), - "score": 0.91, - } - ], - } - ] - } - } - raise AssertionError(f"unexpected request: {method} {path}") - - live_story = demo_command._run_live_demo_flow( - story, - base_url="http://server.test", - request_json=fake_request, - timeout_seconds=3.0, + monkeypatch.delenv(cloud.CLOUD_DEMO_SERVER_URL_ENV, raising=False) + monkeypatch.setenv(cloud.CLOUD_DEMO_KEY_ENV, "demo-key") + + demo_command._launch_interactive_demo( + live=False, server_url=cloud.LIVE_DEMO_SERVER_URL + ) + + assert captured["interactive"] is True + assert captured["base_url"] == cloud.CLOUD_API_BASE_URL + assert str(captured["session_id"]).startswith("everos-demo-") + assert str(captured["user_id"]).startswith("everos_demo_") + assert captured["api_key"] == "demo-key" # optional direct-test override + + +def test_launch_interactive_live_uses_own_cloud_key(monkeypatch) -> None: + captured: dict[str, object] = {} + + monkeypatch.setattr( + demo_command, + "_load_run_demo_tui", + lambda: lambda **kwargs: captured.update(kwargs), + ) + monkeypatch.delenv(cloud.CLOUD_DEMO_SERVER_URL_ENV, raising=False) + monkeypatch.setenv(cloud.CLOUD_USER_KEY_ENV, "user-key") + + demo_command._launch_interactive_demo( + live=True, server_url=cloud.LIVE_DEMO_SERVER_URL ) - assert [path for _, path, _ in calls] == [ - "/health", - "/api/v2/memory/add", - "/api/v2/memory/flush", - "/api/v2/memory/search", - ] - add_body = calls[1][2] - assert add_body is not None - assert add_body["session_id"] == "everos-demo-live" - assert live_story.answer == "Alice loves climbing in Yosemite every spring." - assert live_story.source_filename == "episode:alice_ep_20260623_0001" - assert live_story.fact_filename == "fact:alice_af_20260623_0001" + # --live bypasses the public relay and hits the platform with the user's key. + assert captured["base_url"] == cloud.CLOUD_PLATFORM_API_BASE_URL + assert captured["api_key"] == "user-key" + assert str(captured["session_id"]).startswith("everos-demo-") + + +def test_loading_demo_tui_disables_kitty_keys_for_ime_compatibility( + monkeypatch, +) -> None: + monkeypatch.delenv(demo_command.TEXTUAL_DISABLE_KITTY_KEY_ENV, raising=False) + + demo_command._load_run_demo_tui() + + assert os.environ[demo_command.TEXTUAL_DISABLE_KITTY_KEY_ENV] == "1" + + +def test_loading_demo_tui_preserves_explicit_kitty_key_override(monkeypatch) -> None: + monkeypatch.setenv(demo_command.TEXTUAL_DISABLE_KITTY_KEY_ENV, "0") + + demo_command._load_run_demo_tui() + + assert os.environ[demo_command.TEXTUAL_DISABLE_KITTY_KEY_ENV] == "0" def _strip_ansi(value: str) -> str: diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_app.py b/tests/unit/test_entrypoints/test_tui/test_demo_app.py index 6d94fbbc..77532a60 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -7,20 +7,40 @@ from everos.entrypoints.tui.demo.app import ( SPHERE_FRAME_HEIGHT, SPHERE_FRAME_WIDTH, + SPHERE_STAGE_TICKS, + SPHERE_SUPERNOVA_CYCLE_TICKS, TERMINAL_CELL_HEIGHT_RATIO, + TRACE_STAGES, DotSphereWidget, EverOSDemoApp, + QueryAnswerBar, + _capabilities_text, + _conversation_text, _field_header_text, _hero_text, - _payoff_text, + _idle_sphere_state, _recall_proof_text, _signal_rail_text, _source_tree_text, - _sphere_caption, + _sphere_state_phase, + _state_to_stage, ) -from everos.entrypoints.tui.demo.data import build_demo_story +from everos.entrypoints.tui.demo.data import DemoStory from everos.entrypoints.tui.demo.widgets.sphere import SPHERE_STATES +_YELLOW = "#F9B91C" + + +def _story(memory: str, query: str, answer: str) -> DemoStory: + return DemoStory( + owner="you", + memory=memory, + query=query, + answer=answer, + source_filename="episode-demo.md", + fact_filename="atomic_fact-demo.md", + ) + def test_demo_tui_uses_poster_derived_brand_palette() -> None: css = EverOSDemoApp.CSS @@ -41,8 +61,9 @@ def test_demo_tui_uses_elevated_instrument_layout() -> None: assert "#command-strip" in css assert "#memory-field" in css assert "#signal-rail" in css + assert "#capabilities" in css assert "#provenance-strip" in css - assert "#payoff" in css + assert "#conversation" in css assert "FooterKey" in css assert "background: #F9B91C" in css assert any("on #F9B91C" in span.style for span in _hero_text().spans) @@ -55,9 +76,9 @@ def test_demo_tui_uses_balanced_panel_proportions() -> None: command_strip = _css_block(css, "#command-strip") signal_rail = _css_block(css, "#signal-rail") - payoff = _css_block(css, "#payoff") + conversation = _css_block(css, "#conversation") - assert "height: 2;" in command_strip + assert "height: 1;" in command_strip assert "border-left: thick" not in command_strip assert "background: #31302B" not in command_strip assert len(_hero_text().plain.splitlines()) == 1 @@ -65,15 +86,12 @@ def test_demo_tui_uses_balanced_panel_proportions() -> None: assert "height: 1fr;" in DotSphereWidget.DEFAULT_CSS - assert "height: 100%;" in signal_rail + assert "height: 1fr;" in signal_rail assert "source route" in _signal_rail_text().plain - assert "recall proof" in _signal_rail_text().plain - assert "height: 2;" in payoff - assert "background: #24231E;" in payoff - assert "padding: 0 1;" in payoff - assert _payoff_text().plain.startswith("memory formed:") - assert "bold #F9B91C" in {span.style for span in _payoff_text().spans} + # The conversation log is wrapped in a full rounded border, like the + # other panels (not just a top rule). + assert "border: round #F9B91C;" in conversation def test_demo_tui_sphere_renders_round_in_terminal_cells() -> None: @@ -86,26 +104,360 @@ def test_demo_tui_sphere_renders_round_in_terminal_cells() -> None: assert SPHERE_FRAME_HEIGHT == 17 -def test_demo_tui_celebrates_after_source_reveal() -> None: - assert DotSphereWidget.STATES[-2:] == ("source", "celebrating") +def test_demo_tui_celebrates_directly_after_recall() -> None: + assert DotSphereWidget.STATES[-2:] == ("recalling", "celebrating") + assert "remembered" not in DotSphereWidget.STATES + assert "source" not in DotSphereWidget.STATES assert set(DotSphereWidget.STATES).issubset(SPHERE_STATES) -def test_demo_tui_renders_playable_story_copy() -> None: - story = build_demo_story( - "I keep my Monday design review notes in Notion.", - "Where are my Monday review notes?", +def test_signal_rail_lights_reflect_state() -> None: + idle = _signal_rail_text().plain + assert "memory core" in idle + assert "not ready" in idle # core idle => not ready + assert "source route" in idle + + active = _signal_rail_text( + { + "core": "ready", + "conversation": "captured", + "facts": "live", + "index": "synced", + "recall": "hit", + } + ).plain + for label in ("ready", "captured", "live", "synced", "hit"): + assert label in active + + +def test_signal_rail_light_colors_follow_white_yellow_black() -> None: + rail = _signal_rail_text( + { + "core": "error", + "conversation": "idle", + "facts": "live", + "index": "idle", + "recall": "idle", + } + ) + dot_styles = [ + span.style for span in rail.spans if "●" in rail.plain[span.start : span.end] + ] + assert f"bold {_YELLOW}" in dot_styles # an active light is yellow + assert "bold #1D1C18" in dot_styles # the errored light is black + + +def test_capabilities_box_uses_real_website_numbers() -> None: + text = _capabilities_text().plain + # Real highlights from evermind.ai: token efficiency + one SOTA benchmark. + assert "1/10 of full context" in text # real token-efficiency claim + assert "93.05%" in text # one headline benchmark (LoCoMo) + assert "83.00%" not in text # only one score now + assert "rerank" in text + # local-first is dropped here (already shown in the field header scope). + assert "local-first" not in text + + +def test_source_lock_uses_date_stamped_filenames() -> None: + text = _source_tree_text().plain + assert "episode-" in text and ".md" in text + assert "atomic_fact-" in text + + +def test_recall_lock_shows_real_score_and_demo_scope() -> None: + story = DemoStory( + owner="everos_demo_abc", + memory="m", + query="q", + answer="a", + source_filename="", + fact_filename="", + score=0.873, + ) + text = _recall_proof_text(story, user_label="YangtzeSeventh", saved_pct=62).plain + assert "0.873" in text + assert "user=YangtzeSeventh" in text # local user, not the session id or alice + assert "project=demo" in text + assert "~62% tokens (est)" in text + assert "similarity" not in text + # No saved figure until a round has run. + assert "saved —" in _recall_proof_text(story, user_label="x").plain + + +def test_conversation_log_accumulates_turns() -> None: + empty = _conversation_text([]).plain + assert "will appear here" in empty + + filled = _conversation_text( + [ + ("you", "I climb in Yosemite"), + ("ask", "where do I climb?"), + ("everos", "Yosemite"), + ] + ).plain + assert "you" in filled + assert "ask" in filled + assert "where do I climb?" in filled + assert "everos" in filled + assert "Yosemite" in filled + + +def test_field_header_shows_local_user_and_trace_stages() -> None: + header = _field_header_text(user_label="YangtzeSeventh", active_stage=1) + + assert "user=YangtzeSeventh" in header.plain + assert "scope=local-first" in header.plain + for stage in TRACE_STAGES: + assert stage in header.plain + + +def test_field_header_highlights_only_the_active_stage() -> None: + header = _field_header_text(user_label="you", active_stage=2) + + highlighted = { + header.plain[span.start : span.end] + for span in header.spans + if span.style == f"bold {_YELLOW}" + } + assert highlighted & set(TRACE_STAGES) == {"index"} + + +def test_state_to_stage_maps_sphere_states_to_trace_words() -> None: + assert _state_to_stage("ingesting") == 0 + assert _state_to_stage("extracting") == 1 + assert _state_to_stage("indexing") == 2 + assert _state_to_stage("recalling") == 3 + assert _state_to_stage("booting") == -1 + + +def test_supernova_phase_repeats_after_one_complete_cycle() -> None: + assert _sphere_state_phase("celebrating", 0) == 0.0 + assert ( + _sphere_state_phase( + "celebrating", + SPHERE_SUPERNOVA_CYCLE_TICKS - 1, + ) + == 1.0 + ) + assert ( + _sphere_state_phase( + "celebrating", + SPHERE_SUPERNOVA_CYCLE_TICKS, + ) + == 0.0 + ) + + +def test_idle_loop_plays_the_complete_supernova_before_restarting() -> None: + regular_ticks = (len(DotSphereWidget.STATES) - 1) * SPHERE_STAGE_TICKS + + assert _idle_sphere_state(0) == "booting" + assert _idle_sphere_state(SPHERE_STAGE_TICKS) == "ingesting" + assert _idle_sphere_state(regular_ticks) == "celebrating" + assert ( + _idle_sphere_state(regular_ticks + SPHERE_SUPERNOVA_CYCLE_TICKS - 1) + == "celebrating" + ) + assert ( + _idle_sphere_state(regular_ticks + SPHERE_SUPERNOVA_CYCLE_TICKS) == "ingesting" + ) + + +def test_query_answer_bar_keeps_both_labels() -> None: + rendered = QueryAnswerBar().render().plain + + assert "Query" in rendered + assert "Answer" in rendered + + +async def test_sphere_tracks_furthest_lit_rail_stage() -> None: + app = EverOSDemoApp( + interactive=True, base_url="http://server.test", session_id="s", user_id="u" + ) + async with app.run_test() as pilot: + await pilot.pause() + sphere = app.query_one(DotSphereWidget) + # Nothing stored yet -> the sphere free-runs its idle loop. + assert sphere._driven_state is None + + # Storing walks the sphere up the pipeline and then HOLDS at indexing — + # recall is not reached just by storing. + app._set_light("conversation", "captured") + assert sphere._driven_state == "ingesting" + app._set_light("facts", "live") + assert sphere._driven_state == "extracting" + app._set_light("index", "synced") + assert sphere._driven_state == "indexing" + + # Clearing recall before a question keeps it at the furthest lit stage. + app._reset_recall_light() + assert sphere._driven_state == "indexing" + + # Only an actual recall advances the sphere to "recalling". + app._set_light("recall", "hit") + assert sphere._driven_state == "recalling" + + # Resetting the pipeline (next round's store) returns to the idle loop. + app._reset_round_lights() + assert sphere._driven_state is None + + +async def test_successful_recall_goes_directly_to_celebration() -> None: + app = EverOSDemoApp(interactive=True) + async with app.run_test(): + sphere = app.query_one(DotSphereWidget) + app._set_light("recall", "hit") + assert sphere._driven_state == "recalling" + + app._celebrate_recall() + assert sphere._driven_state == "celebrating" + source_phase = sphere._celebration_source_phase + sphere._advance() + assert sphere._celebration_source_phase == source_phase + + sphere._state_tick = SPHERE_SUPERNOVA_CYCLE_TICKS + sphere._advance() + assert sphere._driven_state is None + assert sphere._rendered_state == "ingesting" + + +def test_ctrl_c_is_a_priority_quit_binding() -> None: + quit_keys = { + binding.key + for binding in EverOSDemoApp.BINDINGS + if getattr(binding, "action", None) == "quit" + and getattr(binding, "priority", False) + } + assert "ctrl+c" in quit_keys + assert "ctrl+q" in quit_keys + + +def test_commands_and_unknown_command_text() -> None: + from everos.entrypoints.tui.demo.app import _commands_text, _unknown_command_text + + commands_plain = _commands_text().plain + for command in ("/live", "/replay", "/clear", "/quit"): + assert command in commands_plain + assert "/help" not in commands_plain + unknown = _unknown_command_text("/bogus").plain + assert "unknown command /bogus" in unknown + assert "/help" not in unknown + + +def test_live_guidance_points_to_own_key_flow() -> None: + from everos.entrypoints.tui.demo.app import _live_guidance_text + + text = _live_guidance_text().plain + assert "everos init" in text + assert "everos demo --live" in text + + +async def test_slash_live_does_not_consume_a_turn() -> None: + from textual.widgets import Input + + app = EverOSDemoApp( + interactive=True, base_url="http://server.test", session_id="s", user_id="u" + ) + async with app.run_test() as pilot: + console_input = app.query_one("#console-input", Input) + console_input.value = "/live" + await pilot.press("enter") + await pilot.pause() + + assert app._conversation_phase == "memory" + assert app._round == 0 + + +async def test_typing_registers_keystrokes_in_the_input() -> None: + from textual.widgets import Input + + app = EverOSDemoApp( + interactive=True, base_url="http://server.test", session_id="s", user_id="u" + ) + async with app.run_test() as pilot: + assert app.focused is not None and app.focused.id == "console-input" + await pilot.press("h", "i") + await pilot.pause() + assert app.query_one("#console-input", Input).value == "hi" + + +async def test_conversation_panel_scrolls_when_log_overflows() -> None: + # A bare Static clips but never scrolls (max_scroll_y == 0), leaving older + # turns unreachable. The panel must be a real scroll container so the user + # can read back through the history once it grows past the panel height. + from textual.containers import VerticalScroll + + app = EverOSDemoApp( + interactive=True, base_url="http://server.test", session_id="s", user_id="u" + ) + async with app.run_test(size=(120, 40)) as pilot: + for i in range(8): + app._record_line("you", f"memory {i}") + app._record_line("everos", f"a long recalled answer for round {i}") + await pilot.pause() + + panel = app.query_one("#conversation", VerticalScroll) + assert panel.max_scroll_y > 0 # content overflows and is scrollable + assert panel.scroll_y == panel.max_scroll_y # newest line auto-pinned + panel.scroll_home(animate=False) + await pilot.pause() + assert panel.scroll_y == 0 # user can scroll back to the start + + +async def test_slash_clear_wipes_the_conversation_log() -> None: + from textual.widgets import Input + + app = EverOSDemoApp( + interactive=True, base_url="http://server.test", session_id="s", user_id="u" + ) + async with app.run_test() as pilot: + app._record_line("you", "I climb in Yosemite") + assert app._log + + console_input = app.query_one("#console-input", Input) + console_input.value = "/clear" + await pilot.press("enter") + await pilot.pause() + + assert app._log == [] + + +async def test_empty_submission_is_ignored_no_canned_default() -> None: + # Pressing enter on an empty box must NOT substitute scripted Yosemite + # content; the conversation stays empty and the phase does not advance. + from textual.widgets import Input + + app = EverOSDemoApp( + interactive=True, base_url="http://server.test", session_id="s", user_id="u" + ) + async with app.run_test() as pilot: + console_input = app.query_one("#console-input", Input) + console_input.value = "" + await pilot.press("enter") + await pilot.pause() + + assert app._log == [] + assert app._conversation_phase == "memory" # still waiting for a memory + + +async def test_typing_quit_exits_the_app(monkeypatch) -> None: + from textual.widgets import Input + + app = EverOSDemoApp( + interactive=True, + base_url="http://server.test", + session_id="s", + user_id="u", ) + async with app.run_test() as pilot: + exited: list[bool] = [] + monkeypatch.setattr(app, "exit", lambda *a, **k: exited.append(True)) + console_input = app.query_one("#console-input", Input) + console_input.value = "quit" + await pilot.press("enter") + await pilot.pause() - assert "user=you" in _field_header_text(story).plain - assert "Where are my Monday review notes?" in _sphere_caption(story).plain - assert story.answer in _sphere_caption(story).plain - assert "server wake" not in _signal_rail_text(story).plain - assert "memory core" in _signal_rail_text(story).plain - assert story.source_filename in _source_tree_text(story).plain - assert story.fact_filename in _source_tree_text(story).plain - assert story.answer in _recall_proof_text(story).plain - assert story.answer in _payoff_text(story).plain + assert exited == [True] def test_demo_tui_signal_rail_keeps_source_status_columns_separate() -> None: @@ -116,6 +468,115 @@ def test_demo_tui_signal_rail_keeps_source_status_columns_separate() -> None: assert "..." in rail +async def test_demo_tui_store_step_has_no_answer_then_ask_answers(monkeypatch) -> None: + from textual.widgets import Input + + from everos.entrypoints.tui.demo import cloud + + searched: list[tuple[str, str, tuple[str, ...]]] = [] + + monkeypatch.setattr(cloud, "add_memory", lambda *_, **__: None) + monkeypatch.setattr(cloud, "flush_memory", lambda *_, **__: None) + + def fake_search( + memory: str, + query: str, + *, + stored_memories: list[str], + base_url: str, + user_id: str, + **_: object, + ) -> DemoStory: + assert base_url == "http://server.test" + searched.append((memory, query, tuple(stored_memories))) + return _story(memory, query, f"recalled<{query}>") + + monkeypatch.setattr(cloud, "search_recall", fake_search) + + app = EverOSDemoApp( + interactive=True, + max_rounds=1, # one full store->ask round + base_url="http://server.test", + session_id="everos-demo-x", + user_id="everos_demo_x", + ) + async with app.run_test() as pilot: + console_input = app.query_one("#console-input", Input) + + # Step 1: storing a memory must NOT trigger a recall or an answer. + console_input.value = "我喜欢吃杨梅" + await pilot.press("enter") + await app.workers.wait_for_complete() + await pilot.pause() + assert app._conversation_phase == "query" # advanced to the ask step + assert searched == [] # no recall happened on store + assert app._log == [("you", "我喜欢吃杨梅")] # only the memory, no answer + assert app._lights["core"] == "ready" # the store pipeline lit up + + # Step 2: the question is what produces the everos answer. + console_input.value = "我喜欢吃什么" + await pilot.press("enter") + await app.workers.wait_for_complete() + await pilot.pause() + assert searched == [("我喜欢吃杨梅", "我喜欢吃什么", ("我喜欢吃杨梅",))] + assert app._story.query == "我喜欢吃什么" + assert app._story.answer == "recalled<我喜欢吃什么>" + assert "Yosemite" not in app._story.answer # BUG 305 stays fixed + assert app._lights["recall"] == "hit" + assert app._saved_pct is not None + # you (memory) -> ask (question) -> everos (answer), in order. + assert app._log == [ + ("you", "我喜欢吃杨梅"), + ("ask", "我喜欢吃什么"), + ("everos", "recalled<我喜欢吃什么>"), + ] + # One round done = cap reached; input stays usable for /live, /quit. + assert app._conversation_phase == "done" + assert console_input.disabled is False + + +async def test_demo_tui_interactive_shows_quota_guidance(monkeypatch) -> None: + from textual.widgets import Input + + from everos.entrypoints.tui.demo import cloud + from everos.entrypoints.tui.demo.app import _quota_guidance_text + + def quota(*_: object, **__: object) -> None: + raise cloud.CloudQuotaError("http://server.test") + + monkeypatch.setattr(cloud, "add_memory", quota) + + app = EverOSDemoApp( + interactive=True, + base_url="http://server.test", + session_id="s", + user_id="u", + ) + async with app.run_test() as pilot: + console_input = app.query_one("#console-input", Input) + console_input.value = "a memory" + await pilot.press("enter") + console_input.value = "a question" + await pilot.press("enter") + await app.workers.wait_for_complete() + await pilot.pause() + + assert app._conversation_phase == "done" + assert console_input.disabled is False + + assert "everos init" in _quota_guidance_text().plain + + +async def test_demo_tui_non_interactive_has_no_input_box() -> None: + from textual.css.query import NoMatches + from textual.widgets import Input + + app = EverOSDemoApp() + async with app.run_test(): + with pytest.raises(NoMatches): + app.query_one("#console-input", Input) + + def _css_block(css: str, selector: str) -> str: start = css.index(f"{selector} {{") end = css.index("}", start) diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py b/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py new file mode 100644 index 00000000..a9c24254 --- /dev/null +++ b/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py @@ -0,0 +1,577 @@ +"""EverOS Cloud demo client contracts.""" + +from __future__ import annotations + +import urllib.error + +import pytest + +from everos.entrypoints.tui.demo import cloud + + +def test_resolve_cloud_base_url_prefers_explicit_then_env(monkeypatch) -> None: + monkeypatch.delenv(cloud.CLOUD_DEMO_SERVER_URL_ENV, raising=False) + assert ( + cloud.resolve_cloud_base_url(cloud.LIVE_DEMO_SERVER_URL) + == cloud.CLOUD_API_BASE_URL + ) + + monkeypatch.setenv(cloud.CLOUD_DEMO_SERVER_URL_ENV, "https://env.test") + assert ( + cloud.resolve_cloud_base_url(cloud.LIVE_DEMO_SERVER_URL) == "https://env.test" + ) + + assert ( + cloud.resolve_cloud_base_url("https://explicit.test") == "https://explicit.test" + ) + + assert cloud.resolve_live_base_url(cloud.LIVE_DEMO_SERVER_URL) == ( + cloud.CLOUD_PLATFORM_API_BASE_URL + ) + assert cloud.resolve_live_base_url("https://live.test") == "https://live.test" + + +def test_resolve_keys_read_their_env_vars(monkeypatch) -> None: + monkeypatch.setenv(cloud.CLOUD_DEMO_KEY_ENV, "demo-key") + monkeypatch.setenv(cloud.CLOUD_USER_KEY_ENV, "user-key") + assert cloud.resolve_demo_key() == "demo-key" + assert cloud.resolve_user_key() == "user-key" + + +def test_public_demo_ships_no_api_key(monkeypatch) -> None: + monkeypatch.delenv(cloud.CLOUD_DEMO_KEY_ENV, raising=False) + assert cloud.CLOUD_API_BASE_URL == "https://everosdemo.com" + assert cloud.resolve_demo_key() == "" + + +def test_new_demo_identity_is_unique_and_paired() -> None: + session_a, user_a = cloud.new_demo_identity() + session_b, user_b = cloud.new_demo_identity() + + assert session_a != session_b + assert user_a != user_b + assert session_a.startswith("everos-demo-") + assert user_a.startswith("everos_demo_") + + +def test_add_memory_posts_v2_message_synchronously() -> None: + calls: list[tuple[str, str, dict[str, object] | None, str | None]] = [] + + def fake_request( + method: str, + path: str, + *, + base_url: str, + api_key: str | None = None, + json_body: dict[str, object] | None = None, + timeout_seconds: float, + ) -> dict[str, object]: + calls.append((method, path, json_body, api_key)) + return {"request_id": "req-123", "data": {"message_count": 1}} + + result = cloud.add_memory( + "我喜欢吃杨梅", + base_url="https://api.test", + session_id="everos-demo-abc", + user_id="everos_demo_abc", + api_key="k-1", + request_json=fake_request, + ) + + assert result is None + method, path, body, api_key = calls[0] + assert (method, path) == ("POST", "/api/v2/memory/add") + assert api_key == "k-1" + assert body["async_mode"] is False + assert body["messages"][0]["role"] == "user" + assert body["messages"][0]["sender_id"] == "everos_demo_abc" + assert body["messages"][0]["content"] == "我喜欢吃杨梅" + + +def test_flush_memory_forces_extraction() -> None: + bodies: list[dict[str, object] | None] = [] + + def fake_request( + method: str, + path: str, + *, + base_url: str, + api_key: str | None = None, + json_body: dict[str, object] | None = None, + timeout_seconds: float, + ) -> dict[str, object]: + bodies.append(json_body) + assert path == "/api/v2/memory/flush" + return {"request_id": "req-456", "data": {}} + + cloud.flush_memory( + base_url="https://api.test", + session_id="s", + api_key="k", + request_json=fake_request, + ) + + assert bodies[0] == {"session_id": "s"} + + +def test_search_recall_parses_atomic_fact_and_score() -> None: + def fake_request( + method: str, + path: str, + *, + base_url: str, + api_key: str | None = None, + json_body: dict[str, object] | None = None, + timeout_seconds: float, + ) -> dict[str, object]: + assert path == "/api/v2/memory/search" + assert json_body["user_id"] == "everos_demo_abc" + assert json_body["filters"] == {"session_id": "everos-demo-abc"} + assert json_body["include_profile"] is True + return { + "data": { + "episodes": [ + { + "id": "ep1", + "summary": "long summary", + "episode": "long episode text", + "score": None, + "atomic_facts": [ + { + "id": "af1", + "content": "You like Yangmei.", + "score": 0.57, + } + ], + } + ] + } + } + + story = cloud.search_recall( + "我喜欢吃杨梅", + "我喜欢吃什么", + base_url="https://api.test", + session_id="everos-demo-abc", + user_id="everos_demo_abc", + api_key="k", + request_json=fake_request, + settle_seconds=0.0, + ) + + assert story is not None + assert story.owner == "everos_demo_abc" + assert story.answer == "You like Yangmei." # from atomic_fact, not summary + assert story.score == 0.57 # fact-level score (episode score is null) + assert story.source_filename == "episode:ep1" + + +def test_search_recall_picks_highest_scored_episode_not_first() -> None: + # The platform does not pre-sort episodes by score, so taking episodes[0] + # blindly can return an unrelated memory. The best-scored one must win. + def fake_request(*_: object, **kwargs: object) -> dict[str, object]: + return { + "data": { + "episodes": [ + {"id": "ep_durian", "summary": "dislikes durian", "score": None}, + { + "id": "ep_climb", + "summary": "climbs in Yosemite", + "score": 0.61, + }, + ] + } + } + + story = cloud.search_recall( + "I climb in Yosemite", + "Where do I climb?", + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", + request_json=fake_request, + settle_seconds=0.0, + ) + + assert story is not None + assert story.source_filename == "episode:ep_climb" # not ep_durian at [0] + assert story.score == 0.61 + + +def test_search_recall_prefers_higher_scored_profile_over_episode() -> None: + # Profiles are concise, answer-shaped facts that score well on natural + # questions; when a profile out-scores the episodes, it must win. + def fake_request(*_: object, **__: object) -> dict[str, object]: + return { + "data": { + "profiles": [ + { + "id": "pr1", + "score": 0.72, + "profile_data": { + "embed_text": "hobby: Enjoys climbing in Yosemite" + }, + } + ], + "episodes": [ + {"id": "ep_durian", "summary": "dislikes durian", "score": 0.40}, + ], + } + } + + story = cloud.search_recall( + "I climb in Yosemite", + "Where do I climb?", + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", + request_json=fake_request, + settle_seconds=0.0, + ) + + assert story is not None + assert story.answer == "Enjoys climbing in Yosemite" # profile, "hobby:" dropped + assert story.score == 0.72 + assert story.source_filename == "profile:pr1" + + +def test_search_recall_waits_for_current_memory_instead_of_stale_profile() -> None: + calls = 0 + + def fake_request(*_: object, **__: object) -> dict[str, object]: + nonlocal calls + calls += 1 + stale_profile = { + "id": "old-food", + "score": 0.68, + "profile_data": {"embed_text": "food preference: 我爱吃草莓"}, + } + if calls == 1: + return {"data": {"profiles": [stale_profile], "episodes": []}} + return { + "data": { + "profiles": [stale_profile], + "episodes": [ + { + "id": "current-durian", + "score": 0.56, + "atomic_facts": [ + { + "id": "fact-durian", + "atomic_fact": "我不爱吃榴莲", + } + ], + } + ], + } + } + + story = cloud.search_recall( + "我不爱吃榴莲", + "我不爱吃什么", + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", + request_json=fake_request, + search_attempts=2, + search_interval_seconds=0.0, + settle_seconds=0.0, + ) + + assert calls == 2 + assert story is not None + assert story.answer == "我不爱吃榴莲" + assert story.source_filename == "episode:current-durian" + + +def test_current_memory_match_penalizes_opposite_preference() -> None: + memory = "我不爱吃榴莲" + + current = cloud._memory_match_score(memory, "用户不喜欢吃榴莲") + stale = cloud._memory_match_score(memory, "我爱吃草莓") + + assert current >= cloud.CURRENT_MEMORY_MATCH_THRESHOLD + assert current > stale + + +def test_search_recall_below_relevance_floor_is_a_miss() -> None: + # An off-topic query still gets a best-but-weak candidate from the platform; + # below the relevance floor we must report a miss, not an absurd answer. + def fake_request(*_: object, **__: object) -> dict[str, object]: + return { + "data": { + "profiles": [ + { + "id": "p", + "score": 0.40, + "profile_data": { + "embed_text": "food preference: 用户喜欢吃杨梅" + }, + } + ], + # v2 may also expose the just-written message while extraction + # is settling; an unrelated question must not surface it. + "unprocessed_messages": [{"id": "buffered", "content": "我喜欢吃杨梅"}], + } + } + + story = cloud.search_recall( + "我喜欢吃杨梅", + "我是程序员吗", + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", + request_json=fake_request, + settle_seconds=0.0, + search_interval_seconds=0.0, + ) + + assert story is None # 0.40 < MIN_RELEVANCE_SCORE + + +def test_search_recall_accepts_low_score_when_it_is_clearly_this_round() -> None: + calls = 0 + + def fake_request(*_: object, **__: object) -> dict[str, object]: + nonlocal calls + calls += 1 + return { + "data": { + "episodes": [ + { + "id": "current-strawberry", + "score": 0.49, + "atomic_facts": [ + { + "id": "fact-strawberry", + "content": "我喜欢吃草莓", + "score": 0.49, + } + ], + } + ] + } + } + + story = cloud.search_recall( + "我喜欢吃草莓", + "我喜欢吃什么", + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", + request_json=fake_request, + settle_seconds=0.0, + search_interval_seconds=0.0, + ) + + assert calls == 1 + assert story is not None + assert story.answer == "我喜欢吃草莓" + assert story.score == 0.49 + + +def test_search_recall_retries_translated_low_score_then_uses_current_memory() -> None: + """A translated v2 candidate must not stop polling and become a false miss.""" + + calls = 0 + + def fake_request(*_: object, **__: object) -> dict[str, object]: + nonlocal calls + calls += 1 + return { + "data": { + "episodes": [ + { + "id": "current-strawberry", + "score": 0.49, + "atomic_facts": [ + { + "id": "fact-strawberry", + "content": "The user likes strawberries.", + "score": 0.49, + } + ], + } + ] + } + } + + story = cloud.search_recall( + "我喜欢吃草莓", + "我喜欢吃什么", + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", + request_json=fake_request, + search_attempts=2, + settle_seconds=0.0, + search_interval_seconds=0.0, + ) + + assert calls == 2 + assert story is not None + assert story.answer == "我喜欢吃草莓" + assert story.source_filename == "buffer:current" + assert story.score == 0.0 + + +def test_search_recall_can_fall_back_to_an_earlier_demo_round() -> None: + """A question may target any memory stored earlier in the same demo run.""" + + def fake_request(*_: object, **__: object) -> dict[str, object]: + return { + "data": { + "episodes": [ + { + "id": "strawberry", + "score": 0.49, + "atomic_facts": [ + { + "id": "fact-strawberry", + "content": "The user likes strawberries.", + "score": 0.49, + } + ], + } + ] + } + } + + story = cloud.search_recall( + "我不喜欢吃榴莲", + "我喜欢吃什么", + stored_memories=["我喜欢吃草莓", "我不喜欢吃榴莲"], + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", + request_json=fake_request, + search_attempts=2, + settle_seconds=0.0, + search_interval_seconds=0.0, + ) + + assert story is not None + assert story.answer == "我喜欢吃草莓" + assert story.source_filename == "buffer:history" + assert story.score == 0.0 + + +def test_search_recall_uses_v2_unprocessed_message_after_polling() -> None: + calls = 0 + + def fake_request(*_: object, **__: object) -> dict[str, object]: + nonlocal calls + calls += 1 + return { + "data": { + "episodes": [], + "profiles": [], + "unprocessed_messages": [ + { + "id": "message-strawberry", + "session_id": "s", + "sender_id": "u", + "content": "我喜欢吃草莓", + } + ], + } + } + + story = cloud.search_recall( + "我喜欢吃草莓", + "我喜欢吃什么", + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", + request_json=fake_request, + search_attempts=2, + settle_seconds=0.0, + search_interval_seconds=0.0, + ) + + assert calls == 2 + assert story is not None + assert story.answer == "我喜欢吃草莓" + assert story.source_filename == "buffer:message-stra" + assert story.score == 0.0 + + +def test_clean_profile_text_strips_label_across_colon_widths() -> None: + assert cloud._clean_profile_text("hobby: Enjoys climbing") == "Enjoys climbing" + zh = cloud._clean_profile_text("food preference: 用户喜欢吃杨梅") + assert zh == "用户喜欢吃杨梅" + assert cloud._clean_profile_text("爱好:喜欢爬山") == "喜欢爬山" + # No short label -> keep the whole text. + assert cloud._clean_profile_text("just a plain sentence") == "just a plain sentence" + + +def test_search_recall_returns_none_on_miss() -> None: + def fake_request(*_: object, **__: object) -> dict[str, object]: + return {"data": {"episodes": []}} + + story = cloud.search_recall( + "m", + "q", + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", + request_json=fake_request, + search_attempts=2, + search_interval_seconds=0.0, + settle_seconds=0.0, + ) + + assert story is None + + +def test_request_json_sets_bearer_header(monkeypatch) -> None: + captured: dict[str, str | None] = {} + + class FakeResp: + def __enter__(self) -> FakeResp: + return self + + def __exit__(self, *_: object) -> bool: + return False + + def read(self) -> bytes: + return b'{"ok": true}' + + def fake_urlopen(req: object, timeout: float) -> FakeResp: + captured["auth"] = req.headers.get("Authorization") # type: ignore[attr-defined] + return FakeResp() + + monkeypatch.setattr(cloud.urllib.request, "urlopen", fake_urlopen) + cloud._request_json( + "GET", "/x", base_url="https://api.test", api_key="abc", timeout_seconds=1.0 + ) + assert captured["auth"] == "Bearer abc" + + +def test_request_json_maps_401_and_429(monkeypatch) -> None: + def boom(code: int): + def _raise(*_: object, **__: object) -> object: + raise urllib.error.HTTPError("https://api.test", code, "x", {}, None) + + return _raise + + monkeypatch.setattr(cloud.urllib.request, "urlopen", boom(401)) + with pytest.raises(cloud.CloudAuthError): + cloud._request_json( + "GET", "/x", base_url="https://api.test", timeout_seconds=1.0 + ) + + monkeypatch.setattr(cloud.urllib.request, "urlopen", boom(429)) + with pytest.raises(cloud.CloudQuotaError): + cloud._request_json( + "GET", "/x", base_url="https://api.test", timeout_seconds=1.0 + ) diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_data.py b/tests/unit/test_entrypoints/test_tui/test_demo_data.py index 04311f43..4420fbaa 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_data.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_data.py @@ -1,29 +1,23 @@ -"""EverOS playable demo story contracts.""" +"""EverOS demo story data contracts.""" from __future__ import annotations from everos.entrypoints.tui.demo.data import ( DEFAULT_MEMORY_SEED, DEFAULT_QUERY, - build_demo_story, + DemoStory, + default_demo_story, ) -def test_demo_story_preserves_prompted_memory_and_query() -> None: - story = build_demo_story( - "I keep my Monday design review notes in Notion.", - "Where are my Monday review notes?", - ) - - assert story.owner == "you" - assert story.memory == "I keep my Monday design review notes in Notion." - assert story.query == "Where are my Monday review notes?" - assert story.answer == "I keep my Monday design review notes in Notion." - assert story.source_filename == "episode-demo.md" - assert story.fact_filename == "atomic_fact-demo.md" +def test_default_demo_story_is_the_static_showcase() -> None: + story = default_demo_story() + assert isinstance(story, DemoStory) + assert story.memory == DEFAULT_MEMORY_SEED + assert story.answer == "Yosemite every spring" + assert story.source_filename == "episode-2026-06-20.md" -def test_demo_story_keeps_default_yosemite_success_moment() -> None: - story = build_demo_story(DEFAULT_MEMORY_SEED, DEFAULT_QUERY) - assert story.answer == "Yosemite every spring" +def test_default_query_constant_is_available() -> None: + assert DEFAULT_QUERY diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py b/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py index d4221e14..b258f91a 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py @@ -103,7 +103,7 @@ def fast_mount(self: DotSphereWidget) -> None: await _export_frame(path, FramePlan(state="booting", phase=0.0)) svg = html.unescape(path.read_text()).replace("\xa0", " ") - assert "forming local memory field" in svg + assert "working..." in svg assert "ingesting conversation dots" not in svg diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_sphere.py b/tests/unit/test_entrypoints/test_tui/test_demo_sphere.py index 61fa1af7..7b8a9418 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_sphere.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_sphere.py @@ -2,54 +2,366 @@ from __future__ import annotations +import math + from everos.entrypoints.tui.demo.widgets.sphere import ( + EXTRACT_BRANCH_COUNT, + SHARED_EDGE_INNER_RADIUS, SPHERE_STATES, DotSphereFrame, + _cell_projection_radius, + _sphere_geometry, + blend_dot_sphere_frames, build_dot_sphere, ) -def test_dot_sphere_forms_round_bounded_cloud() -> None: - frame = build_dot_sphere(width=41, height=19, phase=0.0, state_key="extracting") - - assert frame.width == 41 - assert frame.height == 19 - assert frame.caption == "extracting episode -> atomic facts" - assert len(frame.cells) >= 90 +def test_pipeline_states_form_complete_particle_spheres() -> None: + for state_key in ("ingesting", "extracting", "indexing", "recalling"): + frame = build_dot_sphere( + width=41, + height=19, + phase=0.0, + state_key=state_key, + ) - center_x = (frame.width - 1) / 2 - center_y = (frame.height - 1) / 2 - radius_x = center_x - radius_y = center_y - for cell in frame.cells: - normalized = ((cell.x - center_x) / radius_x) ** 2 + ( - (cell.y - center_y) / radius_y - ) ** 2 - assert normalized <= 1.08 - - row_counts: dict[int, int] = {} - for cell in frame.cells: - row_counts[cell.y] = row_counts.get(cell.y, 0) + 1 - assert row_counts[frame.height // 2] > row_counts[min(row_counts)] - assert row_counts[frame.height // 2] > row_counts[max(row_counts)] - - -def test_dot_sphere_keeps_terminal_poles_visually_round() -> None: - frame = build_dot_sphere(width=37, height=17, phase=0.0, state_key="booting") + assert frame.width == 41 + assert frame.height == 19 + assert len(frame.cells) >= 300 + + center_x = (frame.width - 1) / 2 + center_y = (frame.height - 1) / 2 + radius_x = center_x + radius_y = center_y + for cell in frame.cells: + normalized = ((cell.x - center_x) / radius_x) ** 2 + ( + (cell.y - center_y) / radius_y + ) ** 2 + assert normalized <= 1.08 + + center_cells = [ + cell + for cell in frame.cells + if abs(cell.x - center_x) < frame.width * 0.08 + and abs(cell.y - center_y) < frame.height * 0.12 + ] + assert len(center_cells) >= 20 + + +def test_dot_sphere_keeps_complete_sphere_inside_terminal_frame() -> None: + frame = build_dot_sphere(width=37, height=17, phase=0.0, state_key="indexing") row_spans = _row_spans(frame) - assert row_spans[0] <= 8 - assert row_spans[1] <= 20 - assert row_spans[2] <= 26 - assert row_spans[frame.height // 2] >= 31 - assert row_spans[frame.height - 2] <= 20 - assert row_spans[frame.height - 1] <= 8 + occupied_rows = [y for y, span in row_spans.items() if span] + assert min(occupied_rows) >= 1 + assert max(occupied_rows) <= frame.height - 2 + assert row_spans[frame.height // 2] >= 28 + + +def test_all_pipeline_states_keep_a_round_outer_shell_through_cycle() -> None: + for state_key in ("ingesting", "extracting", "indexing", "recalling"): + occupied_widths = [] + occupied_heights = [] + for phase in (0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875): + frame = build_dot_sphere( + width=37, + height=17, + phase=phase, + state_key=state_key, + ) + xs = [cell.x for cell in frame.cells] + ys = [cell.y for cell in frame.cells] + occupied_width = max(xs) - min(xs) + 1 + occupied_height = max(ys) - min(ys) + 1 + physical_height = occupied_height * 2 + occupied_widths.append(occupied_width) + occupied_heights.append(occupied_height) + + assert 0.93 <= occupied_width / physical_height <= 1.08 + + assert max(occupied_widths) - min(occupied_widths) <= 2 + assert max(occupied_heights) - min(occupied_heights) <= 1 + + +def test_processing_states_match_the_unchanged_particle_density() -> None: + reference = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key="indexing", + ) + reference_density = sum( + _braille_subdot_count(cell.glyph) for cell in reference.cells + ) + + for state_key in ("booting", "ingesting", "extracting"): + frame = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key=state_key, + ) + density = sum(_braille_subdot_count(cell.glyph) for cell in frame.cells) + + assert reference_density * 0.9 <= density <= reference_density * 1.15 + + +def test_processing_particle_density_adapts_to_terminal_size() -> None: + for state_key in ("ingesting", "extracting", "indexing", "recalling"): + dot_counts = [] + for width, height in ((25, 11), (41, 19), (61, 27)): + frame = build_dot_sphere( + width=width, + height=height, + phase=0.25, + state_key=state_key, + ) + dot_counts.append( + sum(_braille_subdot_count(cell.glyph) for cell in frame.cells) + ) + + assert dot_counts == sorted(dot_counts) + assert dot_counts[-1] > dot_counts[0] * 3 + + +def test_processing_states_share_the_same_adaptive_outer_size() -> None: + for width, height in ((25, 11), (37, 17), (41, 19), (61, 27)): + reference = build_dot_sphere( + width=width, + height=height, + phase=0.25, + state_key="ingesting", + ) + for state_key in ("extracting", "indexing", "recalling"): + frame = build_dot_sphere( + width=width, + height=height, + phase=0.25, + state_key=state_key, + ) + assert _frame_extents(reference) == _frame_extents(frame) + + +def test_pipeline_states_keep_the_same_outer_position_through_cycle() -> None: + for phase in (0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875): + reference = build_dot_sphere( + width=41, + height=19, + phase=phase, + state_key="ingesting", + ) + for state_key in ("extracting", "indexing", "recalling"): + frame = build_dot_sphere( + width=41, + height=19, + phase=phase, + state_key=state_key, + ) + assert _frame_extents(reference) == _frame_extents(frame) + + +def test_pipeline_states_share_the_exact_same_particle_edge() -> None: + _, _, center_x, center_y, radius_x, radius_y = _sphere_geometry(41, 19) + + for phase in (0.0, 0.25, 0.5, 0.75): + edges = [] + for state_key in ("ingesting", "extracting", "indexing", "recalling"): + frame = build_dot_sphere( + width=41, + height=19, + phase=phase, + state_key=state_key, + ) + edges.append( + { + (cell.x, cell.y, cell.glyph, cell.style) + for cell in frame.cells + if _cell_projection_radius( + (cell.x, cell.y), + center_x=center_x, + center_y=center_y, + radius_x=radius_x, + radius_y=radius_y, + ) + >= SHARED_EDGE_INNER_RADIUS + } + ) + + assert all(edge == edges[0] for edge in edges[1:]) + + +def test_pipeline_states_share_the_exact_same_particle_field() -> None: + for phase in (0.0, 0.25, 0.5, 0.75): + reference = build_dot_sphere( + width=41, + height=19, + phase=phase, + state_key="ingesting", + ) + reference_geometry = [ + (cell.x, cell.y, cell.glyph, cell.z) for cell in reference.cells + ] + + for state_key in ("extracting", "indexing", "recalling"): + frame = build_dot_sphere( + width=41, + height=19, + phase=phase, + state_key=state_key, + ) + assert [ + (cell.x, cell.y, cell.glyph, cell.z) for cell in frame.cells + ] == reference_geometry + + +def test_pipeline_state_color_transition_preserves_particle_geometry() -> None: + ingesting = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key="ingesting", + ) + extracting = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key="extracting", + ) + blended = blend_dot_sphere_frames(ingesting, extracting, 0.5) + + assert [(cell.x, cell.y, cell.glyph) for cell in blended.cells] == [ + (cell.x, cell.y, cell.glyph) for cell in ingesting.cells + ] + assert {cell.style for cell in blended.cells} != { + cell.style for cell in ingesting.cells + } + assert {cell.style for cell in blended.cells} != { + cell.style for cell in extracting.cells + } + + +def test_shared_particle_edge_moves_without_density_or_position_jumps() -> None: + _, _, center_x, center_y, radius_x, radius_y = _sphere_geometry(41, 19) + + edge_frames = [] + for phase in (0.25, 0.2625, 0.5): + frame = build_dot_sphere( + width=41, + height=19, + phase=phase, + state_key="ingesting", + ) + edge_frames.append( + { + (cell.x, cell.y, cell.glyph) + for cell in frame.cells + if _cell_projection_radius( + (cell.x, cell.y), + center_x=center_x, + center_y=center_y, + radius_x=radius_x, + radius_y=radius_y, + ) + >= SHARED_EDGE_INNER_RADIUS + } + ) + + assert edge_frames[0] != edge_frames[1] + assert len(edge_frames[0] ^ edge_frames[2]) > len(edge_frames[0]) * 0.2 + for index in range(len(edge_frames) - 1): + before = edge_frames[index] + after = edge_frames[index + 1] + assert abs(len(after) - len(before)) < len(before) * 0.06 + assert ( + _position_hausdorff_distance( + {(x, y) for x, y, _ in before}, + {(x, y) for x, y, _ in after}, + ) + <= 1 + ) + + +def test_pipeline_states_keep_comparable_default_size_density() -> None: + for phase in (0.0, 0.5, 1.0, 1.5, 1.8): + reference = build_dot_sphere( + width=37, + height=17, + phase=phase, + state_key="ingesting", + ) + reference_density = sum( + _braille_subdot_count(cell.glyph) for cell in reference.cells + ) + for state_key in ("extracting", "indexing", "recalling"): + frame = build_dot_sphere( + width=37, + height=17, + phase=phase, + state_key=state_key, + ) + density = sum(_braille_subdot_count(cell.glyph) for cell in frame.cells) + assert reference_density * 0.88 <= density + assert density <= reference_density * 1.12 + + +def test_working_reference_uses_dark_paths_and_bright_moving_particles() -> None: + frame = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key="booting", + ) + dark_styles = {"#61522F", "#76612F", "#8C6D2B", "#A97D25"} + bright_styles = {"#DDA21E", "#F9B91C", "#FFD267"} + dark_cells = [cell for cell in frame.cells if cell.style in dark_styles] + + assert len(dark_cells) > len(frame.cells) * 0.75 + assert bright_styles <= {cell.style for cell in frame.cells} + + +def test_processing_states_make_near_particles_larger_than_far_particles() -> None: + for state_key in ("ingesting", "extracting", "indexing", "recalling"): + frame = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key=state_key, + ) + near = [cell for cell in frame.cells if cell.z > 0.6] + far = [cell for cell in frame.cells if cell.z < -0.6] + near_size = sum(_braille_subdot_count(cell.glyph) for cell in near) / len(near) + far_size = sum(_braille_subdot_count(cell.glyph) for cell in far) / len(far) + + assert near_size > far_size * 1.35 + assert {"#FFD267", "#F5EDDC"} & {cell.style for cell in near} + assert "#F5EDDC" not in {cell.style for cell in far} + + +def test_processing_particles_move_without_density_jumps() -> None: + for state_key in ("ingesting", "extracting"): + before = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key=state_key, + ) + after = build_dot_sphere( + width=41, + height=19, + phase=0.3, + state_key=state_key, + ) + before_active = {(cell.x, cell.y) for cell in before.cells if cell.highlighted} + after_active = {(cell.x, cell.y) for cell in after.cells if cell.highlighted} + before_density = sum(_braille_subdot_count(cell.glyph) for cell in before.cells) + after_density = sum(_braille_subdot_count(cell.glyph) for cell in after.cells) + + assert before_active != after_active + assert abs(before_density - after_density) < before_density * 0.08 def test_dot_sphere_uses_braille_fine_dot_cells() -> None: for state_key in SPHERE_STATES: - if state_key == "celebrating": - continue frame = build_dot_sphere( width=37, height=17, @@ -63,23 +375,183 @@ def test_dot_sphere_uses_braille_fine_dot_cells() -> None: def test_dot_sphere_packs_multiple_subdots_per_terminal_cell() -> None: - frame = build_dot_sphere(width=37, height=17, phase=0.0, state_key="booting") + frame = build_dot_sphere(width=37, height=17, phase=0.0, state_key="indexing") subdot_count = sum(_braille_subdot_count(cell.glyph) for cell in frame.cells) assert subdot_count > len(frame.cells) * 1.8 - assert subdot_count > frame.width * frame.height * 0.9 + assert subdot_count > frame.width * frame.height * 0.65 assert any(_braille_subdot_count(cell.glyph) >= 4 for cell in frame.cells) -def test_dot_sphere_avoids_flat_sides_in_terminal_frame() -> None: +def test_dot_sphere_breathes_between_animation_phases() -> None: frame = build_dot_sphere(width=37, height=17, phase=0.0, state_key="booting") - row_spans = _row_spans(frame) + next_frame = build_dot_sphere(width=37, height=17, phase=0.125, state_key="booting") + + assert {(cell.x, cell.y, cell.glyph) for cell in frame.cells} != { + (cell.x, cell.y, cell.glyph) for cell in next_frame.cells + } + + +def test_dot_sphere_stays_continuous_past_old_phase_wrap() -> None: + before = build_dot_sphere( + width=41, + height=19, + phase=0.9875, + state_key="extracting", + ) + after = build_dot_sphere( + width=41, + height=19, + phase=1.0, + state_key="extracting", + ) + wrapped = build_dot_sphere( + width=41, + height=19, + phase=0.0, + state_key="extracting", + ) + before_positions = {(cell.x, cell.y) for cell in before.cells} + after_positions = {(cell.x, cell.y) for cell in after.cells} + wrapped_positions = {(cell.x, cell.y) for cell in wrapped.cells} + + assert len(before_positions ^ after_positions) < len( + before_positions ^ wrapped_positions + ) + + +def test_pipeline_states_keep_a_filled_center_during_animation_cycle() -> None: + for state_key in ("ingesting", "extracting", "indexing", "recalling"): + for phase in (0.0, 0.25, 0.5, 0.75): + frame = build_dot_sphere( + width=41, + height=19, + phase=phase, + state_key=state_key, + ) + center_x = (frame.width - 1) / 2 + center_y = (frame.height - 1) / 2 + + center_cells = [ + cell + for cell in frame.cells + if abs(cell.x - center_x) < frame.width * 0.08 + and abs(cell.y - center_y) < frame.height * 0.12 + ] + assert len(center_cells) >= 20 + + +def test_working_and_ingesting_share_particle_motion_but_not_color() -> None: + working = build_dot_sphere(width=41, height=19, phase=0.25, state_key="booting") + ingesting = build_dot_sphere(width=41, height=19, phase=0.25, state_key="ingesting") + + assert [(cell.x, cell.y, cell.glyph) for cell in working.cells] == [ + (cell.x, cell.y, cell.glyph) for cell in ingesting.cells + ] + assert len(working.cells) >= 190 + assert "#F5EDDC" not in {cell.style for cell in working.cells} + assert "#F5EDDC" in {cell.style for cell in ingesting.cells} + assert working.caption == "working..." - assert max(row_spans.values()) <= frame.width - 4 - assert row_spans[frame.height // 2] >= frame.width - 4 - for y in range(frame.height // 2): - assert abs(row_spans[y] - row_spans[frame.height - 1 - y]) <= 4 + +def test_extracting_uses_outward_branches_with_internal_sparks() -> None: + frame = build_dot_sphere(width=41, height=19, phase=0.25, state_key="extracting") + center_x = (frame.width - 1) / 2 + center_y = (frame.height - 1) / 2 + + assert any( + abs(cell.x - center_x) < frame.width * 0.08 + and abs(cell.y - center_y) < frame.height * 0.12 + for cell in frame.cells + ) + highlighted = [cell for cell in frame.cells if cell.highlighted] + assert len(frame.cells) >= 120 + assert len(highlighted) >= EXTRACT_BRANCH_COUNT + highlighted_styles = {cell.style for cell in highlighted} + assert "#F5EDDC" in highlighted_styles + assert highlighted_styles <= { + "#76612F", + "#8C6D2B", + "#A97D25", + "#C48E20", + "#DDA21E", + "#F9B91C", + "#FFD267", + "#F5EDDC", + } + assert "#FFD267" in {cell.style for cell in frame.cells} + + +def test_extracting_signals_follow_branches_without_teleporting() -> None: + for phase in (0.0, 0.2, 0.4, 0.6, 0.8, 1.0): + before = build_dot_sphere( + width=41, + height=19, + phase=phase, + state_key="extracting", + ) + after = build_dot_sphere( + width=41, + height=19, + phase=phase + 0.002, + state_key="extracting", + ) + before_signals = {(cell.x, cell.y) for cell in before.cells if cell.highlighted} + after_signals = {(cell.x, cell.y) for cell in after.cells if cell.highlighted} + + assert _position_hausdorff_distance(before_signals, after_signals) <= 2 + + +def test_extracting_uses_color_depth_without_changing_geometry() -> None: + frame = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key="extracting", + ) + color_rank = { + "#61522F": 0, + "#76612F": 1, + "#8C6D2B": 2, + "#A97D25": 3, + "#C48E20": 4, + "#DDA21E": 5, + "#F9B91C": 6, + "#FFD267": 7, + } + front = [cell for cell in frame.cells if cell.z > 0.45 and not cell.highlighted] + back = [cell for cell in frame.cells if cell.z < -0.45 and not cell.highlighted] + front_brightness = sum(color_rank[cell.style] for cell in front) / len(front) + back_brightness = sum(color_rank[cell.style] for cell in back) / len(back) + + assert front_brightness > back_brightness + 3 + ingesting = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key="ingesting", + ) + assert _frame_extents(frame) == _frame_extents(ingesting) + + +def test_extracting_white_signal_visits_front_and_side() -> None: + white_depths = [] + for phase in (0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75): + frame = build_dot_sphere( + width=41, + height=19, + phase=phase, + state_key="extracting", + ) + white_depths.extend( + cell.z + for cell in frame.cells + if cell.highlighted and cell.style == "#F5EDDC" + ) + + assert any(depth > 0.35 for depth in white_depths) + assert any(-0.32 < depth < 0.25 for depth in white_depths) def test_dot_sphere_remembered_state_has_highlighted_node() -> None: @@ -89,32 +561,132 @@ def test_dot_sphere_remembered_state_has_highlighted_node() -> None: assert len(highlighted) == 1 assert _is_braille_cell(highlighted[0].glyph) assert highlighted[0].style == "#F9B91C" - assert frame.caption == "remembered Yosemite preference" - - -def test_dot_sphere_celebrating_state_bursts_into_confetti() -> None: - frame = build_dot_sphere(width=41, height=19, phase=0.93, state_key="celebrating") - - assert frame.caption == "memory crystallized" - confetti = [cell for cell in frame.cells if cell.glyph in {"*", "+", ".", "x"}] - assert len(confetti) >= 70 - assert all(not _is_braille_cell(cell.glyph) for cell in confetti) - assert not any(cell.style.startswith("bold ") for cell in confetti) - - center_x = (frame.width - 1) / 2 - center_y = (frame.height - 1) / 2 - radius_x = max(1.0, center_x - 3) - radius_y = max(1.0, center_y - 2) - distances = [ - ((cell.x - center_x) / radius_x) ** 2 + ((cell.y - center_y) / radius_y) ** 2 - for cell in confetti + assert frame.caption == "found the matching memory" + + +def test_celebrating_supernova_seeds_the_center_and_restores_the_sphere() -> None: + recalled = build_dot_sphere( + width=41, + height=19, + phase=0.93, + state_key="recalling", + ) + start = build_dot_sphere( + width=41, + height=19, + phase=0.93, + state_key="celebrating", + state_phase=0.0, + ) + burst = build_dot_sphere( + width=41, + height=19, + phase=0.93, + state_key="celebrating", + state_phase=0.06, + ) + scattered = build_dot_sphere( + width=41, + height=19, + phase=0.93, + state_key="celebrating", + state_phase=0.16, + ) + drifted = build_dot_sphere( + width=41, + height=19, + phase=0.93, + state_key="celebrating", + state_phase=0.2, + ) + post_burst_blank = build_dot_sphere( + width=41, + height=19, + phase=0.93, + state_key="celebrating", + state_phase=0.3, + ) + core_frames = [ + build_dot_sphere( + width=41, + height=19, + phase=0.93, + state_key="celebrating", + state_phase=core_phase, + ) + for core_phase in (0.4, 0.5, 0.64) + ] + emerging = build_dot_sphere( + width=41, + height=19, + phase=0.93, + state_key="celebrating", + state_phase=0.72, + ) + restored = build_dot_sphere( + width=41, + height=19, + phase=0.93, + state_key="celebrating", + state_phase=1.0, + ) + next_ingest = build_dot_sphere( + width=41, + height=19, + phase=0.93 + 2.4, + state_key="ingesting", + ) + + assert start.caption == "memory crystallized" + assert [(cell.x, cell.y, cell.glyph, cell.style) for cell in start.cells] == [ + (cell.x, cell.y, cell.glyph, cell.style) for cell in recalled.cells + ] + assert all(_is_braille_cell(cell.glyph) for cell in burst.cells) + assert not any(cell.glyph in {"*", "+", ".", "x"} for cell in burst.cells) + + center_x = (scattered.width - 1) / 2 + center_y = (scattered.height - 1) / 2 + + def mean_radius(frame: DotSphereFrame) -> float: + return sum( + math.hypot(cell.x - center_x, (cell.y - center_y) * 2) + for cell in frame.cells + ) / len(frame.cells) + + assert mean_radius(scattered) > mean_radius(start) * 1.14 + start_dots = sum(_braille_subdot_count(cell.glyph) for cell in start.cells) + burst_dots = sum(_braille_subdot_count(cell.glyph) for cell in burst.cells) + assert burst_dots > start_dots * 1.12 + for exploding_frame in (burst, scattered, drifted): + border_cells = sum( + cell.x in {0, 40} or cell.y in {0, 18} for cell in exploding_frame.cells + ) + assert border_cells == 0 + corner_cells = sum( + abs(cell.x - center_x) > 14 and abs(cell.y - center_y) > 6 + for cell in exploding_frame.cells + ) + assert corner_cells < len(exploding_frame.cells) * 0.04 + assert [(cell.x, cell.y, cell.glyph) for cell in drifted.cells] != [ + (cell.x, cell.y, cell.glyph) for cell in scattered.cells + ] + assert not post_burst_blank.cells + for core in core_frames: + assert 0 < len(core.cells) < len(start.cells) * 0.2 + assert all( + math.hypot(cell.x - center_x, (cell.y - center_y) * 2) < 6.5 + for cell in core.cells + ) + core_styles = {cell.style for cell in core_frames[1].cells} + assert "#F5EDDC" in core_styles + assert core_styles - {"#F5EDDC", "#24231E"} + assert [(cell.x, cell.y, cell.glyph) for cell in core_frames[1].cells] != [ + (cell.x, cell.y, cell.glyph) for cell in core_frames[2].cells + ] + assert 0 < len(emerging.cells) < len(restored.cells) + assert [(cell.x, cell.y, cell.glyph, cell.style) for cell in restored.cells] == [ + (cell.x, cell.y, cell.glyph, cell.style) for cell in next_ingest.cells ] - assert max(distances) > 1.10 - assert sum(distance > 0.72 for distance in distances) > len(distances) * 0.4 - - styles = {cell.style for cell in confetti} - assert "#F9B91C" in styles - assert "#F6C23B" in styles def test_dot_sphere_front_light_uses_poster_gold_primary() -> None: @@ -126,6 +698,88 @@ def test_dot_sphere_front_light_uses_poster_gold_primary() -> None: assert "#FFE600" not in front_styles +def test_dot_sphere_uses_eight_gold_depth_levels() -> None: + frame = build_dot_sphere(width=41, height=19, phase=0.25, state_key="booting") + styles = {cell.style for cell in frame.cells if not cell.highlighted} + + assert styles == { + "#61522F", + "#76612F", + "#8C6D2B", + "#A97D25", + "#C48E20", + "#DDA21E", + "#F9B91C", + "#FFD267", + } + assert "#F5EDDC" not in styles + + +def test_dot_sphere_preserves_state_specific_white_and_gold_effects() -> None: + ingesting = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key="ingesting", + ) + indexing = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key="indexing", + ) + extracting = build_dot_sphere( + width=41, + height=19, + phase=0.25, + state_key="extracting", + ) + + assert "#F5EDDC" in {cell.style for cell in ingesting.cells} + assert "#F5EDDC" in {cell.style for cell in indexing.cells} + extracting_styles = {cell.style for cell in extracting.cells} + assert "#F5EDDC" in extracting_styles + assert "#FFD267" in extracting_styles + assert "#C09525" not in extracting_styles + + +def test_ingesting_and_indexing_use_different_white_proportions() -> None: + ingesting = build_dot_sphere(width=41, height=19, phase=0.25, state_key="ingesting") + indexing = build_dot_sphere(width=41, height=19, phase=0.25, state_key="indexing") + + ingesting_white = sum(cell.style == "#F5EDDC" for cell in ingesting.cells) + indexing_white = sum(cell.style == "#F5EDDC" for cell in indexing.cells) + assert indexing_white > ingesting_white * 1.5 + + allowed_styles = { + "#61522F", + "#76612F", + "#8C6D2B", + "#A97D25", + "#C48E20", + "#DDA21E", + "#F9B91C", + "#FFD267", + "#F5EDDC", + } + assert {cell.style for cell in ingesting.cells} <= allowed_styles + assert {cell.style for cell in indexing.cells} <= allowed_styles + + +def test_recalling_highlights_several_white_memory_nodes() -> None: + for phase in (0.0, 0.5, 1.0, 1.5, 2.0): + frame = build_dot_sphere( + width=41, + height=19, + phase=phase, + state_key="recalling", + ) + + highlighted = [cell for cell in frame.cells if cell.highlighted] + assert len(highlighted) == 4 + assert {cell.style for cell in highlighted} == {"#F5EDDC"} + + def _row_spans(frame: DotSphereFrame) -> dict[int, int]: spans: dict[int, int] = {} for y in range(frame.height): @@ -134,6 +788,31 @@ def _row_spans(frame: DotSphereFrame) -> dict[int, int]: return spans +def _frame_extents(frame: DotSphereFrame) -> tuple[int, int, int, int]: + xs = [cell.x for cell in frame.cells] + ys = [cell.y for cell in frame.cells] + return min(xs), max(xs), min(ys), max(ys) + + +def _position_hausdorff_distance( + before: set[tuple[int, int]], + after: set[tuple[int, int]], +) -> int: + def directed( + source: set[tuple[int, int]], + target: set[tuple[int, int]], + ) -> int: + return max( + min( + max(abs(x - target_x), abs(y - target_y)) + for target_x, target_y in target + ) + for x, y in source + ) + + return max(directed(before, after), directed(after, before)) + + def _is_braille_cell(glyph: str) -> bool: return len(glyph) == 1 and 0x2800 < ord(glyph) <= 0x28FF