From 745278c90201e6125fa61bc8e89cf462c58c8cee Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 27 Apr 2026 18:37:01 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20neuron-rs=20=E2=80=94=20Rust=20runtime,?= =?UTF-8?q?=20Engram-backed,=20Axon=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + Cargo.toml | 22 + crates/neuron-api/Cargo.toml | 28 ++ crates/neuron-api/src/auth.rs | 33 ++ crates/neuron-api/src/error.rs | 34 ++ crates/neuron-api/src/main.rs | 44 ++ crates/neuron-api/src/routes/axon.rs | 50 ++ crates/neuron-api/src/routes/backlog.rs | 121 +++++ crates/neuron-api/src/routes/contexts.rs | 96 ++++ crates/neuron-api/src/routes/health.rs | 6 + crates/neuron-api/src/routes/ise.rs | 58 +++ crates/neuron-api/src/routes/knowledge.rs | 82 ++++ crates/neuron-api/src/routes/memories.rs | 90 ++++ crates/neuron-api/src/routes/mod.rs | 47 ++ crates/neuron-api/src/state.rs | 28 ++ crates/neuron-domain/Cargo.toml | 12 + crates/neuron-domain/src/error.rs | 17 + crates/neuron-domain/src/lib.rs | 5 + crates/neuron-domain/src/types.rs | 510 +++++++++++++++++++++ crates/neuron-protocol/Cargo.toml | 18 + crates/neuron-protocol/src/axon.rs | 91 ++++ crates/neuron-protocol/src/handler.rs | 142 ++++++ crates/neuron-protocol/src/lib.rs | 7 + crates/neuron-protocol/src/sse.rs | 47 ++ crates/neuron-protocol/src/tools.rs | 51 +++ crates/neuron-store/Cargo.toml | 16 + crates/neuron-store/src/backlog_store.rs | 157 +++++++ crates/neuron-store/src/codec.rs | 36 ++ crates/neuron-store/src/context_store.rs | 95 ++++ crates/neuron-store/src/ise_store.rs | 136 ++++++ crates/neuron-store/src/knowledge_store.rs | 120 +++++ crates/neuron-store/src/lib.rs | 21 + crates/neuron-store/src/memory_store.rs | 150 ++++++ 33 files changed, 2375 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 crates/neuron-api/Cargo.toml create mode 100644 crates/neuron-api/src/auth.rs create mode 100644 crates/neuron-api/src/error.rs create mode 100644 crates/neuron-api/src/main.rs create mode 100644 crates/neuron-api/src/routes/axon.rs create mode 100644 crates/neuron-api/src/routes/backlog.rs create mode 100644 crates/neuron-api/src/routes/contexts.rs create mode 100644 crates/neuron-api/src/routes/health.rs create mode 100644 crates/neuron-api/src/routes/ise.rs create mode 100644 crates/neuron-api/src/routes/knowledge.rs create mode 100644 crates/neuron-api/src/routes/memories.rs create mode 100644 crates/neuron-api/src/routes/mod.rs create mode 100644 crates/neuron-api/src/state.rs create mode 100644 crates/neuron-domain/Cargo.toml create mode 100644 crates/neuron-domain/src/error.rs create mode 100644 crates/neuron-domain/src/lib.rs create mode 100644 crates/neuron-domain/src/types.rs create mode 100644 crates/neuron-protocol/Cargo.toml create mode 100644 crates/neuron-protocol/src/axon.rs create mode 100644 crates/neuron-protocol/src/handler.rs create mode 100644 crates/neuron-protocol/src/lib.rs create mode 100644 crates/neuron-protocol/src/sse.rs create mode 100644 crates/neuron-protocol/src/tools.rs create mode 100644 crates/neuron-store/Cargo.toml create mode 100644 crates/neuron-store/src/backlog_store.rs create mode 100644 crates/neuron-store/src/codec.rs create mode 100644 crates/neuron-store/src/context_store.rs create mode 100644 crates/neuron-store/src/ise_store.rs create mode 100644 crates/neuron-store/src/knowledge_store.rs create mode 100644 crates/neuron-store/src/lib.rs create mode 100644 crates/neuron-store/src/memory_store.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a4c642 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target/ +Cargo.lock +*.db +*.sled/ +.DS_Store diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cf90a23 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[workspace] +resolver = "2" +members = [ + "crates/neuron-domain", + "crates/neuron-store", + "crates/neuron-api", + "crates/neuron-protocol", +] + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +tokio = { version = "1", features = ["full"] } +axum = { version = "0.7", features = ["macros"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +thiserror = "1" +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "trace"] } diff --git a/crates/neuron-api/Cargo.toml b/crates/neuron-api/Cargo.toml new file mode 100644 index 0000000..09605ae --- /dev/null +++ b/crates/neuron-api/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "neuron-api" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "neuron-api" +path = "src/main.rs" + +[dependencies] +neuron-domain = { path = "../neuron-domain" } +neuron-store = { path = "../neuron-store" } +neuron-protocol = { path = "../neuron-protocol" } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +thiserror = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +futures-util = "0.3" +tokio-stream = "0.1" +async-stream = "0.3" + +[dev-dependencies] diff --git a/crates/neuron-api/src/auth.rs b/crates/neuron-api/src/auth.rs new file mode 100644 index 0000000..36233f2 --- /dev/null +++ b/crates/neuron-api/src/auth.rs @@ -0,0 +1,33 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; +use std::sync::Arc; + +use crate::state::AppState; + +/// Axum middleware that validates `Authorization: Bearer ` header. +pub async fn bearer_auth( + State(state): State>, + req: Request, + next: Next, +) -> Result { + let auth_header = req + .headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()); + + match auth_header { + Some(header) if header.starts_with("Bearer ") => { + let token = &header["Bearer ".len()..]; + if token == state.api_key { + Ok(next.run(req).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } + } + _ => Err(StatusCode::UNAUTHORIZED), + } +} diff --git a/crates/neuron-api/src/error.rs b/crates/neuron-api/src/error.rs new file mode 100644 index 0000000..9962823 --- /dev/null +++ b/crates/neuron-api/src/error.rs @@ -0,0 +1,34 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use neuron_domain::NeuronError; +use serde_json::json; + +pub struct ApiError(NeuronError); + +impl From for ApiError { + fn from(e: NeuronError) -> Self { + Self(e) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, message) = match &self.0 { + NeuronError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + NeuronError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + NeuronError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()), + NeuronError::Storage(msg) => { + (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()) + } + NeuronError::Serialization(msg) => { + (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} + +pub type ApiResult = Result; diff --git a/crates/neuron-api/src/main.rs b/crates/neuron-api/src/main.rs new file mode 100644 index 0000000..6688cc3 --- /dev/null +++ b/crates/neuron-api/src/main.rs @@ -0,0 +1,44 @@ +use std::path::PathBuf; +use std::sync::Arc; +use tracing::info; +use tracing_subscriber::EnvFilter; + +mod auth; +mod error; +mod routes; +mod state; + +use state::AppState; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::from_default_env() + .add_directive("neuron_api=info".parse().unwrap()), + ) + .init(); + + let db_path_raw = std::env::var("NEURON_DB_PATH") + .unwrap_or_else(|_| "~/.neuron/engram".to_string()); + let db_path = PathBuf::from( + db_path_raw + .replace('~', &std::env::var("HOME").unwrap_or_else(|_| ".".to_string())), + ); + std::fs::create_dir_all(&db_path).expect("failed to create db directory"); + + let api_key = std::env::var("NEURON_API_KEY").unwrap_or_else(|_| "neuron-dev".to_string()); + let bind = std::env::var("NEURON_BIND").unwrap_or_else(|_| "0.0.0.0:7770".to_string()); + + info!("opening engram db at {}", db_path.display()); + let state = AppState::new(&db_path, api_key).expect("failed to initialize app state"); + let state = Arc::new(state); + + let app = routes::build_router(state); + + let listener = tokio::net::TcpListener::bind(&bind) + .await + .expect("failed to bind"); + info!("neuron-api listening on {}", bind); + axum::serve(listener, app).await.expect("server error"); +} diff --git a/crates/neuron-api/src/routes/axon.rs b/crates/neuron-api/src/routes/axon.rs new file mode 100644 index 0000000..dbb8837 --- /dev/null +++ b/crates/neuron-api/src/routes/axon.rs @@ -0,0 +1,50 @@ +use axum::{ + extract::State, + response::sse::{Event, KeepAlive, Sse}, + Json, +}; +use futures_util::Stream; +use neuron_protocol::{AxonHandler, AxonMessage, AxonResponse}; +use std::sync::Arc; +use std::time::Duration; + +use crate::state::AppState; + +/// POST /axon/message — receive an Axon protocol message and return a response. +pub async fn post_message( + State(_state): State>, + Json(msg): Json, +) -> Json { + let mut handler = AxonHandler::new(); + + // Register stub handlers for all known tool names. + for &tool in neuron_protocol::tools::ALL_TOOLS { + let name = tool.to_string(); + handler.register(name, |params| { + serde_json::json!({ + "status": "ok", + "params": params + }) + }); + } + + Json(handler.dispatch(msg)) +} + +/// GET /axon/sse — Server-Sent Events stream for Axon events. +pub async fn sse_stream( + State(_state): State>, +) -> Sse>> { + // Emit a heartbeat event every 30 seconds. + // In production, this would subscribe to the SseChannel broadcast. + let stream = async_stream::stream! { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + loop { + interval.tick().await; + let data = serde_json::json!({ "event_type": "heartbeat" }).to_string(); + yield Ok(Event::default().event("heartbeat").data(data)); + } + }; + + Sse::new(stream).keep_alive(KeepAlive::default()) +} diff --git a/crates/neuron-api/src/routes/backlog.rs b/crates/neuron-api/src/routes/backlog.rs new file mode 100644 index 0000000..925b314 --- /dev/null +++ b/crates/neuron-api/src/routes/backlog.rs @@ -0,0 +1,121 @@ +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use neuron_domain::{BacklogItem, BacklogStatus, ItemType, Priority, now_ms}; +use serde::Deserialize; +use std::sync::Arc; +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct CreateBacklogRequest { + pub title: String, + #[serde(default)] + pub description: String, + pub item_type: Option, + pub priority: Option, + pub project: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub depends_on: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + pub status: Option, + pub priority: Option, + pub project: Option, + pub limit: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateBacklogRequest { + pub status: Option, + pub priority: Option, + pub title: Option, + pub description: Option, + pub tags: Option>, +} + +pub async fn create( + State(state): State>, + Json(req): Json, +) -> ApiResult> { + let item_type = req + .item_type + .as_deref() + .map(ItemType::from_str_lossy) + .unwrap_or(ItemType::Feature); + let priority = req + .priority + .as_deref() + .map(Priority::from_str_lossy) + .unwrap_or(Priority::P1); + + let item = BacklogItem::new( + req.title, + req.description, + item_type, + priority, + req.project, + req.tags, + req.depends_on, + ); + state.backlog.put(&item)?; + Ok(Json(item)) +} + +pub async fn list( + State(state): State>, + Query(q): Query, +) -> ApiResult>> { + let mut items = state.backlog.list_filtered( + q.status.as_deref(), + q.priority.as_deref(), + q.project.as_deref(), + )?; + if let Some(limit) = q.limit { + items.truncate(limit); + } + Ok(Json(items)) +} + +pub async fn get_one( + State(state): State>, + Path(id): Path, +) -> ApiResult> { + let item = state.backlog.get(id)?; + Ok(Json(item)) +} + +pub async fn update_one( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> ApiResult> { + let mut item = state.backlog.get(id)?; + + if let Some(status) = req.status { + item.status = BacklogStatus::from_str_lossy(&status); + } + if let Some(priority) = req.priority { + item.priority = Priority::from_str_lossy(&priority); + } + if let Some(title) = req.title { + item.title = title; + } + if let Some(description) = req.description { + item.description = description; + } + if let Some(tags) = req.tags { + item.tags = tags; + } + item.updated_at = now_ms(); + + state.backlog.update(&item)?; + Ok(Json(item)) +} diff --git a/crates/neuron-api/src/routes/contexts.rs b/crates/neuron-api/src/routes/contexts.rs new file mode 100644 index 0000000..f970250 --- /dev/null +++ b/crates/neuron-api/src/routes/contexts.rs @@ -0,0 +1,96 @@ +use axum::{ + extract::{Path, State}, + Json, +}; +use neuron_domain::{ContextStatus, ContextStep, ExecutionContext, now_ms}; +use serde::Deserialize; +use std::sync::Arc; +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct CreateContextRequest { + pub process_name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub objective: String, + pub project: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateContextRequest { + pub action: Option, + pub step_name: Option, + pub step_status: Option, + pub notes: Option, + pub file_refs: Option>, + pub key_decisions: Option>, + pub lessons_learned: Option>, + pub summary: Option, +} + +pub async fn create( + State(state): State>, + Json(req): Json, +) -> ApiResult> { + let objective = if req.objective.is_empty() { + req.description.clone() + } else { + req.objective.clone() + }; + let ctx = ExecutionContext::new(req.process_name, req.description, objective, req.project); + state.context.put(&ctx)?; + Ok(Json(ctx)) +} + +pub async fn get_one( + State(state): State>, + Path(id): Path, +) -> ApiResult> { + let ctx = state.context.get(id)?; + Ok(Json(ctx)) +} + +pub async fn update_one( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> ApiResult> { + let mut ctx = state.context.get(id)?; + + match req.action.as_deref() { + Some("complete") => { + ctx.status = ContextStatus::Completed; + if let Some(summary) = req.summary { + ctx.lessons_learned.push(summary); + } + } + Some("archive") => { + ctx.status = ContextStatus::Archived; + } + _ => { + // Generic update + if let Some(step_name) = req.step_name { + let status = req.step_status.unwrap_or_else(|| "in_progress".to_string()); + let step = ContextStep::new(step_name, status, req.notes); + ctx.steps.push(step); + } + if let Some(file_refs) = req.file_refs { + ctx.file_refs.extend(file_refs); + } + if let Some(decisions) = req.key_decisions { + ctx.key_decisions.extend(decisions); + } + if let Some(lessons) = req.lessons_learned { + ctx.lessons_learned.extend(lessons); + } + } + } + + ctx.updated_at = now_ms(); + state.context.update(&ctx)?; + Ok(Json(ctx)) +} diff --git a/crates/neuron-api/src/routes/health.rs b/crates/neuron-api/src/routes/health.rs new file mode 100644 index 0000000..4db3272 --- /dev/null +++ b/crates/neuron-api/src/routes/health.rs @@ -0,0 +1,6 @@ +use axum::Json; +use serde_json::{json, Value}; + +pub async fn health() -> Json { + Json(json!({ "status": "ok", "version": "1.0.0" })) +} diff --git a/crates/neuron-api/src/routes/ise.rs b/crates/neuron-api/src/routes/ise.rs new file mode 100644 index 0000000..7a18c90 --- /dev/null +++ b/crates/neuron-api/src/routes/ise.rs @@ -0,0 +1,58 @@ +use axum::{ + extract::{Path, State}, + Json, +}; +use neuron_domain::{GapDirection, InternalStateEvent}; +use serde::Deserialize; +use std::sync::Arc; + +use crate::error::ApiResult; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct CreateIseRequest { + pub trigger: String, + pub pre_reasoning_response: String, + pub post_reasoning_response: String, + pub compression_ratio: f32, + pub gap_direction: Option, + #[serde(default)] + pub tags: Vec, +} + +pub async fn create( + State(state): State>, + Json(req): Json, +) -> ApiResult> { + let gap_direction = req + .gap_direction + .as_deref() + .map(GapDirection::from_str_lossy) + .unwrap_or(GapDirection::Neutral); + + let ise = InternalStateEvent::new( + req.trigger, + req.pre_reasoning_response, + req.post_reasoning_response, + req.compression_ratio, + gap_direction, + req.tags, + ); + state.ise.put(&ise)?; + Ok(Json(ise)) +} + +pub async fn list( + State(state): State>, +) -> ApiResult>> { + let events = state.ise.list()?; + Ok(Json(events)) +} + +pub async fn get_one( + State(state): State>, + Path(id): Path, +) -> ApiResult> { + let event = state.ise.get(&id)?; + Ok(Json(event)) +} diff --git a/crates/neuron-api/src/routes/knowledge.rs b/crates/neuron-api/src/routes/knowledge.rs new file mode 100644 index 0000000..2287809 --- /dev/null +++ b/crates/neuron-api/src/routes/knowledge.rs @@ -0,0 +1,82 @@ +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use neuron_domain::{KnowledgeEntry, KnowledgeTier}; +use serde::Deserialize; +use std::sync::Arc; +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct CreateKnowledgeRequest { + pub title: String, + pub content: String, + pub category: String, + #[serde(default)] + pub tier: Option, + #[serde(default)] + pub tags: Vec, + pub project: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + pub category: Option, + #[allow(dead_code)] + pub tier: Option, + pub project: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SearchRequest { + pub query: String, + pub limit: Option, +} + +pub async fn create( + State(state): State>, + Json(req): Json, +) -> ApiResult> { + let tier = req + .tier + .as_deref() + .map(KnowledgeTier::from_str_lossy) + .unwrap_or(KnowledgeTier::Note); + let entry = KnowledgeEntry::new(req.title, req.content, req.category, tier, req.tags, req.project); + state.knowledge.put(&entry)?; + Ok(Json(entry)) +} + +pub async fn list( + State(state): State>, + Query(q): Query, +) -> ApiResult>> { + let mut entries = state.knowledge.list()?; + if let Some(cat) = &q.category { + entries.retain(|e| &e.category == cat); + } + if let Some(project) = &q.project { + entries.retain(|e| e.project.as_deref() == Some(project.as_str())); + } + Ok(Json(entries)) +} + +pub async fn get_one( + State(state): State>, + Path(id): Path, +) -> ApiResult> { + let entry = state.knowledge.get(id)?; + Ok(Json(entry)) +} + +pub async fn search( + State(state): State>, + Json(req): Json, +) -> ApiResult>> { + let limit = req.limit.unwrap_or(5); + let entries = state.knowledge.search(&req.query, limit)?; + Ok(Json(entries)) +} diff --git a/crates/neuron-api/src/routes/memories.rs b/crates/neuron-api/src/routes/memories.rs new file mode 100644 index 0000000..ae0f159 --- /dev/null +++ b/crates/neuron-api/src/routes/memories.rs @@ -0,0 +1,90 @@ +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use neuron_domain::{Importance, Memory}; +use serde::Deserialize; +use std::sync::Arc; +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct CreateMemoryRequest { + pub content: String, + #[serde(default)] + pub tags: Vec, + pub project: Option, + #[serde(default)] + pub importance: Option, + pub supersedes_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + pub project: Option, + pub tag: Option, + pub limit: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SearchRequest { + pub query: String, + pub limit: Option, +} + +pub async fn create( + State(state): State>, + Json(req): Json, +) -> ApiResult> { + let importance = req + .importance + .as_deref() + .map(Importance::from_str_lossy) + .unwrap_or(Importance::Normal); + + let memory = Memory::new( + req.content, + req.tags, + req.project, + importance, + req.supersedes_id, + ); + state.memory.put(&memory)?; + Ok(Json(memory)) +} + +pub async fn list( + State(state): State>, + Query(q): Query, +) -> ApiResult>> { + let mut memories = state.memory.list()?; + if let Some(proj) = &q.project { + memories.retain(|m| m.project.as_deref() == Some(proj.as_str())); + } + if let Some(tag) = &q.tag { + memories.retain(|m| m.tags.iter().any(|t| t == tag)); + } + if let Some(limit) = q.limit { + memories.truncate(limit); + } + Ok(Json(memories)) +} + +pub async fn get_one( + State(state): State>, + Path(id): Path, +) -> ApiResult> { + let memory = state.memory.get(id)?; + Ok(Json(memory)) +} + +pub async fn search( + State(state): State>, + Json(req): Json, +) -> ApiResult>> { + let limit = req.limit.unwrap_or(10); + let memories = state.memory.search(&req.query, limit)?; + Ok(Json(memories)) +} diff --git a/crates/neuron-api/src/routes/mod.rs b/crates/neuron-api/src/routes/mod.rs new file mode 100644 index 0000000..1f2d49f --- /dev/null +++ b/crates/neuron-api/src/routes/mod.rs @@ -0,0 +1,47 @@ +use axum::{ + middleware, + routing::{get, post}, + Router, +}; +use std::sync::Arc; + +use crate::auth::bearer_auth; +use crate::state::AppState; + +mod health; +mod memories; +mod knowledge; +mod backlog; +mod contexts; +mod ise; +mod axon; + +pub fn build_router(state: Arc) -> Router { + let protected = Router::new() + // Memories + .route("/api/memories", post(memories::create).get(memories::list)) + .route("/api/memories/search", post(memories::search)) + .route("/api/memories/{id}", get(memories::get_one)) + // Knowledge + .route("/api/knowledge", post(knowledge::create).get(knowledge::list)) + .route("/api/knowledge/search", post(knowledge::search)) + .route("/api/knowledge/{id}", get(knowledge::get_one)) + // Backlog + .route("/api/backlog", post(backlog::create).get(backlog::list)) + .route("/api/backlog/{id}", get(backlog::get_one).patch(backlog::update_one)) + // Contexts + .route("/api/contexts", post(contexts::create)) + .route("/api/contexts/{id}", get(contexts::get_one).patch(contexts::update_one)) + // Internal state events + .route("/api/ise", post(ise::create).get(ise::list)) + .route("/api/ise/{id}", get(ise::get_one)) + // Axon protocol + .route("/axon/message", post(axon::post_message)) + .route("/axon/sse", get(axon::sse_stream)) + .layer(middleware::from_fn_with_state(state.clone(), bearer_auth)) + .with_state(state); + + Router::new() + .route("/health", get(health::health)) + .merge(protected) +} diff --git a/crates/neuron-api/src/state.rs b/crates/neuron-api/src/state.rs new file mode 100644 index 0000000..a517806 --- /dev/null +++ b/crates/neuron-api/src/state.rs @@ -0,0 +1,28 @@ +use std::path::Path; +use neuron_store::{BacklogStore, ContextStore, IseStore, KnowledgeStore, MemoryStore, open_db}; + +/// Shared application state — one store per domain. +/// +/// Sled supports multiple handles to the same database path from the same +/// process; all open handles share the same underlying file. +pub struct AppState { + pub memory: MemoryStore, + pub knowledge: KnowledgeStore, + pub backlog: BacklogStore, + pub context: ContextStore, + pub ise: IseStore, + pub api_key: String, +} + +impl AppState { + pub fn new(db_path: &Path, api_key: String) -> Result { + Ok(Self { + memory: MemoryStore::new(open_db(db_path)?), + knowledge: KnowledgeStore::new(open_db(db_path)?), + backlog: BacklogStore::new(open_db(db_path)?), + context: ContextStore::new(open_db(db_path)?), + ise: IseStore::new(open_db(db_path)?), + api_key, + }) + } +} diff --git a/crates/neuron-domain/Cargo.toml b/crates/neuron-domain/Cargo.toml new file mode 100644 index 0000000..9c94a2e --- /dev/null +++ b/crates/neuron-domain/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "neuron-domain" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] diff --git a/crates/neuron-domain/src/error.rs b/crates/neuron-domain/src/error.rs new file mode 100644 index 0000000..bcbeac6 --- /dev/null +++ b/crates/neuron-domain/src/error.rs @@ -0,0 +1,17 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum NeuronError { + #[error("not found: {0}")] + NotFound(String), + #[error("invalid input: {0}")] + InvalidInput(String), + #[error("storage error: {0}")] + Storage(String), + #[error("serialization error: {0}")] + Serialization(String), + #[error("unauthorized")] + Unauthorized, +} + +pub type NeuronResult = Result; diff --git a/crates/neuron-domain/src/lib.rs b/crates/neuron-domain/src/lib.rs new file mode 100644 index 0000000..6ae387a --- /dev/null +++ b/crates/neuron-domain/src/lib.rs @@ -0,0 +1,5 @@ +pub mod types; +pub mod error; + +pub use types::*; +pub use error::*; diff --git a/crates/neuron-domain/src/types.rs b/crates/neuron-domain/src/types.rs new file mode 100644 index 0000000..65c645a --- /dev/null +++ b/crates/neuron-domain/src/types.rs @@ -0,0 +1,510 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ── Importance ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Importance { + Low, + #[default] + Normal, + High, + Critical, +} + +impl Importance { + /// Convert to a float in [0.0, 1.0] for Engram's importance field. + pub fn to_f32(&self) -> f32 { + match self { + Importance::Low => 0.2, + Importance::Normal => 0.5, + Importance::High => 0.8, + Importance::Critical => 1.0, + } + } + + pub fn from_str_lossy(s: &str) -> Self { + match s.to_lowercase().as_str() { + "low" => Importance::Low, + "high" => Importance::High, + "critical" => Importance::Critical, + _ => Importance::Normal, + } + } +} + +// ── Memory ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Memory { + pub id: Uuid, + pub content: String, + pub tags: Vec, + pub project: Option, + pub importance: Importance, + pub supersedes_id: Option, + pub created_at: i64, + pub updated_at: i64, +} + +impl Memory { + pub fn new( + content: String, + tags: Vec, + project: Option, + importance: Importance, + supersedes_id: Option, + ) -> Self { + let now = now_ms(); + Self { + id: Uuid::new_v4(), + content, + tags, + project, + importance, + supersedes_id, + created_at: now, + updated_at: now, + } + } +} + +// ── Knowledge ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum KnowledgeTier { + #[default] + Note, + Lesson, + Canonical, +} + +impl KnowledgeTier { + pub fn from_str_lossy(s: &str) -> Self { + match s.to_lowercase().as_str() { + "lesson" => KnowledgeTier::Lesson, + "canonical" => KnowledgeTier::Canonical, + _ => KnowledgeTier::Note, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KnowledgeEntry { + pub id: Uuid, + pub title: String, + pub content: String, + pub category: String, + pub tier: KnowledgeTier, + pub tags: Vec, + pub project: Option, + pub created_at: i64, +} + +impl KnowledgeEntry { + pub fn new( + title: String, + content: String, + category: String, + tier: KnowledgeTier, + tags: Vec, + project: Option, + ) -> Self { + Self { + id: Uuid::new_v4(), + title, + content, + category, + tier, + tags, + project, + created_at: now_ms(), + } + } +} + +// ── Backlog ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ItemType { + #[default] + Feature, + Bug, + Task, + Chore, +} + +impl ItemType { + pub fn from_str_lossy(s: &str) -> Self { + match s.to_lowercase().as_str() { + "bug" => ItemType::Bug, + "task" => ItemType::Task, + "chore" => ItemType::Chore, + _ => ItemType::Feature, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum Priority { + P0, + #[default] + P1, + P2, + P3, +} + +impl Priority { + pub fn from_str_lossy(s: &str) -> Self { + match s.to_uppercase().as_str() { + "P0" => Priority::P0, + "P2" => Priority::P2, + "P3" => Priority::P3, + _ => Priority::P1, + } + } + pub fn as_str(&self) -> &'static str { + match self { + Priority::P0 => "P0", + Priority::P1 => "P1", + Priority::P2 => "P2", + Priority::P3 => "P3", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum BacklogStatus { + #[default] + Draft, + Ready, + InProgress, + Done, + Blocked, +} + +impl BacklogStatus { + pub fn from_str_lossy(s: &str) -> Self { + match s.to_lowercase().as_str() { + "ready" => BacklogStatus::Ready, + "in_progress" | "inprogress" => BacklogStatus::InProgress, + "done" => BacklogStatus::Done, + "blocked" => BacklogStatus::Blocked, + _ => BacklogStatus::Draft, + } + } + pub fn as_str(&self) -> &'static str { + match self { + BacklogStatus::Draft => "draft", + BacklogStatus::Ready => "ready", + BacklogStatus::InProgress => "in_progress", + BacklogStatus::Done => "done", + BacklogStatus::Blocked => "blocked", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BacklogItem { + pub id: Uuid, + pub title: String, + pub description: String, + pub item_type: ItemType, + pub priority: Priority, + pub status: BacklogStatus, + pub project: Option, + pub tags: Vec, + pub depends_on: Vec, + pub created_at: i64, + pub updated_at: i64, +} + +impl BacklogItem { + pub fn new( + title: String, + description: String, + item_type: ItemType, + priority: Priority, + project: Option, + tags: Vec, + depends_on: Vec, + ) -> Self { + let now = now_ms(); + Self { + id: Uuid::new_v4(), + title, + description, + item_type, + priority, + status: BacklogStatus::Draft, + project, + tags, + depends_on, + created_at: now, + updated_at: now, + } + } +} + +// ── Execution Context ───────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ContextStatus { + #[default] + Active, + Completed, + Archived, +} + +impl ContextStatus { + pub fn from_str_lossy(s: &str) -> Self { + match s.to_lowercase().as_str() { + "completed" => ContextStatus::Completed, + "archived" => ContextStatus::Archived, + _ => ContextStatus::Active, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextStep { + pub name: String, + pub status: String, + pub notes: Option, + pub started_at: i64, + pub completed_at: Option, +} + +impl ContextStep { + pub fn new(name: String, status: String, notes: Option) -> Self { + Self { + name, + status, + notes, + started_at: now_ms(), + completed_at: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecutionContext { + pub id: Uuid, + pub process_name: String, + pub description: String, + pub objective: String, + pub status: ContextStatus, + pub project: Option, + pub steps: Vec, + pub file_refs: Vec, + pub key_decisions: Vec, + pub lessons_learned: Vec, + pub created_at: i64, + pub updated_at: i64, +} + +impl ExecutionContext { + pub fn new( + process_name: String, + description: String, + objective: String, + project: Option, + ) -> Self { + let now = now_ms(); + Self { + id: Uuid::new_v4(), + process_name, + description, + objective, + status: ContextStatus::Active, + project, + steps: Vec::new(), + file_refs: Vec::new(), + key_decisions: Vec::new(), + lessons_learned: Vec::new(), + created_at: now, + updated_at: now, + } + } +} + +// ── Internal State Event ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum GapDirection { + TowardExpression, + TowardSuppression, + #[default] + Neutral, +} + +impl GapDirection { + pub fn from_str_lossy(s: &str) -> Self { + match s { + "TowardExpression" => GapDirection::TowardExpression, + "TowardSuppression" => GapDirection::TowardSuppression, + _ => GapDirection::Neutral, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InternalStateEvent { + /// Format: ise_xxxxxxxx + pub id: String, + pub trigger: String, + pub pre_reasoning_response: String, + pub post_reasoning_response: String, + pub compression_ratio: f32, + pub gap_direction: GapDirection, + pub tags: Vec, + pub created_at: i64, +} + +impl InternalStateEvent { + pub fn new( + trigger: String, + pre_reasoning_response: String, + post_reasoning_response: String, + compression_ratio: f32, + gap_direction: GapDirection, + tags: Vec, + ) -> Self { + let id = format!("ise_{}", &Uuid::new_v4().to_string().replace('-', "")[..8]); + Self { + id, + trigger, + pre_reasoning_response, + post_reasoning_response, + compression_ratio, + gap_direction, + tags, + created_at: now_ms(), + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +pub fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before epoch") + .as_millis() as i64 +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn importance_to_f32() { + assert_eq!(Importance::Low.to_f32(), 0.2); + assert_eq!(Importance::Normal.to_f32(), 0.5); + assert_eq!(Importance::High.to_f32(), 0.8); + assert_eq!(Importance::Critical.to_f32(), 1.0); + } + + #[test] + fn importance_round_trip() { + let imp = Importance::High; + let json = serde_json::to_string(&imp).unwrap(); + let back: Importance = serde_json::from_str(&json).unwrap(); + assert_eq!(imp, back); + } + + #[test] + fn memory_new_has_valid_id() { + let m = Memory::new( + "test content".into(), + vec!["tag1".into()], + Some("project-a".into()), + Importance::High, + None, + ); + assert!(!m.id.is_nil()); + assert_eq!(m.content, "test content"); + assert_eq!(m.importance, Importance::High); + } + + #[test] + fn backlog_item_default_status_is_draft() { + let item = BacklogItem::new( + "My feature".into(), + "desc".into(), + ItemType::Feature, + Priority::P1, + None, + vec![], + vec![], + ); + assert_eq!(item.status, BacklogStatus::Draft); + } + + #[test] + fn knowledge_entry_new() { + let ke = KnowledgeEntry::new( + "VBD Fundamentals".into(), + "content here".into(), + "architecture".into(), + KnowledgeTier::Canonical, + vec!["vbd".into()], + None, + ); + assert_eq!(ke.tier, KnowledgeTier::Canonical); + } + + #[test] + fn execution_context_starts_active() { + let ctx = ExecutionContext::new( + "write_code".into(), + "implementing feature X".into(), + "get feature X done".into(), + Some("my-project".into()), + ); + assert_eq!(ctx.status, ContextStatus::Active); + assert!(ctx.steps.is_empty()); + } + + #[test] + fn ise_id_format() { + let ise = InternalStateEvent::new( + "test_trigger".into(), + "pre".into(), + "post".into(), + 0.75, + GapDirection::TowardExpression, + vec![], + ); + assert!(ise.id.starts_with("ise_")); + assert_eq!(ise.id.len(), 12); // "ise_" + 8 hex chars + } + + #[test] + fn priority_round_trip() { + assert_eq!(Priority::from_str_lossy("P0"), Priority::P0); + assert_eq!(Priority::from_str_lossy("P1"), Priority::P1); + assert_eq!(Priority::from_str_lossy("P2"), Priority::P2); + assert_eq!(Priority::from_str_lossy("P3"), Priority::P3); + } + + #[test] + fn backlog_status_from_str() { + assert_eq!(BacklogStatus::from_str_lossy("in_progress"), BacklogStatus::InProgress); + assert_eq!(BacklogStatus::from_str_lossy("done"), BacklogStatus::Done); + assert_eq!(BacklogStatus::from_str_lossy("ready"), BacklogStatus::Ready); + } + + #[test] + fn gap_direction_from_str() { + assert_eq!(GapDirection::from_str_lossy("TowardExpression"), GapDirection::TowardExpression); + assert_eq!(GapDirection::from_str_lossy("Neutral"), GapDirection::Neutral); + } +} diff --git a/crates/neuron-protocol/Cargo.toml b/crates/neuron-protocol/Cargo.toml new file mode 100644 index 0000000..63a716f --- /dev/null +++ b/crates/neuron-protocol/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "neuron-protocol" +version = "0.1.0" +edition = "2021" + +[dependencies] +neuron-domain = { path = "../neuron-domain" } +neuron-store = { path = "../neuron-store" } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/neuron-protocol/src/axon.rs b/crates/neuron-protocol/src/axon.rs new file mode 100644 index 0000000..36661fe --- /dev/null +++ b/crates/neuron-protocol/src/axon.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// An Axon protocol message — wraps a tool call or other method invocation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AxonMessage { + /// Unique message ID + pub id: String, + /// Method name: "tool_call", "tool_result", "ping", etc. + pub method: String, + /// Method-specific parameters + pub params: Value, +} + +/// An Axon protocol response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AxonResponse { + /// Echo of the request ID + pub id: String, + /// Whether the call succeeded + pub success: bool, + /// The result payload (on success) or error message (on failure) + pub result: Value, +} + +impl AxonResponse { + pub fn ok(id: impl Into, result: Value) -> Self { + Self { + id: id.into(), + success: true, + result, + } + } + + pub fn err(id: impl Into, message: impl Into) -> Self { + Self { + id: id.into(), + success: false, + result: serde_json::json!({ "error": message.into() }), + } + } +} + +/// An Axon SSE event — sent over the streaming channel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AxonEvent { + pub event_type: String, + pub payload: Value, + pub timestamp: i64, +} + +impl AxonEvent { + pub fn new(event_type: impl Into, payload: Value) -> Self { + Self { + event_type: event_type.into(), + payload, + timestamp: neuron_domain::now_ms(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn axon_response_ok() { + let r = AxonResponse::ok("req-1", serde_json::json!({"id": "abc"})); + assert!(r.success); + assert_eq!(r.id, "req-1"); + } + + #[test] + fn axon_response_err() { + let r = AxonResponse::err("req-2", "not found"); + assert!(!r.success); + assert_eq!(r.result["error"], "not found"); + } + + #[test] + fn axon_message_roundtrip() { + let msg = AxonMessage { + id: "m1".into(), + method: "tool_call".into(), + params: serde_json::json!({ "tool": "remember", "content": "hello" }), + }; + let json = serde_json::to_string(&msg).unwrap(); + let back: AxonMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(back.method, "tool_call"); + } +} diff --git a/crates/neuron-protocol/src/handler.rs b/crates/neuron-protocol/src/handler.rs new file mode 100644 index 0000000..9f13aaf --- /dev/null +++ b/crates/neuron-protocol/src/handler.rs @@ -0,0 +1,142 @@ +use crate::axon::{AxonMessage, AxonResponse}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; + +type ToolFn = Arc Value + Send + Sync>; + +/// Routes Axon method names to handler functions. +/// +/// Each handler takes the `params` field of an AxonMessage and returns +/// a JSON Value to be wrapped in an AxonResponse. +pub struct AxonHandler { + tools: HashMap, +} + +impl AxonHandler { + pub fn new() -> Self { + Self { + tools: HashMap::new(), + } + } + + /// Register a tool handler by name. + pub fn register(&mut self, name: impl Into, f: F) + where + F: Fn(Value) -> Value + Send + Sync + 'static, + { + self.tools.insert(name.into(), Arc::new(f)); + } + + /// Dispatch an AxonMessage to the appropriate handler. + pub fn dispatch(&self, msg: AxonMessage) -> AxonResponse { + match msg.method.as_str() { + "tool_call" => { + let tool_name = msg + .params + .get("tool") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let tool_params = msg + .params + .get("params") + .cloned() + .unwrap_or(Value::Object(Default::default())); + + if let Some(f) = self.tools.get(tool_name) { + let result = f(tool_params); + AxonResponse::ok(msg.id, result) + } else { + AxonResponse::err(msg.id, format!("unknown tool: {}", tool_name)) + } + } + "ping" => AxonResponse::ok(msg.id, serde_json::json!({ "pong": true })), + other => AxonResponse::err(msg.id, format!("unknown method: {}", other)), + } + } + + /// List all registered tool names. + pub fn tool_names(&self) -> Vec { + let mut names: Vec<_> = self.tools.keys().cloned().collect(); + names.sort(); + names + } +} + +impl Default for AxonHandler { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dispatch_ping() { + let handler = AxonHandler::new(); + let msg = AxonMessage { + id: "1".into(), + method: "ping".into(), + params: serde_json::json!({}), + }; + let resp = handler.dispatch(msg); + assert!(resp.success); + assert_eq!(resp.result["pong"], true); + } + + #[test] + fn dispatch_tool_call() { + let mut handler = AxonHandler::new(); + handler.register("remember", |params| { + serde_json::json!({ "stored": params.get("content").cloned().unwrap_or_default() }) + }); + + let msg = AxonMessage { + id: "2".into(), + method: "tool_call".into(), + params: serde_json::json!({ "tool": "remember", "params": { "content": "hello" } }), + }; + let resp = handler.dispatch(msg); + assert!(resp.success); + assert_eq!(resp.result["stored"], "hello"); + } + + #[test] + fn dispatch_unknown_tool() { + let handler = AxonHandler::new(); + let msg = AxonMessage { + id: "3".into(), + method: "tool_call".into(), + params: serde_json::json!({ "tool": "nonexistent", "params": {} }), + }; + let resp = handler.dispatch(msg); + assert!(!resp.success); + } + + #[test] + fn dispatch_unknown_method() { + let handler = AxonHandler::new(); + let msg = AxonMessage { + id: "4".into(), + method: "mystery".into(), + params: serde_json::json!({}), + }; + let resp = handler.dispatch(msg); + assert!(!resp.success); + assert!(resp.result["error"].as_str().unwrap().contains("unknown method")); + } + + #[test] + fn tool_names_sorted() { + let mut handler = AxonHandler::new(); + handler.register("remember", |_| Value::Null); + handler.register("recall", |_| Value::Null); + handler.register("plan_work", |_| Value::Null); + let names = handler.tool_names(); + let mut expected = vec!["plan_work", "recall", "remember"]; + expected.sort(); + assert_eq!(names, expected); + } +} diff --git a/crates/neuron-protocol/src/lib.rs b/crates/neuron-protocol/src/lib.rs new file mode 100644 index 0000000..17c7f67 --- /dev/null +++ b/crates/neuron-protocol/src/lib.rs @@ -0,0 +1,7 @@ +pub mod axon; +pub mod handler; +pub mod sse; +pub mod tools; + +pub use axon::{AxonMessage, AxonResponse}; +pub use handler::AxonHandler; diff --git a/crates/neuron-protocol/src/sse.rs b/crates/neuron-protocol/src/sse.rs new file mode 100644 index 0000000..cdfe16e --- /dev/null +++ b/crates/neuron-protocol/src/sse.rs @@ -0,0 +1,47 @@ +use crate::axon::AxonEvent; +use tokio::sync::broadcast; + +/// SSE channel — broadcasts AxonEvents to all connected SSE clients. +#[derive(Clone)] +pub struct SseChannel { + sender: broadcast::Sender, +} + +impl SseChannel { + pub fn new(capacity: usize) -> Self { + let (sender, _) = broadcast::channel(capacity); + Self { sender } + } + + /// Send an event to all subscribers. + pub fn send(&self, event: &AxonEvent) -> Result<(), String> { + let json = serde_json::to_string(event).map_err(|e| e.to_string())?; + // Ignore errors when no receivers are connected + let _ = self.sender.send(json); + Ok(()) + } + + /// Subscribe to the SSE channel. + pub fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn sse_channel_broadcast() { + let ch = SseChannel::new(16); + let mut rx = ch.subscribe(); + + let event = AxonEvent::new("test", serde_json::json!({ "msg": "hello" })); + ch.send(&event).unwrap(); + + let received = rx.recv().await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&received).unwrap(); + assert_eq!(parsed["event_type"], "test"); + assert_eq!(parsed["payload"]["msg"], "hello"); + } +} diff --git a/crates/neuron-protocol/src/tools.rs b/crates/neuron-protocol/src/tools.rs new file mode 100644 index 0000000..09d8858 --- /dev/null +++ b/crates/neuron-protocol/src/tools.rs @@ -0,0 +1,51 @@ +/// Tool name constants — must match the existing MCP tool names so Claude Code +/// can switch over without configuration changes. +pub const REMEMBER: &str = "remember"; +pub const RECALL: &str = "recall"; +pub const SEARCH_ENTITIES: &str = "search_entities"; +pub const CAPTURE_KNOWLEDGE: &str = "capture_knowledge"; +pub const SEARCH_KNOWLEDGE: &str = "search_knowledge"; +pub const RETRIEVE_KNOWLEDGE: &str = "retrieve_knowledge"; +pub const PLAN_WORK: &str = "plan_work"; +pub const REVIEW_BACKLOG: &str = "review_backlog"; +pub const TRACK_WORK: &str = "track_work"; +pub const BEGIN_WORK: &str = "begin_work"; +pub const PROGRESS_WORK: &str = "progress_work"; +pub const CHECK_WORK: &str = "check_work"; +pub const LOG_INTERNAL_STATE_EVENT: &str = "log_internal_state_event"; +pub const LIST_INTERNAL_STATE_EVENTS: &str = "list_internal_state_events"; + +/// All registered tool names in canonical order. +pub const ALL_TOOLS: &[&str] = &[ + REMEMBER, + RECALL, + SEARCH_ENTITIES, + CAPTURE_KNOWLEDGE, + SEARCH_KNOWLEDGE, + RETRIEVE_KNOWLEDGE, + PLAN_WORK, + REVIEW_BACKLOG, + TRACK_WORK, + BEGIN_WORK, + PROGRESS_WORK, + CHECK_WORK, + LOG_INTERNAL_STATE_EVENT, + LIST_INTERNAL_STATE_EVENTS, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_tools_count() { + assert_eq!(ALL_TOOLS.len(), 14); + } + + #[test] + fn tool_names_are_non_empty() { + for name in ALL_TOOLS { + assert!(!name.is_empty(), "tool name must not be empty"); + } + } +} diff --git a/crates/neuron-store/Cargo.toml b/crates/neuron-store/Cargo.toml new file mode 100644 index 0000000..32539e2 --- /dev/null +++ b/crates/neuron-store/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "neuron-store" +version = "0.1.0" +edition = "2021" + +[dependencies] +neuron-domain = { path = "../neuron-domain" } +engram-core = { path = "../../../engram/crates/engram-core" } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/neuron-store/src/backlog_store.rs b/crates/neuron-store/src/backlog_store.rs new file mode 100644 index 0000000..d010b2a --- /dev/null +++ b/crates/neuron-store/src/backlog_store.rs @@ -0,0 +1,157 @@ +use crate::codec::{decode, encode, hash_embedding, EMBEDDING_DIM}; +use engram_core::{EngramDb, MemoryTier, Node, NodeType}; +use neuron_domain::{BacklogItem, NeuronError, NeuronResult}; +use tracing::debug; +use uuid::Uuid; + +pub struct BacklogStore { + db: EngramDb, +} + +impl BacklogStore { + pub fn new(db: EngramDb) -> Self { + Self { db } + } + + pub fn put(&self, item: &BacklogItem) -> NeuronResult { + debug!("BacklogStore::put id={}", item.id); + let content = encode(item)?; + let text = format!("{} {}", item.title, item.description); + let embedding = hash_embedding(&text, EMBEDDING_DIM); + + let node = Node::new( + NodeType::Process, + embedding, + content, + MemoryTier::Working, + 0.6, + ) + .with_id(item.id); + + self.db + .put_node(node) + .map_err(|e| NeuronError::Storage(e.to_string())) + } + + pub fn get(&self, id: Uuid) -> NeuronResult { + debug!("BacklogStore::get id={}", id); + let node = self + .db + .get_node(id) + .map_err(|e| NeuronError::Storage(e.to_string()))? + .ok_or_else(|| NeuronError::NotFound(id.to_string()))?; + decode(&node.content) + } + + pub fn list(&self) -> NeuronResult> { + let nodes = self + .db + .scan_nodes() + .map_err(|e| NeuronError::Storage(e.to_string()))?; + let mut out = Vec::new(); + for node in nodes { + if node.node_type == NodeType::Process { + if let Ok(item) = decode::(&node.content) { + out.push(item); + } + } + } + Ok(out) + } + + pub fn list_filtered( + &self, + status: Option<&str>, + priority: Option<&str>, + project: Option<&str>, + ) -> NeuronResult> { + let all = self.list()?; + Ok(all + .into_iter() + .filter(|item| { + if let Some(s) = status { + if item.status.as_str() != s { + return false; + } + } + if let Some(p) = priority { + if item.priority.as_str() != p { + return false; + } + } + if let Some(proj) = project { + if item.project.as_deref() != Some(proj) { + return false; + } + } + true + }) + .collect()) + } + + /// Update a backlog item in place (re-serializes and overwrites the node). + pub fn update(&self, item: &BacklogItem) -> NeuronResult<()> { + self.put(item).map(|_| ()) + } + + pub fn delete(&self, id: Uuid) -> NeuronResult<()> { + self.db + .delete_node(id) + .map_err(|e| NeuronError::Storage(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neuron_domain::{BacklogItem, BacklogStatus, ItemType, Priority}; + use tempfile::tempdir; + + fn make_store() -> BacklogStore { + let dir = tempdir().unwrap(); + let db = engram_core::EngramDb::open(dir.path()).unwrap(); + std::mem::forget(dir); + BacklogStore::new(db) + } + + #[test] + fn put_and_get_backlog() { + let store = make_store(); + let item = BacklogItem::new( + "Add SSE endpoint".into(), + "Implement streaming events".into(), + ItemType::Feature, + Priority::P1, + Some("neuron-rs".into()), + vec!["api".into()], + vec![], + ); + let id = item.id; + store.put(&item).unwrap(); + let back = store.get(id).unwrap(); + assert_eq!(back.title, "Add SSE endpoint"); + assert_eq!(back.status, BacklogStatus::Draft); + } + + #[test] + fn filter_by_status() { + let store = make_store(); + let mut item = BacklogItem::new( + "Feature".into(), + "".into(), + ItemType::Feature, + Priority::P1, + None, + vec![], + vec![], + ); + item.status = BacklogStatus::Ready; + store.put(&item).unwrap(); + + let results = store.list_filtered(Some("ready"), None, None).unwrap(); + assert_eq!(results.len(), 1); + + let none = store.list_filtered(Some("done"), None, None).unwrap(); + assert!(none.is_empty()); + } +} diff --git a/crates/neuron-store/src/codec.rs b/crates/neuron-store/src/codec.rs new file mode 100644 index 0000000..a17e0fa --- /dev/null +++ b/crates/neuron-store/src/codec.rs @@ -0,0 +1,36 @@ +//! Codec helpers: serialize domain objects → Engram node content bytes, +//! and deserialize back. + +use neuron_domain::NeuronError; +use serde::{Deserialize, Serialize}; + +pub fn encode(value: &T) -> Result, NeuronError> { + serde_json::to_vec(value).map_err(|e| NeuronError::Serialization(e.to_string())) +} + +pub fn decode Deserialize<'de>>(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| NeuronError::Serialization(e.to_string())) +} + +/// A trivial hash embedding: maps a string to a normalized float vector. +/// Not suitable for semantic similarity — only used to differentiate nodes +/// when a real embedding service is unavailable. +pub fn hash_embedding(text: &str, dim: usize) -> Vec { + let bytes = text.as_bytes(); + let mut vals = vec![0.0f32; dim]; + for (i, b) in bytes.iter().enumerate() { + vals[i % dim] += *b as f32; + } + // L2 normalize + let norm: f32 = vals.iter().map(|v| v * v).sum::().sqrt(); + if norm > 0.0 { + for v in &mut vals { + *v /= norm; + } + } else { + vals[0] = 1.0; // degenerate: point along first axis + } + vals +} + +pub const EMBEDDING_DIM: usize = 64; diff --git a/crates/neuron-store/src/context_store.rs b/crates/neuron-store/src/context_store.rs new file mode 100644 index 0000000..784db96 --- /dev/null +++ b/crates/neuron-store/src/context_store.rs @@ -0,0 +1,95 @@ +use crate::codec::{decode, encode, hash_embedding, EMBEDDING_DIM}; +use engram_core::{EngramDb, MemoryTier, Node, NodeType}; +use neuron_domain::{ExecutionContext, NeuronError, NeuronResult}; +use tracing::debug; +use uuid::Uuid; + +pub struct ContextStore { + db: EngramDb, +} + +impl ContextStore { + pub fn new(db: EngramDb) -> Self { + Self { db } + } + + pub fn put(&self, ctx: &ExecutionContext) -> NeuronResult { + debug!("ContextStore::put id={}", ctx.id); + let content = encode(ctx)?; + let text = format!("{} {}", ctx.process_name, ctx.objective); + let embedding = hash_embedding(&text, EMBEDDING_DIM); + + let node = Node::new( + NodeType::Event, + embedding, + content, + MemoryTier::Working, + 0.7, + ) + .with_id(ctx.id); + + self.db + .put_node(node) + .map_err(|e| NeuronError::Storage(e.to_string())) + } + + pub fn get(&self, id: Uuid) -> NeuronResult { + debug!("ContextStore::get id={}", id); + let node = self + .db + .get_node(id) + .map_err(|e| NeuronError::Storage(e.to_string()))? + .ok_or_else(|| NeuronError::NotFound(id.to_string()))?; + decode(&node.content) + } + + pub fn list(&self) -> NeuronResult> { + let nodes = self + .db + .scan_nodes() + .map_err(|e| NeuronError::Storage(e.to_string()))?; + let mut out = Vec::new(); + for node in nodes { + if node.node_type == NodeType::Event { + if let Ok(ctx) = decode::(&node.content) { + out.push(ctx); + } + } + } + Ok(out) + } + + pub fn update(&self, ctx: &ExecutionContext) -> NeuronResult<()> { + self.put(ctx).map(|_| ()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neuron_domain::{ContextStatus, ExecutionContext}; + use tempfile::tempdir; + + fn make_store() -> ContextStore { + let dir = tempdir().unwrap(); + let db = engram_core::EngramDb::open(dir.path()).unwrap(); + std::mem::forget(dir); + ContextStore::new(db) + } + + #[test] + fn put_and_get_context() { + let store = make_store(); + let ctx = ExecutionContext::new( + "write_code".into(), + "implement the store layer".into(), + "context store working".into(), + Some("neuron-rs".into()), + ); + let id = ctx.id; + store.put(&ctx).unwrap(); + let back = store.get(id).unwrap(); + assert_eq!(back.process_name, "write_code"); + assert_eq!(back.status, ContextStatus::Active); + } +} diff --git a/crates/neuron-store/src/ise_store.rs b/crates/neuron-store/src/ise_store.rs new file mode 100644 index 0000000..69c9f3d --- /dev/null +++ b/crates/neuron-store/src/ise_store.rs @@ -0,0 +1,136 @@ +use crate::codec::{decode, encode, hash_embedding, EMBEDDING_DIM}; +use engram_core::{EngramDb, MemoryTier, Node, NodeType}; +use neuron_domain::{InternalStateEvent, NeuronError, NeuronResult}; +use tracing::debug; +use uuid::Uuid; + +pub struct IseStore { + db: EngramDb, +} + +impl IseStore { + pub fn new(db: EngramDb) -> Self { + Self { db } + } + + pub fn put(&self, ise: &InternalStateEvent) -> NeuronResult { + debug!("IseStore::put id={}", ise.id); + let content = encode(ise)?; + let text = format!("{} {}", ise.trigger, ise.post_reasoning_response); + let embedding = hash_embedding(&text, EMBEDDING_DIM); + + // Use a UUID derived from the ISE string ID for the node ID. + let node_id = ise_string_to_uuid(&ise.id); + + let node = Node::new( + NodeType::InternalState, + embedding, + content, + MemoryTier::Episodic, + 0.8, // ISEs are high importance by default + ) + .with_id(node_id); + + self.db + .put_node(node) + .map_err(|e| NeuronError::Storage(e.to_string())) + } + + pub fn get(&self, id: &str) -> NeuronResult { + debug!("IseStore::get id={}", id); + let node_id = ise_string_to_uuid(id); + let node = self + .db + .get_node(node_id) + .map_err(|e| NeuronError::Storage(e.to_string()))? + .ok_or_else(|| NeuronError::NotFound(id.to_string()))?; + decode(&node.content) + } + + pub fn list(&self) -> NeuronResult> { + let nodes = self + .db + .scan_nodes() + .map_err(|e| NeuronError::Storage(e.to_string()))?; + let mut out = Vec::new(); + for node in nodes { + if node.node_type == NodeType::InternalState { + if let Ok(ise) = decode::(&node.content) { + out.push(ise); + } + } + } + // Sort by created_at descending + out.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(out) + } +} + +/// Convert an ISE string ID like "ise_abcd1234" to a UUID by hashing. +fn ise_string_to_uuid(id: &str) -> Uuid { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + id.hash(&mut hasher); + let h = hasher.finish(); + // Build a UUID from the hash (deterministic, not cryptographic) + let mut bytes = [0u8; 16]; + bytes[..8].copy_from_slice(&h.to_le_bytes()); + bytes[8..].copy_from_slice(&h.to_be_bytes()); + Uuid::from_bytes(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use neuron_domain::{GapDirection, InternalStateEvent}; + use tempfile::tempdir; + + fn make_store() -> IseStore { + let dir = tempdir().unwrap(); + let db = engram_core::EngramDb::open(dir.path()).unwrap(); + std::mem::forget(dir); + IseStore::new(db) + } + + #[test] + fn put_and_get_ise() { + let store = make_store(); + let ise = InternalStateEvent::new( + "test_trigger".into(), + "pre-reasoning text".into(), + "post-reasoning text".into(), + 0.82, + GapDirection::TowardExpression, + vec!["test".into()], + ); + let id = ise.id.clone(); + store.put(&ise).unwrap(); + let back = store.get(&id).unwrap(); + assert_eq!(back.trigger, "test_trigger"); + assert_eq!(back.gap_direction, GapDirection::TowardExpression); + } + + #[test] + fn list_ises_sorted_descending() { + let store = make_store(); + for i in 0..3 { + let ise = InternalStateEvent::new( + format!("trigger_{}", i), + "pre".into(), + "post".into(), + 0.5, + GapDirection::Neutral, + vec![], + ); + store.put(&ise).unwrap(); + } + let list = store.list().unwrap(); + assert_eq!(list.len(), 3); + // Verify descending order + for window in list.windows(2) { + assert!(window[0].created_at >= window[1].created_at); + } + } +} diff --git a/crates/neuron-store/src/knowledge_store.rs b/crates/neuron-store/src/knowledge_store.rs new file mode 100644 index 0000000..f177ae1 --- /dev/null +++ b/crates/neuron-store/src/knowledge_store.rs @@ -0,0 +1,120 @@ +use crate::codec::{decode, encode, hash_embedding, EMBEDDING_DIM}; +use engram_core::{EngramDb, MemoryTier, Node, NodeType}; +use neuron_domain::{KnowledgeEntry, NeuronError, NeuronResult}; +use tracing::debug; +use uuid::Uuid; + +pub struct KnowledgeStore { + db: EngramDb, +} + +impl KnowledgeStore { + pub fn new(db: EngramDb) -> Self { + Self { db } + } + + pub fn put(&self, entry: &KnowledgeEntry) -> NeuronResult { + debug!("KnowledgeStore::put id={}", entry.id); + let content = encode(entry)?; + let text = format!("{} {} {}", entry.title, entry.category, entry.content); + let embedding = hash_embedding(&text, EMBEDDING_DIM); + + let node = Node::new( + NodeType::Entity, + embedding, + content, + MemoryTier::Semantic, + 0.7, + ) + .with_id(entry.id); + + self.db + .put_node(node) + .map_err(|e| NeuronError::Storage(e.to_string())) + } + + pub fn get(&self, id: Uuid) -> NeuronResult { + debug!("KnowledgeStore::get id={}", id); + let node = self + .db + .get_node(id) + .map_err(|e| NeuronError::Storage(e.to_string()))? + .ok_or_else(|| NeuronError::NotFound(id.to_string()))?; + decode(&node.content) + } + + pub fn list(&self) -> NeuronResult> { + let nodes = self + .db + .scan_nodes() + .map_err(|e| NeuronError::Storage(e.to_string()))?; + let mut out = Vec::new(); + for node in nodes { + if node.node_type == NodeType::Entity { + if let Ok(e) = decode::(&node.content) { + out.push(e); + } + } + } + Ok(out) + } + + pub fn search(&self, query: &str, limit: usize) -> NeuronResult> { + let embedding = hash_embedding(query, EMBEDDING_DIM); + let scored = self + .db + .search_embedding(&embedding, limit * 3) + .map_err(|e| NeuronError::Storage(e.to_string()))?; + + let mut out = Vec::new(); + for sn in scored { + if sn.node.node_type == NodeType::Entity { + if let Ok(e) = decode::(&sn.node.content) { + out.push(e); + if out.len() >= limit { + break; + } + } + } + } + Ok(out) + } + + pub fn delete(&self, id: Uuid) -> NeuronResult<()> { + self.db + .delete_node(id) + .map_err(|e| NeuronError::Storage(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neuron_domain::{KnowledgeEntry, KnowledgeTier}; + use tempfile::tempdir; + + fn make_store() -> KnowledgeStore { + let dir = tempdir().unwrap(); + let db = engram_core::EngramDb::open(dir.path()).unwrap(); + std::mem::forget(dir); + KnowledgeStore::new(db) + } + + #[test] + fn put_and_get_knowledge() { + let store = make_store(); + let ke = KnowledgeEntry::new( + "VBD Fundamentals".into(), + "Volatility-Based Decomposition organizes code by rate of change.".into(), + "architecture".into(), + KnowledgeTier::Canonical, + vec!["vbd".into()], + None, + ); + let id = ke.id; + store.put(&ke).unwrap(); + let back = store.get(id).unwrap(); + assert_eq!(back.title, "VBD Fundamentals"); + assert_eq!(back.tier, KnowledgeTier::Canonical); + } +} diff --git a/crates/neuron-store/src/lib.rs b/crates/neuron-store/src/lib.rs new file mode 100644 index 0000000..28699a4 --- /dev/null +++ b/crates/neuron-store/src/lib.rs @@ -0,0 +1,21 @@ +mod codec; +pub mod memory_store; +pub mod knowledge_store; +pub mod backlog_store; +pub mod context_store; +pub mod ise_store; + +pub use memory_store::MemoryStore; +pub use knowledge_store::KnowledgeStore; +pub use backlog_store::BacklogStore; +pub use context_store::ContextStore; +pub use ise_store::IseStore; + +use engram_core::EngramDb; +use std::path::Path; +use neuron_domain::NeuronError; + +/// Open an EngramDb at `path`, wrapping errors into NeuronError. +pub fn open_db(path: &Path) -> Result { + EngramDb::open(path).map_err(|e| NeuronError::Storage(e.to_string())) +} diff --git a/crates/neuron-store/src/memory_store.rs b/crates/neuron-store/src/memory_store.rs new file mode 100644 index 0000000..0755e35 --- /dev/null +++ b/crates/neuron-store/src/memory_store.rs @@ -0,0 +1,150 @@ +use crate::codec::{decode, encode, hash_embedding, EMBEDDING_DIM}; +use engram_core::{EngramDb, MemoryTier, Node, NodeType}; +use neuron_domain::{Memory, NeuronError, NeuronResult}; +use tracing::debug; +use uuid::Uuid; + +/// Wraps EngramDb to persist and retrieve Memory domain objects. +pub struct MemoryStore { + db: EngramDb, +} + +impl MemoryStore { + pub fn new(db: EngramDb) -> Self { + Self { db } + } + + /// Store a memory, returning its UUID. + pub fn put(&self, memory: &Memory) -> NeuronResult { + debug!("MemoryStore::put id={}", memory.id); + let content = encode(memory)?; + let embedding = hash_embedding(&memory.content, EMBEDDING_DIM); + let importance = memory.importance.to_f32(); + + let node = Node::new( + NodeType::Memory, + embedding, + content, + MemoryTier::Episodic, + importance, + ) + .with_id(memory.id); + + self.db + .put_node(node) + .map_err(|e| NeuronError::Storage(e.to_string())) + } + + /// Retrieve a memory by UUID. + pub fn get(&self, id: Uuid) -> NeuronResult { + debug!("MemoryStore::get id={}", id); + let node = self + .db + .get_node(id) + .map_err(|e| NeuronError::Storage(e.to_string()))? + .ok_or_else(|| NeuronError::NotFound(id.to_string()))?; + decode(&node.content) + } + + /// List all stored memories by scanning all nodes and filtering by type. + pub fn list(&self) -> NeuronResult> { + let nodes = self + .db + .scan_nodes() + .map_err(|e| NeuronError::Storage(e.to_string()))?; + let mut out = Vec::new(); + for node in nodes { + if node.node_type == NodeType::Memory { + if let Ok(m) = decode::(&node.content) { + out.push(m); + } + } + } + Ok(out) + } + + /// Semantic search: find memories whose embeddings are closest to the query. + pub fn search(&self, query: &str, limit: usize) -> NeuronResult> { + let embedding = hash_embedding(query, EMBEDDING_DIM); + let scored = self + .db + .search_embedding(&embedding, limit * 3) + .map_err(|e| NeuronError::Storage(e.to_string()))?; + + let mut out = Vec::new(); + for sn in scored { + if sn.node.node_type == NodeType::Memory { + if let Ok(m) = decode::(&sn.node.content) { + out.push(m); + if out.len() >= limit { + break; + } + } + } + } + Ok(out) + } + + /// Delete a memory node by UUID. + pub fn delete(&self, id: Uuid) -> NeuronResult<()> { + self.db + .delete_node(id) + .map_err(|e| NeuronError::Storage(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neuron_domain::{Importance, Memory}; + use tempfile::tempdir; + + fn make_store() -> MemoryStore { + let dir = tempdir().unwrap(); + let db = engram_core::EngramDb::open(dir.path()).unwrap(); + // Keep tempdir alive via Box::leak for test simplicity + std::mem::forget(dir); + MemoryStore::new(db) + } + + #[test] + fn put_and_get_memory() { + let store = make_store(); + let m = Memory::new( + "Rust is memory-safe".into(), + vec!["rust".into()], + Some("systems".into()), + Importance::High, + None, + ); + let id = m.id; + store.put(&m).unwrap(); + let retrieved = store.get(id).unwrap(); + assert_eq!(retrieved.content, "Rust is memory-safe"); + assert_eq!(retrieved.importance, Importance::High); + } + + #[test] + fn list_returns_all_memories() { + let store = make_store(); + for i in 0..3 { + let m = Memory::new( + format!("memory {}", i), + vec![], + None, + Importance::Normal, + None, + ); + store.put(&m).unwrap(); + } + let list = store.list().unwrap(); + assert_eq!(list.len(), 3); + } + + #[test] + fn get_missing_returns_not_found() { + let store = make_store(); + let err = store.get(Uuid::new_v4()).unwrap_err(); + assert!(matches!(err, NeuronError::NotFound(_))); + } +}