From b8067cedf580aa7a8b0ea55ecf6c5793dc430da6 Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Wed, 24 Jun 2026 20:29:11 +0800 Subject: [PATCH 01/13] chore: ignore local AI agent tooling files Add .agents/ and AGENTS.md (local agent skill/config artifacts) to .gitignore so they don't show up as untracked noise. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index af95e074c..587c92ea8 100755 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,10 @@ dmypy.json .trae/ .cursor/ +# AI agent tooling (local agent skills/config, not part of the project) +.agents/ +AGENTS.md + # Claude Code: track team-shared rules/skills/settings; ignore personal + runtime .claude/* !.claude/rules/ From b443770cc8c84969a18d7ce077ea1065fa6eed51 Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Wed, 24 Jun 2026 20:29:11 +0800 Subject: [PATCH 02/13] fix(tokenizer): silence jieba SyntaxWarning on Python >= 3.12 (#304) jieba 0.42.1 (its last release) ships regex string literals with invalid escape sequences. Python >= 3.12 flags these as SyntaxWarning at first-import compile time, leaking noise on a user's first run. Wrap the single jieba import site in warnings.catch_warnings() to suppress them. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/everos/component/tokenizer/jieba_provider.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/everos/component/tokenizer/jieba_provider.py b/src/everos/component/tokenizer/jieba_provider.py index ab083aa93..d01a80bfb 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 From b46726e05844e70b67a1f829e745086fb44e0b37 Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Wed, 24 Jun 2026 20:29:24 +0800 Subject: [PATCH 03/13] feat(demo): cloud-backed interactive demo TUI (#305) Make `everos demo` an interactive TUI with an in-UI input box, where each round runs the real memory pipeline (add -> flush -> search) against a hosted EverOS server that holds the LLM + embedding keys server-side. The keys never reach the client; each run uses a fresh, isolated (session_id, user_id) pair. - Extract a typer-free hosted-demo HTTP client into tui/demo/cloud.py. - Drive the rounds off the event loop via an anyio worker so the UI stays responsive; surface unreachable/quota errors honestly instead of faking. - After a few rounds, guide the user to configure their own key (--live). - Fix #305: panels (incl. sphere captions) follow the user's input rather than the hard-coded Yosemite default; remove the offline answer stub. - Keep --plain / --cinematic as static, no-network previews. - Endpoint overridable via EVEROS_CLOUD_DEMO_URL / --server-url. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 16 +- README.zh-CN.md | 9 +- docs/everos-demo.md | 72 ++--- src/everos/entrypoints/cli/commands/demo.py | 264 +++--------------- src/everos/entrypoints/tui/demo/app.py | 204 +++++++++++++- src/everos/entrypoints/tui/demo/cloud.py | 237 ++++++++++++++++ src/everos/entrypoints/tui/demo/data.py | 49 +--- .../entrypoints/tui/demo/widgets/sphere.py | 2 +- .../test_cli/test_demo_command.py | 165 +++-------- .../test_tui/test_demo_app.py | 108 ++++++- .../test_tui/test_demo_cloud.py | 139 +++++++++ .../test_tui/test_demo_data.py | 28 +- .../test_tui/test_demo_sphere.py | 2 +- 13 files changed, 834 insertions(+), 461 deletions(-) create mode 100644 src/everos/entrypoints/tui/demo/cloud.py create mode 100644 tests/unit/test_entrypoints/test_tui/test_demo_cloud.py diff --git a/README.md b/README.md index 9611e1b53..02fa3b928 100644 --- a/README.md +++ b/README.md @@ -126,16 +126,18 @@ 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 +EverMind's hosted demo server (keys live server-side, 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. +to quit. 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 a3c636fcb..7a2344ca9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -126,15 +126,16 @@ uv pip install everos everos demo ``` -这个命令会询问一条记忆和一个召回问题,然后打开一个全屏 terminal UI。 -这是一个 educational visualizer:它是 hardcoded 的,只在 CLI 本地运行, -不会连接 EverOS server。它的作用是把 memory lifecycle 变成可感知的过程: +全屏 terminal UI 里带有一个输入框:你直接在 UI 里输入一条记忆,再输入一个 +召回问题。每一轮都会对 EverMind 托管的 demo server 跑真实的 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` 可以退出。 +按 `q` 可以退出。几轮之后,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 53b3c209f..d8bb91c50 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,53 @@ 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 (`add -> flush -> search`) against EverMind's hosted demo server. The +panels follow your own input: conversation -> memory sphere -> recall -> source +proof -> confetti. -For non-interactive shells or a copyable preview, use: +The hosted server holds the LLM and embedding keys server-side, so you need no +local keys. Each run uses a fresh, isolated `(session_id, user_id)` pair, so +concurrent visitors never see each other's memories. -```bash -everos demo --plain -``` - -For the looping showroom view used by README media, use: +After a few rounds the demo points you at configuring your own keys (`everos +init`, then `everos demo --live`). -```bash -everos demo --cinematic -``` +The hosted endpoint can be overridden with the `EVEROS_CLOUD_DEMO_URL` +environment variable (or `--server-url `). If the server is unreachable or +the free quota is exhausted, the UI says so rather than faking a result. -## Run It Against A Server +## Run It Against Your Own Server -After `everos init` and `everos server start`, run: +After `everos init` and `everos server start`, run the same interactive TUI +against your own server (your own keys): ```bash everos demo --live ``` -Live mode keeps the same TUI, but the memory lifecycle is backed by real -server calls: +Each round performs `GET /health` -> `POST /api/v1/memory/add` -> +`POST /api/v1/memory/flush` -> `POST /api/v1/memory/search`. If your server is +not on `http://127.0.0.1:8000`, pass `--server-url `. -1. `GET /health` -2. `POST /api/v1/memory/add` -3. `POST /api/v1/memory/flush` -4. `POST /api/v1/memory/search` +> Before the hosted demo server (and its DNS) is deployed, you can point the +> default demo at a local server with +> `EVEROS_CLOUD_DEMO_URL=http://127.0.0.1:8000 everos demo`. -If your server is not running on `http://127.0.0.1:8000`, pass -`--server-url `. +## Static Previews -## What It Does Not Do +For non-interactive shells or a copyable preview (no input box, no network): -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 --plain +``` + +For the looping showroom view used by README media: + +```bash +everos demo --cinematic +``` ## Source Layout @@ -60,8 +65,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 hosted-demo 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/entrypoints/cli/commands/demo.py b/src/everos/entrypoints/cli/commands/demo.py index 12c3463a7..96b879471 100644 --- a/src/everos/entrypoints/cli/commands/demo.py +++ b/src/everos/entrypoints/cli/commands/demo.py @@ -1,27 +1,22 @@ -"""``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 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 +24,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 +38,53 @@ 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="Run the interactive flow against your own EverOS server.", + ), + cloud_mode: bool = typer.Option( + False, + "--cloud", + help="Run against EverMind's hosted demo server (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="EverOS server URL used by --live (and to override --cloud).", ), ) -> 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) - - -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) + if cinematic: + _load_run_demo_tui()() + return + _launch_interactive_demo(live=live, server_url=server_url) -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 _launch_interactive_demo(*, live: bool, server_url: str) -> None: + """Launch the cloud-backed interactive TUI, or --live against your own server.""" - if run_demo_tui is None: # pragma: no cover - guarded by plain branch. - raise typer.Exit(code=1) - run_demo_tui(story=live_story) + run_demo_tui = _load_run_demo_tui() + if live: + base_url = server_url + session_id, user_id = cloud.LIVE_DEMO_SESSION_ID, cloud.LIVE_DEMO_USER_ID + else: + base_url = cloud.resolve_cloud_base_url(server_url) + session_id, user_id = cloud.new_demo_identity() + + run_demo_tui( + interactive=True, + base_url=base_url, + session_id=session_id, + user_id=user_id, + ) def _load_run_demo_tui(): @@ -118,178 +104,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 b1eabafc4..0ea83086d 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -2,13 +2,22 @@ from __future__ import annotations +from functools import partial + +import anyio from rich.text import Text from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical 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.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 +40,9 @@ 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 class DotSphereWidget(Static): @@ -174,6 +186,22 @@ class EverOSDemoApp(App[None]): content-align: left middle; }} + #console {{ + height: 4; + margin-top: 1; + }} + + #console-prompt {{ + height: 1; + padding: 0 1; + content-align: left middle; + }} + + #console-input {{ + border: round {EVEROS_AMBER}; + background: {EVEROS_SURFACE}; + }} + Footer {{ background: {EVEROS_BLACK}; color: {EVEROS_MUTED}; @@ -195,9 +223,26 @@ 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.DEFAULT_CLOUD_DEMO_SERVER_URL, + session_id: str = cloud.LIVE_DEMO_SESSION_ID, + user_id: str = cloud.LIVE_DEMO_USER_ID, + 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._max_rounds = max_rounds + self._round = 0 + self._conversation_phase = "memory" + self._pending_memory = "" def compose(self) -> ComposeResult: with Vertical(id="shell"): @@ -220,8 +265,107 @@ def compose(self) -> ComposeResult: recall_lock.border_title = "recall lock" yield recall_lock yield Static(_payoff_text(self._story), id="payoff") + 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 and press enter", + id="console-input", + ) yield Footer(show_command_palette=False) + def on_mount(self) -> None: + if self._interactive: + self.query_one("#console-input", Input).focus() + + 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() + 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, + ) + + async def _recall(self, memory: str, query: str) -> None: + call = partial( + cloud.recall_round, + memory, + query, + base_url=self._base_url, + session_id=self._session_id, + user_id=self._user_id, + ) + try: + story = await anyio.to_thread.run_sync(call) + except cloud.CloudQuotaError: + self._enter_done(_quota_guidance_text()) + return + except cloud.CloudDemoError as exc: + self._show_recall_error(str(exc)) + return + self._on_recall_success(story) + + def _on_recall_success(self, story: DemoStory) -> None: + self._apply_story(story) + self._round += 1 + if self._round >= self._max_rounds: + self._enter_done(_quota_guidance_text()) + return + self._conversation_phase = "memory" + prompt = self.query_one("#console-prompt", Static) + prompt.update(_prompt_memory_text(self._round, self._max_rounds)) + self._reenable_input() + + 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 _apply_story(self, story: DemoStory) -> None: + """Refresh every story-driven panel so the demo follows the user's input.""" + + self._story = story + self.query_one("#field-header", Static).update(_field_header_text(story)) + self.query_one("#field-answer", Static).update(_sphere_caption(story)) + self.query_one("#signal-rail", Static).update(_signal_rail_text(story)) + self.query_one("#source-lock", Static).update(_source_tree_text(story)) + self.query_one("#recall-lock", Static).update(_recall_proof_text(story)) + self.query_one("#payoff", Static).update(_payoff_text(story)) + self.action_replay() + def action_replay(self) -> None: widget = self.query_one(DotSphereWidget) widget._tick = 0 @@ -229,8 +373,56 @@ 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.DEFAULT_CLOUD_DEMO_SERVER_URL, + session_id: str = cloud.LIVE_DEMO_SESSION_ID, + user_id: str = cloud.LIVE_DEMO_USER_ID, +) -> None: + EverOSDemoApp( + story=story, + interactive=interactive, + base_url=base_url, + session_id=session_id, + user_id=user_id, + ).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 _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( + ("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 _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: diff --git a/src/everos/entrypoints/tui/demo/cloud.py b/src/everos/entrypoints/tui/demo/cloud.py new file mode 100644 index 000000000..3e57f89ba --- /dev/null +++ b/src/everos/entrypoints/tui/demo/cloud.py @@ -0,0 +1,237 @@ +"""Hosted-demo HTTP client for ``everos demo``. + +The default demo runs the *real* memory pipeline against a hosted EverOS server +that holds the LLM + embedding keys server-side, so a user experiences genuine +extraction and recall without configuring any keys locally. The keys never reach +the client; this module only speaks the public memory HTTP API. + +Each demo run uses a fresh ``(session_id, user_id)`` pair (see +:func:`new_demo_identity`) so concurrent visitors on the shared hosted server +never read each other's memories. + +The functions here are typer-free on purpose: they are called from the Textual +TUI worker, which must not depend on the CLI presentation layer. Failures raise +:class:`CloudDemoError` (or :class:`CloudQuotaError` for an exhausted free +quota), and 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 + +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 + +# Hosted demo endpoint. Overridable via the env var so the URL is not hard-wired +# into releases; the default is a placeholder until the server is deployed. +CLOUD_DEMO_SERVER_URL_ENV = "EVEROS_CLOUD_DEMO_URL" +DEFAULT_CLOUD_DEMO_SERVER_URL = "https://demo.everos.evermind.ai" + + +class CloudDemoError(Exception): + """A hosted demo round could not be completed.""" + + +class CloudQuotaError(CloudDemoError): + """The hosted demo server hit its free per-visitor quota (HTTP 429).""" + + +def resolve_cloud_base_url(server_url: str) -> str: + """Pick the cloud 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, DEFAULT_CLOUD_DEMO_SERVER_URL) + + +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 recall_round( + memory: str, + query: str, + *, + base_url: str, + session_id: str, + user_id: 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 one ``add -> flush -> search`` round against a live EverOS server. + + Blocking (uses ``urllib`` + ``time.sleep`` while indexing catches up); call + it from a worker thread, never directly on an event loop. + """ + + request = request_json or _request_json + health = request( + "GET", + "/health", + base_url=base_url, + timeout_seconds=timeout_seconds, + ) + if health.get("status") != "ok": + raise CloudDemoError(f"EverOS server at {base_url} is not healthy") + + timestamp_ms = int(get_utc_now().timestamp() * 1000) + request( + "POST", + "/api/v1/memory/add", + base_url=base_url, + json_body={ + "session_id": session_id, + "app_id": LIVE_DEMO_APP_ID, + "project_id": LIVE_DEMO_PROJECT_ID, + "messages": [ + { + "sender_id": user_id, + "role": "user", + "timestamp": timestamp_ms, + "content": memory, + } + ], + }, + timeout_seconds=timeout_seconds, + ) + request( + "POST", + "/api/v1/memory/flush", + base_url=base_url, + json_body={ + "session_id": session_id, + "app_id": LIVE_DEMO_APP_ID, + "project_id": LIVE_DEMO_PROJECT_ID, + }, + timeout_seconds=timeout_seconds, + ) + + search_payload = { + "user_id": user_id, + "app_id": LIVE_DEMO_APP_ID, + "project_id": LIVE_DEMO_PROJECT_ID, + "query": 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(memory, query, episode, user_id=user_id) + if attempt < search_attempts - 1: + time.sleep(search_interval_seconds) + + raise CloudDemoError( + "EverOS accepted the memory, but search did not return it yet. " + "Try again once 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.HTTPError as exc: + if exc.code == 429: + raise CloudQuotaError(base_url) from exc + raise CloudDemoError( + f"EverOS server at {base_url} returned HTTP {exc.code}." + ) from exc + except urllib.error.URLError as exc: + raise CloudDemoError(f"Could not reach EverOS server at {base_url}.") from exc + if not raw: + return {} + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise CloudDemoError(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( + 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_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 memory + ) + 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'}", + ) + + +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 "" diff --git a/src/everos/entrypoints/tui/demo/data.py b/src/everos/entrypoints/tui/demo/data.py index c5e23305a..6e10fc059 100644 --- a/src/everos/entrypoints/tui/demo/data.py +++ b/src/everos/entrypoints/tui/demo/data.py @@ -21,7 +21,12 @@ class DemoStory: 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.recall_round`). + """ return DemoStory( owner="alice", @@ -31,45 +36,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 8c15c34d5..971acd9b1 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 adc01d134..6504730be 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,40 @@ 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/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, + monkeypatch.delenv(cloud.CLOUD_DEMO_SERVER_URL_ENV, raising=False) + + demo_command._launch_interactive_demo( + live=False, 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" + assert captured["interactive"] is True + assert captured["base_url"] == cloud.DEFAULT_CLOUD_DEMO_SERVER_URL + assert str(captured["session_id"]).startswith("everos-demo-") + assert str(captured["user_id"]).startswith("everos_demo_") + + +def test_launch_interactive_live_uses_own_server(monkeypatch) -> None: + captured: dict[str, object] = {} + + monkeypatch.setattr( + demo_command, + "_load_run_demo_tui", + lambda: lambda **kwargs: captured.update(kwargs), + ) + + demo_command._launch_interactive_demo(live=True, server_url="http://127.0.0.1:9000") + + assert captured["base_url"] == "http://127.0.0.1:9000" + assert captured["session_id"] == cloud.LIVE_DEMO_SESSION_ID + assert captured["user_id"] == cloud.LIVE_DEMO_USER_ID 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 6d94fbbc1..b7dff6cd6 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -18,10 +18,21 @@ _source_tree_text, _sphere_caption, ) -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 +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 @@ -92,9 +103,10 @@ def test_demo_tui_celebrates_after_source_reveal() -> None: def test_demo_tui_renders_playable_story_copy() -> None: - story = build_demo_story( + story = _story( "I keep my Monday design review notes in Notion.", "Where are my Monday review notes?", + "In Notion.", ) assert "user=you" in _field_header_text(story).plain @@ -116,6 +128,98 @@ 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 + + def fake_recall( + memory: str, query: str, *, base_url: str, session_id: str, user_id: str + ) -> DemoStory: + # Stand in for the hosted server: echo the real call's identity through. + assert base_url == "http://server.test" + return _story(memory, query, f"recalled<{memory}>") + + monkeypatch.setattr(cloud, "recall_round", fake_recall) + + 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 + + # 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) -> DemoStory: + raise cloud.CloudQuotaError("http://server.test") + + monkeypatch.setattr(cloud, "recall_round", 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 000000000..2a423ec94 --- /dev/null +++ b/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py @@ -0,0 +1,139 @@ +"""EverOS hosted-demo cloud client contracts.""" + +from __future__ import annotations + +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.DEFAULT_CLOUD_DEMO_SERVER_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" + ) + + # An explicit --server-url always wins over the env default. + assert ( + cloud.resolve_cloud_base_url("https://explicit.test") == "https://explicit.test" + ) + + +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_recall_round_runs_real_flow_with_isolated_identity() -> None: + 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" + if path == "/health": + return {"status": "ok"} + if path == "/api/v1/memory/search": + return { + "data": { + "episodes": [ + { + "id": "ep1", + "summary": "You like Yangmei.", + "atomic_facts": [ + {"id": "af1", "content": "You like Yangmei."} + ], + } + ] + } + } + return {"status": "ok"} + + story = cloud.recall_round( + "我喜欢吃杨梅", + "我喜欢吃什么", + base_url="http://server.test", + session_id="everos-demo-abc", + user_id="everos_demo_abc", + request_json=fake_request, + timeout_seconds=1.0, + ) + + assert [path for _, path, _ in calls] == [ + "/health", + "/api/v1/memory/add", + "/api/v1/memory/flush", + "/api/v1/memory/search", + ] + add_body = calls[1][2] + search_body = calls[3][2] + assert add_body is not None and search_body is not None + assert add_body["session_id"] == "everos-demo-abc" + assert add_body["messages"][0]["sender_id"] == "everos_demo_abc" + assert add_body["messages"][0]["content"] == "我喜欢吃杨梅" + assert search_body["user_id"] == "everos_demo_abc" + assert story.owner == "everos_demo_abc" + assert story.memory == "我喜欢吃杨梅" + assert story.query == "我喜欢吃什么" + assert story.answer == "You like Yangmei." + assert story.source_filename == "episode:ep1" + + +def test_recall_round_raises_quota_error_on_429() -> None: + def fake_request(*_: object, **__: object) -> dict[str, object]: + raise cloud.CloudQuotaError("http://server.test") + + with pytest.raises(cloud.CloudQuotaError): + cloud.recall_round( + "m", + "q", + base_url="http://server.test", + session_id="s", + user_id="u", + request_json=fake_request, + ) + + +def test_recall_round_raises_demo_error_when_search_never_returns() -> None: + def fake_request( + method: str, + path: str, + *, + base_url: str, + json_body: dict[str, object] | None = None, + timeout_seconds: float, + ) -> dict[str, object]: + if path == "/health": + return {"status": "ok"} + if path == "/api/v1/memory/search": + return {"data": {"episodes": []}} + return {"status": "ok"} + + with pytest.raises(cloud.CloudDemoError): + cloud.recall_round( + "m", + "q", + base_url="http://server.test", + session_id="s", + user_id="u", + request_json=fake_request, + search_attempts=2, + search_interval_seconds=0.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 04311f432..4420fbaab 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 61fa1af7b..510583586 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: From 94d71128177e83bb164892cbf834385cebe4dd2d Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Thu, 25 Jun 2026 11:05:23 +0800 Subject: [PATCH 04/13] feat(demo): local user, synced trace stages, Query<->Answer bar, reliable quit Top-half TUI polish for the interactive demo: - Header shows the local git/OS user name (local-first) instead of the per-round session id; scope stays local-first. - Trace becomes the four pipeline stages (ingest/extract/index/recall); the sphere posts a StageChanged message so the active word highlights in sync with the animation. Stage dwell time is an explicit 3s constant. - Replace the query/answer caption with a Query <-> Answer bar whose marker propagates back and forth. - Make quitting reliable: ctrl+c / ctrl+q are priority bindings (work even while the input box has focus), and typing 'quit'/'exit' in the box exits. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/everos/entrypoints/cli/commands/demo.py | 36 +++- src/everos/entrypoints/tui/demo/app.py | 159 +++++++++++++++--- .../test_tui/test_demo_app.py | 75 ++++++++- 3 files changed, 237 insertions(+), 33 deletions(-) diff --git a/src/everos/entrypoints/cli/commands/demo.py b/src/everos/entrypoints/cli/commands/demo.py index 96b879471..ed69ea8d7 100644 --- a/src/everos/entrypoints/cli/commands/demo.py +++ b/src/everos/entrypoints/cli/commands/demo.py @@ -9,6 +9,8 @@ from __future__ import annotations +import getpass +import subprocess import sys import typer @@ -61,14 +63,19 @@ def demo( _print_plain_demo() return + user_label = _resolve_local_user() if cinematic: - _load_run_demo_tui()() + _load_run_demo_tui()(user_label=user_label) return - _launch_interactive_demo(live=live, server_url=server_url) + _launch_interactive_demo( + live=live, server_url=server_url, user_label=user_label + ) -def _launch_interactive_demo(*, live: bool, server_url: str) -> None: +def _launch_interactive_demo( + *, live: bool, server_url: str, user_label: str = "you" +) -> None: """Launch the cloud-backed interactive TUI, or --live against your own server.""" run_demo_tui = _load_run_demo_tui() @@ -84,9 +91,32 @@ def _launch_interactive_demo(*, live: bool, server_url: str) -> None: base_url=base_url, session_id=session_id, user_id=user_id, + user_label=user_label, ) +def _resolve_local_user() -> str: + """Local-first display name: the clone's git identity, else the OS user.""" + + 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(): try: from everos.entrypoints.tui.demo.app import run_demo_tui diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py index 0ea83086d..73145adb1 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -6,8 +6,11 @@ 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, Input, Static @@ -44,6 +47,33 @@ # 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"}) +_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): """Animated dot sphere that represents EverOS memory activity.""" @@ -66,14 +96,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: @@ -83,7 +121,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, @@ -92,13 +130,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"), ] @@ -145,6 +229,7 @@ class EverOSDemoApp(App[None]): border-top: hkey {EVEROS_AMBER_DIM}; background: {EVEROS_SURFACE_RAISED}; padding: 0 1; + content-align: center middle; }} #signal-rail {{ @@ -231,6 +316,7 @@ def __init__( base_url: str = cloud.DEFAULT_CLOUD_DEMO_SERVER_URL, session_id: str = cloud.LIVE_DEMO_SESSION_ID, user_id: str = cloud.LIVE_DEMO_USER_ID, + user_label: str = "you", max_rounds: int = DEFAULT_DEMO_ROUNDS, ) -> None: super().__init__() @@ -239,8 +325,10 @@ def __init__( self._base_url = base_url self._session_id = session_id self._user_id = user_id + self._user_label = user_label self._max_rounds = max_rounds self._round = 0 + self._active_stage = -1 self._conversation_phase = "memory" self._pending_memory = "" @@ -251,9 +339,15 @@ 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") + yield QueryAnswerBar(id="field-answer") signal_rail = Static(_signal_rail_text(self._story), id="signal-rail") signal_rail.border_title = "signal rail" yield signal_rail @@ -272,7 +366,9 @@ def compose(self) -> ComposeResult: id="console-prompt", ) yield Input( - placeholder="type a memory and press enter", + placeholder=( + "type a memory and press enter · 'quit' or ctrl+c to exit" + ), id="console-input", ) yield Footer(show_command_palette=False) @@ -281,10 +377,23 @@ 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 prompt = self.query_one("#console-prompt", Static) field = self.query_one("#console-input", Input) if self._conversation_phase == "memory": @@ -355,11 +464,13 @@ def _reenable_input(self) -> None: field.focus() def _apply_story(self, story: DemoStory) -> None: - """Refresh every story-driven panel so the demo follows the user's input.""" + """Refresh every story-driven panel so the demo follows the user's input. + + The header (user/scope/trace) and the Query<->Answer bar are not + story-driven, so they are left to their own animations. + """ self._story = story - self.query_one("#field-header", Static).update(_field_header_text(story)) - self.query_one("#field-answer", Static).update(_sphere_caption(story)) self.query_one("#signal-rail", Static).update(_signal_rail_text(story)) self.query_one("#source-lock", Static).update(_source_tree_text(story)) self.query_one("#recall-lock", Static).update(_recall_proof_text(story)) @@ -380,6 +491,7 @@ def run_demo_tui( base_url: str = cloud.DEFAULT_CLOUD_DEMO_SERVER_URL, session_id: str = cloud.LIVE_DEMO_SESSION_ID, user_id: str = cloud.LIVE_DEMO_USER_ID, + user_label: str = "you", ) -> None: EverOSDemoApp( story=story, @@ -387,6 +499,7 @@ def run_demo_tui( base_url=base_url, session_id=session_id, user_id=user_id, + user_label=user_label, ).run() @@ -433,26 +546,20 @@ def _hero_text() -> Text: ) -def _field_header_text(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() - return Text.assemble( - (f"user={story.owner}", f"bold {EVEROS_INK}"), +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), - ("conversation -> facts -> index", f"bold {EVEROS_YELLOW}"), - (" live", f"bold {EVEROS_ORANGE}"), - ) - - -def _sphere_caption(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() - 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}"), - ) + ] + 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 _signal_rail_text(story: DemoStory | None = None) -> Text: 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 b7dff6cd6..42f932e08 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,23 @@ SPHERE_FRAME_HEIGHT, SPHERE_FRAME_WIDTH, TERMINAL_CELL_HEIGHT_RATIO, + TRACE_STAGES, DotSphereWidget, EverOSDemoApp, + QueryAnswerBar, _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 DemoStory from everos.entrypoints.tui.demo.widgets.sphere import SPHERE_STATES +_YELLOW = "#F9B91C" + def _story(memory: str, query: str, answer: str) -> DemoStory: return DemoStory( @@ -109,9 +113,6 @@ def test_demo_tui_renders_playable_story_copy() -> None: "In Notion.", ) - 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 @@ -120,6 +121,72 @@ def test_demo_tui_renders_playable_story_copy() -> None: assert story.answer in _payoff_text(story).plain +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 + + +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: rail = _signal_rail_text().plain From bbd58bf44c859b3b506dc3ac8e7499c2ce02d9d0 Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Thu, 25 Jun 2026 15:17:39 +0800 Subject: [PATCH 05/13] feat(demo): real-time signal rail, capabilities box, recall scoring, conversation log Make the demo's right column and bottom reflect real pipeline state instead of hardcoded values: - Signal rail status lights are now stateful, driven by the actual round steps (health -> add -> flush -> search). Colors: white = not ready/idle, yellow = ready/active/hit, black = error. Split cloud.recall_round into check_health/add_memory/flush_memory/search_recall so the UI lights up between steps. - New 'EverOS strengths' box above the signal rail showing real product capabilities (hybrid retrieval, agentic rerank, multilingual, multimodal, md-first, local-first) -- no fabricated metrics. - Source route / source lock filenames are date-stamped from the demo-usage day (episode-YYYY-MM-DD.md) instead of hardcoded. - Recall lock shows the real recall score (similarity to the stored memory) and scope (real user id, project=demo); dropped the sample answer line. - Bottom 'conversation' log (below the yellow line) accumulates you/everos turns across rounds, replacing the static payoff strip. - DemoStory gains a score field; search misses surface as a 'miss' light. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/everos/entrypoints/tui/demo/app.py | 293 ++++++++++++------ src/everos/entrypoints/tui/demo/cloud.py | 84 +++-- src/everos/entrypoints/tui/demo/data.py | 1 + .../test_tui/test_demo_app.py | 118 +++++-- .../test_tui/test_demo_cloud.py | 133 ++++---- 5 files changed, 431 insertions(+), 198 deletions(-) diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py index 73145adb1..95401fe34 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -14,6 +14,7 @@ from textual.timer import Timer 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, @@ -232,10 +233,23 @@ class EverOSDemoApp(App[None]): content-align: center middle; }} - #signal-rail {{ + #right-rail {{ width: 48; height: 100%; margin-left: 1; + }} + + #capabilities {{ + height: 9; + border: thick {EVEROS_YELLOW}; + background: {EVEROS_SURFACE}; + padding: 0 2; + margin-bottom: 1; + }} + + #signal-rail {{ + width: 100%; + height: 1fr; border: round {EVEROS_AMBER}; background: {EVEROS_SURFACE}; padding: 1 2; @@ -261,14 +275,13 @@ class EverOSDemoApp(App[None]): padding: 0 2; }} - #payoff {{ - height: 2; + #conversation {{ + height: 7; border-top: hkey {EVEROS_YELLOW}; background: {EVEROS_SURFACE}; color: {EVEROS_INK}; padding: 0 1; margin-top: 1; - content-align: left middle; }} #console {{ @@ -331,6 +344,8 @@ def __init__( self._active_stage = -1 self._conversation_phase = "memory" self._pending_memory = "" + self._lights = _initial_lights() + self._log: list[tuple[str, str]] = [] def compose(self) -> ComposeResult: with Vertical(id="shell"): @@ -348,17 +363,25 @@ def compose(self) -> ComposeResult: ) yield DotSphereWidget() yield QueryAnswerBar(id="field-answer") - signal_rail = Static(_signal_rail_text(self._story), id="signal-rail") - signal_rail.border_title = "signal rail" - yield signal_rail + 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.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( @@ -417,35 +440,96 @@ def on_input_submitted(self, event: Input.Submitted) -> None: ) async def _recall(self, memory: str, query: str) -> None: - call = partial( - cloud.recall_round, - memory, - query, - base_url=self._base_url, - session_id=self._session_id, - user_id=self._user_id, + # 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 = ( + self._base_url, + self._session_id, + self._user_id, ) try: - story = await anyio.to_thread.run_sync(call) + await anyio.to_thread.run_sync( + partial(cloud.check_health, base_url=base_url) + ) + self._set_light("core", "ready") + await anyio.to_thread.run_sync( + partial( + cloud.add_memory, + memory, + base_url=base_url, + session_id=session_id, + user_id=user_id, + ) + ) + self._set_light("conversation", "captured") + await anyio.to_thread.run_sync( + partial(cloud.flush_memory, base_url=base_url, session_id=session_id) + ) + 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, + ) + ) 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 - self._on_recall_success(story) - def _on_recall_success(self, story: DemoStory) -> None: - self._apply_story(story) + 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._finish_round(story) + + def _finish_round(self, story: DemoStory) -> None: + self._story = story + self.query_one("#recall-lock", Static).update(_recall_proof_text(story)) + self.action_replay() self._round += 1 if self._round >= self._max_rounds: self._enter_done(_quota_guidance_text()) return self._conversation_phase = "memory" - prompt = self.query_one("#console-prompt", Static) - prompt.update(_prompt_memory_text(self._round, self._max_rounds)) + 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) @@ -463,20 +547,6 @@ def _reenable_input(self) -> None: field.disabled = False field.focus() - def _apply_story(self, story: DemoStory) -> None: - """Refresh every story-driven panel so the demo follows the user's input. - - The header (user/scope/trace) and the Query<->Answer bar are not - story-driven, so they are left to their own animations. - """ - - self._story = story - self.query_one("#signal-rail", Static).update(_signal_rail_text(story)) - self.query_one("#source-lock", Static).update(_source_tree_text(story)) - self.query_one("#recall-lock", Static).update(_recall_proof_text(story)) - self.query_one("#payoff", Static).update(_payoff_text(story)) - self.action_replay() - def action_replay(self) -> None: widget = self.query_one(DotSphereWidget) widget._tick = 0 @@ -562,43 +632,58 @@ def _field_header_text(*, user_label: str = "you", active_stage: int = -1) -> Te return Text.assemble(*parts) -def _signal_rail_text(story: DemoStory | None = None) -> Text: - story = story or default_demo_story() - 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}"), - ) +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: @@ -607,38 +692,64 @@ def _rail_cell(value: str, *, width: int = SIGNAL_RAIL_SOURCE_WIDTH) -> str: 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: + rows = ( + ("hybrid retrieval ", "BM25 + vector", EVEROS_YELLOW), + ("agentic rerank ", "on", EVEROS_GREEN), + ("multilingual ", "CJK + EN", EVEROS_CYAN), + ("multimodal ", "image / pdf / audio", EVEROS_ORANGE), + ("md-first ", "auditable source", EVEROS_YELLOW_SOFT), + ("local-first ", "runs on your machine", EVEROS_INK), + ) + 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: story = story or default_demo_story() + score = f"{story.score:.3f}" if story.score else "—" return Text.assemble( ("score ", EVEROS_MUTED), - ("0.628\n", f"bold {EVEROS_GREEN}"), + (f"{score}\n", f"bold {EVEROS_GREEN}"), ("scope ", EVEROS_MUTED), - (f"user={story.owner} project=default\n", EVEROS_INK), - ("answer ", EVEROS_MUTED), - (story.answer, f"bold {EVEROS_YELLOW}"), + (f"user={story.owner} project=demo\n", EVEROS_INK), + ("= similarity to your stored memory", EVEROS_MUTED), ) -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 index 3e57f89ba..ba4bc6e7b 100644 --- a/src/everos/entrypoints/tui/demo/cloud.py +++ b/src/everos/entrypoints/tui/demo/cloud.py @@ -67,34 +67,34 @@ def new_demo_identity() -> tuple[str, str]: return f"everos-demo-{token}", f"everos_demo_{token}" -def recall_round( - memory: str, - query: str, +def check_health( *, base_url: str, - session_id: str, - user_id: 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 one ``add -> flush -> search`` round against a live EverOS server. - - Blocking (uses ``urllib`` + ``time.sleep`` while indexing catches up); call - it from a worker thread, never directly on an event loop. - """ + request_json: Callable[..., dict[str, Any]] | None = None, +) -> None: + """Raise CloudDemoError unless the server reports healthy. Blocking.""" request = request_json or _request_json health = request( - "GET", - "/health", - base_url=base_url, - timeout_seconds=timeout_seconds, + "GET", "/health", base_url=base_url, timeout_seconds=timeout_seconds ) if health.get("status") != "ok": raise CloudDemoError(f"EverOS server at {base_url} is not healthy") + +def add_memory( + memory: str, + *, + base_url: str, + session_id: str, + user_id: str, + timeout_seconds: float = LIVE_DEMO_TIMEOUT_SECONDS, + request_json: Callable[..., dict[str, Any]] | None = None, +) -> None: + """Send one user memory to the server. Blocking.""" + + request = request_json or _request_json timestamp_ms = int(get_utc_now().timestamp() * 1000) request( "POST", @@ -115,6 +115,18 @@ def recall_round( }, timeout_seconds=timeout_seconds, ) + + +def flush_memory( + *, + base_url: str, + session_id: str, + timeout_seconds: float = LIVE_DEMO_TIMEOUT_SECONDS, + request_json: Callable[..., dict[str, Any]] | None = None, +) -> None: + """Force extraction of the accumulated session into episodes/facts. Blocking.""" + + request = request_json or _request_json request( "POST", "/api/v1/memory/flush", @@ -127,6 +139,25 @@ def recall_round( timeout_seconds=timeout_seconds, ) + +def search_recall( + memory: str, + query: str, + *, + base_url: str, + user_id: str, + timeout_seconds: float = LIVE_DEMO_TIMEOUT_SECONDS, + search_attempts: int = LIVE_DEMO_SEARCH_ATTEMPTS, + search_interval_seconds: float = LIVE_DEMO_SEARCH_INTERVAL_SECONDS, + request_json: Callable[..., dict[str, Any]] | None = None, +) -> 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 server answered but returned nothing). Blocking. + """ + + request = request_json or _request_json search_payload = { "user_id": user_id, "app_id": LIVE_DEMO_APP_ID, @@ -147,11 +178,7 @@ def recall_round( return _story_from_live_episode(memory, query, episode, user_id=user_id) if attempt < search_attempts - 1: time.sleep(search_interval_seconds) - - raise CloudDemoError( - "EverOS accepted the memory, but search did not return it yet. " - "Try again once indexing catches up." - ) + return None def _request_json( @@ -220,6 +247,9 @@ def _story_from_live_episode( or memory ) episode_id = _string_field(episode, "id") or "live" + score = _float_field(first_fact, "score") if isinstance(first_fact, dict) else 0.0 + if not score: + score = _float_field(episode, "score") return DemoStory( owner=user_id, memory=memory, @@ -227,6 +257,7 @@ def _story_from_live_episode( answer=answer, source_filename=f"episode:{episode_id}", fact_filename=f"fact:{fact_id or 'live'}", + score=score, ) @@ -235,3 +266,10 @@ def _string_field(payload: dict[str, Any] | None, key: str) -> str: 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 6e10fc059..70e2b6718 100644 --- a/src/everos/entrypoints/tui/demo/data.py +++ b/src/everos/entrypoints/tui/demo/data.py @@ -18,6 +18,7 @@ class DemoStory: answer: str source_filename: str fact_filename: str + score: float = 0.0 def default_demo_story() -> DemoStory: 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 42f932e08..117909475 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -12,9 +12,10 @@ DotSphereWidget, EverOSDemoApp, QueryAnswerBar, + _capabilities_text, + _conversation_text, _field_header_text, _hero_text, - _payoff_text, _recall_proof_text, _signal_rail_text, _source_tree_text, @@ -56,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) @@ -70,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 @@ -80,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: @@ -106,19 +104,79 @@ 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 = _story( - "I keep my Monday design review notes in Notion.", - "Where are my Monday review notes?", - "In Notion.", +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_lists_real_strengths() -> None: + text = _capabilities_text().plain + for feature in ("hybrid retrieval", "rerank", "multilingual", "multimodal"): + assert feature 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).plain + assert "0.873" in text + assert "user=everos_demo_abc" in text + assert "project=demo" in text + - 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 +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: @@ -200,14 +258,17 @@ async def test_demo_tui_interactive_runs_cloud_round_per_input(monkeypatch) -> N from everos.entrypoints.tui.demo import cloud - def fake_recall( - memory: str, query: str, *, base_url: str, session_id: str, user_id: str + monkeypatch.setattr(cloud, "check_health", lambda **_: None) + monkeypatch.setattr(cloud, "add_memory", lambda *_, **__: None) + monkeypatch.setattr(cloud, "flush_memory", lambda **_: None) + + def fake_search( + memory: str, query: str, *, base_url: str, user_id: str, **_: object ) -> DemoStory: - # Stand in for the hosted server: echo the real call's identity through. assert base_url == "http://server.test" return _story(memory, query, f"recalled<{memory}>") - monkeypatch.setattr(cloud, "recall_round", fake_recall) + monkeypatch.setattr(cloud, "search_recall", fake_search) app = EverOSDemoApp( interactive=True, @@ -232,6 +293,9 @@ def fake_recall( 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" # Round 2 reaches the cap and locks the input behind the upgrade nudge. console_input.value = "I bike to work" @@ -251,10 +315,10 @@ async def test_demo_tui_interactive_shows_quota_guidance(monkeypatch) -> None: from everos.entrypoints.tui.demo import cloud from everos.entrypoints.tui.demo.app import _quota_guidance_text - def quota(*_: object, **__: object) -> DemoStory: + def quota(*_: object, **__: object) -> None: raise cloud.CloudQuotaError("http://server.test") - monkeypatch.setattr(cloud, "recall_round", quota) + monkeypatch.setattr(cloud, "check_health", quota) app = EverOSDemoApp( interactive=True, diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py b/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py index 2a423ec94..114cd46fe 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py @@ -2,6 +2,8 @@ from __future__ import annotations +import urllib.error + import pytest from everos.entrypoints.tui.demo import cloud @@ -35,8 +37,16 @@ def test_new_demo_identity_is_unique_and_paired() -> None: assert user_a.startswith("everos_demo_") -def test_recall_round_runs_real_flow_with_isolated_identity() -> None: - calls: list[tuple[str, str, dict[str, object] | None]] = [] +def test_check_health_raises_when_not_ok() -> None: + def fake_request(*_: object, **__: object) -> dict[str, object]: + return {"status": "degraded"} + + with pytest.raises(cloud.CloudDemoError): + cloud.check_health(base_url="http://server.test", request_json=fake_request) + + +def test_add_memory_sends_isolated_identity() -> None: + bodies: list[tuple[str, dict[str, object] | None]] = [] def fake_request( method: str, @@ -46,10 +56,34 @@ def fake_request( 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" - if path == "/health": - return {"status": "ok"} + bodies.append((path, json_body)) + return {} + + cloud.add_memory( + "我喜欢吃杨梅", + base_url="http://server.test", + session_id="everos-demo-abc", + user_id="everos_demo_abc", + request_json=fake_request, + ) + + path, body = bodies[0] + assert path == "/api/v1/memory/add" + assert body is not None + assert body["session_id"] == "everos-demo-abc" + assert body["messages"][0]["sender_id"] == "everos_demo_abc" + assert body["messages"][0]["content"] == "我喜欢吃杨梅" + + +def test_search_recall_returns_story_with_real_score() -> None: + def fake_request( + method: str, + path: str, + *, + base_url: str, + json_body: dict[str, object] | None = None, + timeout_seconds: float, + ) -> dict[str, object]: if path == "/api/v1/memory/search": return { "data": { @@ -57,61 +91,36 @@ def fake_request( { "id": "ep1", "summary": "You like Yangmei.", + "score": 0.41, "atomic_facts": [ - {"id": "af1", "content": "You like Yangmei."} + { + "id": "af1", + "content": "You like Yangmei.", + "score": 0.87, + } ], } ] } } - return {"status": "ok"} + return {} - story = cloud.recall_round( + story = cloud.search_recall( "我喜欢吃杨梅", "我喜欢吃什么", base_url="http://server.test", - session_id="everos-demo-abc", user_id="everos_demo_abc", request_json=fake_request, - timeout_seconds=1.0, ) - assert [path for _, path, _ in calls] == [ - "/health", - "/api/v1/memory/add", - "/api/v1/memory/flush", - "/api/v1/memory/search", - ] - add_body = calls[1][2] - search_body = calls[3][2] - assert add_body is not None and search_body is not None - assert add_body["session_id"] == "everos-demo-abc" - assert add_body["messages"][0]["sender_id"] == "everos_demo_abc" - assert add_body["messages"][0]["content"] == "我喜欢吃杨梅" - assert search_body["user_id"] == "everos_demo_abc" + assert story is not None assert story.owner == "everos_demo_abc" - assert story.memory == "我喜欢吃杨梅" assert story.query == "我喜欢吃什么" assert story.answer == "You like Yangmei." - assert story.source_filename == "episode:ep1" + assert story.score == 0.87 # prefers the top fact's score -def test_recall_round_raises_quota_error_on_429() -> None: - def fake_request(*_: object, **__: object) -> dict[str, object]: - raise cloud.CloudQuotaError("http://server.test") - - with pytest.raises(cloud.CloudQuotaError): - cloud.recall_round( - "m", - "q", - base_url="http://server.test", - session_id="s", - user_id="u", - request_json=fake_request, - ) - - -def test_recall_round_raises_demo_error_when_search_never_returns() -> None: +def test_search_recall_returns_none_on_miss() -> None: def fake_request( method: str, path: str, @@ -120,20 +129,30 @@ def fake_request( json_body: dict[str, object] | None = None, timeout_seconds: float, ) -> dict[str, object]: - if path == "/health": - return {"status": "ok"} - if path == "/api/v1/memory/search": - return {"data": {"episodes": []}} - return {"status": "ok"} + return {"data": {"episodes": []}} - with pytest.raises(cloud.CloudDemoError): - cloud.recall_round( - "m", - "q", - base_url="http://server.test", - session_id="s", - user_id="u", - request_json=fake_request, - search_attempts=2, - search_interval_seconds=0.0, + story = cloud.search_recall( + "m", + "q", + base_url="http://server.test", + user_id="u", + request_json=fake_request, + search_attempts=2, + search_interval_seconds=0.0, + ) + + assert story is None + + +def test_request_json_maps_429_to_quota_error(monkeypatch) -> None: + def boom(*_: object, **__: object) -> object: + raise urllib.error.HTTPError( + "http://server.test", 429, "Too Many Requests", {}, None + ) + + monkeypatch.setattr(cloud.urllib.request, "urlopen", boom) + + with pytest.raises(cloud.CloudQuotaError): + cloud._request_json( + "GET", "/health", base_url="http://server.test", timeout_seconds=1.0 ) From b300c2386ba796ef57dcbc19eef49ff9c788ca2e Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Thu, 25 Jun 2026 22:41:49 +0800 Subject: [PATCH 06/13] fix(demo): compact bottom, distinct capabilities box, real recall user - Shrink the conversation log + input box so the memory sphere stays the visual focus (conversation 7->4, input border removed to 1 row). - Give the capabilities box a distinct 'panel' border with a titled bar instead of the plain thick border. - Drop the multilingual capability row. - Recall lock shows the local user (user_label) and project=demo, never the alice default or session id; remove the 'similarity' caption line. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/everos/entrypoints/tui/demo/app.py | 35 ++++++++++++------- .../test_tui/test_demo_app.py | 8 +++-- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py index 95401fe34..3c030b6a1 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -240,9 +240,12 @@ class EverOSDemoApp(App[None]): }} #capabilities {{ - height: 9; - border: thick {EVEROS_YELLOW}; - background: {EVEROS_SURFACE}; + height: 8; + 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; }} @@ -276,7 +279,7 @@ class EverOSDemoApp(App[None]): }} #conversation {{ - height: 7; + height: 4; border-top: hkey {EVEROS_YELLOW}; background: {EVEROS_SURFACE}; color: {EVEROS_INK}; @@ -285,7 +288,7 @@ class EverOSDemoApp(App[None]): }} #console {{ - height: 4; + height: 2; margin-top: 1; }} @@ -296,8 +299,9 @@ class EverOSDemoApp(App[None]): }} #console-input {{ - border: round {EVEROS_AMBER}; - background: {EVEROS_SURFACE}; + height: 1; + border: none; + background: {EVEROS_SURFACE_RAISED}; }} Footer {{ @@ -376,7 +380,10 @@ def compose(self) -> ComposeResult: 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 conversation = Static(_conversation_text(self._log), id="conversation") @@ -504,7 +511,9 @@ async def _recall(self, memory: str, query: str) -> None: def _finish_round(self, story: DemoStory) -> None: self._story = story - self.query_one("#recall-lock", Static).update(_recall_proof_text(story)) + self.query_one("#recall-lock", Static).update( + _recall_proof_text(story, user_label=self._user_label) + ) self.action_replay() self._round += 1 if self._round >= self._max_rounds: @@ -706,7 +715,6 @@ def _capabilities_text() -> Text: rows = ( ("hybrid retrieval ", "BM25 + vector", EVEROS_YELLOW), ("agentic rerank ", "on", EVEROS_GREEN), - ("multilingual ", "CJK + EN", EVEROS_CYAN), ("multimodal ", "image / pdf / audio", EVEROS_ORANGE), ("md-first ", "auditable source", EVEROS_YELLOW_SOFT), ("local-first ", "runs on your machine", EVEROS_INK), @@ -731,15 +739,16 @@ def _source_tree_text() -> Text: ) -def _recall_proof_text(story: DemoStory | None = None) -> Text: +def _recall_proof_text( + story: DemoStory | None = None, *, user_label: str = "you" +) -> Text: story = story or default_demo_story() score = f"{story.score:.3f}" if story.score else "—" return Text.assemble( ("score ", EVEROS_MUTED), (f"{score}\n", f"bold {EVEROS_GREEN}"), ("scope ", EVEROS_MUTED), - (f"user={story.owner} project=demo\n", EVEROS_INK), - ("= similarity to your stored memory", EVEROS_MUTED), + (f"user={user_label} project=demo", EVEROS_INK), ) 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 117909475..6ebe5bb92 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -142,8 +142,9 @@ def test_signal_rail_light_colors_follow_white_yellow_black() -> None: def test_capabilities_box_lists_real_strengths() -> None: text = _capabilities_text().plain - for feature in ("hybrid retrieval", "rerank", "multilingual", "multimodal"): + for feature in ("hybrid retrieval", "rerank", "multimodal", "local-first"): assert feature in text + assert "multilingual" not in text def test_source_lock_uses_date_stamped_filenames() -> None: @@ -162,10 +163,11 @@ def test_recall_lock_shows_real_score_and_demo_scope() -> None: fact_filename="", score=0.873, ) - text = _recall_proof_text(story).plain + text = _recall_proof_text(story, user_label="YangtzeSeventh").plain assert "0.873" in text - assert "user=everos_demo_abc" in text + assert "user=YangtzeSeventh" in text # local user, not the session id or alice assert "project=demo" in text + assert "similarity" not in text def test_conversation_log_accumulates_turns() -> None: From 09b347097289ffd48d5b9a5be984769972769f30 Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Thu, 25 Jun 2026 22:56:18 +0800 Subject: [PATCH 07/13] feat(demo): first-class slash commands (/help /replay /clear /quit) Make in-box commands discoverable and give feedback: - /help lists the available commands; unknown /commands show a hint instead of being treated as a memory. - /replay re-runs the sphere animation; /clear wipes the conversation log. - /quit and /exit (with or without slash) exit to the terminal. - Input placeholder now advertises /help and /quit. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/everos/entrypoints/tui/demo/app.py | 44 ++++++++++++++++++- .../test_tui/test_demo_app.py | 44 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py index 3c030b6a1..eebfad92b 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -59,7 +59,7 @@ 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"}) +QUIT_COMMANDS = frozenset({"quit", "exit", ":q", "/quit", "/exit"}) _STATE_TO_STAGE = { "ingesting": 0, "extracting": 1, @@ -397,7 +397,8 @@ def compose(self) -> ComposeResult: ) yield Input( placeholder=( - "type a memory and press enter · 'quit' or ctrl+c to exit" + "type a memory & enter · /help for commands · " + "/quit to exit" ), id="console-input", ) @@ -424,6 +425,9 @@ def on_input_submitted(self, event: Input.Submitted) -> None: 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": @@ -446,6 +450,21 @@ def on_input_submitted(self, event: Input.Submitted) -> None: exclusive=True, ) + 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 == "/replay": + self.action_replay() + elif command == "/clear": + self._log.clear() + self.query_one("#conversation", Static).update( + _conversation_text(self._log) + ) + else: + prompt.update(_unknown_command_text(command)) + 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. @@ -607,6 +626,27 @@ def _recall_error_text(message: str) -> Text: ) +def _help_text() -> Text: + return Text.assemble( + ("commands ", f"bold {EVEROS_YELLOW}"), + ("/help", f"bold {EVEROS_GREEN}"), + (" list ", EVEROS_MUTED), + ("/replay", f"bold {EVEROS_GREEN}"), + (" re-run sphere ", EVEROS_MUTED), + ("/clear", f"bold {EVEROS_GREEN}"), + (" wipe log ", EVEROS_MUTED), + ("/quit", f"bold {EVEROS_GREEN}"), + (" exit", EVEROS_MUTED), + ) + + +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}"), 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 6ebe5bb92..4c5800007 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -227,6 +227,50 @@ def test_ctrl_c_is_a_priority_quit_binding() -> None: 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", "/replay", "/clear", "/quit"): + assert command in help_plain + assert "unknown command /bogus" in _unknown_command_text("/bogus").plain + + +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 From 5c342ea0c84f390a7bb3c5307e4876af897e397d Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Thu, 25 Jun 2026 23:20:12 +0800 Subject: [PATCH 08/13] feat(demo): /live command and live slash-command panel - Add /live: shows the 'use your own key' guidance (everos init -> everos demo --live) on demand, not just after the round cap. - Typing '/' now surfaces the available commands live in the prompt (a slash-command menu) and restores the phase prompt on real text. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/everos/entrypoints/tui/demo/app.py | 33 ++++++++++++++++++- .../test_tui/test_demo_app.py | 26 ++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py index eebfad92b..9471f6b36 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -450,21 +450,41 @@ def on_input_submitted(self, event: Input.Submitted) -> None: 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. @@ -631,8 +651,10 @@ def _help_text() -> Text: ("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 sphere ", EVEROS_MUTED), + (" re-run ", EVEROS_MUTED), ("/clear", f"bold {EVEROS_GREEN}"), (" wipe log ", EVEROS_MUTED), ("/quit", f"bold {EVEROS_GREEN}"), @@ -640,6 +662,15 @@ def _help_text() -> Text: ) +def _live_guidance_text() -> Text: + return Text.assemble( + ("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}"), 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 4c5800007..08a44563d 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -231,11 +231,35 @@ 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", "/replay", "/clear", "/quit"): + 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 app._conversation_phase == "memory" + assert app._pending_memory == "" + + async def test_slash_help_does_not_consume_a_turn() -> None: from textual.widgets import Input From ee2fb9369546b2371f0c85cd4f076592393918aa Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Thu, 25 Jun 2026 23:29:53 +0800 Subject: [PATCH 09/13] feat(demo): show real SOTA benchmark numbers in the strengths box evermind.ai publishes no token-saving percentage, so the strengths box now leads with the site's real SOTA Performance numbers (LoCoMo 93.05%, LongMemEval 83.00%, HaluMem 93.04%) plus core retrieval strengths -- no fabricated figures. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/everos/entrypoints/tui/demo/app.py | 15 +++++++++------ .../test_entrypoints/test_tui/test_demo_app.py | 11 +++++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py index 9471f6b36..0f2f0d362 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -240,7 +240,7 @@ class EverOSDemoApp(App[None]): }} #capabilities {{ - height: 8; + height: 9; border: panel {EVEROS_YELLOW}; border-title-color: {EVEROS_BLACK}; border-title-background: {EVEROS_YELLOW}; @@ -783,12 +783,15 @@ def _demo_fact_name() -> str: def _capabilities_text() -> Text: + # Real published numbers from evermind.ai ("SOTA Performance") plus the + # core retrieval strengths. No fabricated figures. rows = ( - ("hybrid retrieval ", "BM25 + vector", EVEROS_YELLOW), - ("agentic rerank ", "on", EVEROS_GREEN), - ("multimodal ", "image / pdf / audio", EVEROS_ORANGE), - ("md-first ", "auditable source", EVEROS_YELLOW_SOFT), - ("local-first ", "runs on your machine", EVEROS_INK), + ("LoCoMo ", "93.05%", EVEROS_YELLOW), + ("LongMemEval ", "83.00%", EVEROS_GREEN), + ("HaluMem ", "93.04%", EVEROS_CYAN), + ("hybrid ", "BM25 + vector", EVEROS_ORANGE), + ("rerank ", "agentic", EVEROS_YELLOW_SOFT), + ("local-first ", "on your machine", EVEROS_INK), ) parts: list[tuple[str, str]] = [] for label, value, color in rows: 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 08a44563d..ebd70e2ee 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -140,11 +140,14 @@ def test_signal_rail_light_colors_follow_white_yellow_black() -> None: assert "bold #1D1C18" in dot_styles # the errored light is black -def test_capabilities_box_lists_real_strengths() -> None: +def test_capabilities_box_uses_real_website_numbers() -> None: text = _capabilities_text().plain - for feature in ("hybrid retrieval", "rerank", "multimodal", "local-first"): - assert feature in text - assert "multilingual" not in text + # Real SOTA numbers published on evermind.ai (not fabricated token savings). + assert "93.05%" in text # LoCoMo + assert "83.00%" in text # LongMemEval + assert "93.04%" in text # HaluMem + assert "rerank" in text + assert "local-first" in text def test_source_lock_uses_date_stamped_filenames() -> None: From 8a5b4f07b0270d959b83e7ee904cb87669961e4f Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Thu, 25 Jun 2026 23:34:19 +0800 Subject: [PATCH 10/13] feat(demo): per-round token-saving estimate in recall lock Add a 'saved ~XX% tokens (est)' line under the recall score. It is a clearly labelled client-side estimate (carrying the whole conversation as context vs. EverOS returning only the compact recalled answer), since no measured token-saving figure is published and the server does not return token usage. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/everos/entrypoints/tui/demo/app.py | 24 +++++++++++++++++-- .../test_tui/test_demo_app.py | 7 +++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py index 0f2f0d362..236d8f833 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -350,6 +350,8 @@ def __init__( 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"): @@ -546,12 +548,24 @@ async def _recall(self, memory: str, query: str) -> None: 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) + _recall_proof_text( + story, user_label=self._user_label, saved_pct=self._saved_pct + ) ) self.action_replay() self._round += 1 @@ -814,13 +828,19 @@ def _source_tree_text() -> Text: def _recall_proof_text( - story: DemoStory | None = None, *, user_label: str = "you" + 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), (f"{score}\n", f"bold {EVEROS_GREEN}"), + ("saved ", EVEROS_MUTED), + (f"{saved}\n", f"bold {EVEROS_YELLOW}"), ("scope ", EVEROS_MUTED), (f"user={user_label} project=demo", EVEROS_INK), ) 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 ebd70e2ee..5371a7f20 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -166,11 +166,14 @@ def test_recall_lock_shows_real_score_and_demo_scope() -> None: fact_filename="", score=0.873, ) - text = _recall_proof_text(story, user_label="YangtzeSeventh").plain + 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: @@ -369,6 +372,8 @@ def fake_search( # 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" From 76c001fa97be736c98882593bb070f4d8f28d6f4 Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Thu, 25 Jun 2026 23:39:40 +0800 Subject: [PATCH 11/13] feat(demo): fill strengths box with token-efficiency + single benchmark Per the evermind.ai homepage: - Lead with the real token-efficiency claim (1/10 of full context). - Keep a single headline benchmark (LoCoMo 93.05%) instead of three. - Fill the box with real capability highlights (unlimited context, hybrid retrieval, agentic rerank, multimodal, self-evolving). - Drop local-first here (already shown in the field-header scope). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/everos/entrypoints/tui/demo/app.py | 18 ++++++++++-------- .../test_entrypoints/test_tui/test_demo_app.py | 11 ++++++----- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py index 236d8f833..8e7daa1c6 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -797,15 +797,17 @@ def _demo_fact_name() -> str: def _capabilities_text() -> Text: - # Real published numbers from evermind.ai ("SOTA Performance") plus the - # core retrieval strengths. No fabricated figures. + # 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 = ( - ("LoCoMo ", "93.05%", EVEROS_YELLOW), - ("LongMemEval ", "83.00%", EVEROS_GREEN), - ("HaluMem ", "93.04%", EVEROS_CYAN), - ("hybrid ", "BM25 + vector", EVEROS_ORANGE), - ("rerank ", "agentic", EVEROS_YELLOW_SOFT), - ("local-first ", "on your machine", EVEROS_INK), + ("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: 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 5371a7f20..80b452d78 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -142,12 +142,13 @@ def test_signal_rail_light_colors_follow_white_yellow_black() -> None: def test_capabilities_box_uses_real_website_numbers() -> None: text = _capabilities_text().plain - # Real SOTA numbers published on evermind.ai (not fabricated token savings). - assert "93.05%" in text # LoCoMo - assert "83.00%" in text # LongMemEval - assert "93.04%" in text # HaluMem + # 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 - assert "local-first" 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: From 1fb40f572f28cbbd194ba0bdb675f8fd90346281 Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Fri, 26 Jun 2026 00:12:15 +0800 Subject: [PATCH 12/13] docs(readme): sync demo section with slash commands and quit keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the Play With The Demo / 体验 Demo section to match the interactive TUI: typing / surfaces the commands (/replay, /live, /quit) and ctrl+c exits, replacing the stale 'press q to quit' line. Image left in place for later replacement. English and Chinese kept in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 7 ++++--- README.zh-CN.md | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ef01268a2..0964de0fa 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,10 @@ recall -> source proof -> confetti. See 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. After a few rounds the demo points you at configuring your own keys -(`everos init`, then `everos demo --live`). +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 69f86ae34..09bd14d0e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -131,9 +131,9 @@ 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` 可以退出。几轮之后,demo 会引导你去配置自己的 key -(`everos init`,然后 `everos demo --live`)。 +并在第一条记忆落地后进入 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 From f8089ba43470f117c1a0ba1fe8de6d098ce4bad9 Mon Sep 17 00:00:00 2001 From: YangtzeSeventh <1368653777@qq.com> Date: Mon, 29 Jun 2026 17:28:01 +0800 Subject: [PATCH 13/13] feat(demo): connect to EverOS Cloud (api.evermind.ai) instead of self-host The demo now runs against the EverOS Cloud platform, removing all self-host infra (no server/DNS/TLS/rate-limit gateway to deploy): - cloud.py speaks the platform API: POST /api/v1/memories (async, returns a task), task polling, POST /memories/flush with force=true, POST /memories/search with {filters:{user_id}}. Auth via Authorization: Bearer. Parses the real answer from atomic_fact and the fact-level score. - Verified end-to-end against the live platform (add -> task -> flush -> search returns a real recalled answer + score). - Key model: default/--cloud use a restricted demo key (env EVEROS_CLOUD_DEMO_KEY, shippable key can be baked in later); --live uses the user's own platform key (env EVEROS_CLOUD_API_KEY). Keys never hardcoded. - 401/403 -> CloudAuthError (prompts to set the key), 429 -> quota guidance, unreachable -> honest error. Single-message extraction works via force flush. - Docs + README updated to point at EverOS Cloud. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 5 +- README.zh-CN.md | 5 +- docs/everos-demo.md | 41 ++- src/everos/entrypoints/cli/commands/demo.py | 22 +- src/everos/entrypoints/tui/demo/app.py | 32 ++- src/everos/entrypoints/tui/demo/cloud.py | 237 +++++++++++------- src/everos/entrypoints/tui/demo/data.py | 2 +- .../test_cli/test_demo_command.py | 19 +- .../test_tui/test_demo_app.py | 8 +- .../test_tui/test_demo_cloud.py | 199 ++++++++++----- 10 files changed, 360 insertions(+), 210 deletions(-) diff --git a/README.md b/README.md index 0964de0fa..a0f4ce5de 100644 --- a/README.md +++ b/README.md @@ -124,8 +124,9 @@ everos demo 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 -EverMind's hosted demo server (keys live server-side, so you need no local -keys), and the panels follow your own input: conversation -> memory sphere -> +[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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 09bd14d0e..0741e8943 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -125,8 +125,9 @@ everos demo ``` 全屏 terminal UI 里带有一个输入框:你直接在 UI 里输入一条记忆,再输入一个 -召回问题。每一轮都会对 EverMind 托管的 demo server 跑真实的 memory pipeline -(key 在服务端,本地无需任何 API keys),各面板跟着你自己的输入更新: +召回问题。每一轮都会对 [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)。 diff --git a/docs/everos-demo.md b/docs/everos-demo.md index d8bb91c50..213933704 100644 --- a/docs/everos-demo.md +++ b/docs/everos-demo.md @@ -12,37 +12,34 @@ everos demo 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 (`add -> flush -> search`) against EverMind's hosted demo server. The -panels follow your own input: conversation -> memory sphere -> recall -> source -proof -> confetti. +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. -The hosted server holds the LLM and embedding keys server-side, so you need no -local keys. Each run uses a fresh, isolated `(session_id, user_id)` pair, so -concurrent visitors never see each other's memories. +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. -After a few rounds the demo points you at configuring your own keys (`everos -init`, then `everos demo --live`). +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. -The hosted endpoint can be overridden with the `EVEROS_CLOUD_DEMO_URL` -environment variable (or `--server-url `). If the server is unreachable or -the free quota is exhausted, the UI says so rather than faking a result. +The endpoint can be overridden with `EVEROS_CLOUD_DEMO_URL` (or `--server-url`). -## Run It Against Your Own Server +## Run It With Your Own Cloud Key -After `everos init` and `everos server start`, run the same interactive TUI -against your own server (your own keys): +Get a key from , then: ```bash +export EVEROS_CLOUD_API_KEY= everos demo --live ``` -Each round performs `GET /health` -> `POST /api/v1/memory/add` -> -`POST /api/v1/memory/flush` -> `POST /api/v1/memory/search`. If your server is -not on `http://127.0.0.1:8000`, pass `--server-url `. - -> Before the hosted demo server (and its DNS) is deployed, you can point the -> default demo at a local server with -> `EVEROS_CLOUD_DEMO_URL=http://127.0.0.1:8000 everos demo`. +`--live` runs the same flow against the same platform, just authenticated with +your own key instead of the restricted demo key. ## Static Previews @@ -66,7 +63,7 @@ because the public command is still `everos demo`. The TUI implementation lives under `src/everos/entrypoints/tui/demo/`: - `app.py` renders the Textual app and drives the interactive rounds. -- `cloud.py` is the hosted-demo HTTP client (`add -> flush -> search`). +- `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/entrypoints/cli/commands/demo.py b/src/everos/entrypoints/cli/commands/demo.py index ed69ea8d7..159cbbb74 100644 --- a/src/everos/entrypoints/cli/commands/demo.py +++ b/src/everos/entrypoints/cli/commands/demo.py @@ -45,17 +45,17 @@ def demo( live: bool = typer.Option( False, "--live", - help="Run the interactive flow against your own EverOS server.", + help="Use your own EverOS Cloud API key (env EVEROS_CLOUD_API_KEY).", ), cloud_mode: bool = typer.Option( False, "--cloud", - help="Run against EverMind's hosted demo server (this is the default).", + help="Run against EverOS Cloud with the demo key (this is the default).", ), server_url: str = typer.Option( cloud.LIVE_DEMO_SERVER_URL, "--server-url", - help="EverOS server URL used by --live (and to override --cloud).", + help="Override the EverOS Cloud API base URL.", ), ) -> None: """Launch the EverOS first-memory Textual TUI.""" @@ -76,21 +76,23 @@ def demo( def _launch_interactive_demo( *, live: bool, server_url: str, user_label: str = "you" ) -> None: - """Launch the cloud-backed interactive TUI, or --live against your own server.""" + """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() - if live: - base_url = server_url - session_id, user_id = cloud.LIVE_DEMO_SESSION_ID, cloud.LIVE_DEMO_USER_ID - else: - base_url = cloud.resolve_cloud_base_url(server_url) - session_id, user_id = cloud.new_demo_identity() + 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, ) diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py index 8e7daa1c6..e1b373217 100644 --- a/src/everos/entrypoints/tui/demo/app.py +++ b/src/everos/entrypoints/tui/demo/app.py @@ -330,9 +330,10 @@ def __init__( *, story: DemoStory | None = None, interactive: bool = False, - base_url: str = cloud.DEFAULT_CLOUD_DEMO_SERVER_URL, + 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: @@ -342,6 +343,7 @@ def __init__( 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 @@ -491,28 +493,37 @@ 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 = ( + 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.check_health, base_url=base_url) - ) - self._set_light("core", "ready") - await anyio.to_thread.run_sync( + 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.flush_memory, base_url=base_url, session_id=session_id) + 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") @@ -523,6 +534,7 @@ async def _recall(self, memory: str, query: str) -> None: query, base_url=base_url, user_id=user_id, + api_key=api_key, ) ) except cloud.CloudQuotaError: @@ -620,9 +632,10 @@ def run_demo_tui( *, story: DemoStory | None = None, interactive: bool = False, - base_url: str = cloud.DEFAULT_CLOUD_DEMO_SERVER_URL, + 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( @@ -631,6 +644,7 @@ def run_demo_tui( base_url=base_url, session_id=session_id, user_id=user_id, + api_key=api_key, user_label=user_label, ).run() diff --git a/src/everos/entrypoints/tui/demo/cloud.py b/src/everos/entrypoints/tui/demo/cloud.py index ba4bc6e7b..0e3abe6a7 100644 --- a/src/everos/entrypoints/tui/demo/cloud.py +++ b/src/everos/entrypoints/tui/demo/cloud.py @@ -1,18 +1,22 @@ -"""Hosted-demo HTTP client for ``everos demo``. +"""Cloud-platform HTTP client for ``everos demo``. -The default demo runs the *real* memory pipeline against a hosted EverOS server -that holds the LLM + embedding keys server-side, so a user experiences genuine -extraction and recall without configuring any keys locally. The keys never reach -the client; this module only speaks the public memory HTTP API. +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. -Each demo run uses a fresh ``(session_id, user_id)`` pair (see -:func:`new_demo_identity`) so concurrent visitors on the shared hosted server -never read each other's memories. +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, which must not depend on the CLI presentation layer. Failures raise -:class:`CloudDemoError` (or :class:`CloudQuotaError` for an exhausted free -quota), and callers decide how to surface them. +TUI worker. Failures raise :class:`CloudDemoError` (or the more specific +:class:`CloudQuotaError` / :class:`CloudAuthError`); callers decide how to +surface them. """ from __future__ import annotations @@ -29,35 +33,58 @@ 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" -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 - -# Hosted demo endpoint. Overridable via the env var so the URL is not hard-wired -# into releases; the default is a placeholder until the server is deployed. + +CLOUD_API_BASE_URL = "https://api.evermind.ai" CLOUD_DEMO_SERVER_URL_ENV = "EVEROS_CLOUD_DEMO_URL" -DEFAULT_CLOUD_DEMO_SERVER_URL = "https://demo.everos.evermind.ai" +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 hosted demo round could not be completed.""" + """A cloud demo round could not be completed.""" class CloudQuotaError(CloudDemoError): - """The hosted demo server hit its free per-visitor quota (HTTP 429).""" + """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 cloud endpoint: explicit --server-url wins, then env, then default.""" + """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, DEFAULT_CLOUD_DEMO_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]: @@ -67,75 +94,100 @@ def new_demo_identity() -> tuple[str, str]: return f"everos-demo-{token}", f"everos_demo_{token}" -def check_health( - *, - base_url: str, - timeout_seconds: float = LIVE_DEMO_TIMEOUT_SECONDS, - request_json: Callable[..., dict[str, Any]] | None = None, -) -> None: - """Raise CloudDemoError unless the server reports healthy. Blocking.""" - - request = request_json or _request_json - health = request( - "GET", "/health", base_url=base_url, timeout_seconds=timeout_seconds - ) - if health.get("status") != "ok": - raise CloudDemoError(f"EverOS server at {base_url} is not healthy") - - def add_memory( memory: str, *, base_url: str, session_id: str, user_id: str, - timeout_seconds: float = LIVE_DEMO_TIMEOUT_SECONDS, + api_key: str, request_json: Callable[..., dict[str, Any]] | None = None, -) -> None: - """Send one user memory to the server. Blocking.""" + 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) - request( + resp = request( "POST", - "/api/v1/memory/add", + "/api/v1/memories", base_url=base_url, + api_key=api_key, json_body={ + "user_id": user_id, "session_id": session_id, - "app_id": LIVE_DEMO_APP_ID, - "project_id": LIVE_DEMO_PROJECT_ID, "messages": [ - { - "sender_id": user_id, - "role": "user", - "timestamp": timestamp_ms, - "content": memory, - } + {"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, - timeout_seconds: float = LIVE_DEMO_TIMEOUT_SECONDS, + user_id: str, + api_key: str, request_json: Callable[..., dict[str, Any]] | None = None, + timeout_seconds: float = TIMEOUT_SECONDS, ) -> None: - """Force extraction of the accumulated session into episodes/facts. Blocking.""" + """Force extraction of the session into episodes/facts. Blocking.""" request = request_json or _request_json request( "POST", - "/api/v1/memory/flush", + "/api/v1/memories/flush", base_url=base_url, - json_body={ - "session_id": session_id, - "app_id": LIVE_DEMO_APP_ID, - "project_id": LIVE_DEMO_PROJECT_ID, - }, + api_key=api_key, + json_body={"user_id": user_id, "session_id": session_id, "force": True}, timeout_seconds=timeout_seconds, ) @@ -146,31 +198,32 @@ def search_recall( *, base_url: str, user_id: str, - timeout_seconds: float = LIVE_DEMO_TIMEOUT_SECONDS, - search_attempts: int = LIVE_DEMO_SEARCH_ATTEMPTS, - search_interval_seconds: float = LIVE_DEMO_SEARCH_INTERVAL_SECONDS, + 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 server answered but returned nothing). Blocking. + ``None`` on a miss (the platform answered but returned nothing). Blocking. """ request = request_json or _request_json - search_payload = { - "user_id": user_id, - "app_id": LIVE_DEMO_APP_ID, - "project_id": LIVE_DEMO_PROJECT_ID, + payload = { "query": query, + "filters": {"user_id": user_id}, + "method": "hybrid", "top_k": 5, } for attempt in range(search_attempts): search = request( "POST", - "/api/v1/memory/search", + "/api/v1/memories/search", base_url=base_url, - json_body=search_payload, + api_key=api_key, + json_body=payload, timeout_seconds=timeout_seconds, ) episode = _first_live_episode(search) @@ -186,33 +239,36 @@ def _request_json( 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") - request = urllib.request.Request( - url, - data=data, - method=method, - headers={"Content-Type": "application/json"}, - ) + 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 server at {base_url} returned HTTP {exc.code}." + 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 server at {base_url}.") from 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 server returned non-object JSON: {url}") + raise CloudDemoError(f"EverOS Cloud returned non-object JSON: {url}") return parsed @@ -236,20 +292,17 @@ def _story_from_live_episode( ) -> 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 "" + 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 ) - if not answer: - answer = ( - _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" - score = _float_field(first_fact, "score") if isinstance(first_fact, dict) else 0.0 - if not score: - score = _float_field(episode, "score") return DemoStory( owner=user_id, memory=memory, diff --git a/src/everos/entrypoints/tui/demo/data.py b/src/everos/entrypoints/tui/demo/data.py index 70e2b6718..891323849 100644 --- a/src/everos/entrypoints/tui/demo/data.py +++ b/src/everos/entrypoints/tui/demo/data.py @@ -26,7 +26,7 @@ def default_demo_story() -> DemoStory: 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.recall_round`). + :func:`everos.entrypoints.tui.demo.cloud.search_recall`). """ return DemoStory( 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 6504730be..03fbcd8c5 100644 --- a/tests/unit/test_entrypoints/test_cli/test_demo_command.py +++ b/tests/unit/test_entrypoints/test_cli/test_demo_command.py @@ -78,18 +78,20 @@ def test_launch_interactive_defaults_to_cloud_with_unique_identity(monkeypatch) 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 ) assert captured["interactive"] is True - assert captured["base_url"] == cloud.DEFAULT_CLOUD_DEMO_SERVER_URL + 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_server(monkeypatch) -> None: +def test_launch_interactive_live_uses_own_cloud_key(monkeypatch) -> None: captured: dict[str, object] = {} monkeypatch.setattr( @@ -97,12 +99,17 @@ def test_launch_interactive_live_uses_own_server(monkeypatch) -> None: "_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="http://127.0.0.1:9000") + demo_command._launch_interactive_demo( + live=True, server_url=cloud.LIVE_DEMO_SERVER_URL + ) - assert captured["base_url"] == "http://127.0.0.1:9000" - assert captured["session_id"] == cloud.LIVE_DEMO_SESSION_ID - assert captured["user_id"] == cloud.LIVE_DEMO_USER_ID + # --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 80b452d78..0010ef403 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -335,9 +335,9 @@ async def test_demo_tui_interactive_runs_cloud_round_per_input(monkeypatch) -> N from everos.entrypoints.tui.demo import cloud - monkeypatch.setattr(cloud, "check_health", lambda **_: None) - monkeypatch.setattr(cloud, "add_memory", lambda *_, **__: None) - monkeypatch.setattr(cloud, "flush_memory", lambda **_: None) + 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 @@ -397,7 +397,7 @@ async def test_demo_tui_interactive_shows_quota_guidance(monkeypatch) -> None: def quota(*_: object, **__: object) -> None: raise cloud.CloudQuotaError("http://server.test") - monkeypatch.setattr(cloud, "check_health", quota) + monkeypatch.setattr(cloud, "add_memory", quota) app = EverOSDemoApp( interactive=True, diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py b/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py index 114cd46fe..15781e44e 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_cloud.py @@ -1,4 +1,4 @@ -"""EverOS hosted-demo cloud client contracts.""" +"""EverOS Cloud demo client contracts.""" from __future__ import annotations @@ -13,7 +13,7 @@ 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.DEFAULT_CLOUD_DEMO_SERVER_URL + == cloud.CLOUD_API_BASE_URL ) monkeypatch.setenv(cloud.CLOUD_DEMO_SERVER_URL_ENV, "https://env.test") @@ -21,12 +21,18 @@ def test_resolve_cloud_base_url_prefers_explicit_then_env(monkeypatch) -> None: cloud.resolve_cloud_base_url(cloud.LIVE_DEMO_SERVER_URL) == "https://env.test" ) - # An explicit --server-url always wins over the env default. 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() @@ -37,105 +43,144 @@ def test_new_demo_identity_is_unique_and_paired() -> None: assert user_a.startswith("everos_demo_") -def test_check_health_raises_when_not_ok() -> None: - def fake_request(*_: object, **__: object) -> dict[str, object]: - return {"status": "degraded"} - - with pytest.raises(cloud.CloudDemoError): - cloud.check_health(base_url="http://server.test", request_json=fake_request) - - -def test_add_memory_sends_isolated_identity() -> None: - bodies: list[tuple[str, dict[str, object] | None]] = [] +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]: - bodies.append((path, json_body)) - return {} + calls.append((method, path, json_body, api_key)) + return {"data": {"task_id": "task-123", "status": "queued"}} - cloud.add_memory( + task_id = cloud.add_memory( "我喜欢吃杨梅", - base_url="http://server.test", + base_url="https://api.test", session_id="everos-demo-abc", user_id="everos_demo_abc", + api_key="k-1", request_json=fake_request, ) - path, body = bodies[0] - assert path == "/api/v1/memory/add" - assert body is not None - assert body["session_id"] == "everos-demo-abc" - assert body["messages"][0]["sender_id"] == "everos_demo_abc" + 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_search_recall_returns_story_with_real_score() -> None: +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]: - if path == "/api/v1/memory/search": - return { - "data": { - "episodes": [ - { - "id": "ep1", - "summary": "You like Yangmei.", - "score": 0.41, - "atomic_facts": [ - { - "id": "af1", - "content": "You like Yangmei.", - "score": 0.87, - } - ], - } - ] - } - } - return {} + bodies.append(json_body) + assert path == "/api/v1/memories/flush" + return {"data": {"status": "extracted"}} - story = cloud.search_recall( - "我喜欢吃杨梅", - "我喜欢吃什么", - base_url="http://server.test", - user_id="everos_demo_abc", + cloud.flush_memory( + base_url="https://api.test", + session_id="s", + user_id="u", + api_key="k", request_json=fake_request, ) - assert story is not None - assert story.owner == "everos_demo_abc" - assert story.query == "我喜欢吃什么" - assert story.answer == "You like Yangmei." - assert story.score == 0.87 # prefers the top fact's score + assert bodies[0]["force"] is True + assert bodies[0]["user_id"] == "u" -def test_search_recall_returns_none_on_miss() -> None: +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="http://server.test", + base_url="https://api.test", user_id="u", + api_key="k", request_json=fake_request, search_attempts=2, search_interval_seconds=0.0, @@ -144,15 +189,45 @@ def fake_request( assert story is None -def test_request_json_maps_429_to_quota_error(monkeypatch) -> None: - def boom(*_: object, **__: object) -> object: - raise urllib.error.HTTPError( - "http://server.test", 429, "Too Many Requests", {}, 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) - monkeypatch.setattr(cloud.urllib.request, "urlopen", boom) + 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", "/health", base_url="http://server.test", timeout_seconds=1.0 + "GET", "/x", base_url="https://api.test", timeout_seconds=1.0 )