feat(wave20): kei-cortex daemon + cortex-ui — local HTTP + TypeScript web UI

49 crates, 876 tests green (+17 kei-cortex + 10 cortex-ui TS, was 859).

## kei-cortex — local HTTP daemon (Rust)

Axum-based server on :9797 exposing read-only cortex state (ledger,
pet, memory) as JSON for browser UI consumption. Bearer token auth.
CORS for https://keisei.app. Binds 127.0.0.1 only.

### Endpoints

- GET  /healthz — unauthenticated liveness
- GET  /api/v1/cortex/summary — total_dnas + active_pets + recent_sessions
- GET  /api/v1/cortex/pet/:user_id — pet manifest
- POST /api/v1/cortex/pet/:user_id/interaction — log chat
- GET  /api/v1/cortex/ledger/recent?limit=N — recent agent runs
- GET  /api/v1/cortex/memory/search?user_id=X&pet_name=Y&q=... — recall

### Security

- Token at ~/.keisei/cortex.token (32-byte hex, chmod 600 atomic via
  OpenOptions mode 0o600)
- tower-http CorsLayer with configured allow_origin
- tokio::task::spawn_blocking for rusqlite reads
- All non-healthz routes protected by Bearer middleware

### Constructor Pattern

14 files, largest 137 LOC. All functions ≤30 LOC. Split: auth / config
/ error / state / routes + 5 handlers (health/summary/pet/ledger/memory).

17 tests: token roundtrip + chmod 600 (cfg unix) + 401/403/healthz +
summary shape + pet 404 + pet parse + interaction 201 + CORS preflight
+ ledger limit + empty ledger.

## cortex-ui — Svelte 5 + TypeScript + Vite

Static web app, build to dist/ (~500 KB incl sourcemaps, 64 KB minified
JS+CSS), deployable to https://keisei.app/cortex/. Connects to local
kei-cortex daemon via fetch.

### Features

- Setup wizard (first run): daemon URL + token paste, saved to
  localStorage (origin-scoped)
- Dashboard: summary cards + nav
- PetEditor: view pet.toml fields (identity/voice/edge/forbidden)
- LedgerStream: recent agent runs, auto-refresh 5s
- MemorySearch: query form + results list
- Hash-based routing (no server needed)
- Dark-mode via prefers-color-scheme
- URL-param override: ?daemon=URL&token=T for one-click setup

### Stack choice

Svelte 5 for minimal runtime (~2 KB). TypeScript strict inherits
_ts_packages/tsconfig.base.json. Vite for dev + build. vitest for unit
tests (10 passing: api header/error, config precedence/overrides).

## User flow

Non-dev:
1. Install keisei, run `kei-cortex serve`
2. Open https://keisei.app/cortex
3. Paste daemon URL + token from ~/.keisei/cortex.token
4. View dashboard, edit pet, search memory — all local data, zero cloud

Power user (self-host):
1. `cd _ts_packages/packages/cortex-ui && npm run build`
2. Serve dist/ from localhost OR deploy anywhere
3. Point to own daemon URL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-24 01:50:21 +08:00
parent 07eb0b83ea
commit 6672ae48e7
40 changed files with 3492 additions and 1 deletions

View file

@ -2519,6 +2519,26 @@ dependencies = [
"tempfile",
]
[[package]]
name = "kei-cortex"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"clap",
"kei-pet",
"kei-shared",
"rand 0.8.6",
"reqwest",
"rusqlite",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tower-http 0.5.2",
]
[[package]]
name = "kei-crossdomain"
version = "0.1.0"
@ -3850,7 +3870,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.4",
"tower",
"tower-http",
"tower-http 0.6.8",
"tower-service",
"url",
"wasm-bindgen",
@ -4927,6 +4947,23 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.11.1",
"bytes",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.8"

View file

@ -75,6 +75,8 @@ members = [
"kei-dna-index",
# Pet UI v1 — persona manifest parse/validate + Ed25519 identity + overlay renderer
"kei-pet",
# v0.37 Wave 20 — local HTTP daemon (axum) exposing cortex state for web UI at keisei.app
"kei-cortex",
]
[workspace.package]

View file

@ -0,0 +1,32 @@
[package]
name = "kei-cortex"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Local HTTP daemon exposing cortex state for UI consumption"
[[bin]]
name = "kei-cortex"
path = "src/main.rs"
[lib]
name = "kei_cortex"
path = "src/lib.rs"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "net"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive"] }
thiserror = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
anyhow = "1"
rand = "0.8"
kei-pet = { path = "../kei-pet" }
kei-shared = { path = "../kei-shared" }
[dev-dependencies]
tempfile = "3"
reqwest = { version = "0.12", features = ["json", "blocking", "rustls-tls"], default-features = false }

View file

@ -0,0 +1,122 @@
//! Token lifecycle: generate / save (chmod 600) / load / validate.
//!
//! The bearer token is a 32-byte random value rendered as 64 lowercase hex
//! characters. It is stored at `~/.keisei/cortex.token` with file mode
//! 0600 on unix. Reads trim trailing whitespace so a caller-added newline
//! does not corrupt comparisons.
use rand::RngCore;
use std::fs;
use std::io::Write;
use std::path::Path;
/// Length of the raw token in bytes (32 → 64 hex chars).
pub const TOKEN_BYTES: usize = 32;
/// Length of the hex-rendered token (always `2 * TOKEN_BYTES`).
pub const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2;
/// Errors surfaced by this module.
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("token file I/O: {0}")]
Io(#[from] std::io::Error),
#[error("token length invalid: expected {TOKEN_HEX_LEN} hex chars, got {0}")]
BadLength(usize),
#[error("token contained non-hex byte at index {0}")]
NotHex(usize),
}
/// Generate a fresh 32-byte token rendered as 64 lowercase hex characters.
pub fn generate_token() -> String {
let mut buf = [0u8; TOKEN_BYTES];
rand::thread_rng().fill_bytes(&mut buf);
to_hex(&buf)
}
/// Lowercase hex encoder; avoids pulling `hex` crate for one function.
fn to_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
/// Write `token` to `path`, creating parent directories and enforcing
/// mode 0600 on unix (atomic: temp file + rename).
pub fn save_token(path: &Path, token: &str) -> Result<(), AuthError> {
validate_hex(token)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
write_mode_600(path, token.as_bytes())?;
Ok(())
}
/// Read the token from `path`, trimming trailing whitespace, and validate it.
pub fn load_token(path: &Path) -> Result<String, AuthError> {
let raw = fs::read_to_string(path)?;
let token = raw.trim().to_string();
validate_hex(&token)?;
Ok(token)
}
/// Validate the token is exactly `TOKEN_HEX_LEN` lowercase-or-uppercase hex.
pub fn validate_hex(token: &str) -> Result<(), AuthError> {
if token.len() != TOKEN_HEX_LEN {
return Err(AuthError::BadLength(token.len()));
}
for (i, b) in token.bytes().enumerate() {
let ok = b.is_ascii_digit()
|| (b'a'..=b'f').contains(&b)
|| (b'A'..=b'F').contains(&b);
if !ok {
return Err(AuthError::NotHex(i));
}
}
Ok(())
}
/// Constant-time-ish comparison (length + byte-level xor fold).
/// Enough for a local-only daemon with a fresh random token per install.
pub fn tokens_match(expected: &str, got: &str) -> bool {
if expected.len() != got.len() {
return false;
}
let mut diff: u8 = 0;
for (a, b) in expected.bytes().zip(got.bytes()) {
diff |= a ^ b;
}
diff == 0
}
#[cfg(unix)]
fn write_mode_600(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(bytes)?;
f.sync_all()?;
Ok(())
}
#[cfg(not(unix))]
fn write_mode_600(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)?;
f.write_all(bytes)?;
f.sync_all()?;
Ok(())
}

View file

@ -0,0 +1,66 @@
//! Runtime configuration for the cortex daemon.
//!
//! `AppConfig` is assembled once at startup from CLI arguments and handed to
//! the router via `AppState`. All paths are resolved to absolute at construct
//! time so handlers never have to re-resolve `~` or cwd.
use std::path::PathBuf;
/// Default listen port when `--port` is not provided.
pub const DEFAULT_PORT: u16 = 9797;
/// Default CORS origin when `--cors-origin` is not provided.
pub const DEFAULT_CORS_ORIGIN: &str = "https://keisei.app";
/// Runtime configuration.
#[derive(Debug, Clone)]
pub struct AppConfig {
/// TCP port for the local HTTP listener. Bound to 127.0.0.1 only.
pub port: u16,
/// Single CORS origin the daemon will echo back. Exact-match; no wildcards.
pub cors_origin: String,
/// Path to the bearer-token file. Read once at startup.
pub token_path: PathBuf,
/// SQLite database holding the agent ledger (kei-ledger schema).
pub ledger_path: PathBuf,
/// Root directory holding `<user_id>.toml` pet manifests.
pub pet_root: PathBuf,
/// SQLite database holding pet conversation memory (kei-pet schema).
pub memory_db: PathBuf,
}
impl AppConfig {
/// Build a config, defaulting any `None` fields to conventional values
/// rooted at `$HOME/.keisei/`.
pub fn new(
port: Option<u16>,
cors_origin: Option<String>,
token_path: Option<PathBuf>,
ledger_path: Option<PathBuf>,
pet_root: Option<PathBuf>,
memory_db: Option<PathBuf>,
) -> Self {
let home = home_dir();
let base = home.join(".keisei");
Self {
port: port.unwrap_or(DEFAULT_PORT),
cors_origin: cors_origin.unwrap_or_else(|| DEFAULT_CORS_ORIGIN.to_string()),
token_path: token_path.unwrap_or_else(|| base.join("cortex.token")),
ledger_path: ledger_path.unwrap_or_else(|| base.join("ledger.sqlite")),
pet_root: pet_root.unwrap_or_else(|| base.join("pets")),
memory_db: memory_db.unwrap_or_else(|| base.join("pet-memory.sqlite")),
}
}
}
/// `HOME` with a plain-`.` fallback so tests that unset `HOME` still work.
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}

View file

@ -0,0 +1,67 @@
//! Unified error type mapped to HTTP responses with JSON body.
//!
//! Handlers return `Result<T, AppError>` and axum converts the error via
//! `IntoResponse`. All outbound bodies share the shape
//! `{ "error": { "code": "...", "message": "..." } }` so the UI has a single
//! parser.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// Application-level error. Variants map 1:1 to HTTP status codes.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("missing bearer token")]
Unauthorized,
#[error("bearer token rejected")]
Forbidden,
#[error("resource not found: {0}")]
NotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
#[error("database error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("internal error: {0}")]
Internal(String),
}
impl AppError {
fn status_and_code(&self) -> (StatusCode, &'static str) {
match self {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
AppError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "io_error"),
AppError::Sqlite(_) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error"),
AppError::Serde(_) => (StatusCode::INTERNAL_SERVER_ERROR, "serde_error"),
AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = self.status_and_code();
let body = Json(json!({
"error": {
"code": code,
"message": self.to_string(),
}
}));
(status, body).into_response()
}
}

View file

@ -0,0 +1,6 @@
//! Unauthenticated liveness probe.
/// `GET /healthz` → `"ok"` (text/plain). Always returns 200 OK.
pub async fn healthz() -> &'static str {
"ok"
}

View file

@ -0,0 +1,111 @@
//! `GET /api/v1/cortex/ledger/recent?limit=N` — most-recent agent rows.
//!
//! Reads the kei-ledger SQLite database directly. The daemon only needs the
//! columns the UI renders, so we project a compact `LedgerRow` rather than
//! the full kei-ledger struct.
use crate::error::AppError;
use crate::state::AppState;
use axum::extract::{Query, State};
use axum::Json;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
/// Hard upper bound on `limit` to keep responses small.
pub const MAX_LIMIT: usize = 200;
/// Default limit when the query string is omitted.
pub const DEFAULT_LIMIT: usize = 20;
#[derive(Debug, Deserialize)]
pub struct LedgerQuery {
pub limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct LedgerRow {
pub id: String,
pub branch: String,
pub parent_branch: Option<String>,
pub status: String,
pub started_ts: i64,
pub finished_ts: Option<i64>,
pub summary: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct LedgerResponse {
pub rows: Vec<LedgerRow>,
}
/// Handler entry point.
pub async fn recent(
State(state): State<AppState>,
Query(q): Query<LedgerQuery>,
) -> Result<Json<LedgerResponse>, AppError> {
let limit = clamp_limit(q.limit.unwrap_or(DEFAULT_LIMIT));
let cfg = state.config().clone();
let rows = tokio::task::spawn_blocking(move || load_recent(&cfg.ledger_path, limit))
.await
.map_err(|e| AppError::Internal(format!("ledger task join: {e}")))??;
Ok(Json(LedgerResponse { rows }))
}
fn clamp_limit(requested: usize) -> usize {
if requested == 0 {
DEFAULT_LIMIT
} else if requested > MAX_LIMIT {
MAX_LIMIT
} else {
requested
}
}
fn load_recent(path: &std::path::Path, limit: usize) -> Result<Vec<LedgerRow>, AppError> {
if !path.exists() {
return Ok(Vec::new());
}
let conn = Connection::open(path)?;
if !has_agents_table(&conn)? {
return Ok(Vec::new());
}
query_rows(&conn, limit)
}
fn has_agents_table(conn: &Connection) -> Result<bool, AppError> {
let exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='agents'",
[],
|r| r.get(0),
)
.unwrap_or(0);
Ok(exists > 0)
}
fn query_rows(conn: &Connection, limit: usize) -> Result<Vec<LedgerRow>, AppError> {
let mut stmt = conn.prepare(
"SELECT id, branch, parent_branch, status, started_ts, finished_ts, summary
FROM agents
ORDER BY started_ts DESC, id DESC
LIMIT ?1",
)?;
let iter = stmt.query_map([limit as i64], row_to_ledger)?;
let mut out = Vec::with_capacity(limit);
for row in iter {
out.push(row?);
}
Ok(out)
}
fn row_to_ledger(row: &rusqlite::Row<'_>) -> rusqlite::Result<LedgerRow> {
Ok(LedgerRow {
id: row.get(0)?,
branch: row.get(1)?,
parent_branch: row.get(2)?,
status: row.get(3)?,
started_ts: row.get(4)?,
finished_ts: row.get(5)?,
summary: row.get(6)?,
})
}

View file

@ -0,0 +1,104 @@
//! `GET /api/v1/cortex/memory/search` — substring scan over pet memory.
//!
//! Delegates to `kei_pet::memory::search` which implements a LIKE-scoped
//! query keyed by `(user_id, pet_name)`.
use crate::error::AppError;
use crate::state::AppState;
use axum::extract::{Query, State};
use axum::Json;
use kei_pet::memory::{ensure_schema, search, MemoryTag};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
/// Maximum allowed `limit`.
pub const MAX_LIMIT: usize = 200;
/// Default `limit` when absent.
pub const DEFAULT_LIMIT: usize = 20;
#[derive(Debug, Deserialize)]
pub struct MemoryQuery {
pub user_id: String,
pub pet_name: String,
pub q: String,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct MemoryHit {
pub id: i64,
pub role: String,
pub text: String,
pub ts: i64,
}
#[derive(Debug, Serialize)]
pub struct MemoryResponse {
pub hits: Vec<MemoryHit>,
}
/// Handler entry point.
pub async fn search_memory(
State(state): State<AppState>,
Query(q): Query<MemoryQuery>,
) -> Result<Json<MemoryResponse>, AppError> {
validate_query(&q)?;
let limit = clamp_limit(q.limit.unwrap_or(DEFAULT_LIMIT));
let db_path = state.config().memory_db.clone();
let hits = tokio::task::spawn_blocking(move || run_search(&db_path, &q, limit))
.await
.map_err(|e| AppError::Internal(format!("memory task join: {e}")))??;
Ok(Json(MemoryResponse { hits }))
}
fn validate_query(q: &MemoryQuery) -> Result<(), AppError> {
if q.user_id.is_empty() {
return Err(AppError::BadRequest("user_id is empty".into()));
}
if q.pet_name.is_empty() {
return Err(AppError::BadRequest("pet_name is empty".into()));
}
if q.q.is_empty() {
return Err(AppError::BadRequest("q is empty".into()));
}
Ok(())
}
fn clamp_limit(requested: usize) -> usize {
if requested == 0 {
DEFAULT_LIMIT
} else if requested > MAX_LIMIT {
MAX_LIMIT
} else {
requested
}
}
fn run_search(
db_path: &std::path::Path,
q: &MemoryQuery,
limit: usize,
) -> Result<Vec<MemoryHit>, AppError> {
if !db_path.exists() {
return Ok(Vec::new());
}
let conn = Connection::open(db_path)?;
ensure_schema(&conn).map_err(|e| AppError::Internal(format!("memory schema: {e}")))?;
let tag = MemoryTag {
user_id: q.user_id.clone(),
pet_name: q.pet_name.clone(),
};
let rows = search(&conn, &tag, &q.q, limit)
.map_err(|e| AppError::Internal(format!("memory search: {e}")))?;
Ok(rows.into_iter().map(to_hit).collect())
}
fn to_hit(i: kei_pet::memory::Interaction) -> MemoryHit {
MemoryHit {
id: i.id,
role: i.role,
text: i.text,
ts: i.ts,
}
}

View file

@ -0,0 +1,7 @@
//! HTTP handler modules — one file per endpoint family.
pub mod health;
pub mod ledger;
pub mod memory;
pub mod pet;
pub mod summary;

View file

@ -0,0 +1,114 @@
//! Pet endpoints — read a persona manifest + record an interaction.
//!
//! - `GET /api/v1/cortex/pet/:user_id`
//! - `POST /api/v1/cortex/pet/:user_id/interaction`
//!
//! The manifest lives on disk at `<pet_root>/<user_id>.toml`. Interactions
//! are written to the kei-pet SQLite memory store.
use crate::error::AppError;
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use kei_pet::memory::{ensure_schema, record_interaction, MemoryTag};
use kei_pet::PetManifest;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use std::fs;
/// Response body for `GET /pet/:user_id`.
#[derive(Debug, Serialize)]
pub struct PetGetResponse {
pub pet: PetManifest,
}
/// Request body for `POST /pet/:user_id/interaction`.
#[derive(Debug, Deserialize)]
pub struct InteractionRequest {
pub role: String,
pub text: String,
pub ts: i64,
}
/// Response body for `POST /pet/:user_id/interaction`.
#[derive(Debug, Serialize)]
pub struct InteractionResponse {
pub interaction_id: i64,
}
/// Handler — load `<pet_root>/<user_id>.toml` into a `PetManifest`.
pub async fn get_pet(
State(state): State<AppState>,
Path(user_id): Path<String>,
) -> Result<Json<PetGetResponse>, AppError> {
let path = state.config().pet_root.join(format!("{user_id}.toml"));
if !path.exists() {
return Err(AppError::NotFound(format!("pet {user_id}")));
}
let text = fs::read_to_string(&path)?;
let pet = kei_pet::parse(&text)
.map_err(|e| AppError::BadRequest(format!("parse pet.toml: {e}")))?;
Ok(Json(PetGetResponse { pet }))
}
/// Handler — append a single interaction row to the kei-pet memory DB.
pub async fn post_interaction(
State(state): State<AppState>,
Path(user_id): Path<String>,
Json(req): Json<InteractionRequest>,
) -> Result<(StatusCode, Json<InteractionResponse>), AppError> {
validate_interaction(&req)?;
let pet_name = pet_name_for(&state, &user_id).await?;
let cfg = state.config().clone();
let id = tokio::task::spawn_blocking(move || {
write_interaction(&cfg.memory_db, &user_id, &pet_name, &req)
})
.await
.map_err(|e| AppError::Internal(format!("interaction task join: {e}")))??;
Ok((StatusCode::CREATED, Json(InteractionResponse { interaction_id: id })))
}
fn validate_interaction(req: &InteractionRequest) -> Result<(), AppError> {
if req.role.is_empty() {
return Err(AppError::BadRequest("role is empty".into()));
}
if req.text.is_empty() {
return Err(AppError::BadRequest("text is empty".into()));
}
if req.ts <= 0 {
return Err(AppError::BadRequest("ts must be positive".into()));
}
Ok(())
}
async fn pet_name_for(state: &AppState, user_id: &str) -> Result<String, AppError> {
let path = state.config().pet_root.join(format!("{user_id}.toml"));
if !path.exists() {
return Err(AppError::NotFound(format!("pet {user_id}")));
}
let text = fs::read_to_string(&path)?;
let pet = kei_pet::parse(&text)
.map_err(|e| AppError::BadRequest(format!("parse pet.toml: {e}")))?;
Ok(pet.identity.pet_name)
}
fn write_interaction(
db_path: &std::path::Path,
user_id: &str,
pet_name: &str,
req: &InteractionRequest,
) -> Result<i64, AppError> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(db_path)?;
ensure_schema(&conn).map_err(|e| AppError::Internal(format!("memory schema: {e}")))?;
let tag = MemoryTag {
user_id: user_id.to_string(),
pet_name: pet_name.to_string(),
};
let id = record_interaction(&conn, &tag, &req.role, &req.text, req.ts)
.map_err(|e| AppError::Internal(format!("record interaction: {e}")))?;
Ok(id)
}

View file

@ -0,0 +1,107 @@
//! `GET /api/v1/cortex/summary` — aggregate counters over ledger + pets.
//!
//! The endpoint is intentionally cheap: a couple of indexed COUNTs + a
//! directory scan. It exists so the UI can render a landing page without
//! hitting four separate endpoints.
use crate::error::AppError;
use crate::state::AppState;
use axum::extract::State;
use axum::Json;
use rusqlite::Connection;
use serde::Serialize;
use std::fs;
/// JSON body returned by `/summary`.
#[derive(Debug, Serialize)]
pub struct SummaryResponse {
pub total_dnas: i64,
pub active_pets: Vec<String>,
pub ledger_last_ts: Option<i64>,
pub recent_sessions: i64,
}
/// Handler entry point.
pub async fn summary(State(state): State<AppState>) -> Result<Json<SummaryResponse>, AppError> {
let cfg = state.config().clone();
let body = tokio::task::spawn_blocking(move || build_summary(&cfg))
.await
.map_err(|e| AppError::Internal(format!("summary task join: {e}")))??;
Ok(Json(body))
}
/// Blocking helper: opens the ledger DB, runs 3 queries, lists the pet dir.
fn build_summary(cfg: &crate::AppConfig) -> Result<SummaryResponse, AppError> {
let total_dnas = count_ledger(&cfg.ledger_path, "SELECT COUNT(*) FROM agents")?;
let ledger_last_ts = last_ledger_ts(&cfg.ledger_path)?;
let recent_sessions = count_ledger(
&cfg.ledger_path,
"SELECT COUNT(*) FROM agents WHERE started_ts >= strftime('%s','now','-1 day')",
)?;
let active_pets = list_pet_user_ids(&cfg.pet_root)?;
Ok(SummaryResponse {
total_dnas,
active_pets,
ledger_last_ts,
recent_sessions,
})
}
/// Run a single scalar COUNT query against the ledger DB if present. Missing
/// file or missing `agents` table yield `0` so a first-boot daemon still
/// serves a useful response.
fn count_ledger(path: &std::path::Path, sql: &str) -> Result<i64, AppError> {
if !path.exists() {
return Ok(0);
}
let conn = Connection::open(path)?;
if !has_agents_table(&conn)? {
return Ok(0);
}
let count: i64 = conn.query_row(sql, [], |r| r.get(0)).unwrap_or(0);
Ok(count)
}
/// Return max(started_ts) from the agents table, or `None` if table is empty.
fn last_ledger_ts(path: &std::path::Path) -> Result<Option<i64>, AppError> {
if !path.exists() {
return Ok(None);
}
let conn = Connection::open(path)?;
if !has_agents_table(&conn)? {
return Ok(None);
}
let ts: Option<i64> = conn
.query_row("SELECT MAX(started_ts) FROM agents", [], |r| r.get(0))
.unwrap_or(None);
Ok(ts)
}
fn has_agents_table(conn: &Connection) -> Result<bool, AppError> {
let exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='agents'",
[],
|r| r.get(0),
)
.unwrap_or(0);
Ok(exists > 0)
}
fn list_pet_user_ids(root: &std::path::Path) -> Result<Vec<String>, AppError> {
if !root.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("toml") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
out.push(stem.to_string());
}
}
}
out.sort();
Ok(out)
}

View file

@ -0,0 +1,22 @@
//! kei-cortex — local HTTP daemon exposing cortex state for UI consumption.
//!
//! Constructor Pattern: one module = one responsibility. This crate wires up:
//! `auth` (bearer-token lifecycle), `config` (CLI/env binding), `error`
//! (typed JSON responses), `state` (shared handler state), `routes` (router
//! + middleware), `handlers` (endpoint implementations).
//!
//! The daemon is intended to serve a single user on `127.0.0.1:9797` and
//! is fronted by a bearer token read from `~/.keisei/cortex.token`. CORS is
//! locked to a single origin provided at startup.
pub mod auth;
pub mod config;
pub mod error;
pub mod handlers;
pub mod routes;
pub mod state;
pub use config::AppConfig;
pub use error::AppError;
pub use routes::build_router;
pub use state::AppState;

View file

@ -0,0 +1,93 @@
//! `kei-cortex` CLI — `serve` subcommand starts the daemon.
//!
//! Token is auto-generated on first launch if missing. The daemon binds to
//! `127.0.0.1:<port>` only; public binding is forbidden by design.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use kei_cortex::{auth, build_router, AppConfig, AppState};
use tokio::net::TcpListener;
#[derive(Parser, Debug)]
#[command(name = "kei-cortex", about = "Local HTTP daemon exposing cortex state")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Start the daemon on 127.0.0.1.
Serve(ServeArgs),
}
#[derive(clap::Args, Debug)]
struct ServeArgs {
#[arg(long, default_value_t = kei_cortex::config::DEFAULT_PORT)]
port: u16,
#[arg(long, default_value_t = kei_cortex::config::DEFAULT_CORS_ORIGIN.to_string())]
cors_origin: String,
#[arg(long)]
token_path: Option<PathBuf>,
#[arg(long)]
ledger_path: Option<PathBuf>,
#[arg(long)]
pet_root: Option<PathBuf>,
#[arg(long)]
memory_db: Option<PathBuf>,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Serve(args) => serve(args).await,
}
}
async fn serve(args: ServeArgs) -> Result<()> {
let config = AppConfig::new(
Some(args.port),
Some(args.cors_origin),
args.token_path,
args.ledger_path,
args.pet_root,
args.memory_db,
);
let token = load_or_bootstrap_token(&config.token_path)?;
let state = AppState::new(config.clone(), token);
let router = build_router(state);
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), config.port);
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("bind {addr}"))?;
eprintln!("kei-cortex listening on http://{addr}");
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await
.context("axum serve")?;
Ok(())
}
fn load_or_bootstrap_token(path: &std::path::Path) -> Result<String> {
if path.exists() {
Ok(auth::load_token(path).with_context(|| format!("load token from {path:?}"))?)
} else {
let token = auth::generate_token();
auth::save_token(path, &token).with_context(|| format!("save token to {path:?}"))?;
eprintln!("generated new bearer token at {path:?}");
Ok(token)
}
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
}

View file

@ -0,0 +1,79 @@
//! Router assembly + bearer-token middleware + CORS layer.
//!
//! `/healthz` is mounted OUTSIDE the auth middleware so monitors can hit it
//! without a token. Everything under `/api` goes through `require_bearer`.
use crate::auth::tokens_match;
use crate::error::AppError;
use crate::handlers::{health, ledger, memory, pet, summary};
use crate::state::AppState;
use axum::extract::{Request, State};
use axum::http::{header, HeaderValue, Method};
use axum::middleware::{self, Next};
use axum::response::Response;
use axum::routing::{get, post};
use axum::Router;
use tower_http::cors::CorsLayer;
/// Build the top-level router.
pub fn build_router(state: AppState) -> Router {
let cors = build_cors(state.config().cors_origin.as_str())
.expect("cors_origin must be a valid HTTP header value");
let api = Router::new()
.route("/api/v1/cortex/summary", get(summary::summary))
.route("/api/v1/cortex/pet/:user_id", get(pet::get_pet))
.route(
"/api/v1/cortex/pet/:user_id/interaction",
post(pet::post_interaction),
)
.route("/api/v1/cortex/ledger/recent", get(ledger::recent))
.route("/api/v1/cortex/memory/search", get(memory::search_memory))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer,
));
Router::new()
.route("/healthz", get(health::healthz))
.merge(api)
.layer(cors)
.with_state(state)
}
/// Build the CORS layer locked to a single origin.
fn build_cors(origin: &str) -> Result<CorsLayer, String> {
let origin_header: HeaderValue = origin
.parse()
.map_err(|e| format!("parse cors origin {origin:?}: {e}"))?;
Ok(CorsLayer::new()
.allow_origin(origin_header)
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true))
}
/// Bearer-token middleware.
///
/// * Missing `Authorization` header → 401
/// * Header present but value wrong → 403
async fn require_bearer(
State(state): State<AppState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let header_val = req
.headers()
.get(header::AUTHORIZATION)
.ok_or(AppError::Unauthorized)?;
let got = header_val
.to_str()
.map_err(|_| AppError::Forbidden)?
.strip_prefix("Bearer ")
.ok_or(AppError::Forbidden)?
.trim();
if !tokens_match(state.token(), got) {
return Err(AppError::Forbidden);
}
Ok(next.run(req).await)
}

View file

@ -0,0 +1,38 @@
//! Shared state passed to every handler via `axum::extract::State`.
//!
//! Holds the loaded configuration and the bearer token. Wrapped in `Arc`
//! transparently by axum; no inner locks are needed because the fields are
//! read-only after startup.
use crate::config::AppConfig;
use std::sync::Arc;
/// Read-only handler state (cheaply cloneable via `Arc`).
#[derive(Clone)]
pub struct AppState {
inner: Arc<Inner>,
}
struct Inner {
config: AppConfig,
token: String,
}
impl AppState {
/// Construct new state from a validated config and bearer token.
pub fn new(config: AppConfig, token: String) -> Self {
Self {
inner: Arc::new(Inner { config, token }),
}
}
/// Borrow the configuration.
pub fn config(&self) -> &AppConfig {
&self.inner.config
}
/// Borrow the bearer token.
pub fn token(&self) -> &str {
&self.inner.token
}
}

View file

@ -0,0 +1,61 @@
//! Unit coverage for `kei_cortex::auth` — token lifecycle.
use kei_cortex::auth;
use tempfile::tempdir;
#[test]
fn token_generate_creates_64_hex_chars() {
let tok = auth::generate_token();
assert_eq!(tok.len(), 64, "hex-encoded 32 bytes = 64 chars");
assert!(
tok.chars().all(|c| c.is_ascii_hexdigit()),
"every char must be hex"
);
}
#[test]
fn token_load_roundtrips() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("cortex.token");
let original = auth::generate_token();
auth::save_token(&path, &original).unwrap();
let loaded = auth::load_token(&path).unwrap();
assert_eq!(loaded, original);
}
#[test]
#[cfg(unix)]
fn token_file_chmod_600_on_unix() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().unwrap();
let path = tmp.path().join("cortex.token");
auth::save_token(&path, &auth::generate_token()).unwrap();
let meta = std::fs::metadata(&path).unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "token file must be 0600, got {mode:o}");
}
#[test]
fn token_validate_rejects_short() {
assert!(auth::validate_hex("abc").is_err());
}
#[test]
fn token_validate_rejects_non_hex() {
let mut bad = "a".repeat(63);
bad.push('Z');
assert!(auth::validate_hex(&bad).is_err());
}
#[test]
fn tokens_match_true_on_equal() {
let t = auth::generate_token();
assert!(auth::tokens_match(&t, &t));
}
#[test]
fn tokens_match_false_on_diff() {
let a = auth::generate_token();
let b = auth::generate_token();
assert!(!auth::tokens_match(&a, &b));
}

View file

@ -0,0 +1,116 @@
//! Shared test harness: spins up the router on an ephemeral port and hands
//! back the base URL + bearer token + config to the test body.
//!
//! Every integration-test file includes this module with `mod common;`, so
//! items unused by one file still count as live via the others. The
//! `#![allow(dead_code)]` silences per-file false positives.
#![allow(dead_code)]
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use kei_cortex::{auth, build_router, AppConfig, AppState};
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
/// Minimal valid pet.toml used by multiple tests.
pub const MINIMAL_PET_TOML: &str = r#"
schema = 1
[identity]
pet_name = "Kei"
user_name = "Alex"
addressing = "by-name"
languages = ["en"]
[voice]
tone_primary = "neutral"
tone_secondary = []
humor_style = "none"
humor_frequency = "rare"
[edge]
profanity = "never"
profanity_languages = []
directness = "balanced"
initiative = "wait"
[forbidden]
topics = []
tone_patterns = []
[meta]
schema_version_written_by = "kei-pet 0.1.0"
created_at = "2026-04-23T12:30:00Z"
last_tuned = "2026-04-23T12:30:00Z"
tune_count = 0
"#;
/// Handle returned to each test; dropping stops the server.
pub struct TestServer {
pub base_url: String,
pub token: String,
pub config: AppConfig,
pub _tmp: TempDir,
handle: Option<JoinHandle<()>>,
}
impl Drop for TestServer {
fn drop(&mut self) {
if let Some(h) = self.handle.take() {
h.abort();
}
}
}
/// Spin up the router on 127.0.0.1 with a random port.
pub async fn spawn() -> TestServer {
let tmp = tempfile::tempdir().expect("tempdir");
let base = tmp.path().to_path_buf();
let config = AppConfig::new(
Some(0),
Some("https://keisei.app".to_string()),
Some(base.join("cortex.token")),
Some(base.join("ledger.sqlite")),
Some(base.join("pets")),
Some(base.join("pet-memory.sqlite")),
);
std::fs::create_dir_all(&config.pet_root).unwrap();
let token = auth::generate_token();
auth::save_token(&config.token_path, &token).unwrap();
let state = AppState::new(config.clone(), token.clone());
let router = build_router(state);
let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0))
.await
.unwrap();
let actual = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
// Give axum a tick to start accepting connections.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
TestServer {
base_url: format!("http://{}", actual),
token,
config,
_tmp: tmp,
handle: Some(handle),
}
}
/// Write a minimal pet.toml for `user_id` under `<pet_root>/<user_id>.toml`.
pub fn write_minimal_pet(pet_root: &PathBuf, user_id: &str) {
let path = pet_root.join(format!("{user_id}.toml"));
std::fs::write(&path, MINIMAL_PET_TOML).unwrap();
}
/// Build an async reqwest client.
pub fn async_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap()
}

View file

@ -0,0 +1,137 @@
//! HTTP integration tests — spawn the full router on an ephemeral port
//! and exercise the public endpoints. Each test owns its own TempDir.
mod common;
use common::{async_client, spawn, write_minimal_pet};
use reqwest::header;
use serde_json::Value;
#[tokio::test]
async fn healthz_unauthenticated_returns_ok() {
let srv = spawn().await;
let resp = async_client()
.get(format!("{}/healthz", srv.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().await.unwrap(), "ok");
}
#[tokio::test]
async fn protected_route_without_token_returns_401() {
let srv = spawn().await;
let resp = async_client()
.get(format!("{}/api/v1/cortex/summary", srv.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "unauthorized");
}
#[tokio::test]
async fn protected_route_with_wrong_token_returns_403() {
let srv = spawn().await;
let resp = async_client()
.get(format!("{}/api/v1/cortex/summary", srv.base_url))
.header(header::AUTHORIZATION, "Bearer deadbeef")
.send()
.await
.unwrap();
assert_eq!(resp.status(), 403);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "forbidden");
}
#[tokio::test]
async fn summary_returns_valid_json_shape() {
let srv = spawn().await;
let resp = async_client()
.get(format!("{}/api/v1/cortex/summary", srv.base_url))
.header(header::AUTHORIZATION, format!("Bearer {}", srv.token))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.unwrap();
assert!(body["total_dnas"].is_i64());
assert!(body["active_pets"].is_array());
assert!(body["recent_sessions"].is_i64());
}
#[tokio::test]
async fn pet_get_404_when_file_missing() {
let srv = spawn().await;
let resp = async_client()
.get(format!("{}/api/v1/cortex/pet/nobody", srv.base_url))
.header(header::AUTHORIZATION, format!("Bearer {}", srv.token))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "not_found");
}
#[tokio::test]
async fn pet_get_returns_parsed_manifest() {
let srv = spawn().await;
write_minimal_pet(&srv.config.pet_root, "alex");
let resp = async_client()
.get(format!("{}/api/v1/cortex/pet/alex", srv.base_url))
.header(header::AUTHORIZATION, format!("Bearer {}", srv.token))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["pet"]["identity"]["pet_name"], "Kei");
}
#[tokio::test]
async fn interaction_post_returns_201_and_id() {
let srv = spawn().await;
write_minimal_pet(&srv.config.pet_root, "alex");
let resp = async_client()
.post(format!("{}/api/v1/cortex/pet/alex/interaction", srv.base_url))
.header(header::AUTHORIZATION, format!("Bearer {}", srv.token))
.json(&serde_json::json!({
"role": "user",
"text": "hello",
"ts": 1_700_000_000_i64
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: Value = resp.json().await.unwrap();
assert!(body["interaction_id"].as_i64().unwrap() >= 1);
}
#[tokio::test]
async fn cors_preflight_allows_configured_origin() {
let srv = spawn().await;
let resp = async_client()
.request(
reqwest::Method::OPTIONS,
format!("{}/api/v1/cortex/summary", srv.base_url),
)
.header("Origin", "https://keisei.app")
.header("Access-Control-Request-Method", "GET")
.header("Access-Control-Request-Headers", "authorization")
.send()
.await
.unwrap();
assert!(resp.status().is_success() || resp.status().as_u16() == 204);
let allow_origin = resp
.headers()
.get("access-control-allow-origin")
.expect("ACAO present")
.to_str()
.unwrap()
.to_string();
assert_eq!(allow_origin, "https://keisei.app");
}

View file

@ -0,0 +1,84 @@
//! `GET /api/v1/cortex/ledger/recent` — integration test with a seeded DB.
//!
//! Uses the minimal v1 schema subset that the cortex query depends on.
//! Not linked against `kei-ledger` to keep the test hermetic.
mod common;
use common::{async_client, spawn};
use reqwest::header;
use rusqlite::{params, Connection};
use serde_json::Value;
/// Create the subset of the kei-ledger v1 `agents` table we need + seed rows.
fn seed_ledger(path: &std::path::Path, rows: &[(i64, &str)]) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
branch TEXT NOT NULL,
parent_branch TEXT,
spec_sha TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('running','done','failed','merged','rejected')),
started_ts INTEGER NOT NULL,
finished_ts INTEGER,
summary TEXT,
worktree_path TEXT
)",
)
.unwrap();
for (started_ts, id) in rows {
conn.execute(
"INSERT INTO agents (id, branch, spec_sha, status, started_ts)
VALUES (?1, ?2, ?3, 'running', ?4)",
params![id, format!("feat/{id}"), "sha-test", started_ts],
)
.unwrap();
}
}
#[tokio::test]
async fn ledger_recent_respects_limit_param() {
let srv = spawn().await;
seed_ledger(
&srv.config.ledger_path,
&[
(1_000, "agent-a"),
(2_000, "agent-b"),
(3_000, "agent-c"),
(4_000, "agent-d"),
(5_000, "agent-e"),
],
);
let resp = async_client()
.get(format!(
"{}/api/v1/cortex/ledger/recent?limit=2",
srv.base_url
))
.header(header::AUTHORIZATION, format!("Bearer {}", srv.token))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.unwrap();
let rows = body["rows"].as_array().unwrap();
assert_eq!(rows.len(), 2);
// Newest first, order by started_ts DESC.
assert_eq!(rows[0]["id"], "agent-e");
assert_eq!(rows[1]["id"], "agent-d");
}
#[tokio::test]
async fn ledger_recent_returns_empty_when_db_absent() {
let srv = spawn().await;
// Do NOT seed — the DB file does not exist.
let resp = async_client()
.get(format!("{}/api/v1/cortex/ledger/recent", srv.base_url))
.header(header::AUTHORIZATION, format!("Bearer {}", srv.token))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.unwrap();
assert!(body["rows"].as_array().unwrap().is_empty());
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./public/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cortex UI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,23 @@
{
"name": "@keisei/cortex-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"svelte": "^5.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte-check": "^4.0.0",
"vite": "^5.4.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0",
"@testing-library/svelte": "^5.2.0",
"jsdom": "^25.0.0"
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#4f46e5"/><text x="16" y="22" font-family="monospace" font-size="18" font-weight="bold" text-anchor="middle" fill="#fff">C</text></svg>

After

Width:  |  Height:  |  Size: 238 B

View file

@ -0,0 +1,59 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { load_config, type CortexConfig } from './lib/config';
import Setup from './routes/Setup.svelte';
import Dashboard from './routes/Dashboard.svelte';
import PetEditor from './routes/PetEditor.svelte';
import LedgerStream from './routes/LedgerStream.svelte';
import MemorySearch from './routes/MemorySearch.svelte';
let hash = $state(window.location.hash || '#/');
let cfg = $state<CortexConfig | null>(null);
function on_hash(): void {
hash = window.location.hash || '#/';
}
onMount(() => {
cfg = load_config();
window.addEventListener('hashchange', on_hash);
});
onDestroy(() => {
window.removeEventListener('hashchange', on_hash);
});
const route = $derived.by(() => {
const h = hash.replace(/^#\/?/, '');
const [path, ...rest] = h.split('/');
return { path: path ?? '', arg: rest.join('/') };
});
</script>
<header>
<h1>Cortex UI</h1>
{#if cfg}
<nav class="nav">
<a href="#/">Dashboard</a>
<a href="#/ledger">Ledger</a>
<a href="#/memory">Memory</a>
<a href="#/setup">Setup</a>
</nav>
{/if}
</header>
<main>
{#if !cfg}
<Setup on_saved={() => (cfg = load_config())} />
{:else if route.path === 'setup'}
<Setup on_saved={() => (cfg = load_config())} />
{:else if route.path === 'pet'}
<PetEditor config={cfg} user_id={route.arg} />
{:else if route.path === 'ledger'}
<LedgerStream config={cfg} />
{:else if route.path === 'memory'}
<MemorySearch config={cfg} />
{:else}
<Dashboard config={cfg} />
{/if}
</main>

View file

@ -0,0 +1,43 @@
import type { CortexConfig } from './config';
import type { Summary, PetManifest, LedgerRow, MemoryHit } from './types';
async function api<T>(
c: CortexConfig,
path: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(`${c.daemon_url}${path}`, {
...init,
headers: {
...(init?.headers ?? {}),
Authorization: `Bearer ${c.token}`,
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}
export const summary = (c: CortexConfig) =>
api<Summary>(c, '/api/v1/cortex/summary');
export const pet = (c: CortexConfig, user_id: string) =>
api<{ pet: PetManifest }>(
c,
`/api/v1/cortex/pet/${encodeURIComponent(user_id)}`,
);
export const ledger = (c: CortexConfig, limit = 20) =>
api<{ rows: LedgerRow[] }>(c, `/api/v1/cortex/ledger/recent?limit=${limit}`);
export const memory_search = (
c: CortexConfig,
user_id: string,
pet_name: string,
q: string,
) =>
api<{ hits: MemoryHit[] }>(
c,
`/api/v1/cortex/memory/search?user_id=${encodeURIComponent(user_id)}&pet_name=${encodeURIComponent(pet_name)}&q=${encodeURIComponent(q)}`,
);

View file

@ -0,0 +1,28 @@
export interface CortexConfig {
daemon_url: string;
token: string;
}
const DEFAULT_DAEMON = 'http://localhost:9797';
const KEY_DAEMON = 'kei-cortex-daemon';
const KEY_TOKEN = 'kei-cortex-token';
export function load_config(): CortexConfig | null {
const params = new URLSearchParams(window.location.search);
const override = params.get('daemon');
const daemon_url =
override ?? localStorage.getItem(KEY_DAEMON) ?? DEFAULT_DAEMON;
const token = params.get('token') ?? localStorage.getItem(KEY_TOKEN) ?? '';
if (!token) return null;
return { daemon_url, token };
}
export function save_config(c: CortexConfig): void {
localStorage.setItem(KEY_DAEMON, c.daemon_url);
localStorage.setItem(KEY_TOKEN, c.token);
}
export function clear_config(): void {
localStorage.removeItem(KEY_DAEMON);
localStorage.removeItem(KEY_TOKEN);
}

View file

@ -0,0 +1,47 @@
export interface PetManifest {
schema: number;
identity: {
pet_name: string;
user_name: string;
addressing: string;
languages: string[];
};
voice: {
tone_primary: string;
tone_secondary: string[];
humor_style: string;
humor_frequency: string;
};
edge: {
profanity: string;
profanity_languages: string[];
directness: string;
initiative: string;
};
forbidden: {
topics: string[];
tone_patterns: string[];
};
meta: Record<string, unknown>;
}
export interface LedgerRow {
id: string;
dna: string | null;
status: string;
started_ts: number;
}
export interface Summary {
total_dnas: number;
active_pets: string[];
ledger_last_ts: number | null;
recent_sessions: number;
}
export interface MemoryHit {
id: number;
role: string;
text: string;
ts: number;
}

View file

@ -0,0 +1,8 @@
import { mount } from 'svelte';
import App from './App.svelte';
import './styles/app.css';
const target = document.getElementById('app');
if (!target) throw new Error('missing #app mount node');
mount(App, { target });

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { CortexConfig } from '../lib/config';
import type { Summary } from '../lib/types';
import { summary as fetch_summary } from '../lib/api';
interface Props {
config: CortexConfig;
}
const { config }: Props = $props();
let data = $state<Summary | null>(null);
let error = $state<string | null>(null);
let loading = $state(true);
onMount(async () => {
try {
data = await fetch_summary(config);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
});
</script>
<h2>Dashboard</h2>
{#if loading}
<p class="muted">Loading summary…</p>
{:else if error}
<div class="error">Failed to load: {error}</div>
{:else if data}
<div class="cards">
<div class="card">
<h3>Total DNAs</h3>
<div class="value">{data.total_dnas}</div>
</div>
<div class="card">
<h3>Active pets</h3>
<div class="value">{data.active_pets.length}</div>
</div>
<div class="card">
<h3>Recent sessions</h3>
<div class="value">{data.recent_sessions}</div>
</div>
</div>
<h3>Pets</h3>
<ul>
{#each data.active_pets as uid}
<li><a href="#/pet/{uid}">{uid}</a></li>
{/each}
</ul>
{/if}

View file

@ -0,0 +1,59 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { CortexConfig } from '../lib/config';
import type { LedgerRow } from '../lib/types';
import { ledger as fetch_ledger } from '../lib/api';
interface Props {
config: CortexConfig;
}
const { config }: Props = $props();
let rows = $state<LedgerRow[]>([]);
let error = $state<string | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
async function refresh(): Promise<void> {
try {
const res = await fetch_ledger(config, 20);
rows = res.rows;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
function format_ts(ts: number): string {
return new Date(ts * 1000).toLocaleString();
}
onMount(() => {
refresh();
timer = setInterval(refresh, 5000);
});
onDestroy(() => {
if (timer !== null) clearInterval(timer);
});
</script>
<h2>Ledger</h2>
<p class="muted">Most recent 20 rows; refreshes every 5 s.</p>
{#if error}
<div class="error">{error}</div>
{/if}
{#if rows.length === 0 && !error}
<p class="muted">No entries yet.</p>
{:else}
{#each rows as row (row.id)}
<div class="row">
<span><strong>{row.status}</strong></span>
<span class="muted">{row.id.slice(0, 8)}</span>
<span class="muted">{row.dna ?? '—'}</span>
<span class="muted" style="margin-left: auto;">{format_ts(row.started_ts)}</span>
</div>
{/each}
{/if}

View file

@ -0,0 +1,71 @@
<script lang="ts">
import type { CortexConfig } from '../lib/config';
import type { MemoryHit } from '../lib/types';
import { memory_search } from '../lib/api';
interface Props {
config: CortexConfig;
}
const { config }: Props = $props();
let user_id = $state('');
let pet_name = $state('');
let q = $state('');
let hits = $state<MemoryHit[]>([]);
let error = $state<string | null>(null);
let loading = $state(false);
async function submit(event: Event): Promise<void> {
event.preventDefault();
error = null;
loading = true;
try {
const res = await memory_search(config, user_id, pet_name, q);
hits = res.hits;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function format_ts(ts: number): string {
return new Date(ts * 1000).toLocaleString();
}
</script>
<h2>Memory search</h2>
<form onsubmit={submit}>
<label for="user_id">User ID</label>
<input id="user_id" bind:value={user_id} required />
<label for="pet_name">Pet name</label>
<input id="pet_name" bind:value={pet_name} required />
<label for="q">Query</label>
<input id="q" bind:value={q} required />
<div style="margin-top: 12px;">
<button type="submit" disabled={loading}>
{loading ? 'Searching…' : 'Search'}
</button>
</div>
</form>
{#if error}
<div class="error">{error}</div>
{/if}
{#if hits.length > 0}
<h3 style="margin-top: 20px;">{hits.length} hits</h3>
{#each hits as hit (hit.id)}
<div class="card">
<div class="muted">
#{hit.id} · {hit.role} · {format_ts(hit.ts)}
</div>
<div style="margin-top: 6px;">{hit.text}</div>
</div>
{/each}
{/if}

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { CortexConfig } from '../lib/config';
import type { PetManifest } from '../lib/types';
import { pet as fetch_pet } from '../lib/api';
interface Props {
config: CortexConfig;
user_id: string;
}
const { config, user_id }: Props = $props();
let manifest = $state<PetManifest | null>(null);
let error = $state<string | null>(null);
let loading = $state(true);
onMount(async () => {
if (!user_id) {
error = 'missing user_id in route';
loading = false;
return;
}
try {
const res = await fetch_pet(config, user_id);
manifest = res.pet;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
});
</script>
<h2>Pet: {user_id}</h2>
{#if loading}
<p class="muted">Loading manifest…</p>
{:else if error}
<div class="error">{error}</div>
{:else if manifest}
<details>
<summary>Identity</summary>
<pre>{JSON.stringify(manifest.identity, null, 2)}</pre>
</details>
<details>
<summary>Voice</summary>
<pre>{JSON.stringify(manifest.voice, null, 2)}</pre>
</details>
<details>
<summary>Edge</summary>
<pre>{JSON.stringify(manifest.edge, null, 2)}</pre>
</details>
<details>
<summary>Forbidden</summary>
<pre>{JSON.stringify(manifest.forbidden, null, 2)}</pre>
</details>
<div style="margin-top: 16px;">
<button class="secondary" disabled>Tune (coming soon)</button>
</div>
{/if}

View file

@ -0,0 +1,54 @@
<script lang="ts">
import { save_config, load_config } from '../lib/config';
interface Props {
on_saved: () => void;
}
const { on_saved }: Props = $props();
const existing = load_config();
let daemon_url = $state(existing?.daemon_url ?? 'http://localhost:9797');
let token = $state(existing?.token ?? '');
let error = $state<string | null>(null);
function submit(event: Event): void {
event.preventDefault();
if (!token.trim()) {
error = 'Token required';
return;
}
save_config({ daemon_url: daemon_url.trim(), token: token.trim() });
error = null;
on_saved();
window.location.hash = '#/';
}
</script>
<h2>Setup</h2>
<p class="muted">
Connect to a local <code>kei-cortex</code> daemon. Credentials are stored in
localStorage only — never transmitted except to the daemon you specify.
</p>
<form onsubmit={submit}>
<label for="daemon">Daemon URL</label>
<input
id="daemon"
type="url"
bind:value={daemon_url}
placeholder="http://localhost:9797"
required
/>
<label for="token">Bearer token</label>
<input id="token" type="password" bind:value={token} required />
{#if error}
<div class="error">{error}</div>
{/if}
<div style="margin-top: 16px;">
<button type="submit">Save</button>
</div>
</form>

View file

@ -0,0 +1,186 @@
:root {
--bg: #ffffff;
--fg: #1a1a1a;
--muted: #666666;
--border: #e5e5e5;
--accent: #4f46e5;
--card: #f7f7f8;
--danger: #dc2626;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e0e11;
--fg: #e8e8ea;
--muted: #9ca3af;
--border: #27272a;
--accent: #818cf8;
--card: #18181b;
--danger: #f87171;
}
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
}
#app {
max-width: 900px;
margin: 0 auto;
padding: 24px 20px 80px;
}
h1,
h2,
h3 {
margin: 0 0 12px;
font-weight: 600;
}
h1 {
font-size: 24px;
}
h2 {
font-size: 19px;
margin-top: 24px;
}
h3 {
font-size: 16px;
color: var(--muted);
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 14px;
border-radius: 6px;
font: inherit;
cursor: pointer;
}
button:hover {
filter: brightness(1.1);
}
button.secondary {
background: var(--card);
color: var(--fg);
border: 1px solid var(--border);
}
input,
textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
font: inherit;
}
input:focus,
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
label {
display: block;
margin: 14px 0 4px;
font-size: 13px;
color: var(--muted);
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin: 8px 0;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin: 16px 0 24px;
}
.card .value {
font-size: 28px;
font-weight: 600;
margin-top: 4px;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0;
}
.row {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.row:last-child {
border-bottom: none;
}
.muted {
color: var(--muted);
font-size: 13px;
}
.error {
color: var(--danger);
background: rgba(220, 38, 38, 0.1);
border: 1px solid var(--danger);
border-radius: 6px;
padding: 10px 12px;
margin: 12px 0;
}
details {
margin: 8px 0;
}
details > summary {
cursor: pointer;
font-weight: 500;
padding: 6px 0;
}
details[open] > summary {
margin-bottom: 8px;
}
pre {
background: var(--card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
overflow-x: auto;
font-size: 13px;
}

View file

@ -0,0 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
compilerOptions: {
runes: true,
},
};

View file

@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { summary, ledger, memory_search, pet } from '../src/lib/api';
describe('api wrapper', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('adds Authorization header', async () => {
const mock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
total_dnas: 0,
active_pets: [],
ledger_last_ts: null,
recent_sessions: 0,
}),
});
globalThis.fetch = mock as unknown as typeof fetch;
await summary({ daemon_url: 'http://x', token: 't' });
expect(mock).toHaveBeenCalledWith(
'http://x/api/v1/cortex/summary',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer t' }),
}),
);
});
it('throws on non-2xx', async () => {
globalThis.fetch = vi
.fn()
.mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
}) as unknown as typeof fetch;
await expect(
summary({ daemon_url: 'http://x', token: 't' }),
).rejects.toThrow('401');
});
it('encodes query params for memory_search', async () => {
const mock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ hits: [] }),
});
globalThis.fetch = mock as unknown as typeof fetch;
await memory_search(
{ daemon_url: 'http://x', token: 't' },
'user one',
'kei/pet',
'hello world',
);
const [url] = mock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('user_id=user%20one');
expect(url).toContain('pet_name=kei%2Fpet');
expect(url).toContain('q=hello%20world');
});
it('passes limit to ledger', async () => {
const mock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ rows: [] }),
});
globalThis.fetch = mock as unknown as typeof fetch;
await ledger({ daemon_url: 'http://x', token: 't' }, 50);
const [url] = mock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('http://x/api/v1/cortex/ledger/recent?limit=50');
});
it('encodes user_id in pet path', async () => {
const mock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ pet: {} }),
});
globalThis.fetch = mock as unknown as typeof fetch;
await pet({ daemon_url: 'http://x', token: 't' }, 'alice@example.com');
const [url] = mock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
'http://x/api/v1/cortex/pet/alice%40example.com',
);
});
});

View file

@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { load_config, save_config, clear_config } from '../src/lib/config';
function set_location(search: string): void {
// jsdom lets us mutate window.location via history; pass as relative URL
// since the jsdom default origin is http://localhost/ and replaceState()
// refuses cross-origin-ish rewrites from the default about:blank.
const url = search || '/';
window.history.replaceState({}, '', url);
}
describe('config', () => {
beforeEach(() => {
localStorage.clear();
set_location('');
});
it('returns null when no token present', () => {
set_location('');
expect(load_config()).toBeNull();
});
it('URL param overrides localStorage for daemon', () => {
save_config({ daemon_url: 'http://stored:9797', token: 'tkn' });
set_location('?daemon=http://override:8080');
const cfg = load_config();
expect(cfg).not.toBeNull();
expect(cfg!.daemon_url).toBe('http://override:8080');
expect(cfg!.token).toBe('tkn');
});
it('URL param token bootstraps config even when localStorage empty', () => {
set_location('?token=fromurl');
const cfg = load_config();
expect(cfg).not.toBeNull();
expect(cfg!.token).toBe('fromurl');
expect(cfg!.daemon_url).toBe('http://localhost:9797');
});
it('falls back to default daemon when nothing stored', () => {
save_config({ daemon_url: 'http://localhost:9797', token: 't' });
const cfg = load_config();
expect(cfg!.daemon_url).toBe('http://localhost:9797');
});
it('clear_config removes both keys', () => {
save_config({ daemon_url: 'http://x', token: 't' });
clear_config();
expect(load_config()).toBeNull();
});
});

View file

@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"types": ["svelte", "vite/client"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noEmit": true,
"composite": false,
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*.ts", "src/**/*.svelte", "tests/**/*.ts"]
}

View file

@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
},
test: {
environment: 'jsdom',
environmentOptions: {
jsdom: {
url: 'http://localhost/',
},
},
globals: false,
include: ['tests/**/*.test.ts'],
},
});