Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ demo/memcell_outputs/

# macOS
.DS_Store

# Office temporary lock files
~$*

# AI agent tooling (local agent skills/config, not part of the project)
.agents/
AGENTS.md
*.duckdb


Expand Down
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,20 @@ Before configuring a provider or starting the server, run:
everos demo
```

The command asks for one memory and one recall question, then opens a
full-screen terminal visualizer. It is hardcoded and local to the CLI: it does
not need an API key, start or call the EverOS server, or change anything in the
real memory workflow below.

<p align="center">
<img src="https://gist.githubusercontent.com/cyfyifanchen/afa2cf40bf138a3ec96d917e8f2791a2/raw/d4ce82a6ddd7b3ebaf221e4825af993aeca5a7ce/everos-demo-tui-animation.svg" alt="Animated EverOS demo preview showing the memory sphere moving through recall and confetti states" width="720">
</p>
The full-screen terminal UI has an input box: type something EverOS should
remember, then ask a question that recalls it. No API key or server setup is
needed. Each round lets you watch the memory move through the real lifecycle:
ingest -> extract -> index -> recall.

The particle sphere changes with each stage, then bursts across the memory
field and fades away. A small core of yellow and white particles keeps moving
at the center, then expands smoothly into the next round. See
[docs/everos-demo.md](docs/everos-demo.md) for the complete experience. Type
`/` to see the commands (`/replay`, `/live`, `/quit`); `ctrl+c` exits anytime.
After a few rounds the demo points you at configuring your own keys (`everos
init`, then `everos demo --live`).

<https://github.com/user-attachments/assets/98cb8e1e-2ca8-4504-b0a6-0b9a040a0a5c>

Press `r` to replay and `q` to quit. For a non-interactive preview, use
`everos demo --plain`; for the looping showroom view, use
Expand Down
16 changes: 10 additions & 6 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,17 @@ uv pip install everos
everos demo
```

这个命令会询问一条记忆和一个召回问题,然后打开全屏 terminal visualizer。
它是 hardcoded 的本地 CLI 演示:不需要 API Key,不会启动或连接 EverOS
server,也不会修改下面真实记忆流程的任何数据
全屏 terminal UI 里带有一个输入框:先输入一件希望 EverOS 记住的事情,再提出
一个能够召回它的问题。不需要 API key,也不需要提前启动 server。每一轮都能
直观看到记忆经历完整流程:ingest -> extract -> index -> recall

<p align="center">
<img src="https://gist.githubusercontent.com/cyfyifanchen/afa2cf40bf138a3ec96d917e8f2791a2/raw/d4ce82a6ddd7b3ebaf221e4825af993aeca5a7ce/everos-demo-tui-animation.svg" alt="Animated EverOS demo preview showing the memory sphere moving through recall and confetti states" width="720">
</p>
粒子球会随四个阶段持续变化,随后散落到 memory field 并淡出;中心会先留下少量
黄白粒子攒动,再由中心向外扩展成完整球体,进入下一轮。完整体验见
[docs/everos-demo.md](docs/everos-demo.md)。输入 `/` 可以
查看可用命令(`/replay`、`/live`、`/quit`);`ctrl+c` 随时退出。几轮之后,demo
会引导你配置自己的 key(`everos init`,然后 `everos demo --live`)。

<https://github.com/user-attachments/assets/98cb8e1e-2ca8-4504-b0a6-0b9a040a0a5c>

按 `r` replay,按 `q` 退出。非交互式预览可以使用 `everos demo --plain`;
循环 showroom view 可以使用 `everos demo --cinematic`。Visualizer 的范围见
Expand Down
52 changes: 52 additions & 0 deletions deploy/netlify_relay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# EverOS demo relay on Netlify

This project deploys the public `everos demo` relay as a Netlify Function. The
client sends no credentials. The function accepts only the demo API surface,
injects the platform key stored in Netlify, applies distributed per-IP quotas
with Upstash Redis, and forwards the request to EverOS Cloud.

## Required Netlify environment variables

Configure these under **Project configuration -> Environment variables**. Never
put their values in this repository.

| Variable | Required | Default |
| --- | --- | --- |
| `EVEROS_CLOUD_API_KEY` | yes | none |
| `UPSTASH_REDIS_REST_URL` | yes | none |
| `UPSTASH_REDIS_REST_TOKEN` | yes | none |
| `EVEROS_CLOUD_UPSTREAM` | no | `https://api.evermind.ai` |
| `RELAY_RATE_PER_MIN` | no | `30` |
| `RELAY_DAILY_ROUNDS` | no | `3` |
| `RELAY_UPSTREAM_TIMEOUT_MS` | no | `20000` |
| `RELAY_MAX_BODY_BYTES` | no | `1000000` |

The per-minute limit counts every relay request. The daily limit counts only
`POST /api/v2/memory/add`, which starts a demo round; flush and search requests
do not spend additional rounds. The quota service fails closed:
demo API requests return `503` when Redis is not configured or unavailable.
`/healthz` remains available for diagnostics and reports only whether secrets
are configured, never their values.

## Deploy from GitHub

1. In Netlify, choose **Add new project -> Import an existing project -> GitHub**.
2. Select the EverOS fork and the `feat/demo-cloud-interactive` branch.
3. Set **Base directory** to `deploy/netlify_relay`.
4. Leave the build command empty. `netlify.toml` supplies the publish and
functions directories.
5. Add the required environment variables and deploy.
6. Verify `https://<site>.netlify.app/healthz` reports both configuration flags
as `true`.
7. Test the client with
`EVEROS_CLOUD_DEMO_URL=https://<site>.netlify.app everos demo`.
8. After validation, add the production custom domain.

## Local checks

```bash
cd deploy/netlify_relay
npm run check
```

The test suite is credential-free and mocks both Redis and EverOS Cloud.
13 changes: 13 additions & 0 deletions deploy/netlify_relay/netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[build]
publish = "public"
functions = "netlify/functions"

[build.environment]
NODE_VERSION = "22"

[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
Referrer-Policy = "no-referrer"
X-Frame-Options = "DENY"
199 changes: 199 additions & 0 deletions deploy/netlify_relay/netlify/functions/relay.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
const DEFAULT_UPSTREAM = "https://api.evermind.ai";
const DEFAULT_RATE_PER_MINUTE = 30;
const DEFAULT_DAILY_ROUNDS = 3;
const DEFAULT_TIMEOUT_MS = 20_000;
const DEFAULT_MAX_BODY_BYTES = 1_000_000;

const ALLOWED_EXACT = new Set([
"POST /api/v2/memory/add",
"POST /api/v2/memory/flush",
"POST /api/v2/memory/search",
]);

function readPositiveInteger(name, fallback) {
const value = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(value) && value > 0 ? value : fallback;
}

function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}

export function isAllowed(method, path) {
return ALLOWED_EXACT.has(`${method} ${path}`);
}

function clientIp(request, context) {
return context?.ip || request.headers.get("x-nf-client-connection-ip") || "unknown";
}

async function hashClientIp(ip) {
const bytes = new TextEncoder().encode(ip);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"),
)
.join("")
.slice(0, 24);
}

async function checkQuota(ip, countRound, now = Date.now()) {
const redisUrl = (process.env.UPSTASH_REDIS_REST_URL ?? "").replace(/\/$/, "");
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN ?? "";
if (!redisUrl || !redisToken) {
throw new Error("quota store is not configured");
}

const fingerprint = await hashClientIp(ip);
const minute = Math.floor(now / 60_000);
const day = new Date(now).toISOString().slice(0, 10);
const minuteKey = `everos-demo:minute:${fingerprint}:${minute}`;
const commands = [
["INCR", minuteKey],
["EXPIRE", minuteKey, 120],
];
if (countRound) {
const dailyKey = `everos-demo:rounds:${fingerprint}:${day}`;
commands.push(["INCR", dailyKey], ["EXPIRE", dailyKey, 172_800]);
}

const response = await fetch(`${redisUrl}/pipeline`, {
method: "POST",
headers: {
authorization: `Bearer ${redisToken}`,
"content-type": "application/json",
},
body: JSON.stringify(commands),
});
if (!response.ok) {
throw new Error("quota store request failed");
}

const results = await response.json();
if (!Array.isArray(results) || results.length !== commands.length) {
throw new Error("quota store returned an invalid response");
}
if (results.some((result) => result?.error)) {
throw new Error("quota store command failed");
}

const minuteCount = Number(results[0]?.result);
const dailyCount = countRound ? Number(results[2]?.result) : 0;
if (!Number.isFinite(minuteCount) || !Number.isFinite(dailyCount)) {
throw new Error("quota store returned invalid counters");
}

return {
minuteCount,
dailyCount,
minuteLimit: readPositiveInteger(
"RELAY_RATE_PER_MIN",
DEFAULT_RATE_PER_MINUTE,
),
dailyLimit: readPositiveInteger(
"RELAY_DAILY_ROUNDS",
DEFAULT_DAILY_ROUNDS,
),
};
}

async function forwardRequest(request, path, context) {
const apiKey = process.env.EVEROS_CLOUD_API_KEY ?? "";
if (!apiKey) {
return jsonResponse({ error: "relay API key is not configured" }, 500);
}

let quota;
try {
const countRound =
request.method === "POST" && path === "/api/v2/memory/add";
quota = await checkQuota(clientIp(request, context), countRound);
} catch {
return jsonResponse({ error: "relay quota service is unavailable" }, 503);
}
if (quota.minuteCount > quota.minuteLimit) {
return jsonResponse({ error: "rate limit exceeded, slow down" }, 429);
}
if (quota.dailyCount > quota.dailyLimit) {
return jsonResponse(
{ error: "daily demo round limit reached, configure your own key" },
429,
);
}

const maxBodyBytes = readPositiveInteger(
"RELAY_MAX_BODY_BYTES",
DEFAULT_MAX_BODY_BYTES,
);
const body = request.method === "GET" ? null : await request.arrayBuffer();
if (body && body.byteLength > maxBodyBytes) {
return jsonResponse({ error: "request body is too large" }, 413);
}

const upstream = (process.env.EVEROS_CLOUD_UPSTREAM ?? DEFAULT_UPSTREAM).replace(
/\/$/,
"",
);
const incomingUrl = new URL(request.url);
const upstreamUrl = `${upstream}${path}${incomingUrl.search}`;
const timeoutMs = readPositiveInteger(
"RELAY_UPSTREAM_TIMEOUT_MS",
DEFAULT_TIMEOUT_MS,
);

let response;
try {
response = await fetch(upstreamUrl, {
method: request.method,
headers: {
accept: "application/json",
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
body,
signal: AbortSignal.timeout(timeoutMs),
});
} catch {
return jsonResponse({ error: "upstream unreachable" }, 502);
}

const headers = new Headers();
headers.set(
"content-type",
response.headers.get("content-type") ?? "application/json",
);
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
headers.set("retry-after", retryAfter);
}
return new Response(await response.arrayBuffer(), {
status: response.status,
headers,
});
}

export default async function relay(request, context) {
const path = new URL(request.url).pathname;
if (path === "/healthz") {
return jsonResponse({
ok: true,
upstream: process.env.EVEROS_CLOUD_UPSTREAM ?? DEFAULT_UPSTREAM,
key_configured: Boolean(process.env.EVEROS_CLOUD_API_KEY),
quota_configured: Boolean(
process.env.UPSTASH_REDIS_REST_URL &&
process.env.UPSTASH_REDIS_REST_TOKEN,
),
});
}
if (!isAllowed(request.method, path)) {
return jsonResponse({ error: "endpoint not allowed" }, 403);
}
return forwardRequest(request, path, context);
}

export const config = {
path: ["/healthz", "/api/v2/*"],
};
15 changes: 15 additions & 0 deletions deploy/netlify_relay/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions deploy/netlify_relay/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "everos-demo-netlify-relay",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"check": "node --check netlify/functions/relay.mjs && node --test tests/*.test.mjs",
"test": "node --test tests/*.test.mjs"
},
"engines": {
"node": ">=22"
}
}
16 changes: 16 additions & 0 deletions deploy/netlify_relay/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>EverOS Demo Relay</title>
</head>
<body>
<main>
<h1>EverOS Demo Relay</h1>
<p>This service powers the public <code>everos demo</code> experience.</p>
<p><a href="/healthz">Service health</a></p>
</main>
</body>
</html>
Loading