feat: neuron-rs — Rust runtime, Engram-backed, Axon protocol

This commit is contained in:
2026-04-27 18:37:01 -05:00
commit 745278c902
33 changed files with 2375 additions and 0 deletions
+28
View File
@@ -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]
+33
View File
@@ -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),
}
}
+34
View File
@@ -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>;
+44
View File
@@ -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");
}
+50
View File
@@ -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())
}
+121
View File
@@ -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))
}
+96
View File
@@ -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))
}
+6
View File
@@ -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" }))
}
+58
View File
@@ -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))
}
+82
View File
@@ -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))
}
+90
View File
@@ -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))
}
+47
View File
@@ -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)
}
+28
View File
@@ -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,
})
}
}
+12
View File
@@ -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]
+17
View File
@@ -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>;
+5
View File
@@ -0,0 +1,5 @@
pub mod types;
pub mod error;
pub use types::*;
pub use error::*;
+510
View File
@@ -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);
}
}
+18
View File
@@ -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"
+91
View File
@@ -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");
}
}
+142
View File
@@ -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);
}
}
+7
View File
@@ -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;
+47
View File
@@ -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");
}
}
+51
View File
@@ -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");
}
}
}
+16
View File
@@ -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"
+157
View File
@@ -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());
}
}
+36
View File
@@ -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;
+95
View File
@@ -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);
}
}
+136
View File
@@ -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);
}
}
}
+120
View File
@@ -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);
}
}
+21
View File
@@ -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()))
}
+150
View File
@@ -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(_)));
}
}