diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 8d35e97..3665247 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -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" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index 3a7cb98..bcc0d1b 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -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] diff --git a/_primitives/_rust/kei-cortex/Cargo.toml b/_primitives/_rust/kei-cortex/Cargo.toml new file mode 100644 index 0000000..a60df2d --- /dev/null +++ b/_primitives/_rust/kei-cortex/Cargo.toml @@ -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 } diff --git a/_primitives/_rust/kei-cortex/src/auth.rs b/_primitives/_rust/kei-cortex/src/auth.rs new file mode 100644 index 0000000..22dc550 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/auth.rs @@ -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 { + 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(()) +} diff --git a/_primitives/_rust/kei-cortex/src/config.rs b/_primitives/_rust/kei-cortex/src/config.rs new file mode 100644 index 0000000..c03ea12 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/config.rs @@ -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 `.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, + cors_origin: Option, + token_path: Option, + ledger_path: Option, + pet_root: Option, + memory_db: Option, + ) -> 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(".")) +} diff --git a/_primitives/_rust/kei-cortex/src/error.rs b/_primitives/_rust/kei-cortex/src/error.rs new file mode 100644 index 0000000..f9fffd9 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/error.rs @@ -0,0 +1,67 @@ +//! Unified error type mapped to HTTP responses with JSON body. +//! +//! Handlers return `Result` 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() + } +} diff --git a/_primitives/_rust/kei-cortex/src/handlers/health.rs b/_primitives/_rust/kei-cortex/src/handlers/health.rs new file mode 100644 index 0000000..da78e5b --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/handlers/health.rs @@ -0,0 +1,6 @@ +//! Unauthenticated liveness probe. + +/// `GET /healthz` → `"ok"` (text/plain). Always returns 200 OK. +pub async fn healthz() -> &'static str { + "ok" +} diff --git a/_primitives/_rust/kei-cortex/src/handlers/ledger.rs b/_primitives/_rust/kei-cortex/src/handlers/ledger.rs new file mode 100644 index 0000000..af0de66 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/handlers/ledger.rs @@ -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, +} + +#[derive(Debug, Serialize)] +pub struct LedgerRow { + pub id: String, + pub branch: String, + pub parent_branch: Option, + pub status: String, + pub started_ts: i64, + pub finished_ts: Option, + pub summary: Option, +} + +#[derive(Debug, Serialize)] +pub struct LedgerResponse { + pub rows: Vec, +} + +/// Handler entry point. +pub async fn recent( + State(state): State, + Query(q): Query, +) -> Result, 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, 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 { + 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, 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 { + 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)?, + }) +} diff --git a/_primitives/_rust/kei-cortex/src/handlers/memory.rs b/_primitives/_rust/kei-cortex/src/handlers/memory.rs new file mode 100644 index 0000000..faa52e2 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/handlers/memory.rs @@ -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, +} + +#[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, +} + +/// Handler entry point. +pub async fn search_memory( + State(state): State, + Query(q): Query, +) -> Result, 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, 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, + } +} diff --git a/_primitives/_rust/kei-cortex/src/handlers/mod.rs b/_primitives/_rust/kei-cortex/src/handlers/mod.rs new file mode 100644 index 0000000..8626180 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/handlers/mod.rs @@ -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; diff --git a/_primitives/_rust/kei-cortex/src/handlers/pet.rs b/_primitives/_rust/kei-cortex/src/handlers/pet.rs new file mode 100644 index 0000000..d03fc26 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/handlers/pet.rs @@ -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 `/.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 `/.toml` into a `PetManifest`. +pub async fn get_pet( + State(state): State, + Path(user_id): Path, +) -> Result, 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, + Path(user_id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), 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 { + 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 { + 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) +} diff --git a/_primitives/_rust/kei-cortex/src/handlers/summary.rs b/_primitives/_rust/kei-cortex/src/handlers/summary.rs new file mode 100644 index 0000000..08de4bb --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/handlers/summary.rs @@ -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, + pub ledger_last_ts: Option, + pub recent_sessions: i64, +} + +/// Handler entry point. +pub async fn summary(State(state): State) -> Result, 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 { + 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 { + 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, AppError> { + if !path.exists() { + return Ok(None); + } + let conn = Connection::open(path)?; + if !has_agents_table(&conn)? { + return Ok(None); + } + let ts: Option = 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 { + 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, 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) +} diff --git a/_primitives/_rust/kei-cortex/src/lib.rs b/_primitives/_rust/kei-cortex/src/lib.rs new file mode 100644 index 0000000..b216664 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/lib.rs @@ -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; diff --git a/_primitives/_rust/kei-cortex/src/main.rs b/_primitives/_rust/kei-cortex/src/main.rs new file mode 100644 index 0000000..e1e0807 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/main.rs @@ -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:` 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, + + #[arg(long)] + ledger_path: Option, + + #[arg(long)] + pet_root: Option, + + #[arg(long)] + memory_db: Option, +} + +#[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 { + 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; +} diff --git a/_primitives/_rust/kei-cortex/src/routes.rs b/_primitives/_rust/kei-cortex/src/routes.rs new file mode 100644 index 0000000..cf737c0 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/routes.rs @@ -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 { + 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, + req: Request, + next: Next, +) -> Result { + 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) +} diff --git a/_primitives/_rust/kei-cortex/src/state.rs b/_primitives/_rust/kei-cortex/src/state.rs new file mode 100644 index 0000000..114c922 --- /dev/null +++ b/_primitives/_rust/kei-cortex/src/state.rs @@ -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, +} + +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 + } +} diff --git a/_primitives/_rust/kei-cortex/tests/auth_tests.rs b/_primitives/_rust/kei-cortex/tests/auth_tests.rs new file mode 100644 index 0000000..a845c1b --- /dev/null +++ b/_primitives/_rust/kei-cortex/tests/auth_tests.rs @@ -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)); +} diff --git a/_primitives/_rust/kei-cortex/tests/common/mod.rs b/_primitives/_rust/kei-cortex/tests/common/mod.rs new file mode 100644 index 0000000..035c56c --- /dev/null +++ b/_primitives/_rust/kei-cortex/tests/common/mod.rs @@ -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>, +} + +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 `/.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() +} diff --git a/_primitives/_rust/kei-cortex/tests/http_tests.rs b/_primitives/_rust/kei-cortex/tests/http_tests.rs new file mode 100644 index 0000000..4dbff75 --- /dev/null +++ b/_primitives/_rust/kei-cortex/tests/http_tests.rs @@ -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"); +} diff --git a/_primitives/_rust/kei-cortex/tests/ledger_tests.rs b/_primitives/_rust/kei-cortex/tests/ledger_tests.rs new file mode 100644 index 0000000..b8ba5ea --- /dev/null +++ b/_primitives/_rust/kei-cortex/tests/ledger_tests.rs @@ -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()); +} diff --git a/_ts_packages/package-lock.json b/_ts_packages/package-lock.json index c767c2a..3df3942 100644 --- a/_ts_packages/package-lock.json +++ b/_ts_packages/package-lock.json @@ -19,6 +19,170 @@ "node": ">=18.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -479,6 +643,38 @@ "hono": "^4" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -486,6 +682,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keisei/cortex-ui": { + "resolved": "packages/cortex-ui", + "link": true + }, "node_modules/@keisei/gmail-adapter": { "resolved": "packages/gmail-adapter", "link": true @@ -918,6 +1129,123 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -935,6 +1263,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1073,6 +1408,19 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1115,6 +1463,39 @@ } } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1125,6 +1506,23 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1259,6 +1657,45 @@ "node": ">= 16" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1330,6 +1767,78 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1347,6 +1856,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1357,6 +1873,26 @@ "node": ">=6" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1366,6 +1902,30 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devalue": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", + "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1404,6 +1964,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1441,6 +2014,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1489,6 +2078,31 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", + "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1663,6 +2277,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -1699,6 +2331,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1957,6 +2629,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -1978,6 +2666,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1998,6 +2699,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -2072,12 +2787,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -2117,6 +2849,91 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -2159,6 +2976,23 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2166,6 +3000,23 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2231,6 +3082,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2313,6 +3174,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2367,6 +3235,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2457,6 +3338,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -2485,6 +3381,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -2524,6 +3430,27 @@ "node": ">= 0.10" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2604,6 +3531,26 @@ "node": ">= 18" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2630,6 +3577,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -2838,6 +3798,75 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/svelte": { + "version": "5.55.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", + "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", + "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2882,6 +3911,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2891,6 +3940,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -3514,6 +4576,26 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", @@ -3580,12 +4662,62 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -3634,6 +4766,45 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", @@ -3655,6 +4826,13 @@ "node": ">=18.0.0" } }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -3673,6 +4851,20 @@ "zod": "^3.25.28 || ^4" } }, + "packages/cortex-ui": { + "name": "@keisei/cortex-ui", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@testing-library/svelte": "^5.2.0", + "jsdom": "^25.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.0.0" + } + }, "packages/gmail-adapter": { "name": "@keisei/gmail-adapter", "version": "0.14.0", diff --git a/_ts_packages/packages/cortex-ui/index.html b/_ts_packages/packages/cortex-ui/index.html new file mode 100644 index 0000000..7361f50 --- /dev/null +++ b/_ts_packages/packages/cortex-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Cortex UI + + +
+ + + diff --git a/_ts_packages/packages/cortex-ui/package.json b/_ts_packages/packages/cortex-ui/package.json new file mode 100644 index 0000000..0f605db --- /dev/null +++ b/_ts_packages/packages/cortex-ui/package.json @@ -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" + } +} diff --git a/_ts_packages/packages/cortex-ui/public/favicon.svg b/_ts_packages/packages/cortex-ui/public/favicon.svg new file mode 100644 index 0000000..4ae0c0d --- /dev/null +++ b/_ts_packages/packages/cortex-ui/public/favicon.svg @@ -0,0 +1 @@ +C diff --git a/_ts_packages/packages/cortex-ui/src/App.svelte b/_ts_packages/packages/cortex-ui/src/App.svelte new file mode 100644 index 0000000..213678e --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/App.svelte @@ -0,0 +1,59 @@ + + +
+

Cortex UI

+ {#if cfg} + + {/if} +
+ +
+ {#if !cfg} + (cfg = load_config())} /> + {:else if route.path === 'setup'} + (cfg = load_config())} /> + {:else if route.path === 'pet'} + + {:else if route.path === 'ledger'} + + {:else if route.path === 'memory'} + + {:else} + + {/if} +
diff --git a/_ts_packages/packages/cortex-ui/src/lib/api.ts b/_ts_packages/packages/cortex-ui/src/lib/api.ts new file mode 100644 index 0000000..1b19610 --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/lib/api.ts @@ -0,0 +1,43 @@ +import type { CortexConfig } from './config'; +import type { Summary, PetManifest, LedgerRow, MemoryHit } from './types'; + +async function api( + c: CortexConfig, + path: string, + init?: RequestInit, +): Promise { + 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; +} + +export const summary = (c: CortexConfig) => + api(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)}`, + ); diff --git a/_ts_packages/packages/cortex-ui/src/lib/config.ts b/_ts_packages/packages/cortex-ui/src/lib/config.ts new file mode 100644 index 0000000..dba36bb --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/lib/config.ts @@ -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); +} diff --git a/_ts_packages/packages/cortex-ui/src/lib/types.ts b/_ts_packages/packages/cortex-ui/src/lib/types.ts new file mode 100644 index 0000000..3670ab6 --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/lib/types.ts @@ -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; +} + +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; +} diff --git a/_ts_packages/packages/cortex-ui/src/main.ts b/_ts_packages/packages/cortex-ui/src/main.ts new file mode 100644 index 0000000..7e8acf6 --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/main.ts @@ -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 }); diff --git a/_ts_packages/packages/cortex-ui/src/routes/Dashboard.svelte b/_ts_packages/packages/cortex-ui/src/routes/Dashboard.svelte new file mode 100644 index 0000000..6b9552a --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/routes/Dashboard.svelte @@ -0,0 +1,56 @@ + + +

Dashboard

+ +{#if loading} +

Loading summary…

+{:else if error} +
Failed to load: {error}
+{:else if data} +
+
+

Total DNAs

+
{data.total_dnas}
+
+
+

Active pets

+
{data.active_pets.length}
+
+
+

Recent sessions

+
{data.recent_sessions}
+
+
+ +

Pets

+
    + {#each data.active_pets as uid} +
  • {uid}
  • + {/each} +
+{/if} diff --git a/_ts_packages/packages/cortex-ui/src/routes/LedgerStream.svelte b/_ts_packages/packages/cortex-ui/src/routes/LedgerStream.svelte new file mode 100644 index 0000000..291f596 --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/routes/LedgerStream.svelte @@ -0,0 +1,59 @@ + + +

Ledger

+

Most recent 20 rows; refreshes every 5 s.

+ +{#if error} +
{error}
+{/if} + +{#if rows.length === 0 && !error} +

No entries yet.

+{:else} + {#each rows as row (row.id)} +
+ {row.status} + {row.id.slice(0, 8)}… + {row.dna ?? '—'} + {format_ts(row.started_ts)} +
+ {/each} +{/if} diff --git a/_ts_packages/packages/cortex-ui/src/routes/MemorySearch.svelte b/_ts_packages/packages/cortex-ui/src/routes/MemorySearch.svelte new file mode 100644 index 0000000..d98801c --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/routes/MemorySearch.svelte @@ -0,0 +1,71 @@ + + +

Memory search

+ +
+ + + + + + + + + +
+ +
+
+ +{#if error} +
{error}
+{/if} + +{#if hits.length > 0} +

{hits.length} hits

+ {#each hits as hit (hit.id)} +
+
+ #{hit.id} · {hit.role} · {format_ts(hit.ts)} +
+
{hit.text}
+
+ {/each} +{/if} diff --git a/_ts_packages/packages/cortex-ui/src/routes/PetEditor.svelte b/_ts_packages/packages/cortex-ui/src/routes/PetEditor.svelte new file mode 100644 index 0000000..b903daa --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/routes/PetEditor.svelte @@ -0,0 +1,65 @@ + + +

Pet: {user_id}

+ +{#if loading} +

Loading manifest…

+{:else if error} +
{error}
+{:else if manifest} +
+ Identity +
{JSON.stringify(manifest.identity, null, 2)}
+
+ +
+ Voice +
{JSON.stringify(manifest.voice, null, 2)}
+
+ +
+ Edge +
{JSON.stringify(manifest.edge, null, 2)}
+
+ +
+ Forbidden +
{JSON.stringify(manifest.forbidden, null, 2)}
+
+ +
+ +
+{/if} diff --git a/_ts_packages/packages/cortex-ui/src/routes/Setup.svelte b/_ts_packages/packages/cortex-ui/src/routes/Setup.svelte new file mode 100644 index 0000000..854c981 --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/routes/Setup.svelte @@ -0,0 +1,54 @@ + + +

Setup

+

+ Connect to a local kei-cortex daemon. Credentials are stored in + localStorage only — never transmitted except to the daemon you specify. +

+ +
+ + + + + + + {#if error} +
{error}
+ {/if} + +
+ +
+
diff --git a/_ts_packages/packages/cortex-ui/src/styles/app.css b/_ts_packages/packages/cortex-ui/src/styles/app.css new file mode 100644 index 0000000..3b2e19a --- /dev/null +++ b/_ts_packages/packages/cortex-ui/src/styles/app.css @@ -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; +} diff --git a/_ts_packages/packages/cortex-ui/svelte.config.js b/_ts_packages/packages/cortex-ui/svelte.config.js new file mode 100644 index 0000000..6005ca8 --- /dev/null +++ b/_ts_packages/packages/cortex-ui/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, +}; diff --git a/_ts_packages/packages/cortex-ui/tests/api.test.ts b/_ts_packages/packages/cortex-ui/tests/api.test.ts new file mode 100644 index 0000000..4c45294 --- /dev/null +++ b/_ts_packages/packages/cortex-ui/tests/api.test.ts @@ -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', + ); + }); +}); diff --git a/_ts_packages/packages/cortex-ui/tests/config.test.ts b/_ts_packages/packages/cortex-ui/tests/config.test.ts new file mode 100644 index 0000000..2820fc7 --- /dev/null +++ b/_ts_packages/packages/cortex-ui/tests/config.test.ts @@ -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(); + }); +}); diff --git a/_ts_packages/packages/cortex-ui/tsconfig.json b/_ts_packages/packages/cortex-ui/tsconfig.json new file mode 100644 index 0000000..051820c --- /dev/null +++ b/_ts_packages/packages/cortex-ui/tsconfig.json @@ -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"] +} diff --git a/_ts_packages/packages/cortex-ui/vite.config.ts b/_ts_packages/packages/cortex-ui/vite.config.ts new file mode 100644 index 0000000..88be01b --- /dev/null +++ b/_ts_packages/packages/cortex-ui/vite.config.ts @@ -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'], + }, +});