diff --git a/.gitignore b/.gitignore index 6bd1a98c..4d6999ba 100755 --- a/.gitignore +++ b/.gitignore @@ -151,6 +151,10 @@ demo/memcell_outputs/ # macOS .DS_Store + +# 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 c63c4008..a0f4ce5d 100644 --- a/README.md +++ b/README.md @@ -122,16 +122,20 @@ Run this before configuring API keys or starting the server: everos demo ``` -The command asks for one memory and one recall question, then opens a -full-screen terminal UI. This is an educational visualizer: it is hardcoded, -local to the CLI, and does not connect to the EverOS server. Its job is to make -the memory lifecycle visible: conversation -> memory sphere -> recall -> source -proof -> confetti. See [docs/everos-demo.md](docs/everos-demo.md) for the demo -scope and TUI source layout. +The full-screen terminal UI has an input box: type a memory, then a recall +question, directly in the UI. Each round runs the real memory pipeline against +[EverOS Cloud](https://everos.evermind.ai) (keys and storage live on the +platform, so you need no local keys), and the panels follow your own input: +conversation -> memory sphere -> +recall -> source proof -> confetti. See +[docs/everos-demo.md](docs/everos-demo.md) for the demo scope and TUI source +layout. The sphere moves through ingest, extraction, indexing, recall, source reveal, -and a confetti burst after the first memory lands. Press `r` to replay and `q` -to quit. +and a confetti burst after the first memory lands. 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`).

Animated EverOS demo preview showing the memory sphere moving through recall and confetti states diff --git a/README.zh-CN.md b/README.zh-CN.md index 2d7feb15..0741e894 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -124,15 +124,17 @@ uv pip install everos everos demo ``` -这个命令会询问一条记忆和一个召回问题,然后打开一个全屏 terminal UI。 -这是一个 educational visualizer:它是 hardcoded 的,只在 CLI 本地运行, -不会连接 EverOS server。它的作用是把 memory lifecycle 变成可感知的过程: +全屏 terminal UI 里带有一个输入框:你直接在 UI 里输入一条记忆,再输入一个 +召回问题。每一轮都会对 [EverOS Cloud](https://everos.evermind.ai) 跑真实的 +memory pipeline(key 和存储都在平台侧,本地无需任何 API keys),各面板跟着 +你自己的输入更新: conversation -> memory sphere -> recall -> source proof -> confetti。Demo 范围和 TUI 代码结构见 [docs/everos-demo.md](docs/everos-demo.md)。 Sphere 会经历 ingest、extraction、indexing、recall、source reveal, -并在第一条记忆落地后进入 confetti successful moment。按 `r` 可以 replay, -按 `q` 可以退出。 +并在第一条记忆落地后进入 confetti successful moment。输入 `/` 可以查看可用命令 +(`/replay`、`/live`、`/quit`);`ctrl+c` 随时退出。几轮之后,demo 会引导你去 +配置自己的 key(`everos init`,然后 `everos demo --live`)。

Animated EverOS demo preview showing the memory sphere moving through recall and confetti states diff --git a/docs/everos-demo.md b/docs/everos-demo.md index 53b3c209..21393370 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,50 @@ 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. You type a memory and a +recall question directly in the UI, and each round runs the **real** memory +pipeline against [EverOS Cloud](https://everos.evermind.ai) (`https://api.evermind.ai`): +`POST /api/v1/memories` -> `POST /api/v1/memories/flush` -> +`POST /api/v1/memories/search`. The panels follow your own input: conversation +-> memory sphere -> recall -> source proof -> confetti. -For non-interactive shells or a copyable preview, use: +EverOS Cloud holds all model keys and manages storage, so the demo needs only a +single platform API key — no server to deploy, no model keys locally. Each run +uses a fresh, isolated `(session_id, user_id)` pair, so demo visitors never see +each other's memories. -```bash -everos demo --plain -``` - -For the looping showroom view used by README media, use: +The demo key is read from the `EVEROS_CLOUD_DEMO_KEY` environment variable (a +restricted, shippable key can be baked in later). If no key is set, the key is +rejected, or the quota is exhausted, the UI says so and points you at +configuring your own key — it never fakes a result. -```bash -everos demo --cinematic -``` +The endpoint can be overridden with `EVEROS_CLOUD_DEMO_URL` (or `--server-url`). -## 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` runs the same flow against the same platform, just authenticated with +your own key instead of the restricted demo key. -1. `GET /health` -2. `POST /api/v1/memory/add` -3. `POST /api/v1/memory/flush` -4. `POST /api/v1/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 +62,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` is the EverOS Cloud HTTP client (`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 12c3463a..159cbbb7 100644 --- a/src/everos/entrypoints/cli/commands/demo.py +++ b/src/everos/entrypoints/cli/commands/demo.py @@ -1,27 +1,24 @@ -"""``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 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.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, @@ -29,15 +26,6 @@ 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 - def register(parent: typer.Typer) -> None: """Attach the ``demo`` command to the root CLI app.""" @@ -52,53 +40,83 @@ 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.", ), ) -> None: """Launch the EverOS first-memory Textual TUI.""" - 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 _run_interactive_demo(*, cinematic: bool) -> None: +def _launch_interactive_demo( + *, live: bool, server_url: str, user_label: str = "you" +) -> None: + """Launch the cloud-platform interactive TUI. + + Both modes talk to EverOS Cloud; ``--live`` just uses the user's own + platform key instead of the restricted demo key. + """ + run_demo_tui = _load_run_demo_tui() - story = None if cinematic else _collect_playable_story() - run_demo_tui(story=story) + base_url = 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(): @@ -118,178 +136,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/v1/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/v1/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/v1/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..e1b37321 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -2,13 +2,26 @@ 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.binding import Binding from textual.containers import Horizontal, Vertical +from textual.message import Message from textual.timer import Timer -from textual.widgets import Footer, Static - -from everos.entrypoints.tui.demo.data import DemoStory, default_demo_story +from textual.widgets import Footer, Input, Static + +from everos.component.utils.datetime import today_with_timezone +from everos.entrypoints.tui.demo import cloud +from everos.entrypoints.tui.demo.data import ( + DEFAULT_MEMORY_SEED, + DEFAULT_QUERY, + DemoStory, + default_demo_story, +) from everos.entrypoints.tui.demo.widgets.sphere import ( EVEROS_AMBER, EVEROS_AMBER_DIM, @@ -31,6 +44,36 @@ 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 = 12 +SPHERE_STAGE_SECONDS = 3.0 +SPHERE_STAGE_TICKS = round(SPHERE_FPS * SPHERE_STAGE_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") + +# 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) class DotSphereWidget(Static): @@ -54,14 +97,22 @@ class DotSphereWidget(Static): "celebrating", ) + 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._animation_timer: Timer | None = None 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: @@ -71,7 +122,7 @@ def pause_animation(self) -> None: def _advance(self) -> None: self._phase = (self._phase + 0.025) % 1.0 self._tick += 1 - state = self.STATES[(self._tick // 36) % len(self.STATES)] + state = self.STATES[(self._tick // SPHERE_STAGE_TICKS) % len(self.STATES)] frame = build_dot_sphere( width=SPHERE_FRAME_WIDTH, height=SPHERE_FRAME_HEIGHT, @@ -80,13 +131,59 @@ def _advance(self) -> None: ) self.update(render_dot_sphere_text(frame)) + 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]): """Fullscreen first-run demo cockpit.""" 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"), ] @@ -133,12 +230,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; @@ -164,16 +278,32 @@ class EverOSDemoApp(App[None]): padding: 0 2; }} - #payoff {{ - height: 2; + #conversation {{ + height: 4; border-top: hkey {EVEROS_YELLOW}; background: {EVEROS_SURFACE}; color: {EVEROS_INK}; padding: 0 1; margin-top: 1; + }} + + #console {{ + height: 2; + margin-top: 1; + }} + + #console-prompt {{ + height: 1; + padding: 0 1; content-align: left middle; }} + #console-input {{ + height: 1; + border: none; + background: {EVEROS_SURFACE_RAISED}; + }} + Footer {{ background: {EVEROS_BLACK}; color: {EVEROS_MUTED}; @@ -195,9 +325,35 @@ 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._round = 0 + self._active_stage = -1 + self._conversation_phase = "memory" + self._pending_memory = "" + self._lights = _initial_lights() + self._log: list[tuple[str, str]] = [] + self._history_chars = 0 + self._saved_pct: int | None = None def compose(self) -> ComposeResult: with Vertical(id="shell"): @@ -206,22 +362,265 @@ 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") + conversation = Static(_conversation_text(self._log), id="conversation") + conversation.border_title = "conversation" + yield conversation + if self._interactive: + with Vertical(id="console"): + yield Static( + _prompt_memory_text(self._round, self._max_rounds), + id="console-prompt", + ) + yield Input( + placeholder=( + "type a memory & enter · /help for commands · " + "/quit to exit" + ), + 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 or self._conversation_phase in {"recalling", "done"}: + return + value = event.value.strip() + if value.lower() in QUIT_COMMANDS: + self.exit() + return + 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 == "memory": + self._pending_memory = value or DEFAULT_MEMORY_SEED + self._conversation_phase = "query" + prompt.update(_prompt_query_text()) + field.value = "" + return + + # Query submitted: run the real cloud round off the event loop so the UI + # (sphere animation, input) stays responsive while we wait on the server. + query = value or DEFAULT_QUERY + self._conversation_phase = "recalling" + field.value = "" + field.disabled = True + prompt.update(_recalling_text()) + self.run_worker( + self._recall(self._pending_memory, query), + group="recall", + 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 {"recalling", "done"}: + return + prompt = self.query_one("#console-prompt", Static) + if event.value.startswith("/"): + prompt.update(_help_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 in {"/help", "/?"}: + prompt.update(_help_text()) + elif 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", 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 == "query": + return _prompt_query_text() + return _prompt_memory_text(self._round, self._max_rounds) + + async def _recall(self, memory: str, query: str) -> None: + # Reset the per-round lights; each step below lights up as it completes, + # so the signal rail mirrors the real add -> flush -> search pipeline. + 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: + task_id = 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.wait_task, task_id, base_url=base_url, api_key=api_key) + ) + await anyio.to_thread.run_sync( + partial( + cloud.flush_memory, + base_url=base_url, + session_id=session_id, + user_id=user_id, + api_key=api_key, + ) + ) + self._set_light("facts", "live") + self._set_light("index", "synced") + story = await anyio.to_thread.run_sync( + partial( + cloud.search_recall, + memory, + query, + base_url=base_url, + user_id=user_id, + api_key=api_key, + ) + ) + except cloud.CloudQuotaError: + self._enter_done(_quota_guidance_text()) + return + except cloud.CloudDemoError as exc: + self._set_light("core", "error") + self._show_recall_error(str(exc)) + return + + if story is None: + self._set_light("recall", "miss") + answer = "(no matching memory found)" + self._record_turn(query, answer) + story = DemoStory( + owner=user_id, + memory=memory, + query=query, + answer=answer, + source_filename="", + fact_filename="", + ) + else: + self._set_light("recall", "hit") + self._record_turn(story.query, story.answer) + self._update_savings(memory, query, story.answer) + self._finish_round(story) + + def _update_savings(self, memory: str, 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(memory) + 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() + 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_round_lights(self) -> None: + self._lights.update( + conversation="idle", facts="idle", index="idle", recall="idle" + ) + self.query_one("#signal-rail", Static).update(_signal_rail_text(self._lights)) + + 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)) + + def _record_turn(self, query: str, answer: str) -> None: + self._log.append((query, answer)) + self.query_one("#conversation", Static).update(_conversation_text(self._log)) + + def _enter_done(self, message: Text) -> None: + self._conversation_phase = "done" + self.query_one("#console-prompt", Static).update(message) + self.query_one("#console-input", Input).disabled = True + + def _show_recall_error(self, message: str) -> None: + # Recall failed (server unreachable, unhealthy, or slow). Surface the + # reason honestly and let the user retry a fresh round. + self._conversation_phase = "memory" + 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 +628,247 @@ 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 _prompt_memory_text(round_index: int, total_rounds: int) -> Text: + if round_index == 0: + return Text("What should EverOS remember?", style=f"bold {EVEROS_YELLOW}") + return Text.assemble( + (f"round {round_index + 1}/{total_rounds} ", EVEROS_MUTED), + ("what should EverOS remember next?", f"bold {EVEROS_YELLOW}"), + ) -def _hero_text() -> Text: +def _prompt_query_text() -> Text: + return Text("Now ask EverOS to recall it.", style=f"bold {EVEROS_CYAN}") + + +def _recalling_text() -> Text: + return Text("recalling from EverOS...", style=f"bold {EVEROS_ORANGE}") + + +def _recall_error_text(message: str) -> Text: return Text.assemble( - (" everos demo ", f"bold black on {EVEROS_YELLOW}"), - (" memory core ", f"bold {EVEROS_YELLOW}"), - ("online", EVEROS_MUTED), + ("could not reach the demo server ", f"bold {EVEROS_ORANGE}"), + (f"({message}) ", EVEROS_MUTED), + ("set EVEROS_CLOUD_DEMO_URL or use --live; type to retry", EVEROS_INK), ) -def _field_header_text(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() +def _help_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}"), + ("commands ", f"bold {EVEROS_YELLOW}"), + ("/help", f"bold {EVEROS_GREEN}"), + (" list ", EVEROS_MUTED), + ("/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 _sphere_caption(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() +def _live_guidance_text() -> 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}"), + ("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 _signal_rail_text(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() +def _unknown_command_text(command: str) -> Text: + return Text.assemble( + (f"unknown command {command} ", f"bold {EVEROS_ORANGE}"), + ("try /help", 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( - ("● ", 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}"), + (" 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"}) + + +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}", - ), - ) +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 query, answer in log: + parts.append(("you ", f"bold {EVEROS_CYAN}")) + parts.append((f"{query}\n", EVEROS_INK)) + parts.append(("everos ", f"bold {EVEROS_GREEN}")) + parts.append((f"{answer}\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..0e3abe6a --- /dev/null +++ b/src/everos/entrypoints/tui/demo/cloud.py @@ -0,0 +1,328 @@ +"""Cloud-platform HTTP client for ``everos demo``. + +The interactive demo runs the *real* memory pipeline against the EverOS Cloud +platform (``https://api.evermind.ai``). The platform holds all model keys and +manages storage, so a user experiences genuine extraction and recall by passing +a single platform API key — no server to deploy, no DNS, no model keys locally. + +Auth is ``Authorization: Bearer ``. The default demo uses a restricted +demo key (env ``EVEROS_CLOUD_DEMO_KEY``); ``--live`` uses the user's own +platform key (env ``EVEROS_CLOUD_API_KEY``). + +One round is: ``add`` (async, returns a task) -> wait for the task -> ``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 time +import urllib.error +import urllib.request +import uuid +from collections.abc import Callable +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_API_BASE_URL = "https://api.evermind.ai" +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" +# Restricted, shippable demo key goes here once the platform issues one. Empty +# means the demo reads the key from the env var instead (and otherwise reports +# "not configured"). +DEFAULT_CLOUD_DEMO_KEY = "" + +TIMEOUT_SECONDS = 15.0 +TASK_ATTEMPTS = 12 +TASK_INTERVAL_SECONDS = 1.0 +SEARCH_ATTEMPTS = 8 +SEARCH_INTERVAL_SECONDS = 1.5 + + +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_demo_key() -> str: + """The restricted demo key: env override, else the shipped default.""" + + 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, +) -> str: + """Queue one user memory; returns the async task id. Blocking.""" + + request = request_json or _request_json + timestamp_ms = int(get_utc_now().timestamp() * 1000) + resp = request( + "POST", + "/api/v1/memories", + base_url=base_url, + api_key=api_key, + json_body={ + "user_id": user_id, + "session_id": session_id, + "messages": [ + {"role": "user", "timestamp": timestamp_ms, "content": memory} + ], + }, + timeout_seconds=timeout_seconds, + ) + data = resp.get("data") + return _string_field(data if isinstance(data, dict) else None, "task_id") + + +def wait_task( + task_id: str, + *, + base_url: str, + api_key: str, + request_json: Callable[..., dict[str, Any]] | None = None, + attempts: int = TASK_ATTEMPTS, + interval_seconds: float = TASK_INTERVAL_SECONDS, + timeout_seconds: float = TIMEOUT_SECONDS, +) -> None: + """Best-effort poll of the async add task until it finishes. Blocking. + + Transient errors (e.g. a 404 before the task registers) are tolerated; if + the task never reports success we return anyway and let flush/search drive + eventual consistency. A reported failure raises. + """ + + if not task_id: + return + request = request_json or _request_json + for attempt in range(attempts): + status = "" + try: + resp = request( + "GET", + f"/api/v1/tasks/{task_id}", + base_url=base_url, + api_key=api_key, + timeout_seconds=timeout_seconds, + ) + data = resp.get("data") if isinstance(resp.get("data"), dict) else resp + status = _string_field(data, "status") + except CloudQuotaError: + raise + except CloudDemoError: + status = "" # task not registered yet / transient + if status in {"success", "completed", "done"}: + return + if status in {"failed", "error"}: + raise CloudDemoError("memory processing failed") + if attempt < attempts - 1: + time.sleep(interval_seconds) + + +def flush_memory( + *, + 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: + """Force extraction of the session into episodes/facts. Blocking.""" + + request = request_json or _request_json + request( + "POST", + "/api/v1/memories/flush", + base_url=base_url, + api_key=api_key, + json_body={"user_id": user_id, "session_id": session_id, "force": True}, + timeout_seconds=timeout_seconds, + ) + + +def search_recall( + memory: str, + query: str, + *, + base_url: 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, + 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 (the platform answered but returned nothing). Blocking. + """ + + request = request_json or _request_json + payload = { + "query": query, + "filters": {"user_id": user_id}, + "method": "hybrid", + "top_k": 5, + } + for attempt in range(search_attempts): + search = request( + "POST", + "/api/v1/memories/search", + base_url=base_url, + api_key=api_key, + json_body=payload, + timeout_seconds=timeout_seconds, + ) + episode = _first_live_episode(search) + if episode is not None: + return _story_from_live_episode(memory, query, episode, user_id=user_id) + if attempt < search_attempts - 1: + time.sleep(search_interval_seconds) + return None + + +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 _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( + memory: str, + query: str, + episode: dict[str, Any], + *, + user_id: str, +) -> DemoStory: + 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 + fact_id = _string_field(fact, "id") + # Cloud puts the recalled content in ``atomic_fact`` and the score on the + # fact (episode-level score is null). + answer = _string_field(fact, "atomic_fact") or ( + _string_field(episode, "summary") + or _string_field(episode, "episode") + or memory + ) + score = _float_field(fact, "score") or _float_field(episode, "score") + episode_id = _string_field(episode, "id") or "live" + return DemoStory( + owner=user_id, + memory=memory, + query=query, + answer=answer, + source_filename=f"episode:{episode_id}", + fact_filename=f"fact:{fact_id or 'live'}", + score=score, + ) + + +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/widgets/sphere.py b/src/everos/entrypoints/tui/demo/widgets/sphere.py index 8c15c34d..971acd9b 100644 --- a/src/everos/entrypoints/tui/demo/widgets/sphere.py +++ b/src/everos/entrypoints/tui/demo/widgets/sphere.py @@ -99,7 +99,7 @@ def caption(self) -> str: ), "remembered": SphereState( key="remembered", - caption="remembered Yosemite preference", + caption="found the matching memory", accent=EVEROS_YELLOW, ), "source": SphereState( 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 adc01d13..03fbcd8c 100644 --- a/tests/unit/test_entrypoints/test_cli/test_demo_command.py +++ b/tests/unit/test_entrypoints/test_cli/test_demo_command.py @@ -4,26 +4,16 @@ 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: - app = typer.Typer() - demo_command.register(app) - - result = CliRunner().invoke(app, ["demo", "--help"], terminal_width=120) - - assert result.exit_code == 0 - assert "--cinematic" in _strip_ansi(result.stdout) - - -def test_demo_help_exposes_live_mode() -> None: +def test_demo_help_exposes_all_modes() -> None: app = typer.Typer() demo_command.register(app) @@ -31,47 +21,8 @@ def test_demo_help_exposes_live_mode() -> None: 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) + for flag in ("--cinematic", "--live", "--cloud", "--server-url"): + assert flag in help_text def test_plain_demo_uses_poster_gold_brand_primary(monkeypatch) -> None: @@ -102,9 +53,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 +69,47 @@ 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), + ) + 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 ) - 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/v1/memory/add": - return {"message_count": 1, "status": "accumulated"} - if path == "/api/v1/memory/flush": - return {"message_count": 1, "status": "extracted"} - if path == "/api/v1/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, + + 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" # default mode uses the demo key + + +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/v1/memory/add", - "/api/v1/memory/flush", - "/api/v1/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 still hits the cloud platform, just with the user's own key. + assert captured["base_url"] == cloud.CLOUD_API_BASE_URL + assert captured["api_key"] == "user-key" + assert str(captured["session_id"]).startswith("everos-demo-") 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..0010ef40 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -8,19 +8,35 @@ SPHERE_FRAME_HEIGHT, SPHERE_FRAME_WIDTH, TERMINAL_CELL_HEIGHT_RATIO, + TRACE_STAGES, DotSphereWidget, EverOSDemoApp, + QueryAnswerBar, + _capabilities_text, + _conversation_text, _field_header_text, _hero_text, - _payoff_text, _recall_proof_text, _signal_rail_text, _source_tree_text, - _sphere_caption, + _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 +57,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,7 +72,7 @@ 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 "border-left: thick" not in command_strip @@ -65,15 +82,11 @@ 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 sits below the yellow line. + assert "border-top: hkey #F9B91C;" in conversation def test_demo_tui_sphere_renders_round_in_terminal_cells() -> None: @@ -91,21 +104,222 @@ def test_demo_tui_celebrates_after_source_reveal() -> None: 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([("where do I climb?", "Yosemite")]).plain + assert "you" 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_query_answer_bar_keeps_both_labels() -> None: + rendered = QueryAnswerBar().render().plain + + assert "Query" in rendered + assert "Answer" in rendered + + +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_help_and_unknown_command_text() -> None: + from everos.entrypoints.tui.demo.app import _help_text, _unknown_command_text + + help_plain = _help_text().plain + for command in ("/help", "/live", "/replay", "/clear", "/quit"): + assert command in help_plain + assert "unknown command /bogus" in _unknown_command_text("/bogus").plain + + +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 "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 app._conversation_phase == "memory" + assert app._pending_memory == "" + + +async def test_slash_help_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 = "/help" + await pilot.press("enter") + await pilot.pause() + + # /help is a command, not a memory: the conversation does not advance. + assert app._conversation_phase == "memory" + assert app._pending_memory == "" + + +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_turn("where do I climb?", "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_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 exited == [True] def test_demo_tui_signal_rail_keeps_source_status_columns_separate() -> None: @@ -116,6 +330,106 @@ def test_demo_tui_signal_rail_keeps_source_status_columns_separate() -> None: assert "..." in rail +async def test_demo_tui_interactive_runs_cloud_round_per_input(monkeypatch) -> None: + from textual.widgets import Input + + from everos.entrypoints.tui.demo import cloud + + monkeypatch.setattr(cloud, "add_memory", lambda *_, **__: "task-1") + monkeypatch.setattr(cloud, "wait_task", lambda *_, **__: None) + monkeypatch.setattr(cloud, "flush_memory", lambda *_, **__: None) + + def fake_search( + memory: str, query: str, *, base_url: str, user_id: str, **_: object + ) -> DemoStory: + assert base_url == "http://server.test" + return _story(memory, query, f"recalled<{memory}>") + + monkeypatch.setattr(cloud, "search_recall", fake_search) + + app = EverOSDemoApp( + interactive=True, + max_rounds=2, + 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) + + # Round 1: a memory, then a recall query -> a real (faked) cloud round. + console_input.value = "我喜欢吃杨梅" + await pilot.press("enter") + console_input.value = "我喜欢吃什么" + await pilot.press("enter") + await app.workers.wait_for_complete() + await pilot.pause() + + # BUG 305: the panels follow the user's own input, never Yosemite. + assert app._story.memory == "我喜欢吃杨梅" + assert app._story.query == "我喜欢吃什么" + assert app._story.answer == "recalled<我喜欢吃杨梅>" + assert "Yosemite" not in app._story.answer + # Lights walked the full pipeline to a hit. + assert app._lights["core"] == "ready" + assert app._lights["recall"] == "hit" + # A per-round token-saving estimate was computed. + assert app._saved_pct is not None + + # Round 2 reaches the cap and locks the input behind the upgrade nudge. + console_input.value = "I bike to work" + await pilot.press("enter") + console_input.value = "How do I commute?" + await pilot.press("enter") + await app.workers.wait_for_complete() + await pilot.pause() + + assert app._conversation_phase == "done" + assert console_input.disabled is True + + +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 True + + 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..15781e44 --- /dev/null +++ b/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py @@ -0,0 +1,233 @@ +"""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" + ) + + +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_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_messages_and_returns_task_id() -> 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 {"data": {"task_id": "task-123", "status": "queued"}} + + task_id = 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 task_id == "task-123" + method, path, body, api_key = calls[0] + assert (method, path) == ("POST", "/api/v1/memories") + assert api_key == "k-1" + assert body["user_id"] == "everos_demo_abc" + assert body["messages"][0]["role"] == "user" + 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/v1/memories/flush" + return {"data": {"status": "extracted"}} + + cloud.flush_memory( + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", + request_json=fake_request, + ) + + assert bodies[0]["force"] is True + assert bodies[0]["user_id"] == "u" + + +def test_wait_task_succeeds_and_fails() -> None: + def ok(*_: object, **__: object) -> dict[str, object]: + return {"task_id": "t", "status": "success"} + + cloud.wait_task("t", base_url="https://api.test", api_key="k", request_json=ok) + + def failed(*_: object, **__: object) -> dict[str, object]: + return {"task_id": "t", "status": "failed"} + + with pytest.raises(cloud.CloudDemoError): + cloud.wait_task( + "t", + base_url="https://api.test", + api_key="k", + request_json=failed, + attempts=1, + ) + + +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/v1/memories/search" + assert json_body["filters"] == {"user_id": "everos_demo_abc"} + return { + "data": { + "episodes": [ + { + "id": "ep1", + "summary": "long summary", + "episode": "long episode text", + "score": None, + "atomic_facts": [ + { + "id": "af1", + "atomic_fact": "You like Yangmei.", + "score": 0.57, + } + ], + } + ] + } + } + + story = cloud.search_recall( + "我喜欢吃杨梅", + "我喜欢吃什么", + base_url="https://api.test", + user_id="everos_demo_abc", + api_key="k", + request_json=fake_request, + ) + + 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_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", + user_id="u", + api_key="k", + request_json=fake_request, + search_attempts=2, + search_interval_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_sphere.py b/tests/unit/test_entrypoints/test_tui/test_demo_sphere.py index 61fa1af7..51058358 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_sphere.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_sphere.py @@ -89,7 +89,7 @@ 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" + assert frame.caption == "found the matching memory" def test_dot_sphere_celebrating_state_bursts_into_confetti() -> None: