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.
-
-
-
-
+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。
-
-
-
+粒子球会随四个阶段持续变化,随后散落到 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