feat: neuron-rs — Rust runtime, Engram-backed, Axon protocol
This commit is contained in:
@@ -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]
|
||||
@@ -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 <key>` header.
|
||||
pub async fn bearer_auth(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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<NeuronError> 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<T> = Result<T, ApiError>;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<Arc<AppState>>,
|
||||
Json(msg): Json<AxonMessage>,
|
||||
) -> Json<AxonResponse> {
|
||||
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<Arc<AppState>>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
// 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())
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub priority: Option<String>,
|
||||
pub project: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub depends_on: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub status: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub project: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateBacklogRequest {
|
||||
pub status: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CreateBacklogRequest>,
|
||||
) -> ApiResult<Json<BacklogItem>> {
|
||||
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<Arc<AppState>>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> ApiResult<Json<Vec<BacklogItem>>> {
|
||||
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<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<BacklogItem>> {
|
||||
let item = state.backlog.get(id)?;
|
||||
Ok(Json(item))
|
||||
}
|
||||
|
||||
pub async fn update_one(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateBacklogRequest>,
|
||||
) -> ApiResult<Json<BacklogItem>> {
|
||||
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))
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateContextRequest {
|
||||
pub action: Option<String>,
|
||||
pub step_name: Option<String>,
|
||||
pub step_status: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub file_refs: Option<Vec<String>>,
|
||||
pub key_decisions: Option<Vec<String>>,
|
||||
pub lessons_learned: Option<Vec<String>>,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CreateContextRequest>,
|
||||
) -> ApiResult<Json<ExecutionContext>> {
|
||||
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<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<ExecutionContext>> {
|
||||
let ctx = state.context.get(id)?;
|
||||
Ok(Json(ctx))
|
||||
}
|
||||
|
||||
pub async fn update_one(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateContextRequest>,
|
||||
) -> ApiResult<Json<ExecutionContext>> {
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn health() -> Json<Value> {
|
||||
Json(json!({ "status": "ok", "version": "1.0.0" }))
|
||||
}
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CreateIseRequest>,
|
||||
) -> ApiResult<Json<InternalStateEvent>> {
|
||||
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<Arc<AppState>>,
|
||||
) -> ApiResult<Json<Vec<InternalStateEvent>>> {
|
||||
let events = state.ise.list()?;
|
||||
Ok(Json(events))
|
||||
}
|
||||
|
||||
pub async fn get_one(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<InternalStateEvent>> {
|
||||
let event = state.ise.get(&id)?;
|
||||
Ok(Json(event))
|
||||
}
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub project: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub category: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub tier: Option<String>,
|
||||
pub project: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchRequest {
|
||||
pub query: String,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CreateKnowledgeRequest>,
|
||||
) -> ApiResult<Json<KnowledgeEntry>> {
|
||||
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<Arc<AppState>>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> ApiResult<Json<Vec<KnowledgeEntry>>> {
|
||||
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<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<KnowledgeEntry>> {
|
||||
let entry = state.knowledge.get(id)?;
|
||||
Ok(Json(entry))
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SearchRequest>,
|
||||
) -> ApiResult<Json<Vec<KnowledgeEntry>>> {
|
||||
let limit = req.limit.unwrap_or(5);
|
||||
let entries = state.knowledge.search(&req.query, limit)?;
|
||||
Ok(Json(entries))
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub project: Option<String>,
|
||||
#[serde(default)]
|
||||
pub importance: Option<String>,
|
||||
pub supersedes_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub project: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchRequest {
|
||||
pub query: String,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CreateMemoryRequest>,
|
||||
) -> ApiResult<Json<Memory>> {
|
||||
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<Arc<AppState>>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> ApiResult<Json<Vec<Memory>>> {
|
||||
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<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<Memory>> {
|
||||
let memory = state.memory.get(id)?;
|
||||
Ok(Json(memory))
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SearchRequest>,
|
||||
) -> ApiResult<Json<Vec<Memory>>> {
|
||||
let limit = req.limit.unwrap_or(10);
|
||||
let memories = state.memory.search(&req.query, limit)?;
|
||||
Ok(Json(memories))
|
||||
}
|
||||
@@ -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<AppState>) -> 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)
|
||||
}
|
||||
@@ -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<Self, neuron_domain::NeuronError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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<T> = Result<T, NeuronError>;
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod types;
|
||||
pub mod error;
|
||||
|
||||
pub use types::*;
|
||||
pub use error::*;
|
||||
@@ -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<String>,
|
||||
pub project: Option<String>,
|
||||
pub importance: Importance,
|
||||
pub supersedes_id: Option<Uuid>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
pub fn new(
|
||||
content: String,
|
||||
tags: Vec<String>,
|
||||
project: Option<String>,
|
||||
importance: Importance,
|
||||
supersedes_id: Option<Uuid>,
|
||||
) -> 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<String>,
|
||||
pub project: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl KnowledgeEntry {
|
||||
pub fn new(
|
||||
title: String,
|
||||
content: String,
|
||||
category: String,
|
||||
tier: KnowledgeTier,
|
||||
tags: Vec<String>,
|
||||
project: Option<String>,
|
||||
) -> 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<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub depends_on: Vec<Uuid>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl BacklogItem {
|
||||
pub fn new(
|
||||
title: String,
|
||||
description: String,
|
||||
item_type: ItemType,
|
||||
priority: Priority,
|
||||
project: Option<String>,
|
||||
tags: Vec<String>,
|
||||
depends_on: Vec<Uuid>,
|
||||
) -> 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<String>,
|
||||
pub started_at: i64,
|
||||
pub completed_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl ContextStep {
|
||||
pub fn new(name: String, status: String, notes: Option<String>) -> 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<String>,
|
||||
pub steps: Vec<ContextStep>,
|
||||
pub file_refs: Vec<String>,
|
||||
pub key_decisions: Vec<String>,
|
||||
pub lessons_learned: Vec<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl ExecutionContext {
|
||||
pub fn new(
|
||||
process_name: String,
|
||||
description: String,
|
||||
objective: String,
|
||||
project: Option<String>,
|
||||
) -> 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<String>,
|
||||
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<String>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<String>, result: Value) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
success: true,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn err(id: impl Into<String>, message: impl Into<String>) -> 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<String>, 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use crate::axon::{AxonMessage, AxonResponse};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
type ToolFn = Arc<dyn Fn(Value) -> 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<String, ToolFn>,
|
||||
}
|
||||
|
||||
impl AxonHandler {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a tool handler by name.
|
||||
pub fn register<F>(&mut self, name: impl Into<String>, 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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<Uuid> {
|
||||
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<BacklogItem> {
|
||||
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<Vec<BacklogItem>> {
|
||||
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::<BacklogItem>(&node.content) {
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn list_filtered(
|
||||
&self,
|
||||
status: Option<&str>,
|
||||
priority: Option<&str>,
|
||||
project: Option<&str>,
|
||||
) -> NeuronResult<Vec<BacklogItem>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<T: Serialize>(value: &T) -> Result<Vec<u8>, NeuronError> {
|
||||
serde_json::to_vec(value).map_err(|e| NeuronError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn decode<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T, NeuronError> {
|
||||
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<f32> {
|
||||
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::<f32>().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;
|
||||
@@ -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<Uuid> {
|
||||
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<ExecutionContext> {
|
||||
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<Vec<ExecutionContext>> {
|
||||
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::<ExecutionContext>(&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);
|
||||
}
|
||||
}
|
||||
@@ -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<Uuid> {
|
||||
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<InternalStateEvent> {
|
||||
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<Vec<InternalStateEvent>> {
|
||||
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::<InternalStateEvent>(&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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Uuid> {
|
||||
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<KnowledgeEntry> {
|
||||
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<Vec<KnowledgeEntry>> {
|
||||
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::<KnowledgeEntry>(&node.content) {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn search(&self, query: &str, limit: usize) -> NeuronResult<Vec<KnowledgeEntry>> {
|
||||
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::<KnowledgeEntry>(&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);
|
||||
}
|
||||
}
|
||||
@@ -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, NeuronError> {
|
||||
EngramDb::open(path).map_err(|e| NeuronError::Storage(e.to_string()))
|
||||
}
|
||||
@@ -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<Uuid> {
|
||||
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<Memory> {
|
||||
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<Vec<Memory>> {
|
||||
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::<Memory>(&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<Vec<Memory>> {
|
||||
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::<Memory>(&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(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user