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:
parent
07eb0b83ea
commit
6672ae48e7
40 changed files with 3492 additions and 1 deletions
39
_primitives/_rust/Cargo.lock
generated
39
_primitives/_rust/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
32
_primitives/_rust/kei-cortex/Cargo.toml
Normal file
32
_primitives/_rust/kei-cortex/Cargo.toml
Normal 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 }
|
||||
122
_primitives/_rust/kei-cortex/src/auth.rs
Normal file
122
_primitives/_rust/kei-cortex/src/auth.rs
Normal 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(())
|
||||
}
|
||||
66
_primitives/_rust/kei-cortex/src/config.rs
Normal file
66
_primitives/_rust/kei-cortex/src/config.rs
Normal 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("."))
|
||||
}
|
||||
67
_primitives/_rust/kei-cortex/src/error.rs
Normal file
67
_primitives/_rust/kei-cortex/src/error.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
6
_primitives/_rust/kei-cortex/src/handlers/health.rs
Normal file
6
_primitives/_rust/kei-cortex/src/handlers/health.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
//! Unauthenticated liveness probe.
|
||||
|
||||
/// `GET /healthz` → `"ok"` (text/plain). Always returns 200 OK.
|
||||
pub async fn healthz() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
111
_primitives/_rust/kei-cortex/src/handlers/ledger.rs
Normal file
111
_primitives/_rust/kei-cortex/src/handlers/ledger.rs
Normal 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)?,
|
||||
})
|
||||
}
|
||||
104
_primitives/_rust/kei-cortex/src/handlers/memory.rs
Normal file
104
_primitives/_rust/kei-cortex/src/handlers/memory.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
7
_primitives/_rust/kei-cortex/src/handlers/mod.rs
Normal file
7
_primitives/_rust/kei-cortex/src/handlers/mod.rs
Normal 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;
|
||||
114
_primitives/_rust/kei-cortex/src/handlers/pet.rs
Normal file
114
_primitives/_rust/kei-cortex/src/handlers/pet.rs
Normal 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)
|
||||
}
|
||||
107
_primitives/_rust/kei-cortex/src/handlers/summary.rs
Normal file
107
_primitives/_rust/kei-cortex/src/handlers/summary.rs
Normal 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)
|
||||
}
|
||||
22
_primitives/_rust/kei-cortex/src/lib.rs
Normal file
22
_primitives/_rust/kei-cortex/src/lib.rs
Normal 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;
|
||||
93
_primitives/_rust/kei-cortex/src/main.rs
Normal file
93
_primitives/_rust/kei-cortex/src/main.rs
Normal 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;
|
||||
}
|
||||
79
_primitives/_rust/kei-cortex/src/routes.rs
Normal file
79
_primitives/_rust/kei-cortex/src/routes.rs
Normal 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)
|
||||
}
|
||||
38
_primitives/_rust/kei-cortex/src/state.rs
Normal file
38
_primitives/_rust/kei-cortex/src/state.rs
Normal 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
|
||||
}
|
||||
}
|
||||
61
_primitives/_rust/kei-cortex/tests/auth_tests.rs
Normal file
61
_primitives/_rust/kei-cortex/tests/auth_tests.rs
Normal 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));
|
||||
}
|
||||
116
_primitives/_rust/kei-cortex/tests/common/mod.rs
Normal file
116
_primitives/_rust/kei-cortex/tests/common/mod.rs
Normal 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()
|
||||
}
|
||||
137
_primitives/_rust/kei-cortex/tests/http_tests.rs
Normal file
137
_primitives/_rust/kei-cortex/tests/http_tests.rs
Normal 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");
|
||||
}
|
||||
84
_primitives/_rust/kei-cortex/tests/ledger_tests.rs
Normal file
84
_primitives/_rust/kei-cortex/tests/ledger_tests.rs
Normal 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());
|
||||
}
|
||||
1192
_ts_packages/package-lock.json
generated
1192
_ts_packages/package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
_ts_packages/packages/cortex-ui/index.html
Normal file
13
_ts_packages/packages/cortex-ui/index.html
Normal 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>
|
||||
23
_ts_packages/packages/cortex-ui/package.json
Normal file
23
_ts_packages/packages/cortex-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
_ts_packages/packages/cortex-ui/public/favicon.svg
Normal file
1
_ts_packages/packages/cortex-ui/public/favicon.svg
Normal 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 |
59
_ts_packages/packages/cortex-ui/src/App.svelte
Normal file
59
_ts_packages/packages/cortex-ui/src/App.svelte
Normal 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>
|
||||
43
_ts_packages/packages/cortex-ui/src/lib/api.ts
Normal file
43
_ts_packages/packages/cortex-ui/src/lib/api.ts
Normal 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)}`,
|
||||
);
|
||||
28
_ts_packages/packages/cortex-ui/src/lib/config.ts
Normal file
28
_ts_packages/packages/cortex-ui/src/lib/config.ts
Normal 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);
|
||||
}
|
||||
47
_ts_packages/packages/cortex-ui/src/lib/types.ts
Normal file
47
_ts_packages/packages/cortex-ui/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
8
_ts_packages/packages/cortex-ui/src/main.ts
Normal file
8
_ts_packages/packages/cortex-ui/src/main.ts
Normal 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 });
|
||||
56
_ts_packages/packages/cortex-ui/src/routes/Dashboard.svelte
Normal file
56
_ts_packages/packages/cortex-ui/src/routes/Dashboard.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
65
_ts_packages/packages/cortex-ui/src/routes/PetEditor.svelte
Normal file
65
_ts_packages/packages/cortex-ui/src/routes/PetEditor.svelte
Normal 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}
|
||||
54
_ts_packages/packages/cortex-ui/src/routes/Setup.svelte
Normal file
54
_ts_packages/packages/cortex-ui/src/routes/Setup.svelte
Normal 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>
|
||||
186
_ts_packages/packages/cortex-ui/src/styles/app.css
Normal file
186
_ts_packages/packages/cortex-ui/src/styles/app.css
Normal 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;
|
||||
}
|
||||
8
_ts_packages/packages/cortex-ui/svelte.config.js
Normal file
8
_ts_packages/packages/cortex-ui/svelte.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
compilerOptions: {
|
||||
runes: true,
|
||||
},
|
||||
};
|
||||
83
_ts_packages/packages/cortex-ui/tests/api.test.ts
Normal file
83
_ts_packages/packages/cortex-ui/tests/api.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
51
_ts_packages/packages/cortex-ui/tests/config.test.ts
Normal file
51
_ts_packages/packages/cortex-ui/tests/config.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
17
_ts_packages/packages/cortex-ui/tsconfig.json
Normal file
17
_ts_packages/packages/cortex-ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
22
_ts_packages/packages/cortex-ui/vite.config.ts
Normal file
22
_ts_packages/packages/cortex-ui/vite.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue