diff --git a/Cargo.toml b/Cargo.toml index beaa8f2..7773c40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,13 +5,16 @@ members = [ "crates/neuron-store", "crates/neuron-api", "crates/neuron-protocol", - "crates/axon-events", "crates/neuron-runtime", "crates/neuron-migrate", "crates/neuron-cli", ] [workspace.dependencies] +# Platform-level Axon protocol crates (canonical source of truth) +axon-events = { path = "../../platform/protocols/axon/crates/axon-events" } +axon-protocol = { path = "../../platform/protocols/axon/crates/axon-protocol" } + serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4", "serde"] } diff --git a/crates/axon-events/Cargo.toml b/crates/axon-events/Cargo.toml deleted file mode 100644 index eb0220e..0000000 --- a/crates/axon-events/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "axon-events" -version = "0.1.0" -edition = "2021" - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -uuid = { workspace = true } -tokio = { workspace = true } -thiserror = { workspace = true } -chrono = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } diff --git a/crates/axon-events/src/common.rs b/crates/axon-events/src/common.rs deleted file mode 100644 index 7115eb3..0000000 --- a/crates/axon-events/src/common.rs +++ /dev/null @@ -1,190 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::project::ProjectType; - -/// Opaque unique identifier for an event. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct EventId(pub Uuid); - -impl EventId { - /// Generate a new random event ID. - pub fn new() -> Self { - Self(Uuid::new_v4()) - } -} - -impl Default for EventId { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Display for EventId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -/// Milliseconds since the Unix epoch. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub struct Timestamp(pub i64); - -impl Timestamp { - /// Current time as a millisecond timestamp. - pub fn now() -> Self { - use std::time::{SystemTime, UNIX_EPOCH}; - let ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before epoch") - .as_millis() as i64; - Self(ms) - } - - /// Construct from a raw millisecond value. - pub fn from_ms(ms: i64) -> Self { - Self(ms) - } - - /// Return the raw millisecond value. - pub fn as_ms(self) -> i64 { - self.0 - } -} - -impl Default for Timestamp { - fn default() -> Self { - Self::now() - } -} - -/// Diagnostic / compile-error severity level. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum Severity { - #[default] - Info, - Warning, - Error, -} - -/// Universal envelope that wraps every typed event. -/// -/// Services never send raw events — they always send an `AxonEnvelope`. -/// This provides routing metadata (project type, timestamp) without -/// coupling the event payload itself to those concerns. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AxonEnvelope { - /// Unique ID for this event instance. - pub event_id: EventId, - /// Which project emitted this event. - pub project_type: ProjectType, - /// When the event was created (ms since epoch). - pub timestamp: Timestamp, - /// The typed event payload. - pub payload: T, -} - -impl Deserialize<'de>> AxonEnvelope { - /// Wrap a payload in an envelope for the given project type. - pub fn new(project_type: ProjectType, payload: T) -> Self { - Self { - event_id: EventId::new(), - project_type, - timestamp: Timestamp::now(), - payload, - } - } - - /// Serialise this envelope to a JSON string. - pub fn to_json(&self) -> Result { - serde_json::to_string(self) - } - - /// Deserialise an envelope from a JSON string. - pub fn from_json(s: &str) -> Result { - serde_json::from_str(s) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::project::ProjectType; - - #[test] - fn event_id_is_unique() { - let a = EventId::new(); - let b = EventId::new(); - assert_ne!(a, b); - } - - #[test] - fn event_id_default_is_unique() { - let a = EventId::default(); - let b = EventId::default(); - assert_ne!(a, b); - } - - #[test] - fn timestamp_now_is_positive() { - let t = Timestamp::now(); - assert!(t.as_ms() > 0); - } - - #[test] - fn timestamp_roundtrip() { - let t = Timestamp::from_ms(1_700_000_000_000); - let json = serde_json::to_string(&t).unwrap(); - let back: Timestamp = serde_json::from_str(&json).unwrap(); - assert_eq!(back.as_ms(), 1_700_000_000_000); - } - - #[test] - fn severity_serde_roundtrip() { - for s in [Severity::Info, Severity::Warning, Severity::Error] { - let json = serde_json::to_string(&s).unwrap(); - let back: Severity = serde_json::from_str(&json).unwrap(); - assert_eq!(back, s); - } - } - - #[test] - fn severity_default_is_info() { - assert_eq!(Severity::default(), Severity::Info); - } - - #[test] - fn axon_envelope_roundtrip() { - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] - struct Ping { - msg: String, - } - - let payload = Ping { msg: "hello".into() }; - let env = AxonEnvelope::new(ProjectType::NeuronRs, payload); - let json = env.to_json().unwrap(); - let back: AxonEnvelope = AxonEnvelope::from_json(&json).unwrap(); - - assert_eq!(back.project_type, ProjectType::NeuronRs); - assert_eq!(back.payload.msg, "hello"); - assert_eq!(back.event_id, env.event_id); - } - - #[test] - fn axon_envelope_timestamp_is_recent() { - #[derive(Debug, Clone, Serialize, Deserialize)] - struct Empty; - - let env = AxonEnvelope::new(ProjectType::Engram, Empty); - let now = Timestamp::now().as_ms(); - // Should be within 5 seconds - assert!(now - env.timestamp.as_ms() < 5_000); - } - - #[test] - fn event_id_display() { - let id = EventId(Uuid::nil()); - assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000"); - } -} diff --git a/crates/axon-events/src/compiler.rs b/crates/axon-events/src/compiler.rs deleted file mode 100644 index fd324a7..0000000 --- a/crates/axon-events/src/compiler.rs +++ /dev/null @@ -1,183 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::common::Severity; - -/// Events emitted by the engram-lang compiler toolchain. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum CompilerEvent { - CompileStarted(CompileStarted), - CompileSucceeded(CompileSucceeded), - CompileFailed(CompileFailed), - TestRunStarted(TestRunStarted), - TestPassed(TestPassed), - TestFailed(TestFailed), - TestRunCompleted(TestRunCompleted), -} - -/// Compilation target platform. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum CompileTarget { - #[default] - Web, - Native, - Sealed, -} - -/// A compilation job has started. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CompileStarted { - pub file: String, - pub target: CompileTarget, -} - -/// Compilation finished successfully and produced an artifact. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CompileSucceeded { - pub file: String, - pub target: CompileTarget, - pub artifact_path: String, - pub duration_ms: u64, -} - -/// Compilation failed with one or more errors. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CompileFailed { - pub file: String, - pub target: CompileTarget, - pub errors: Vec, -} - -/// A single compiler diagnostic. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CompileError { - pub line: u32, - pub col: u32, - pub message: String, - pub severity: Severity, -} - -/// A test run has started. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TestRunStarted { - pub test_count: u64, -} - -/// An individual test passed. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TestPassed { - pub name: String, - pub duration_ms: u64, -} - -/// An individual test failed. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TestFailed { - pub name: String, - pub message: String, - pub duration_ms: u64, -} - -/// All tests in the run have finished. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TestRunCompleted { - pub passed: u64, - pub failed: u64, - pub duration_ms: u64, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::common::AxonEnvelope; - use crate::project::ProjectType; - - #[test] - fn compile_started_roundtrip() { - let evt = CompileStarted { - file: "src/main.el".into(), - target: CompileTarget::Web, - }; - let env = AxonEnvelope::new(ProjectType::EngramLang, evt); - let json = env.to_json().unwrap(); - let back: AxonEnvelope = AxonEnvelope::from_json(&json).unwrap(); - assert_eq!(back.payload.file, "src/main.el"); - assert_eq!(back.payload.target, CompileTarget::Web); - } - - #[test] - fn compile_failed_with_errors() { - let evt = CompileFailed { - file: "lib.el".into(), - target: CompileTarget::Native, - errors: vec![ - CompileError { - line: 10, - col: 5, - message: "unexpected token".into(), - severity: Severity::Error, - }, - CompileError { - line: 20, - col: 1, - message: "unused variable".into(), - severity: Severity::Warning, - }, - ], - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: CompileFailed = serde_json::from_str(&json).unwrap(); - assert_eq!(back.errors.len(), 2); - assert_eq!(back.errors[0].severity, Severity::Error); - assert_eq!(back.errors[1].severity, Severity::Warning); - } - - #[test] - fn test_run_completed_roundtrip() { - let evt = TestRunCompleted { - passed: 42, - failed: 3, - duration_ms: 1500, - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: TestRunCompleted = serde_json::from_str(&json).unwrap(); - assert_eq!(back.passed, 42); - assert_eq!(back.failed, 3); - } - - #[test] - fn compile_target_default_is_web() { - assert_eq!(CompileTarget::default(), CompileTarget::Web); - } - - #[test] - fn compiler_event_enum_roundtrip() { - let variant = CompilerEvent::TestFailed(TestFailed { - name: "test_parse".into(), - message: "assertion failed".into(), - duration_ms: 10, - }); - let json = serde_json::to_string(&variant).unwrap(); - assert!(json.contains("test_failed")); - let back: CompilerEvent = serde_json::from_str(&json).unwrap(); - if let CompilerEvent::TestFailed(t) = back { - assert_eq!(t.name, "test_parse"); - } else { - panic!("wrong variant"); - } - } - - #[test] - fn compile_succeeded_envelope() { - let evt = CompileSucceeded { - file: "app.el".into(), - target: CompileTarget::Sealed, - artifact_path: "dist/app.sealed".into(), - duration_ms: 2300, - }; - let env = AxonEnvelope::new(ProjectType::EngramLang, evt); - assert_eq!(env.project_type, ProjectType::EngramLang); - assert_eq!(env.payload.artifact_path, "dist/app.sealed"); - } -} diff --git a/crates/axon-events/src/engram.rs b/crates/axon-events/src/engram.rs deleted file mode 100644 index a250228..0000000 --- a/crates/axon-events/src/engram.rs +++ /dev/null @@ -1,156 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Events emitted by the engram graph-database layer. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum EngramEvent { - NodeCreated(NodeCreated), - NodeUpdated(NodeUpdated), - NodeDeleted(NodeDeleted), - EdgeCreated(EdgeCreated), - ActivationCompleted(ActivationCompleted), - ConsolidationRan(ConsolidationRan), - SyncStarted(SyncStarted), - SyncCompleted(SyncCompleted), - SyncFailed(SyncFailed), -} - -/// A new node was inserted into the graph. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct NodeCreated { - pub id: String, - pub node_type: String, - pub salience: f64, -} - -/// An existing node's fields were modified. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct NodeUpdated { - pub id: String, - pub node_type: String, - /// Free-form list of field names that changed. - pub changes: Vec, -} - -/// A node was removed from the graph. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct NodeDeleted { - pub id: String, - pub node_type: String, -} - -/// A directed edge was added between two nodes. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct EdgeCreated { - pub from_id: String, - pub to_id: String, - pub edge_type: String, -} - -/// Spreading activation finished from a root node. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ActivationCompleted { - pub root_id: String, - pub activated_count: u64, - pub duration_ms: u64, -} - -/// The consolidation pass ran — promotes high-salience nodes. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ConsolidationRan { - pub promoted_count: u64, - pub duration_ms: u64, -} - -/// A peer-sync session began. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct SyncStarted { - pub peer_id: String, - pub peer_name: String, -} - -/// A peer-sync session completed successfully. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct SyncCompleted { - pub peer_id: String, - pub nodes_sent: u64, - pub nodes_received: u64, - pub duration_ms: u64, -} - -/// A peer-sync session failed. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct SyncFailed { - pub peer_id: String, - pub error: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::common::AxonEnvelope; - use crate::project::ProjectType; - - #[test] - fn node_created_envelope_roundtrip() { - let evt = NodeCreated { - id: "node-1".into(), - node_type: "memory".into(), - salience: 0.85, - }; - let env = AxonEnvelope::new(ProjectType::Engram, evt); - let json = env.to_json().unwrap(); - let back: AxonEnvelope = AxonEnvelope::from_json(&json).unwrap(); - assert_eq!(back.payload.id, "node-1"); - assert_eq!(back.payload.node_type, "memory"); - assert!((back.payload.salience - 0.85).abs() < f64::EPSILON); - } - - #[test] - fn edge_created_roundtrip() { - let evt = EdgeCreated { - from_id: "a".into(), - to_id: "b".into(), - edge_type: "relates_to".into(), - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: EdgeCreated = serde_json::from_str(&json).unwrap(); - assert_eq!(back.from_id, "a"); - assert_eq!(back.to_id, "b"); - } - - #[test] - fn activation_completed_defaults() { - let evt = ActivationCompleted::default(); - assert_eq!(evt.activated_count, 0); - assert_eq!(evt.duration_ms, 0); - } - - #[test] - fn engram_event_enum_serde() { - let variant = EngramEvent::SyncFailed(SyncFailed { - peer_id: "peer-99".into(), - error: "connection refused".into(), - }); - let json = serde_json::to_string(&variant).unwrap(); - assert!(json.contains("sync_failed")); - let back: EngramEvent = serde_json::from_str(&json).unwrap(); - if let EngramEvent::SyncFailed(f) = back { - assert_eq!(f.peer_id, "peer-99"); - } else { - panic!("wrong variant"); - } - } - - #[test] - fn sync_completed_envelope() { - let evt = SyncCompleted { - peer_id: "p1".into(), - nodes_sent: 10, - nodes_received: 5, - duration_ms: 200, - }; - let env = AxonEnvelope::new(ProjectType::Engram, evt); - assert_eq!(env.project_type, ProjectType::Engram); - } -} diff --git a/crates/axon-events/src/ide.rs b/crates/axon-events/src/ide.rs deleted file mode 100644 index 2fd1505..0000000 --- a/crates/axon-events/src/ide.rs +++ /dev/null @@ -1,163 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::common::Severity; - -/// Events emitted by the el-ide editor. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum IdeEvent { - FileOpened(FileOpened), - FileSaved(FileSaved), - DiagnosticPublished(DiagnosticPublished), - CompletionRequested(CompletionRequested), - HoverRequested(HoverRequested), - GoToDefinitionRequested(GoToDefinitionRequested), - FormatRequested(FormatRequested), - ActionExecuted(ActionExecuted), -} - -/// A file was opened in the editor. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct FileOpened { - pub path: String, -} - -/// A file was saved. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct FileSaved { - pub path: String, -} - -/// A single IDE diagnostic (error, warning, or info). -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct Diagnostic { - pub line: u32, - pub col: u32, - pub message: String, - pub severity: Severity, -} - -/// New diagnostics were published for a file. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DiagnosticPublished { - pub path: String, - pub diagnostics: Vec, -} - -/// An auto-completion was requested at a cursor position. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CompletionRequested { - pub path: String, - pub line: u32, - pub col: u32, -} - -/// A hover tooltip was requested at a cursor position. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct HoverRequested { - pub path: String, - pub line: u32, - pub col: u32, -} - -/// Go-to-definition was requested at a cursor position. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct GoToDefinitionRequested { - pub path: String, - pub line: u32, - pub col: u32, -} - -/// Document formatting was requested for a file. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct FormatRequested { - pub path: String, -} - -/// A named IDE action was executed (e.g. "run-tests", "open-terminal"). -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ActionExecuted { - pub action_name: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::common::AxonEnvelope; - use crate::project::ProjectType; - - #[test] - fn file_opened_roundtrip() { - let evt = FileOpened { - path: "src/main.el".into(), - }; - let env = AxonEnvelope::new(ProjectType::ElIde, evt); - let json = env.to_json().unwrap(); - let back: AxonEnvelope = AxonEnvelope::from_json(&json).unwrap(); - assert_eq!(back.payload.path, "src/main.el"); - assert_eq!(back.project_type, ProjectType::ElIde); - } - - #[test] - fn diagnostic_published_with_multiple() { - let evt = DiagnosticPublished { - path: "lib.el".into(), - diagnostics: vec![ - Diagnostic { - line: 5, - col: 3, - message: "type mismatch".into(), - severity: Severity::Error, - }, - Diagnostic { - line: 12, - col: 1, - message: "shadowed binding".into(), - severity: Severity::Warning, - }, - ], - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: DiagnosticPublished = serde_json::from_str(&json).unwrap(); - assert_eq!(back.diagnostics.len(), 2); - assert_eq!(back.diagnostics[0].severity, Severity::Error); - } - - #[test] - fn completion_requested_roundtrip() { - let evt = CompletionRequested { - path: "src/lib.el".into(), - line: 42, - col: 10, - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: CompletionRequested = serde_json::from_str(&json).unwrap(); - assert_eq!(back.line, 42); - assert_eq!(back.col, 10); - } - - #[test] - fn ide_event_enum_roundtrip() { - let variant = IdeEvent::ActionExecuted(ActionExecuted { - action_name: "run-tests".into(), - }); - let json = serde_json::to_string(&variant).unwrap(); - assert!(json.contains("action_executed")); - let back: IdeEvent = serde_json::from_str(&json).unwrap(); - if let IdeEvent::ActionExecuted(a) = back { - assert_eq!(a.action_name, "run-tests"); - } else { - panic!("wrong variant"); - } - } - - #[test] - fn format_requested_roundtrip() { - let evt = FormatRequested { - path: "src/fmt.el".into(), - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: FormatRequested = serde_json::from_str(&json).unwrap(); - assert_eq!(back.path, "src/fmt.el"); - } -} diff --git a/crates/axon-events/src/lib.rs b/crates/axon-events/src/lib.rs deleted file mode 100644 index c6e7c14..0000000 --- a/crates/axon-events/src/lib.rs +++ /dev/null @@ -1,37 +0,0 @@ -#![deny(warnings)] - -//! `axon-events` — typed event schema for the full neuron-technologies stack. -//! -//! Every service emits and consumes typed events wrapped in [`common::AxonEnvelope`]. -//! Events are grouped by the project that emits them: -//! -//! | Module | Project | -//! |--------|---------| -//! | [`engram`] | engram database layer | -//! | [`compiler`] | engram-lang compiler | -//! | [`ui`] | el-ui frontend framework | -//! | [`ide`] | el-ide editor | -//! | [`runtime`] | neuron-rs agent host | -//! | [`publish`] | el-publish app-store pipeline | -//! -//! [`project`] defines [`project::ProjectType`], the discriminant tag -//! carried by every envelope. -//! -//! [`routing`] provides [`routing::AxonRouter`], the type-safe async -//! event dispatcher, and [`routing::AxonSubscription`] for subscription -//! negotiation. - -pub mod common; -pub mod compiler; -pub mod engram; -pub mod ide; -pub mod project; -pub mod publish; -pub mod routing; -pub mod runtime; -pub mod ui; - -// Convenience re-exports -pub use common::{AxonEnvelope, EventId, Severity, Timestamp}; -pub use project::{ProjectContext, ProjectType}; -pub use routing::{AxonError, AxonRouter, AxonSubscription}; diff --git a/crates/axon-events/src/project.rs b/crates/axon-events/src/project.rs deleted file mode 100644 index 5690ccf..0000000 --- a/crates/axon-events/src/project.rs +++ /dev/null @@ -1,136 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Every service in the neuron-technologies stack is identified by a -/// `ProjectType`. All Axon envelopes carry this tag so routers and -/// subscribers can filter without inspecting the payload. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProjectType { - /// engram — the graph database layer. - Engram, - /// engram-lang — the compiler / language toolchain. - EngramLang, - /// el-ui — the cross-platform UI framework. - ElUi, - /// el-ide — the integrated development environment. - ElIde, - /// neuron-rs — the Rust runtime / agent host. - NeuronRs, - /// neuron-agent — the cognitive agent layer. - NeuronAgent, - /// el-publish — the app-store publishing pipeline. - ElPublish, -} - -impl std::fmt::Display for ProjectType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - Self::Engram => "engram", - Self::EngramLang => "engram_lang", - Self::ElUi => "el_ui", - Self::ElIde => "el_ide", - Self::NeuronRs => "neuron_rs", - Self::NeuronAgent => "neuron_agent", - Self::ElPublish => "el_publish", - }; - write!(f, "{}", s) - } -} - -/// Contextual metadata attached to a service or session — optional, but -/// useful when a single process manages multiple project contexts. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProjectContext { - pub project_type: ProjectType, - /// Human-readable label for this context (e.g. service instance name). - pub label: Option, - /// Semantic version of the emitting service, e.g. `"0.3.1"`. - pub version: Option, -} - -impl ProjectContext { - pub fn new(project_type: ProjectType) -> Self { - Self { - project_type, - label: None, - version: None, - } - } - - pub fn with_label(mut self, label: impl Into) -> Self { - self.label = Some(label.into()); - self - } - - pub fn with_version(mut self, version: impl Into) -> Self { - self.version = Some(version.into()); - self - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn project_type_serde_roundtrip() { - let variants = [ - ProjectType::Engram, - ProjectType::EngramLang, - ProjectType::ElUi, - ProjectType::ElIde, - ProjectType::NeuronRs, - ProjectType::NeuronAgent, - ProjectType::ElPublish, - ]; - for v in variants { - let json = serde_json::to_string(&v).unwrap(); - let back: ProjectType = serde_json::from_str(&json).unwrap(); - assert_eq!(back, v, "roundtrip failed for {:?}", v); - } - } - - #[test] - fn project_type_display() { - assert_eq!(ProjectType::Engram.to_string(), "engram"); - assert_eq!(ProjectType::EngramLang.to_string(), "engram_lang"); - assert_eq!(ProjectType::NeuronAgent.to_string(), "neuron_agent"); - } - - #[test] - fn project_context_builder() { - let ctx = ProjectContext::new(ProjectType::ElUi) - .with_label("renderer-1") - .with_version("1.2.3"); - assert_eq!(ctx.project_type, ProjectType::ElUi); - assert_eq!(ctx.label.as_deref(), Some("renderer-1")); - assert_eq!(ctx.version.as_deref(), Some("1.2.3")); - } - - #[test] - fn project_context_serde_roundtrip() { - let ctx = ProjectContext::new(ProjectType::NeuronRs) - .with_label("runtime") - .with_version("0.1.0"); - let json = serde_json::to_string(&ctx).unwrap(); - let back: ProjectContext = serde_json::from_str(&json).unwrap(); - assert_eq!(back.project_type, ProjectType::NeuronRs); - assert_eq!(back.label.as_deref(), Some("runtime")); - } - - #[test] - fn project_type_all_variants_have_unique_display() { - use std::collections::HashSet; - let variants = [ - ProjectType::Engram, - ProjectType::EngramLang, - ProjectType::ElUi, - ProjectType::ElIde, - ProjectType::NeuronRs, - ProjectType::NeuronAgent, - ProjectType::ElPublish, - ]; - let strings: HashSet = variants.iter().map(|v| v.to_string()).collect(); - assert_eq!(strings.len(), variants.len()); - } -} diff --git a/crates/axon-events/src/publish.rs b/crates/axon-events/src/publish.rs deleted file mode 100644 index 541f8aa..0000000 --- a/crates/axon-events/src/publish.rs +++ /dev/null @@ -1,169 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Events emitted by the el-publish app-store pipeline. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum PublishEvent { - PublishStarted(PublishStarted), - CertificateValidated(CertificateValidated), - CertificateExpiringSoon(CertificateExpiringSoon), - BuildUploaded(BuildUploaded), - ReviewSubmitted(ReviewSubmitted), - ReviewApproved(ReviewApproved), - ReviewRejected(ReviewRejected), - RolloutAdvanced(RolloutAdvanced), - RolloutHalted(RolloutHalted), -} - -/// A publish pipeline started for a platform+version. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct PublishStarted { - pub platform: String, - pub version: String, -} - -/// A code-signing certificate was validated successfully. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CertificateValidated { - pub platform: String, - /// ISO-8601 expiry date string, e.g. `"2026-01-01"`. - pub expiry_date: String, -} - -/// A code-signing certificate is expiring within `days_remaining` days. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CertificateExpiringSoon { - pub platform: String, - pub days_remaining: u32, -} - -/// A build artifact was uploaded to the store's distribution service. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct BuildUploaded { - pub platform: String, - pub build_number: String, - pub size_bytes: u64, -} - -/// The build was submitted to store review. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ReviewSubmitted { - pub platform: String, - pub version: String, -} - -/// Store review approved the submission. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ReviewApproved { - pub platform: String, - pub version: String, -} - -/// Store review rejected the submission. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ReviewRejected { - pub platform: String, - pub version: String, - pub reason: String, -} - -/// A phased rollout percentage was advanced. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct RolloutAdvanced { - pub platform: String, - pub version: String, - /// Percentage of users on this version (0–100). - pub percentage: u8, -} - -/// A phased rollout was halted (e.g. due to crash spike). -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct RolloutHalted { - pub platform: String, - pub version: String, - pub reason: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::common::AxonEnvelope; - use crate::project::ProjectType; - - #[test] - fn publish_started_roundtrip() { - let evt = PublishStarted { - platform: "ios".into(), - version: "2.1.0".into(), - }; - let env = AxonEnvelope::new(ProjectType::ElPublish, evt); - let json = env.to_json().unwrap(); - let back: AxonEnvelope = AxonEnvelope::from_json(&json).unwrap(); - assert_eq!(back.payload.platform, "ios"); - assert_eq!(back.payload.version, "2.1.0"); - assert_eq!(back.project_type, ProjectType::ElPublish); - } - - #[test] - fn certificate_expiring_soon() { - let evt = CertificateExpiringSoon { - platform: "android".into(), - days_remaining: 14, - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: CertificateExpiringSoon = serde_json::from_str(&json).unwrap(); - assert_eq!(back.days_remaining, 14); - } - - #[test] - fn review_rejected_with_reason() { - let evt = ReviewRejected { - platform: "ios".into(), - version: "1.0.0".into(), - reason: "privacy policy missing".into(), - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: ReviewRejected = serde_json::from_str(&json).unwrap(); - assert_eq!(back.reason, "privacy policy missing"); - } - - #[test] - fn rollout_advanced_percentage_bounds() { - let evt = RolloutAdvanced { - platform: "web".into(), - version: "3.0.0".into(), - percentage: 50, - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: RolloutAdvanced = serde_json::from_str(&json).unwrap(); - assert_eq!(back.percentage, 50); - } - - #[test] - fn publish_event_enum_roundtrip() { - let variant = PublishEvent::RolloutHalted(RolloutHalted { - platform: "android".into(), - version: "1.5.0".into(), - reason: "crash rate spike".into(), - }); - let json = serde_json::to_string(&variant).unwrap(); - assert!(json.contains("rollout_halted")); - let back: PublishEvent = serde_json::from_str(&json).unwrap(); - if let PublishEvent::RolloutHalted(r) = back { - assert_eq!(r.reason, "crash rate spike"); - } else { - panic!("wrong variant"); - } - } - - #[test] - fn build_uploaded_roundtrip() { - let evt = BuildUploaded { - platform: "ios".into(), - build_number: "1234".into(), - size_bytes: 50_000_000, - }; - let env = AxonEnvelope::new(ProjectType::ElPublish, evt); - assert_eq!(env.payload.size_bytes, 50_000_000); - } -} diff --git a/crates/axon-events/src/routing.rs b/crates/axon-events/src/routing.rs deleted file mode 100644 index ba214a9..0000000 --- a/crates/axon-events/src/routing.rs +++ /dev/null @@ -1,323 +0,0 @@ -use std::any::TypeId; -use std::collections::HashMap; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -use serde::{Deserialize, Serialize}; - -use crate::common::AxonEnvelope; -use crate::project::ProjectType; - -/// Error returned when routing or handler execution fails. -#[derive(Debug, thiserror::Error)] -pub enum AxonError { - #[error("no handler registered for event type")] - NoHandler, - - #[error("failed to deserialize envelope: {0}")] - Deserialize(#[from] serde_json::Error), - - #[error("handler error: {0}")] - Handler(String), -} - -// ------------------------------------------------------------------ -// Internal type aliases -// ------------------------------------------------------------------ - -type BoxFuture<'a, T> = Pin + Send + 'a>>; - -type ErasedHandler = Arc< - dyn Fn(serde_json::Value) -> BoxFuture<'static, Result<(), AxonError>> + Send + Sync + 'static, ->; - -// ------------------------------------------------------------------ -// AxonRouter -// ------------------------------------------------------------------ - -/// Type-safe async event router. -/// -/// # Registering handlers -/// -/// ```rust,ignore -/// let mut router = AxonRouter::new(); -/// router.on::(|env| async move { -/// println!("compiled {} in {}ms", env.payload.file, env.payload.duration_ms); -/// Ok(()) -/// }); -/// ``` -/// -/// # Dispatching -/// -/// Pass a raw JSON string; the router deserialises and routes it to the -/// registered handler. If no handler is registered the call returns -/// `Err(AxonError::NoHandler)`. -pub struct AxonRouter { - /// Maps `TypeId` of the payload type → erased async handler. - handlers: HashMap, -} - -impl AxonRouter { - pub fn new() -> Self { - Self { - handlers: HashMap::new(), - } - } - - /// Register an async handler for events carrying payload `T`. - /// - /// The handler receives the fully-typed `AxonEnvelope`. - /// Only one handler per `T` is supported; registering a second one - /// replaces the first. - pub fn on(&mut self, handler: F) - where - T: Serialize + for<'de> Deserialize<'de> + Send + 'static, - F: Fn(AxonEnvelope) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - { - let handler = Arc::new(handler); - let erased: ErasedHandler = Arc::new(move |raw: serde_json::Value| { - let handler = Arc::clone(&handler); - let fut: BoxFuture<'static, Result<(), AxonError>> = Box::pin(async move { - let envelope: AxonEnvelope = serde_json::from_value(raw) - .map_err(AxonError::Deserialize)?; - handler(envelope).await - }); - fut - }); - self.handlers.insert(TypeId::of::(), erased); - } - - /// Dispatch a pre-typed envelope to its registered handler. - /// - /// Returns `Err(AxonError::NoHandler)` when no handler is registered - /// for `T`. - pub async fn dispatch(&self, envelope: AxonEnvelope) -> Result<(), AxonError> - where - T: Serialize + for<'de> Deserialize<'de> + Send + 'static, - { - let type_id = TypeId::of::(); - let handler = self - .handlers - .get(&type_id) - .ok_or(AxonError::NoHandler)?; - let raw = serde_json::to_value(&envelope).map_err(AxonError::Deserialize)?; - handler(raw).await - } - - /// Dispatch a raw JSON string by first deserialising the envelope into - /// `AxonEnvelope` and then routing to the registered handler for `T`. - pub async fn dispatch_json(&self, json: &str) -> Result<(), AxonError> - where - T: Serialize + for<'de> Deserialize<'de> + Send + 'static, - { - let envelope: AxonEnvelope = - serde_json::from_str(json).map_err(AxonError::Deserialize)?; - self.dispatch(envelope).await - } - - /// Returns how many handlers are currently registered. - pub fn handler_count(&self) -> usize { - self.handlers.len() - } - - /// Returns `true` if a handler is registered for payload type `T`. - pub fn has_handler(&self) -> bool { - self.handlers.contains_key(&TypeId::of::()) - } -} - -impl Default for AxonRouter { - fn default() -> Self { - Self::new() - } -} - -// ------------------------------------------------------------------ -// AxonSubscription — filter spec for subscription negotiation -// ------------------------------------------------------------------ - -/// Client subscription request — which project types and event types to -/// receive. Empty `Vec`s mean "subscribe to all". -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct AxonSubscription { - /// Subscribe to all events from these project types. - /// Empty = subscribe to all project types. - pub project_types: Vec, - /// Subscribe only to events whose payload type name is in this list. - /// Empty = subscribe to all event types. - pub event_types: Vec, -} - -impl AxonSubscription { - pub fn new() -> Self { - Self::default() - } - - /// Convenience: subscribe only to events from a single project. - pub fn for_project(project_type: ProjectType) -> Self { - Self { - project_types: vec![project_type], - event_types: vec![], - } - } - - /// Returns `true` when the subscription matches the given envelope - /// metadata. The `event_type` string is compared against the - /// `event_types` filter list. - pub fn matches(&self, project_type: ProjectType, event_type: &str) -> bool { - let project_ok = self.project_types.is_empty() - || self.project_types.contains(&project_type); - let event_ok = self.event_types.is_empty() - || self.event_types.iter().any(|t| t == event_type); - project_ok && event_ok - } -} - -// ------------------------------------------------------------------ -// Tests -// ------------------------------------------------------------------ - -#[cfg(test)] -mod tests { - use super::*; - use crate::compiler::CompileSucceeded; - use crate::runtime::SessionStarted; - use crate::project::ProjectType; - use std::sync::{Arc, Mutex}; - - #[tokio::test] - async fn router_dispatches_to_correct_handler() { - let captured: Arc>> = Arc::new(Mutex::new(vec![])); - let cap = Arc::clone(&captured); - - let mut router = AxonRouter::new(); - router.on(move |env: AxonEnvelope| { - let cap = Arc::clone(&cap); - async move { - cap.lock().unwrap().push(env.payload.session_id.clone()); - Ok(()) - } - }); - - let env = AxonEnvelope::new( - ProjectType::NeuronRs, - SessionStarted { session_id: "s-1".into() }, - ); - router.dispatch(env).await.unwrap(); - - assert_eq!(*captured.lock().unwrap(), vec!["s-1"]); - } - - #[tokio::test] - async fn router_returns_no_handler_error() { - let router = AxonRouter::new(); - let env = AxonEnvelope::new( - ProjectType::EngramLang, - CompileSucceeded { - file: "main.el".into(), - ..Default::default() - }, - ); - let result = router.dispatch(env).await; - assert!(matches!(result, Err(AxonError::NoHandler))); - } - - #[tokio::test] - async fn router_dispatch_json_roundtrip() { - let captured: Arc>> = Arc::new(Mutex::new(None)); - let cap = Arc::clone(&captured); - - let mut router = AxonRouter::new(); - router.on(move |env: AxonEnvelope| { - let cap = Arc::clone(&cap); - async move { - *cap.lock().unwrap() = Some(env.payload.file.clone()); - Ok(()) - } - }); - - let env = AxonEnvelope::new( - ProjectType::EngramLang, - CompileSucceeded { - file: "app.el".into(), - artifact_path: "dist/app.js".into(), - duration_ms: 500, - ..Default::default() - }, - ); - let json = env.to_json().unwrap(); - router.dispatch_json::(&json).await.unwrap(); - - assert_eq!(captured.lock().unwrap().as_deref(), Some("app.el")); - } - - #[tokio::test] - async fn router_replaces_handler_on_second_registration() { - let counter: Arc> = Arc::new(Mutex::new(0)); - let c1 = Arc::clone(&counter); - let c2 = Arc::clone(&counter); - - let mut router = AxonRouter::new(); - router.on(move |_: AxonEnvelope| { - let c = Arc::clone(&c1); - async move { *c.lock().unwrap() += 10; Ok(()) } - }); - // replace with a handler that adds 1 - router.on(move |_: AxonEnvelope| { - let c = Arc::clone(&c2); - async move { *c.lock().unwrap() += 1; Ok(()) } - }); - - let env = AxonEnvelope::new(ProjectType::NeuronRs, SessionStarted::default()); - router.dispatch(env).await.unwrap(); - - assert_eq!(*counter.lock().unwrap(), 1, "second handler should win"); - } - - #[test] - fn router_has_handler_reflects_registration() { - let mut router = AxonRouter::new(); - assert!(!router.has_handler::()); - router.on(|_: AxonEnvelope| async { Ok(()) }); - assert!(router.has_handler::()); - assert_eq!(router.handler_count(), 1); - } - - #[test] - fn subscription_matches_all_when_empty() { - let sub = AxonSubscription::new(); - assert!(sub.matches(ProjectType::Engram, "node_created")); - assert!(sub.matches(ProjectType::ElUi, "build_started")); - } - - #[test] - fn subscription_filters_by_project() { - let sub = AxonSubscription::for_project(ProjectType::EngramLang); - assert!(sub.matches(ProjectType::EngramLang, "compile_started")); - assert!(!sub.matches(ProjectType::Engram, "node_created")); - } - - #[test] - fn subscription_filters_by_event_type() { - let sub = AxonSubscription { - project_types: vec![], - event_types: vec!["compile_succeeded".into()], - }; - assert!(sub.matches(ProjectType::EngramLang, "compile_succeeded")); - assert!(!sub.matches(ProjectType::EngramLang, "compile_failed")); - } - - #[test] - fn subscription_serde_roundtrip() { - let sub = AxonSubscription { - project_types: vec![ProjectType::ElIde, ProjectType::ElUi], - event_types: vec!["file_opened".into()], - }; - let json = serde_json::to_string(&sub).unwrap(); - let back: AxonSubscription = serde_json::from_str(&json).unwrap(); - assert_eq!(back.project_types.len(), 2); - assert_eq!(back.event_types[0], "file_opened"); - } -} diff --git a/crates/axon-events/src/runtime.rs b/crates/axon-events/src/runtime.rs deleted file mode 100644 index 2205f28..0000000 --- a/crates/axon-events/src/runtime.rs +++ /dev/null @@ -1,195 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Events emitted by the neuron-rs runtime / agent host. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum RuntimeEvent { - SessionStarted(SessionStarted), - SessionEnded(SessionEnded), - ToolCallStarted(ToolCallStarted), - ToolCallCompleted(ToolCallCompleted), - ToolCallFailed(ToolCallFailed), - MemoryWritten(MemoryWritten), - KnowledgeQueried(KnowledgeQueried), - BacklogUpdated(BacklogUpdated), - WorkContextOpened(WorkContextOpened), - WorkContextClosed(WorkContextClosed), -} - -/// The action taken on a backlog item. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum BacklogAction { - #[default] - Created, - Started, - Completed, - Blocked, -} - -/// An agent session started. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct SessionStarted { - pub session_id: String, -} - -/// An agent session ended. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct SessionEnded { - pub session_id: String, - pub duration_ms: u64, -} - -/// A tool call was dispatched. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ToolCallStarted { - pub tool_name: String, - pub session_id: String, -} - -/// A tool call returned successfully. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ToolCallCompleted { - pub tool_name: String, - pub session_id: String, - pub duration_ms: u64, -} - -/// A tool call returned an error. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ToolCallFailed { - pub tool_name: String, - pub session_id: String, - pub error: String, -} - -/// A memory node was written to the store. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct MemoryWritten { - pub node_id: String, - pub chain_name: String, -} - -/// A knowledge query was executed. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct KnowledgeQueried { - pub query: String, - pub result_count: u64, - pub duration_ms: u64, -} - -/// A backlog item changed state. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct BacklogUpdated { - pub item_id: String, - pub action: BacklogAction, -} - -/// A work context was opened. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct WorkContextOpened { - pub context_id: String, - pub process_name: String, -} - -/// A work context was closed / completed. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct WorkContextClosed { - pub context_id: String, - pub duration_ms: u64, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::common::AxonEnvelope; - use crate::project::ProjectType; - - #[test] - fn session_started_roundtrip() { - let evt = SessionStarted { - session_id: "sess-abc".into(), - }; - let env = AxonEnvelope::new(ProjectType::NeuronRs, evt); - let json = env.to_json().unwrap(); - let back: AxonEnvelope = AxonEnvelope::from_json(&json).unwrap(); - assert_eq!(back.payload.session_id, "sess-abc"); - assert_eq!(back.project_type, ProjectType::NeuronRs); - } - - #[test] - fn tool_call_lifecycle() { - let start = ToolCallStarted { - tool_name: "remember".into(), - session_id: "sess-1".into(), - }; - let complete = ToolCallCompleted { - tool_name: "remember".into(), - session_id: "sess-1".into(), - duration_ms: 12, - }; - let failed = ToolCallFailed { - tool_name: "recall".into(), - session_id: "sess-1".into(), - error: "not found".into(), - }; - - for json in [ - serde_json::to_string(&start).unwrap(), - serde_json::to_string(&complete).unwrap(), - serde_json::to_string(&failed).unwrap(), - ] { - assert!(!json.is_empty()); - } - } - - #[test] - fn backlog_action_default_is_created() { - assert_eq!(BacklogAction::default(), BacklogAction::Created); - } - - #[test] - fn backlog_updated_all_actions() { - for action in [ - BacklogAction::Created, - BacklogAction::Started, - BacklogAction::Completed, - BacklogAction::Blocked, - ] { - let evt = BacklogUpdated { - item_id: "bl-1".into(), - action, - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: BacklogUpdated = serde_json::from_str(&json).unwrap(); - assert_eq!(back.action, action); - } - } - - #[test] - fn runtime_event_enum_roundtrip() { - let variant = RuntimeEvent::WorkContextOpened(WorkContextOpened { - context_id: "ctx-99".into(), - process_name: "implement-feature".into(), - }); - let json = serde_json::to_string(&variant).unwrap(); - assert!(json.contains("work_context_opened")); - let back: RuntimeEvent = serde_json::from_str(&json).unwrap(); - if let RuntimeEvent::WorkContextOpened(w) = back { - assert_eq!(w.context_id, "ctx-99"); - } else { - panic!("wrong variant"); - } - } - - #[test] - fn knowledge_queried_envelope() { - let evt = KnowledgeQueried { - query: "VBD patterns".into(), - result_count: 5, - duration_ms: 30, - }; - let env = AxonEnvelope::new(ProjectType::NeuronRs, evt); - assert_eq!(env.payload.result_count, 5); - } -} diff --git a/crates/axon-events/src/ui.rs b/crates/axon-events/src/ui.rs deleted file mode 100644 index 2f323d8..0000000 --- a/crates/axon-events/src/ui.rs +++ /dev/null @@ -1,162 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Events emitted by the el-ui frontend framework. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum UiEvent { - BuildStarted(BuildStarted), - BuildSucceeded(BuildSucceeded), - BuildFailed(BuildFailed), - ComponentRendered(ComponentRendered), - ServiceCallStarted(ServiceCallStarted), - ServiceCallCompleted(ServiceCallCompleted), - ServiceCallFailed(ServiceCallFailed), - HotReloadTriggered(HotReloadTriggered), -} - -/// How a service binding communicates with its backend. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum BindingType { - #[default] - Rest, - Ws, - Grpc, - Direct, -} - -/// A UI build has started for a given target platform. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct BuildStarted { - pub target_platform: String, -} - -/// A UI build finished and produced an artifact. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct BuildSucceeded { - pub target_platform: String, - pub artifact_size_bytes: u64, - pub duration_ms: u64, -} - -/// A UI build failed. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct BuildFailed { - pub target_platform: String, - pub errors: Vec, -} - -/// A component finished its render pass. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ComponentRendered { - pub component_name: String, - pub platform: String, - /// Render time in microseconds (sub-millisecond precision for hot paths). - pub duration_us: u64, -} - -/// A service-binding call started. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ServiceCallStarted { - pub binding_type: BindingType, - pub method: String, -} - -/// A service-binding call completed successfully. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ServiceCallCompleted { - pub binding_type: BindingType, - pub method: String, - pub status_code: u16, - pub duration_ms: u64, -} - -/// A service-binding call failed. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ServiceCallFailed { - pub binding_type: BindingType, - pub method: String, - pub error: String, -} - -/// A hot-reload was triggered by file changes. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct HotReloadTriggered { - pub changed_files: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::common::AxonEnvelope; - use crate::project::ProjectType; - - #[test] - fn build_started_roundtrip() { - let evt = BuildStarted { - target_platform: "web".into(), - }; - let env = AxonEnvelope::new(ProjectType::ElUi, evt); - let json = env.to_json().unwrap(); - let back: AxonEnvelope = AxonEnvelope::from_json(&json).unwrap(); - assert_eq!(back.payload.target_platform, "web"); - assert_eq!(back.project_type, ProjectType::ElUi); - } - - #[test] - fn build_failed_with_errors() { - let evt = BuildFailed { - target_platform: "ios".into(), - errors: vec!["missing asset".into(), "lint error".into()], - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: BuildFailed = serde_json::from_str(&json).unwrap(); - assert_eq!(back.errors.len(), 2); - } - - #[test] - fn binding_type_default_is_rest() { - assert_eq!(BindingType::default(), BindingType::Rest); - } - - #[test] - fn service_call_completed_roundtrip() { - let evt = ServiceCallCompleted { - binding_type: BindingType::Grpc, - method: "ListNodes".into(), - status_code: 200, - duration_ms: 45, - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: ServiceCallCompleted = serde_json::from_str(&json).unwrap(); - assert_eq!(back.binding_type, BindingType::Grpc); - assert_eq!(back.status_code, 200); - } - - #[test] - fn hot_reload_roundtrip() { - let evt = HotReloadTriggered { - changed_files: vec!["src/app.el".into(), "styles/main.css".into()], - }; - let json = serde_json::to_string(&evt).unwrap(); - let back: HotReloadTriggered = serde_json::from_str(&json).unwrap(); - assert_eq!(back.changed_files.len(), 2); - } - - #[test] - fn ui_event_enum_serde() { - let variant = UiEvent::ComponentRendered(ComponentRendered { - component_name: "Button".into(), - platform: "web".into(), - duration_us: 150, - }); - let json = serde_json::to_string(&variant).unwrap(); - assert!(json.contains("component_rendered")); - let back: UiEvent = serde_json::from_str(&json).unwrap(); - if let UiEvent::ComponentRendered(c) = back { - assert_eq!(c.component_name, "Button"); - } else { - panic!("wrong variant"); - } - } -} diff --git a/crates/neuron-api/src/main.rs b/crates/neuron-api/src/main.rs index 6688cc3..30b82da 100644 --- a/crates/neuron-api/src/main.rs +++ b/crates/neuron-api/src/main.rs @@ -34,6 +34,10 @@ async fn main() { let state = AppState::new(&db_path, api_key).expect("failed to initialize app state"); let state = Arc::new(state); + // Run on_startup hooks — each .el module with `fn on_startup` gets its + // own background thread. The loop timing and logic live entirely in Engram. + state.runtime.run_startup_hooks(); + let app = routes::build_router(state); let listener = tokio::net::TcpListener::bind(&bind) diff --git a/crates/neuron-cli/Cargo.toml b/crates/neuron-cli/Cargo.toml new file mode 100644 index 0000000..452236a --- /dev/null +++ b/crates/neuron-cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "neuron-cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "neuron" +path = "src/main.rs" + +[dependencies] +reqwest = { version = "0.12", features = ["json", "blocking"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +clap = { version = "4", features = ["derive"] } +rustyline = "14" +uuid = { version = "1", features = ["v4"] } +colored = "2" +chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/neuron-cli/src/main.rs b/crates/neuron-cli/src/main.rs new file mode 100644 index 0000000..cac7dfc --- /dev/null +++ b/crates/neuron-cli/src/main.rs @@ -0,0 +1,339 @@ +// src/main.rs +use clap::{Parser, Subcommand}; +use colored::Colorize; +use rustyline::DefaultEditor; +use serde_json::{json, Value}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "neuron", about = "Neuron intelligence CLI")] +struct Cli { + #[command(subcommand)] + command: Option, + + /// Operator mode — full tool access (uses NEURON_API_KEY) + #[arg(long, global = true)] + operator: bool, + + /// User mode — standard access (uses NEURON_USER_KEY) + #[arg(long, global = true)] + user: bool, +} + +#[derive(Subcommand)] +enum Commands { + /// One-shot ask + Ask { + question: String, + }, + /// Save a memory + Remember { + content: String, + #[arg(short, long)] + importance: Option, + }, + /// Search memories and knowledge + Search { + query: String, + }, + /// Check server status + Status, +} + +struct NeuronClient { + base_url: String, + api_key: String, + tier: Tier, + client: reqwest::Client, +} + +enum Tier { + Operator, + User, +} + +impl NeuronClient { + fn new(operator: bool) -> Self { + let (api_key, tier) = if operator { + ( + std::env::var("NEURON_API_KEY").unwrap_or_else(|_| "neuron-dev".to_string()), + Tier::Operator, + ) + } else { + ( + std::env::var("NEURON_USER_KEY") + .or_else(|_| std::env::var("NEURON_API_KEY")) + .unwrap_or_else(|_| "neuron-dev".to_string()), + Tier::User, + ) + }; + + let base_url = std::env::var("NEURON_URL") + .unwrap_or_else(|_| "http://localhost:7770".to_string()); + + Self { + base_url, + api_key, + tier, + client: reqwest::Client::new(), + } + } + + fn prompt(&self) -> String { + match self.tier { + Tier::Operator => "neuron[op]> ".cyan().bold().to_string(), + Tier::User => "neuron> ".cyan().to_string(), + } + } + + async fn axon_call(&self, tool: &str, params: Value) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let body = json!({ + "id": id, + "method": "tool_call", + "params": { + "tool": tool, + "params": params + } + }); + + let resp = self.client + .post(format!("{}/axon/message", self.base_url)) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| format!("Connection failed: {}. Is neuron running?", e))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("Unauthorized. Check NEURON_API_KEY.".to_string()); + } + + resp.json::() + .await + .map_err(|e| format!("Parse error: {}", e)) + } + + async fn check_status(&self) -> bool { + self.client + .get(format!("{}/health", self.base_url)) + .header("Authorization", format!("Bearer {}", self.api_key)) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + } + + async fn remember(&self, content: &str, importance: Option<&str>) -> String { + let params = json!({ + "content": content, + "importance": importance.unwrap_or("normal"), + "tags": ["cli"] + }); + match self.axon_call("remember", params).await { + Ok(v) => { + let id = v.get("id") + .or_else(|| v.get("result").and_then(|r| r.get("id"))) + .and_then(|i| i.as_str()) + .unwrap_or("saved"); + format!("✓ Remembered ({})", id) + } + Err(e) => format!("✗ {}", e), + } + } + + async fn search(&self, query: &str) -> String { + let params = json!({ "query": query, "limit": 10 }); + match self.axon_call("search_knowledge", params).await { + Ok(v) => format_search_results(&v), + Err(_) => { + // Try memory search + match self.axon_call("recall", json!({ "query": query })).await { + Ok(v) => format_search_results(&v), + Err(e) => format!("✗ {}", e), + } + } + } + } + + async fn chat(&self, message: &str) -> String { + let params = json!({ + "message": message, + "context": "cli" + }); + match self.axon_call("chat", params).await { + Ok(v) => extract_text_result(&v), + Err(e) => format!("✗ {}", e), + } + } +} + +fn extract_text_result(v: &Value) -> String { + // Try various result shapes + if let Some(r) = v.get("result") { + if let Some(s) = r.as_str() { return s.to_string(); } + if let Some(c) = r.get("content").and_then(|c| c.as_str()) { return c.to_string(); } + return serde_json::to_string_pretty(r).unwrap_or_default(); + } + if let Some(e) = v.get("error").and_then(|e| e.as_str()) { + return format!("error: {}", e); + } + serde_json::to_string_pretty(v).unwrap_or_default() +} + +fn format_search_results(v: &Value) -> String { + let items = v.get("result") + .or(Some(v)) + .and_then(|r| r.as_array()) + .cloned() + .unwrap_or_default(); + + if items.is_empty() { + return "No results found.".to_string(); + } + + let mut out = String::new(); + for (i, item) in items.iter().take(10).enumerate() { + let title = item.get("title").and_then(|t| t.as_str()) + .or_else(|| item.get("content").and_then(|c| c.as_str()).map(|s| &s[..s.len().min(60)])) + .unwrap_or("(untitled)"); + let id = item.get("id").and_then(|i| i.as_str()).unwrap_or(""); + out.push_str(&format!("{}. {} [{}]\n", i + 1, title, id)); + } + out +} + +fn history_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join(".neuron").join("cli_history") +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + // Operator mode if --operator flag or neither flag (default operator for now) + let is_operator = cli.operator || !cli.user; + let nc = NeuronClient::new(is_operator); + + match cli.command { + Some(Commands::Status) => { + let ok = nc.check_status().await; + if ok { + println!("{} neuron-api is running at {}", "●".green(), nc.base_url); + } else { + println!("{} neuron-api is not responding at {}", "●".red(), nc.base_url); + std::process::exit(1); + } + } + + Some(Commands::Ask { question }) => { + let result = nc.chat(&question).await; + println!("{}", result); + } + + Some(Commands::Remember { content, importance }) => { + let result = nc.remember(&content, importance.as_deref()).await; + if result.starts_with('✓') { + println!("{}", result.green()); + } else { + eprintln!("{}", result.red()); + std::process::exit(1); + } + } + + Some(Commands::Search { query }) => { + let result = nc.search(&query).await; + println!("{}", result); + } + + None => { + // Interactive REPL + if !nc.check_status().await { + eprintln!( + "{} Neuron is not running at {}.\nStart it with: neuron-api", + "✗".red().bold(), + nc.base_url + ); + std::process::exit(1); + } + + println!( + "{} {}", + "Neuron".cyan().bold(), + "intelligence CLI — type :help for commands, :quit to exit".dimmed() + ); + println!(); + + let hist = history_path(); + let mut rl = DefaultEditor::new().expect("readline init failed"); + if hist.exists() { + let _ = rl.load_history(&hist); + } + + let prompt = nc.prompt(); + + loop { + match rl.readline(&prompt) { + Ok(line) => { + let input = line.trim().to_string(); + if input.is_empty() { continue; } + let _ = rl.add_history_entry(&input); + + match input.as_str() { + ":quit" | ":exit" | ":q" => { + let _ = rl.save_history(&hist); + println!("bye"); + break; + } + ":help" => { + println!("Commands:"); + println!(" :status — check server status"); + println!(" :quit — exit"); + println!(" :help — this message"); + println!(" remember — save a memory"); + println!(" search — search knowledge"); + println!(" anything else — chat"); + } + ":status" => { + let ok = nc.check_status().await; + if ok { + println!("{}", "● online".green()); + } else { + println!("{}", "● offline".red()); + } + } + _ if input.starts_with("remember ") => { + let content = &input["remember ".len()..]; + let result = nc.remember(content, None).await; + println!("{}", if result.starts_with('✓') { result.green() } else { result.red() }); + } + _ if input.starts_with("search ") => { + let query = &input["search ".len()..]; + let result = nc.search(query).await; + println!("{}", result); + } + _ => { + let result = nc.chat(&input).await; + println!("{}", result.white()); + } + } + } + Err(rustyline::error::ReadlineError::Interrupted) => { + println!(); + continue; + } + Err(rustyline::error::ReadlineError::Eof) => { + let _ = rl.save_history(&hist); + break; + } + Err(e) => { + eprintln!("readline error: {}", e); + break; + } + } + } + } + } +} diff --git a/crates/neuron-migrate/Cargo.toml b/crates/neuron-migrate/Cargo.toml new file mode 100644 index 0000000..289c075 --- /dev/null +++ b/crates/neuron-migrate/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "neuron-migrate" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "neuron-migrate" +path = "src/main.rs" + +[dependencies] +neuron-domain = { path = "../neuron-domain" } +neuron-store = { path = "../neuron-store" } +engram-core = { path = "../../../../foundation/engram/engrams/engram-core" } +rusqlite = { version = "0.31", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1" +tracing = "0.1" +tracing-subscriber = "0.3" diff --git a/crates/neuron-migrate/src/main.rs b/crates/neuron-migrate/src/main.rs new file mode 100644 index 0000000..981cf8b --- /dev/null +++ b/crates/neuron-migrate/src/main.rs @@ -0,0 +1,794 @@ +//! neuron-migrate: Migrate the Neuron production SQLite database into Engram DB format. +//! +//! Source: ~/.neuron/neuron.db (old SQLite schema) +//! Target: ~/.neuron/engram/ (new Engram format via neuron-store) +//! +//! Tables migrated: +//! memory_nodes → Memory domain type → MemoryStore (NodeType::Memory) +//! knowledge → KnowledgeEntry → KnowledgeStore (NodeType::Entity) +//! backlog_items → BacklogItem → BacklogStore (NodeType::Process) +//! execution_contexts → ExecutionContext → ContextStore (NodeType::Event) +//! artifacts → Memory (tagged) → MemoryStore (no Artifact domain type yet) +//! config_entries → Memory (tagged) → MemoryStore (preserved as structured JSON) +//! +//! IMPORTANT: sled's EngramDb uses an exclusive file lock per path, so we open +//! the DB *once* and perform all node writes through that single handle, bypassing +//! the individual store wrappers (which each take ownership of a separate EngramDb). +//! The encoding (serde_json) and NodeType assignments mirror neuron-store exactly. + +use anyhow::{Context, Result}; +use engram_core::{EngramDb, MemoryTier, Node, NodeType}; +use neuron_domain::{ + BacklogItem, BacklogStatus, ContextStatus, ContextStep, ExecutionContext, Importance, + KnowledgeEntry, KnowledgeTier, ItemType, Memory, Priority, +}; +use rusqlite::Connection; +use serde_json::Value; +use std::path::PathBuf; +use uuid::Uuid; + +// ── Codec (mirrors neuron-store/src/codec.rs) ───────────────────────────────── + +fn encode(value: &T) -> Vec { + serde_json::to_vec(value).expect("serialization should not fail for well-formed domain types") +} + +const EMBEDDING_DIM: usize = 64; + +fn hash_embedding(text: &str) -> Vec { + let bytes = text.as_bytes(); + let mut vals = vec![0.0f32; EMBEDDING_DIM]; + for (i, b) in bytes.iter().enumerate() { + vals[i % EMBEDDING_DIM] += *b as f32; + } + let norm: f32 = vals.iter().map(|v| v * v).sum::().sqrt(); + if norm > 0.0 { + for v in &mut vals { + *v /= norm; + } + } else { + vals[0] = 1.0; + } + vals +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Parse a pipe/comma-separated or JSON-array tag string into a Vec. +fn parse_tags(raw: &str) -> Vec { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return vec![]; + } + if let Ok(Value::Array(arr)) = serde_json::from_str(trimmed) { + return arr + .into_iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + } + trimmed + .split([',', '|']) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Parse a JSON array of UUID strings into Vec. +fn parse_uuid_list(raw: &str) -> Vec { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed == "[]" { + return vec![]; + } + if let Ok(Value::Array(arr)) = serde_json::from_str(trimmed) { + return arr + .into_iter() + .filter_map(|v| v.as_str().and_then(|s| Uuid::parse_str(s).ok())) + .collect(); + } + vec![] +} + +/// Parse a JSON array of strings into Vec. +fn parse_string_list(raw: &str) -> Vec { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed == "[]" { + return vec![]; + } + if let Ok(Value::Array(arr)) = serde_json::from_str(trimmed) { + return arr + .into_iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + } + vec![trimmed.to_string()] +} + +/// Parse a nullable string column to Option, treating empty strings as None. +fn opt_str(s: Option) -> Option { + s.and_then(|v| if v.is_empty() { None } else { Some(v) }) +} + +/// Parse an ISO8601 timestamp string or integer ms into i64 ms. +fn parse_iso_or_ms(s: &str) -> i64 { + let trimmed = s.trim(); + if let Ok(n) = trimmed.parse::() { + return n; + } + use chrono::DateTime; + if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) { + return dt.timestamp_millis(); + } + // Try "YYYY-MM-DD HH:MM:SS+TZ" format + if let Ok(dt) = chrono::DateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S%.f%z") { + return dt.timestamp_millis(); + } + neuron_domain::now_ms() +} + +/// Derive a deterministic UUID from a string identifier by simple mixing. +fn deterministic_uuid(s: &str) -> Uuid { + let bytes = s.as_bytes(); + let mut out = [0u8; 16]; + for (i, b) in bytes.iter().enumerate() { + out[i % 16] ^= b.wrapping_add((i as u8).wrapping_mul(31)); + } + // Set UUID version 5 bits (variant 2, version 5) + out[6] = (out[6] & 0x0f) | 0x50; + out[8] = (out[8] & 0x3f) | 0x80; + Uuid::from_bytes(out) +} + +/// Parse an old-style prefixed ID (e.g. "mem-xxxxxxxx") into a Uuid. +fn parse_id(id_str: &str, prefix: &str) -> Uuid { + // Try plain UUID first + if let Ok(u) = Uuid::parse_str(id_str) { + return u; + } + // Strip known prefix and try again + let stripped = id_str.trim_start_matches(prefix).trim_start_matches('-'); + if let Ok(u) = Uuid::parse_str(stripped) { + return u; + } + // Fall back to deterministic derivation + deterministic_uuid(id_str) +} + +// ── Stats ───────────────────────────────────────────────────────────────────── + +struct Stats { + migrated: usize, + errors: usize, +} + +impl Stats { + fn new() -> Self { Self { migrated: 0, errors: 0 } } +} + +// ── Migration functions ─────────────────────────────────────────────────────── + +fn migrate_memory_nodes(src: &Connection, db: &EngramDb) -> Result { + let mut stmt = src.prepare( + "SELECT id, content, tags, importance, superseded_by, \ + created_at, updated_at, project, metadata, pinned \ + FROM memory_nodes", + )?; + + let mut stats = Stats::new(); + + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + )) + })?; + + for row_result in rows { + let (id_str, content, tags_raw, importance_str, superseded_by, + created_at, updated_at, project, metadata_raw, pinned) = match row_result { + Ok(r) => r, + Err(e) => { eprintln!(" [memory] row error: {e}"); stats.errors += 1; continue; } + }; + + let id = parse_id(&id_str, "mem"); + let supersedes_id = superseded_by.as_deref() + .filter(|s| !s.is_empty()) + .map(|s| parse_id(s, "mem")); + + let mut tags = parse_tags(&tags_raw); + if pinned == Some(1) { tags.push("pinned".to_string()); } + + let extra = match &metadata_raw { + Some(m) if m.trim() != "{}" && !m.trim().is_empty() => { + format!("\n\n[metadata: {}]", m.trim()) + } + _ => String::new(), + }; + let full_content = if extra.is_empty() { content } else { format!("{}{}", content, extra) }; + + let memory = Memory { + id, + content: full_content, + tags, + project: opt_str(project), + importance: Importance::from_str_lossy(&importance_str), + supersedes_id, + created_at, + updated_at, + }; + + let content_bytes = encode(&memory); + let embedding = hash_embedding(&memory.content); + let importance = memory.importance.to_f32(); + + let node = Node::new( + NodeType::Memory, + embedding, + content_bytes, + MemoryTier::Episodic, + importance, + ).with_id(memory.id); + + match db.put_node(node) { + Ok(_) => stats.migrated += 1, + Err(e) => { eprintln!(" [memory] put error for {id_str}: {e}"); stats.errors += 1; } + } + } + + Ok(stats) +} + +fn migrate_knowledge(src: &Connection, db: &EngramDb) -> Result { + let mut stmt = src.prepare( + "SELECT id, key, description, tags, raw_content, compiled_content, \ + tier, disposition, created_at \ + FROM knowledge", + )?; + + let mut stats = Stats::new(); + + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + )) + })?; + + for row_result in rows { + let (id_str, key, description, tags_raw, raw_content, compiled_content, + tier_str, disposition, created_at_str) = match row_result { + Ok(r) => r, + Err(e) => { eprintln!(" [knowledge] row error: {e}"); stats.errors += 1; continue; } + }; + + let id = parse_id(&id_str, "kn"); + + // Use description as title when it differs from the path key + let title = if description.is_empty() || description == key { + key.clone() + } else { + description.clone() + }; + + // Prefer compiled_content; fall back to raw_content + let content = if !compiled_content.trim().is_empty() { + compiled_content + } else { + raw_content + }; + + let mut tags = parse_tags(&tags_raw); + if !disposition.is_empty() && disposition != "experimental" { + tags.push(format!("disposition:{}", disposition)); + } + + // Infer category from path prefix + let category = key.splitn(2, '/').next().unwrap_or("general").to_string(); + let created_at = parse_iso_or_ms(&created_at_str); + + let entry = KnowledgeEntry { + id, + title, + content: content.clone(), + category, + tier: KnowledgeTier::from_str_lossy(&tier_str), + tags, + project: None, + created_at, + }; + + let content_bytes = encode(&entry); + let text = format!("{} {} {}", entry.title, entry.category, content); + let embedding = hash_embedding(&text); + + let node = Node::new( + NodeType::Entity, + embedding, + content_bytes, + MemoryTier::Semantic, + 0.7, + ).with_id(entry.id); + + match db.put_node(node) { + Ok(_) => stats.migrated += 1, + Err(e) => { eprintln!(" [knowledge] put error for {id_str}: {e}"); stats.errors += 1; } + } + } + + Ok(stats) +} + +fn migrate_backlog(src: &Connection, db: &EngramDb) -> Result { + let mut stmt = src.prepare( + "SELECT id, title, description, priority, status, project, tags, \ + item_type, depends_on, created_at, updated_at, \ + complexity, effort_estimate, resolution_notes, assigned_to, process, metadata \ + FROM backlog_items", + )?; + + let mut stats = Stats::new(); + + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, i64>(9)?, + row.get::<_, i64>(10)?, + row.get::<_, Option>(11)?, + row.get::<_, Option>(12)?, + row.get::<_, Option>(13)?, + row.get::<_, Option>(14)?, + row.get::<_, Option>(15)?, + row.get::<_, Option>(16)?, + )) + })?; + + for row_result in rows { + let (id_str, title, description, priority_str, status_str, project, tags_raw, + item_type_str, depends_on_raw, created_at, updated_at, + complexity, effort_estimate, resolution_notes, assigned_to, process, metadata_raw) + = match row_result { + Ok(r) => r, + Err(e) => { eprintln!(" [backlog] row error: {e}"); stats.errors += 1; continue; } + }; + + let id = parse_id(&id_str, "bl"); + + let mut tags = parse_tags(&tags_raw); + if let Some(c) = complexity.as_deref() { + if !c.is_empty() && c != "moderate" { tags.push(format!("complexity:{}", c)); } + } + if let Some(e) = effort_estimate.as_deref() { + if !e.is_empty() { tags.push(format!("effort:{}", e)); } + } + if let Some(a) = assigned_to.as_deref() { + if !a.is_empty() { tags.push(format!("assigned:{}", a)); } + } + if let Some(p) = process.as_deref() { + if !p.is_empty() { tags.push(format!("process:{}", p)); } + } + + let mut full_description = description; + if let Some(notes) = resolution_notes.as_deref() { + if !notes.is_empty() { full_description.push_str(&format!("\n\n[Resolution: {}]", notes)); } + } + if let Some(m) = metadata_raw.as_deref() { + if m.trim() != "{}" && !m.trim().is_empty() { + full_description.push_str(&format!("\n\n[metadata: {}]", m.trim())); + } + } + + let depends_on = parse_uuid_list(&depends_on_raw); + + let item = BacklogItem { + id, + title: title.clone(), + description: full_description.clone(), + item_type: ItemType::from_str_lossy(&item_type_str), + priority: Priority::from_str_lossy(&priority_str), + status: BacklogStatus::from_str_lossy(&status_str), + project: opt_str(project), + tags, + depends_on, + created_at, + updated_at, + }; + + let content_bytes = encode(&item); + let text = format!("{} {}", title, full_description); + let embedding = hash_embedding(&text); + + let node = Node::new( + NodeType::Process, + embedding, + content_bytes, + MemoryTier::Working, + 0.6, + ).with_id(item.id); + + match db.put_node(node) { + Ok(_) => stats.migrated += 1, + Err(e) => { eprintln!(" [backlog] put error for {id_str}: {e}"); stats.errors += 1; } + } + } + + Ok(stats) +} + +fn migrate_contexts(src: &Connection, db: &EngramDb) -> Result { + let mut stmt = src.prepare( + "SELECT id, process_name, description, objective, status, project, \ + COALESCE(steps_json, '[]'), COALESCE(file_refs, '[]'), \ + COALESCE(key_decisions, '[]'), COALESCE(lessons_learned, '[]'), \ + created_at, updated_at, summary, results_summary, error_message \ + FROM execution_contexts", + )?; + + let mut stats = Stats::new(); + + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + row.get::<_, i64>(10)?, + row.get::<_, i64>(11)?, + row.get::<_, Option>(12)?, + row.get::<_, Option>(13)?, + row.get::<_, Option>(14)?, + )) + })?; + + for row_result in rows { + let (id_str, process_name, mut description, objective, status_str, project, + steps_json, file_refs_raw, key_decisions_raw, lessons_learned_raw, + created_at, updated_at, summary, results_summary, error_message) + = match row_result { + Ok(r) => r, + Err(e) => { eprintln!(" [contexts] row error: {e}"); stats.errors += 1; continue; } + }; + + let id = parse_id(&id_str, "ctx"); + + if let Some(s) = summary.as_deref() { + if !s.is_empty() { description.push_str(&format!("\n\n[Summary: {}]", s)); } + } + if let Some(r) = results_summary.as_deref() { + if !r.is_empty() { description.push_str(&format!("\n\n[Results: {}]", r)); } + } + if let Some(e) = error_message.as_deref() { + if !e.is_empty() { description.push_str(&format!("\n\n[Error: {}]", e)); } + } + + let steps = parse_steps_json(&steps_json); + let file_refs = parse_string_list(&file_refs_raw); + let key_decisions = parse_string_list(&key_decisions_raw); + let lessons_learned = parse_string_list(&lessons_learned_raw); + + let ctx = ExecutionContext { + id, + process_name: process_name.clone(), + description, + objective: objective.clone(), + status: ContextStatus::from_str_lossy(&status_str), + project: opt_str(project), + steps, + file_refs, + key_decisions, + lessons_learned, + created_at, + updated_at, + }; + + let content_bytes = encode(&ctx); + let text = format!("{} {}", process_name, objective); + let embedding = hash_embedding(&text); + + let node = Node::new( + NodeType::Event, + embedding, + content_bytes, + MemoryTier::Working, + 0.7, + ).with_id(ctx.id); + + match db.put_node(node) { + Ok(_) => stats.migrated += 1, + Err(e) => { eprintln!(" [contexts] put error for {id_str}: {e}"); stats.errors += 1; } + } + } + + Ok(stats) +} + +/// Migrate artifacts as Memory nodes tagged with "artifact". +fn migrate_artifacts(src: &Connection, db: &EngramDb) -> Result { + let mut stmt = src.prepare( + "SELECT id, title, content, artifact_types, status, project, \ + tags, version, created_at, updated_at \ + FROM artifacts", + )?; + + let mut stats = Stats::new(); + + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + row.get::<_, i64>(7)?, + row.get::<_, i64>(8)?, + row.get::<_, i64>(9)?, + )) + })?; + + for row_result in rows { + let (id_str, title, content, artifact_types, status, project, + tags_raw, version, created_at, updated_at) = match row_result { + Ok(r) => r, + Err(e) => { eprintln!(" [artifacts] row error: {e}"); stats.errors += 1; continue; } + }; + + let id = parse_id(&id_str, "art"); + + let mut tags = parse_tags(&tags_raw); + tags.push("artifact".to_string()); + tags.push(format!("artifact_status:{}", status)); + for atype in parse_tags(&artifact_types) { + tags.push(format!("artifact_type:{}", atype)); + } + if version > 1 { tags.push(format!("version:{}", version)); } + + // Embed full artifact as structured JSON so it's fully recoverable + let artifact_doc = serde_json::json!({ + "type": "artifact", + "id": id_str, + "title": title, + "status": status, + "artifact_types": parse_tags(&artifact_types), + "version": version, + "content": content, + }); + let memory_content = format!( + "ARTIFACT: {}\n\n{}\n\n---\n{}", + title, content, artifact_doc, + ); + + let memory = Memory { + id, + content: memory_content, + tags, + project: opt_str(project), + importance: Importance::High, + supersedes_id: None, + created_at, + updated_at, + }; + + let content_bytes = encode(&memory); + let embedding = hash_embedding(&memory.content); + + let node = Node::new( + NodeType::Memory, + embedding, + content_bytes, + MemoryTier::Episodic, + Importance::High.to_f32(), + ).with_id(memory.id); + + match db.put_node(node) { + Ok(_) => stats.migrated += 1, + Err(e) => { eprintln!(" [artifacts] put error for {id_str}: {e}"); stats.errors += 1; } + } + } + + Ok(stats) +} + +/// Migrate config_entries as Memory nodes tagged with "config". +fn migrate_config(src: &Connection, db: &EngramDb) -> Result { + let mut stmt = src.prepare( + "SELECT key, value, updated_at FROM config_entries", + )?; + + let mut stats = Stats::new(); + + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + })?; + + for row_result in rows { + let (key, value, updated_at) = match row_result { + Ok(r) => r, + Err(e) => { eprintln!(" [config] row error: {e}"); stats.errors += 1; continue; } + }; + + let id = deterministic_uuid(&format!("config:{}", key)); + let content_str = format!("CONFIG: {}\n\nValue: {}", key, value); + + let memory = Memory { + id, + content: content_str, + tags: vec!["config".to_string(), format!("config_key:{}", key)], + project: None, + importance: Importance::High, + supersedes_id: None, + created_at: updated_at, + updated_at, + }; + + let content_bytes = encode(&memory); + let embedding = hash_embedding(&memory.content); + + let node = Node::new( + NodeType::Memory, + embedding, + content_bytes, + MemoryTier::Episodic, + Importance::High.to_f32(), + ).with_id(memory.id); + + match db.put_node(node) { + Ok(_) => { + println!(" config: {}", key); + stats.migrated += 1; + } + Err(e) => { eprintln!(" [config] put error for {key}: {e}"); stats.errors += 1; } + } + } + + Ok(stats) +} + +// ── Step parsers ────────────────────────────────────────────────────────────── + +fn parse_steps_json(raw: &str) -> Vec { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed == "[]" { return vec![]; } + let arr: Vec = match serde_json::from_str(trimmed) { + Ok(Value::Array(a)) => a, + _ => return vec![], + }; + + arr.into_iter().filter_map(|v| { + let name = v.get("name").and_then(|n| n.as_str()) + .or_else(|| v.get("action").and_then(|a| a.as_str())) + .unwrap_or("unknown").to_string(); + let status = v.get("status").and_then(|s| s.as_str()) + .unwrap_or("completed").to_string(); + let notes = v.get("notes").and_then(|n| n.as_str()) + .filter(|s| !s.is_empty()).map(|s| s.to_string()); + let started_at = v.get("started_at").and_then(|t| t.as_i64()) + .unwrap_or_else(neuron_domain::now_ms); + let completed_at = v.get("completed_at").and_then(|t| t.as_i64()); + Some(ContextStep { name, status, notes, started_at, completed_at }) + }).collect() +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter("warn") + .init(); + + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let src_path = PathBuf::from(&home).join(".neuron/neuron.db"); + let dst_path_str = std::env::var("NEURON_DB_PATH") + .unwrap_or_else(|_| format!("{}/.neuron/engram", home)); + let dst_path = PathBuf::from(&dst_path_str); + + println!("neuron-migrate"); + println!(" source: {}", src_path.display()); + println!(" target: {}", dst_path.display()); + println!(); + + if !src_path.exists() { + anyhow::bail!("source database not found: {}", src_path.display()); + } + + std::fs::create_dir_all(&dst_path) + .with_context(|| format!("failed to create target dir: {}", dst_path.display()))?; + + // Open source DB (read-only) + let src = Connection::open_with_flags( + &src_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ).with_context(|| format!("failed to open source DB: {}", src_path.display()))?; + + // Open target Engram DB — SINGLE handle for the entire migration. + // sled uses an exclusive file lock; opening multiple handles to the same path + // in the same process will fail with WouldBlock on the second open. + let db = EngramDb::open(&dst_path) + .map_err(|e| anyhow::anyhow!("failed to open Engram DB: {e}"))?; + + println!("Migrating memory_nodes..."); + let mem_stats = migrate_memory_nodes(&src, &db)?; + println!(" done: {} migrated, {} errors", mem_stats.migrated, mem_stats.errors); + + println!("Migrating knowledge..."); + let kn_stats = migrate_knowledge(&src, &db)?; + println!(" done: {} migrated, {} errors", kn_stats.migrated, kn_stats.errors); + + println!("Migrating backlog_items..."); + let bl_stats = migrate_backlog(&src, &db)?; + println!(" done: {} migrated, {} errors", bl_stats.migrated, bl_stats.errors); + + println!("Migrating execution_contexts..."); + let ctx_stats = migrate_contexts(&src, &db)?; + println!(" done: {} migrated, {} errors", ctx_stats.migrated, ctx_stats.errors); + + println!("Migrating artifacts (as tagged memory nodes)..."); + let art_stats = migrate_artifacts(&src, &db)?; + println!(" done: {} migrated, {} errors", art_stats.migrated, art_stats.errors); + + println!("Migrating config_entries (as tagged memory nodes)..."); + let cfg_stats = migrate_config(&src, &db)?; + println!(" done: {} migrated, {} errors", cfg_stats.migrated, cfg_stats.errors); + + let total_migrated = mem_stats.migrated + kn_stats.migrated + bl_stats.migrated + + ctx_stats.migrated + art_stats.migrated + cfg_stats.migrated; + let total_errors = mem_stats.errors + kn_stats.errors + bl_stats.errors + + ctx_stats.errors + art_stats.errors + cfg_stats.errors; + + println!(); + println!("=== Migration Report ==="); + println!(" memory_nodes : {:>4} records", mem_stats.migrated); + println!(" knowledge : {:>4} records", kn_stats.migrated); + println!(" backlog_items : {:>4} records", bl_stats.migrated); + println!(" execution_contexts : {:>4} records", ctx_stats.migrated); + println!(" artifacts : {:>4} records", art_stats.migrated); + println!(" config_entries : {:>4} records", cfg_stats.migrated); + println!(" ---"); + println!(" TOTAL MIGRATED : {:>4} records", total_migrated); + if total_errors > 0 { + println!(" TOTAL ERRORS : {:>4} records", total_errors); + } + println!(); + println!("Engram DB: {}", dst_path.display()); + println!("Source DB preserved: {}", src_path.display()); + + if total_errors > 0 { + eprintln!("WARNING: {} record(s) could not be migrated (see errors above).", total_errors); + } else { + println!("Migration completed successfully with zero errors."); + } + + Ok(()) +} diff --git a/crates/neuron-protocol/Cargo.toml b/crates/neuron-protocol/Cargo.toml index 4c296a9..edbdc36 100644 --- a/crates/neuron-protocol/Cargo.toml +++ b/crates/neuron-protocol/Cargo.toml @@ -2,18 +2,20 @@ name = "neuron-protocol" version = "0.1.0" edition = "2021" +description = "Thin re-export shim — delegates to the canonical axon-protocol and axon-events platform crates." [dependencies] +# Canonical platform crates — all real types live here +axon-protocol = { workspace = true } +axon-events = { workspace = true } + +# Retained for any neuron-rs–specific protocol extensions neuron-domain = { path = "../neuron-domain" } -neuron-store = { path = "../neuron-store" } -axon-events = { path = "../axon-events" } serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } tokio = { workspace = true } -axum = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } [dev-dependencies] -tempfile = "3" diff --git a/crates/neuron-protocol/src/axon.rs b/crates/neuron-protocol/src/axon.rs deleted file mode 100644 index d498f4d..0000000 --- a/crates/neuron-protocol/src/axon.rs +++ /dev/null @@ -1,266 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -// ------------------------------------------------------------------ -// AxonMethod -// ------------------------------------------------------------------ - -/// Typed method discriminant for Axon protocol messages. -/// -/// Replaces the raw `method: String` field — callers that need a string -/// representation can use [`AxonMethod::as_str`] or the `Display` impl. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AxonMethod { - /// Invoke a named tool. - ToolCall, - /// Return value from a tool invocation. - ToolResult, - /// Deliver a typed Axon event envelope. - Event, - /// Liveness check. - Ping, - /// Liveness reply. - Pong, - /// Subscribe to an event stream. - Subscribe, - /// Cancel a subscription. - Unsubscribe, -} - -impl AxonMethod { - /// Return the canonical lowercase string for this method. - pub fn as_str(self) -> &'static str { - match self { - Self::ToolCall => "tool_call", - Self::ToolResult => "tool_result", - Self::Event => "event", - Self::Ping => "ping", - Self::Pong => "pong", - Self::Subscribe => "subscribe", - Self::Unsubscribe => "unsubscribe", - } - } - - /// Parse a string into an `AxonMethod`, returning `None` on mismatch. - pub fn from_str(s: &str) -> Option { - match s { - "tool_call" => Some(Self::ToolCall), - "tool_result" => Some(Self::ToolResult), - "event" => Some(Self::Event), - "ping" => Some(Self::Ping), - "pong" => Some(Self::Pong), - "subscribe" => Some(Self::Subscribe), - "unsubscribe" => Some(Self::Unsubscribe), - _ => None, - } - } -} - -impl std::fmt::Display for AxonMethod { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -// ------------------------------------------------------------------ -// AxonMessage -// ------------------------------------------------------------------ - -/// An Axon protocol message — wraps a tool call, event delivery, or -/// other method invocation. -/// -/// The `method` field is now typed as [`AxonMethod`]. For backward -/// compatibility the raw string representation is available via -/// [`AxonMessage::method_str`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AxonMessage { - /// Unique message ID. - pub id: String, - /// Typed method discriminant. - pub method: AxonMethod, - /// Method-specific parameters. - pub params: Value, -} - -impl AxonMessage { - /// Return the canonical string representation of the method (e.g. - /// `"tool_call"`, `"ping"`). Useful when serialising to a transport - /// that expects a raw string rather than a tagged enum. - pub fn method_str(&self) -> &'static str { - self.method.as_str() - } - - /// Construct a ping message. - pub fn ping(id: impl Into) -> Self { - Self { - id: id.into(), - method: AxonMethod::Ping, - params: Value::Object(Default::default()), - } - } -} - -// ------------------------------------------------------------------ -// AxonResponse -// ------------------------------------------------------------------ - -/// An Axon protocol response. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AxonResponse { - /// Echo of the request ID. - pub id: String, - /// Whether the call succeeded. - pub success: bool, - /// The result payload (on success) or error message (on failure). - pub result: Value, -} - -impl AxonResponse { - pub fn ok(id: impl Into, result: Value) -> Self { - Self { - id: id.into(), - success: true, - result, - } - } - - pub fn err(id: impl Into, message: impl Into) -> Self { - Self { - id: id.into(), - success: false, - result: serde_json::json!({ "error": message.into() }), - } - } -} - -// ------------------------------------------------------------------ -// AxonEvent (legacy SSE event — kept for backward compat) -// ------------------------------------------------------------------ - -/// An Axon SSE event — sent over the streaming channel. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AxonEvent { - pub event_type: String, - pub payload: Value, - pub timestamp: i64, -} - -impl AxonEvent { - pub fn new(event_type: impl Into, payload: Value) -> Self { - Self { - event_type: event_type.into(), - payload, - timestamp: neuron_domain::now_ms(), - } - } -} - -// ------------------------------------------------------------------ -// Tests -// ------------------------------------------------------------------ - -#[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_with_method_enum() { - let msg = AxonMessage { - id: "m1".into(), - method: AxonMethod::ToolCall, - 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, AxonMethod::ToolCall); - assert_eq!(back.method_str(), "tool_call"); - } - - #[test] - fn axon_method_as_str_all_variants() { - let cases = [ - (AxonMethod::ToolCall, "tool_call"), - (AxonMethod::ToolResult, "tool_result"), - (AxonMethod::Event, "event"), - (AxonMethod::Ping, "ping"), - (AxonMethod::Pong, "pong"), - (AxonMethod::Subscribe, "subscribe"), - (AxonMethod::Unsubscribe, "unsubscribe"), - ]; - for (method, expected) in cases { - assert_eq!(method.as_str(), expected); - assert_eq!(method.to_string(), expected); - } - } - - #[test] - fn axon_method_from_str_roundtrip() { - let variants = [ - AxonMethod::ToolCall, - AxonMethod::ToolResult, - AxonMethod::Event, - AxonMethod::Ping, - AxonMethod::Pong, - AxonMethod::Subscribe, - AxonMethod::Unsubscribe, - ]; - for v in variants { - let s = v.as_str(); - assert_eq!(AxonMethod::from_str(s), Some(v)); - } - } - - #[test] - fn axon_method_from_str_unknown() { - assert_eq!(AxonMethod::from_str("mystery"), None); - assert_eq!(AxonMethod::from_str(""), None); - } - - #[test] - fn axon_method_serde_roundtrip() { - let m = AxonMethod::Subscribe; - let json = serde_json::to_string(&m).unwrap(); - let back: AxonMethod = serde_json::from_str(&json).unwrap(); - assert_eq!(back, AxonMethod::Subscribe); - } - - #[test] - fn axon_message_ping_helper() { - let msg = AxonMessage::ping("p-1"); - assert_eq!(msg.method, AxonMethod::Ping); - assert_eq!(msg.id, "p-1"); - } - - #[test] - fn axon_method_all_variants_have_unique_str() { - use std::collections::HashSet; - let strs: HashSet<&str> = [ - AxonMethod::ToolCall, - AxonMethod::ToolResult, - AxonMethod::Event, - AxonMethod::Ping, - AxonMethod::Pong, - AxonMethod::Subscribe, - AxonMethod::Unsubscribe, - ] - .iter() - .map(|m| m.as_str()) - .collect(); - assert_eq!(strs.len(), 7); - } -} diff --git a/crates/neuron-protocol/src/handler.rs b/crates/neuron-protocol/src/handler.rs deleted file mode 100644 index e68c837..0000000 --- a/crates/neuron-protocol/src/handler.rs +++ /dev/null @@ -1,146 +0,0 @@ -use crate::axon::{AxonMessage, AxonMethod, AxonResponse}; -use serde_json::Value; -use std::collections::HashMap; -use std::sync::Arc; - -type ToolFn = Arc Value + Send + Sync>; - -/// Routes Axon method names to handler functions. -/// -/// Each handler takes the `params` field of an AxonMessage and returns -/// a JSON Value to be wrapped in an AxonResponse. -pub struct AxonHandler { - tools: HashMap, -} - -impl AxonHandler { - pub fn new() -> Self { - Self { - tools: HashMap::new(), - } - } - - /// Register a tool handler by name. - pub fn register(&mut self, name: impl Into, f: F) - where - F: Fn(Value) -> Value + Send + Sync + 'static, - { - self.tools.insert(name.into(), Arc::new(f)); - } - - /// Dispatch an AxonMessage to the appropriate handler. - pub fn dispatch(&self, msg: AxonMessage) -> AxonResponse { - match msg.method { - AxonMethod::ToolCall => { - 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)) - } - } - AxonMethod::Ping => { - AxonResponse::ok(msg.id, serde_json::json!({ "pong": true })) - } - other => { - AxonResponse::err(msg.id, format!("unknown method: {}", other.as_str())) - } - } - } - - /// List all registered tool names. - pub fn tool_names(&self) -> Vec { - let mut names: Vec<_> = self.tools.keys().cloned().collect(); - names.sort(); - names - } -} - -impl Default for AxonHandler { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn dispatch_ping() { - let handler = AxonHandler::new(); - let msg = AxonMessage { - id: "1".into(), - method: AxonMethod::Ping, - 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: AxonMethod::ToolCall, - 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: AxonMethod::ToolCall, - params: serde_json::json!({ "tool": "nonexistent", "params": {} }), - }; - let resp = handler.dispatch(msg); - assert!(!resp.success); - } - - #[test] - fn dispatch_unhandled_method() { - let handler = AxonHandler::new(); - let msg = AxonMessage { - id: "4".into(), - method: AxonMethod::Subscribe, - params: serde_json::json!({}), - }; - let resp = handler.dispatch(msg); - assert!(!resp.success); - assert!(resp.result["error"].as_str().unwrap().contains("unknown method")); - } - - #[test] - fn tool_names_sorted() { - let mut handler = AxonHandler::new(); - handler.register("remember", |_| Value::Null); - handler.register("recall", |_| Value::Null); - handler.register("plan_work", |_| Value::Null); - let names = handler.tool_names(); - let mut expected = vec!["plan_work", "recall", "remember"]; - expected.sort(); - assert_eq!(names, expected); - } -} diff --git a/crates/neuron-protocol/src/lib.rs b/crates/neuron-protocol/src/lib.rs index 0ec4efa..0a61f52 100644 --- a/crates/neuron-protocol/src/lib.rs +++ b/crates/neuron-protocol/src/lib.rs @@ -1,8 +1,20 @@ -pub mod axon; -pub mod handler; -pub mod sse; -pub mod tools; +//! `neuron-protocol` — re-export shim for the canonical Axon platform crates. +//! +//! All types now live in: +//! - `axon-protocol` (`platform/protocols/axon/crates/axon-protocol`) +//! - `axon-events` (`platform/protocols/axon/crates/axon-events`) +//! +//! This crate re-exports everything so existing neuron-rs crates that import +//! `neuron_protocol::*` continue to compile without changes. -pub use axon::{AxonEvent, AxonMessage, AxonMethod, AxonResponse}; -pub use handler::AxonHandler; -pub use tools::ProjectType; +// Re-export wire-protocol types +pub use axon_protocol::handler::AxonHandler; +pub use axon_protocol::message::{AxonEvent, AxonMessage, AxonMethod, AxonResponse}; +pub use axon_protocol::sse::SseChannel; +pub use axon_protocol::tools::{self, ProjectType, ALL_TOOLS}; + +// Re-export event schema +pub use axon_events::{ + AxonEnvelope, AxonError, AxonRouter, AxonSubscription, EventId, ProjectContext, Severity, + Timestamp, +}; diff --git a/crates/neuron-protocol/src/sse.rs b/crates/neuron-protocol/src/sse.rs deleted file mode 100644 index cdfe16e..0000000 --- a/crates/neuron-protocol/src/sse.rs +++ /dev/null @@ -1,47 +0,0 @@ -use crate::axon::AxonEvent; -use tokio::sync::broadcast; - -/// SSE channel — broadcasts AxonEvents to all connected SSE clients. -#[derive(Clone)] -pub struct SseChannel { - sender: broadcast::Sender, -} - -impl SseChannel { - pub fn new(capacity: usize) -> Self { - let (sender, _) = broadcast::channel(capacity); - Self { sender } - } - - /// Send an event to all subscribers. - pub fn send(&self, event: &AxonEvent) -> Result<(), String> { - let json = serde_json::to_string(event).map_err(|e| e.to_string())?; - // Ignore errors when no receivers are connected - let _ = self.sender.send(json); - Ok(()) - } - - /// Subscribe to the SSE channel. - pub fn subscribe(&self) -> broadcast::Receiver { - self.sender.subscribe() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn sse_channel_broadcast() { - let ch = SseChannel::new(16); - let mut rx = ch.subscribe(); - - let event = AxonEvent::new("test", serde_json::json!({ "msg": "hello" })); - ch.send(&event).unwrap(); - - let received = rx.recv().await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&received).unwrap(); - assert_eq!(parsed["event_type"], "test"); - assert_eq!(parsed["payload"]["msg"], "hello"); - } -} diff --git a/crates/neuron-protocol/src/tools.rs b/crates/neuron-protocol/src/tools.rs deleted file mode 100644 index 87f303f..0000000 --- a/crates/neuron-protocol/src/tools.rs +++ /dev/null @@ -1,219 +0,0 @@ -/// Tool name constants — must match the existing MCP tool names so services -/// can switch over without configuration changes. - -// ------------------------------------------------------------------ -// Memory tools -// ------------------------------------------------------------------ -pub const REMEMBER: &str = "remember"; -pub const RECALL: &str = "recall"; -pub const SEARCH_ENTITIES: &str = "search_entities"; -pub const INSPECT_MEMORIES: &str = "inspect_memories"; -pub const EVOLVE_MEMORY: &str = "evolve_memory"; -pub const FORGET: &str = "forget"; -pub const PIN_NODE: &str = "pin_node"; -pub const INSPECT_GRAPH: &str = "inspect_graph"; -pub const SEARCH_GRAPH: &str = "search_graph"; -pub const TRAVERSE_GRAPH: &str = "traverse_graph"; -pub const REBUILD_GRAPH: &str = "rebuild_graph"; -pub const LINK_ENTITIES: &str = "link_entities"; -pub const LINK_CAUSAL: &str = "link_causal"; - -// ------------------------------------------------------------------ -// Knowledge tools -// ------------------------------------------------------------------ -pub const CAPTURE_KNOWLEDGE: &str = "capture_knowledge"; -pub const SEARCH_KNOWLEDGE: &str = "search_knowledge"; -pub const RETRIEVE_KNOWLEDGE: &str = "retrieve_knowledge"; -pub const BROWSE_KNOWLEDGE: &str = "browse_knowledge"; -pub const EVOLVE_KNOWLEDGE: &str = "evolve_knowledge"; -pub const REMOVE_KNOWLEDGE: &str = "remove_knowledge"; - -// ------------------------------------------------------------------ -// Backlog / work tools -// ------------------------------------------------------------------ -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 LIST_WORK: &str = "list_work"; - -// ------------------------------------------------------------------ -// Session / context tools -// ------------------------------------------------------------------ -pub const BEGIN_SESSION: &str = "begin_session"; -pub const COMPILE_CTX: &str = "compile_ctx"; -pub const CONSOLIDATE: &str = "consolidate"; -pub const PROJECT_CONTEXT: &str = "project_context"; - -// ------------------------------------------------------------------ -// Artifact tools -// ------------------------------------------------------------------ -pub const DRAFT_ARTIFACT: &str = "draft_artifact"; -pub const FIND_ARTIFACTS: &str = "find_artifacts"; -pub const RETRIEVE_ARTIFACT: &str = "retrieve_artifact"; -pub const REVISE_ARTIFACT: &str = "revise_artifact"; -pub const MANAGE_ARTIFACT: &str = "manage_artifact"; - -// ------------------------------------------------------------------ -// Process tools -// ------------------------------------------------------------------ -pub const BROWSE_PROCESSES: &str = "browse_processes"; -pub const DEFINE_PROCESS: &str = "define_process"; -pub const RETRIEVE_PROCESS: &str = "retrieve_process"; -pub const EXECUTE_PROCESS: &str = "execute_process"; -pub const EXPORT_PROCESS: &str = "export_process"; -pub const DELETE_PROCESS: &str = "delete_process"; -pub const LIST_PROCESSES: &str = "list_processes"; -pub const CATALOG_ROUTES: &str = "catalog_routes"; -pub const REGISTER_ROUTE: &str = "register_route"; - -// ------------------------------------------------------------------ -// Event / ISE tools -// ------------------------------------------------------------------ -pub const LOG_INTERNAL_STATE_EVENT: &str = "log_internal_state_event"; -pub const LIST_INTERNAL_STATE_EVENTS: &str = "list_internal_state_events"; -pub const GET_INTERNAL_STATE_EVENT: &str = "get_internal_state_event"; -pub const CHECK_EVENTS: &str = "check_events"; -pub const ACKNOWLEDGE_EVENT: &str = "acknowledge_event"; -pub const INSPECT_EVENT: &str = "inspect_event"; -pub const PROCESS_EVENTS: &str = "process_events"; -pub const SEND_NOTIFICATION: &str = "send_notification"; - -// ------------------------------------------------------------------ -// Config tools -// ------------------------------------------------------------------ -pub const INSPECT_CONFIG: &str = "inspect_config"; -pub const TUNE_CONFIG: &str = "tune_config"; -pub const GET_INSTRUCTIONS: &str = "get_instructions"; - -// ------------------------------------------------------------------ -// All tools in canonical alphabetical order -// ------------------------------------------------------------------ -pub const ALL_TOOLS: &[&str] = &[ - ACKNOWLEDGE_EVENT, - BEGIN_SESSION, - BEGIN_WORK, - BROWSE_KNOWLEDGE, - BROWSE_PROCESSES, - CAPTURE_KNOWLEDGE, - CATALOG_ROUTES, - CHECK_EVENTS, - CHECK_WORK, - COMPILE_CTX, - CONSOLIDATE, - DEFINE_PROCESS, - DELETE_PROCESS, - DRAFT_ARTIFACT, - EVOLVE_KNOWLEDGE, - EVOLVE_MEMORY, - EXECUTE_PROCESS, - EXPORT_PROCESS, - FIND_ARTIFACTS, - FORGET, - GET_INSTRUCTIONS, - GET_INTERNAL_STATE_EVENT, - INSPECT_CONFIG, - INSPECT_EVENT, - INSPECT_GRAPH, - INSPECT_MEMORIES, - LINK_CAUSAL, - LINK_ENTITIES, - LIST_INTERNAL_STATE_EVENTS, - LIST_PROCESSES, - LIST_WORK, - LOG_INTERNAL_STATE_EVENT, - MANAGE_ARTIFACT, - PIN_NODE, - PLAN_WORK, - PROCESS_EVENTS, - PROGRESS_WORK, - PROJECT_CONTEXT, - REBUILD_GRAPH, - RECALL, - REGISTER_ROUTE, - REMEMBER, - REMOVE_KNOWLEDGE, - RETRIEVE_ARTIFACT, - RETRIEVE_KNOWLEDGE, - RETRIEVE_PROCESS, - REVIEW_BACKLOG, - REVISE_ARTIFACT, - SEARCH_ENTITIES, - SEARCH_GRAPH, - SEARCH_KNOWLEDGE, - SEND_NOTIFICATION, - TRACK_WORK, - TRAVERSE_GRAPH, - TUNE_CONFIG, -]; - -// Re-export ProjectType from axon-events for convenient protocol-level access. -pub use axon_events::ProjectType; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn all_tools_count() { - // Ensure the list has grown to cover the full Neuron MCP surface. - assert!(ALL_TOOLS.len() >= 50, "expected 50+ tools, got {}", ALL_TOOLS.len()); - } - - #[test] - fn tool_names_are_non_empty() { - for name in ALL_TOOLS { - assert!(!name.is_empty(), "tool name must not be empty"); - } - } - - #[test] - fn all_tools_are_unique() { - use std::collections::HashSet; - let set: HashSet<&&str> = ALL_TOOLS.iter().collect(); - assert_eq!(set.len(), ALL_TOOLS.len(), "duplicate tool name detected"); - } - - #[test] - fn all_tools_are_sorted() { - let mut sorted = ALL_TOOLS.to_vec(); - sorted.sort_unstable(); - assert_eq!(ALL_TOOLS, sorted.as_slice(), "ALL_TOOLS must be in alphabetical order"); - } - - #[test] - fn original_14_tools_still_present() { - let original = [ - 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, - ]; - for name in original { - assert!( - ALL_TOOLS.contains(&name), - "original tool '{}' missing from ALL_TOOLS", - name - ); - } - } - - #[test] - fn project_type_importable_from_tools() { - // Ensure the re-export compiles and is usable. - let _ = ProjectType::NeuronRs; - let _ = ProjectType::EngramLang; - } -} diff --git a/crates/neuron-runtime/Cargo.toml b/crates/neuron-runtime/Cargo.toml new file mode 100644 index 0000000..56f6dc5 --- /dev/null +++ b/crates/neuron-runtime/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "neuron-runtime" +version = "0.1.0" +edition = "2021" + +[dependencies] +# el crates (path deps) +el-compiler = { path = "../../../../foundation/el/engrams/el-compiler" } + +# engram core (for EngramDb type) +engram-core = { path = "../../../../foundation/engram/engrams/engram-core" } + +# neuron-rs workspace crates +neuron-domain = { path = "../neuron-domain" } +neuron-store = { path = "../neuron-store" } +axon-events = { workspace = true } + +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +ureq = { version = "2", features = ["json"] } +crossterm = "0.28" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/neuron-runtime/src/bindings.rs b/crates/neuron-runtime/src/bindings.rs new file mode 100644 index 0000000..cb41c80 --- /dev/null +++ b/crates/neuron-runtime/src/bindings.rs @@ -0,0 +1,1618 @@ +//! Native function bindings for the el VM. +//! +//! Each `native_*` binding is registered by name. When the VM encounters a +//! `Call { name: "native_foo", arity }` instruction it pops `arity` args off +//! the stack, marshals them to JSON, calls the native, and pushes the JSON +//! result back (converted to a VM `Value`). + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; + +use chrono::Utc; +use neuron_domain::{ + BacklogItem, BacklogStatus, ExecutionContext, Importance, InternalStateEvent, + ItemType, KnowledgeEntry, KnowledgeTier, Memory, Priority, +}; +use neuron_store::{BacklogStore, ContextStore, IseStore, KnowledgeStore, MemoryStore}; +use serde_json::Value; +use tracing::warn; +use uuid::Uuid; + +/// A native function: takes JSON params, returns JSON. +pub type NativeFn = Box Value + Send + Sync>; + +// ── QueueStore ──────────────────────────────────────────────────────────────── +// +// In-memory fan-out message queue. +// +// Structure: topic → consumer → VecDeque<(msg_id, payload)> +// +// The special consumer key "_pending" holds messages published before any +// real consumer has registered for the topic. On first consume for a given +// (topic, consumer) pair the pending buffer is drained into that consumer's +// queue so they receive all pre-registration messages. +// +// Backend is swappable — the adapter trait layer can be added later without +// touching el call sites. + +type QueueStore = Arc>>>>; + +// ── NativeBindings ──────────────────────────────────────────────────────────── + +pub struct NativeBindings { + fns: HashMap, + #[allow(dead_code)] + queue: QueueStore, +} + +impl NativeBindings { + /// Build all native bindings wired to the provided stores. + pub fn new( + memory: Arc, + knowledge: Arc, + backlog: Arc, + context: Arc, + ise: Arc, + ) -> Self { + let mut fns: HashMap = HashMap::new(); + let queue: QueueStore = Arc::new(Mutex::new(HashMap::new())); + + // ── Utility ─────────────────────────────────────────────────────────── + + fns.insert( + "native_uuid".into(), + Box::new(|_| Value::String(Uuid::new_v4().to_string())), + ); + + fns.insert( + "native_now".into(), + Box::new(|_| Value::String(Utc::now().to_rfc3339())), + ); + + // ── Memory ──────────────────────────────────────────────────────────── + + let store = memory.clone(); + fns.insert( + "native_store_memory".into(), + Box::new(move |params| { + let content = match params.get("content").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing content"}), + }; + let tags: Vec = params + .get("tags") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + let project = params + .get("project") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let importance = params + .get("importance") + .and_then(|v| v.as_str()) + .map(Importance::from_str_lossy) + .unwrap_or_default(); + let supersedes_id = params + .get("supersedes_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + + let mem = Memory::new(content, tags, project, importance, supersedes_id); + match store.put(&mem) { + Ok(id) => serde_json::json!({"ok": id.to_string()}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = memory.clone(); + fns.insert( + "native_get_memory".into(), + Box::new(move |params| { + let id_str = match params.get("id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing id"}), + }; + let id = match Uuid::parse_str(&id_str) { + Ok(id) => id, + Err(_) => return serde_json::json!({"err": "invalid UUID"}), + }; + match store.get(id) { + Ok(m) => serde_json::to_value(&m).unwrap_or(serde_json::json!({"err": "serialize error"})), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = memory.clone(); + fns.insert( + "native_list_memories".into(), + Box::new(move |_| match store.list() { + Ok(mems) => serde_json::to_value(&mems).unwrap_or(serde_json::json!([])), + Err(e) => serde_json::json!({"err": e.to_string()}), + }), + ); + + let store = memory.clone(); + fns.insert( + "native_search_memories".into(), + Box::new(move |params| { + let query = params + .get("query") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(10) as usize; + match store.search(&query, limit) { + Ok(results) => serde_json::to_value(&results) + .unwrap_or(serde_json::json!([])), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = memory.clone(); + fns.insert( + "native_delete_memory".into(), + Box::new(move |params| { + let id_str = match params.get("id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing id"}), + }; + let id = match Uuid::parse_str(&id_str) { + Ok(id) => id, + Err(_) => return serde_json::json!({"err": "invalid UUID"}), + }; + match store.delete(id) { + Ok(()) => serde_json::json!({"ok": true}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + // ── Knowledge ───────────────────────────────────────────────────────── + + let store = knowledge.clone(); + fns.insert( + "native_store_knowledge".into(), + Box::new(move |params| { + let title = match params.get("title").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing title"}), + }; + let content = params + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let category = params + .get("category") + .and_then(|v| v.as_str()) + .unwrap_or("general") + .to_string(); + let tier = params + .get("tier") + .and_then(|v| v.as_str()) + .map(KnowledgeTier::from_str_lossy) + .unwrap_or_default(); + let tags: Vec = params + .get("tags") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + let project = params + .get("project") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let entry = KnowledgeEntry::new(title, content, category, tier, tags, project); + match store.put(&entry) { + Ok(id) => serde_json::json!({"ok": id.to_string()}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = knowledge.clone(); + fns.insert( + "native_get_knowledge".into(), + Box::new(move |params| { + let id_str = match params.get("id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing id"}), + }; + let id = match Uuid::parse_str(&id_str) { + Ok(id) => id, + Err(_) => return serde_json::json!({"err": "invalid UUID"}), + }; + match store.get(id) { + Ok(e) => serde_json::to_value(&e).unwrap_or(serde_json::json!({"err": "serialize error"})), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = knowledge.clone(); + fns.insert( + "native_list_knowledge".into(), + Box::new(move |_| match store.list() { + Ok(entries) => serde_json::to_value(&entries).unwrap_or(serde_json::json!([])), + Err(e) => serde_json::json!({"err": e.to_string()}), + }), + ); + + let store = knowledge.clone(); + fns.insert( + "native_search_knowledge".into(), + Box::new(move |params| { + let query = params + .get("query") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(10) as usize; + match store.search(&query, limit) { + Ok(results) => serde_json::to_value(&results) + .unwrap_or(serde_json::json!([])), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = knowledge.clone(); + fns.insert( + "native_delete_knowledge".into(), + Box::new(move |params| { + let id_str = match params.get("id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing id"}), + }; + let id = match Uuid::parse_str(&id_str) { + Ok(id) => id, + Err(_) => return serde_json::json!({"err": "invalid UUID"}), + }; + match store.delete(id) { + Ok(()) => serde_json::json!({"ok": true}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + // ── Backlog ─────────────────────────────────────────────────────────── + + let store = backlog.clone(); + fns.insert( + "native_create_backlog_item".into(), + Box::new(move |params| { + let title = match params.get("title").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing title"}), + }; + let description = params + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let item_type = params + .get("item_type") + .and_then(|v| v.as_str()) + .map(ItemType::from_str_lossy) + .unwrap_or_default(); + let priority = params + .get("priority") + .and_then(|v| v.as_str()) + .map(Priority::from_str_lossy) + .unwrap_or_default(); + let project = params + .get("project") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let tags: Vec = params + .get("tags") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + + let item = BacklogItem::new(title, description, item_type, priority, project, tags, vec![]); + match store.put(&item) { + Ok(id) => serde_json::json!({"ok": id.to_string(), "id": format!("bl-{}", &id.to_string()[..8])}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = backlog.clone(); + fns.insert( + "native_list_backlog".into(), + Box::new(move |params| { + let status = params.get("status").and_then(|v| v.as_str()).map(|s| s.to_string()); + let priority = params.get("priority").and_then(|v| v.as_str()).map(|s| s.to_string()); + let project = params.get("project").and_then(|v| v.as_str()).map(|s| s.to_string()); + match store.list_filtered(status.as_deref(), priority.as_deref(), project.as_deref()) { + Ok(items) => serde_json::to_value(&items).unwrap_or(serde_json::json!([])), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = backlog.clone(); + fns.insert( + "native_get_backlog_item".into(), + Box::new(move |params| { + let id_str = match params.get("id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing id"}), + }; + let id = match Uuid::parse_str(&id_str) { + Ok(id) => id, + Err(_) => return serde_json::json!({"err": "invalid UUID"}), + }; + match store.get(id) { + Ok(item) => serde_json::to_value(&item).unwrap_or(serde_json::json!({"err": "serialize error"})), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = backlog.clone(); + fns.insert( + "native_update_backlog_status".into(), + Box::new(move |params| { + let id_str = match params.get("id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing id"}), + }; + let id = match Uuid::parse_str(&id_str) { + Ok(id) => id, + Err(_) => return serde_json::json!({"err": "invalid UUID"}), + }; + let new_status = params + .get("status") + .and_then(|v| v.as_str()) + .map(BacklogStatus::from_str_lossy) + .unwrap_or_default(); + + match store.get(id) { + Err(e) => serde_json::json!({"err": e.to_string()}), + Ok(mut item) => { + item.status = new_status; + match store.update(&item) { + Ok(()) => serde_json::json!({"ok": true}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + } + } + }), + ); + + // ── Context ─────────────────────────────────────────────────────────── + + let store = context.clone(); + fns.insert( + "native_create_context".into(), + Box::new(move |params| { + let process_name = params + .get("process_name") + .and_then(|v| v.as_str()) + .unwrap_or("unnamed") + .to_string(); + let description = params + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let objective = params + .get("objective") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let project = params + .get("project") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let ctx = ExecutionContext::new(process_name, description, objective, project); + match store.put(&ctx) { + Ok(id) => serde_json::json!({"ok": true, "context_id": format!("ctx-{}", &id.to_string()[..8])}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = context.clone(); + fns.insert( + "native_list_contexts".into(), + Box::new(move |_| match store.list() { + Ok(contexts) => serde_json::to_value(&contexts).unwrap_or(serde_json::json!([])), + Err(e) => serde_json::json!({"err": e.to_string()}), + }), + ); + + // ── ISE ─────────────────────────────────────────────────────────────── + + let store = ise.clone(); + fns.insert( + "native_list_ise".into(), + Box::new(move |_| match store.list() { + Ok(events) => serde_json::to_value(&events).unwrap_or(serde_json::json!([])), + Err(e) => serde_json::json!({"err": e.to_string()}), + }), + ); + + let store = ise.clone(); + fns.insert( + "native_get_ise".into(), + Box::new(move |params| { + let id = match params.get("id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing id"}), + }; + match store.get(&id) { + Ok(ise) => serde_json::to_value(&ise).unwrap_or(serde_json::json!({"err": "serialize error"})), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + let store = ise.clone(); + fns.insert( + "native_store_ise".into(), + Box::new(move |params| { + let trigger = match params.get("trigger").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing trigger"}), + }; + let pre = params.get("pre_reasoning_response").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let post = params.get("post_reasoning_response").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let compression_ratio = params.get("compression_ratio").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32; + let gap_direction = params + .get("gap_direction") + .and_then(|v| v.as_str()) + .map(neuron_domain::GapDirection::from_str_lossy) + .unwrap_or_default(); + let tags: Vec = params + .get("tags") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + + let ise_event = InternalStateEvent::new(trigger, pre, post, compression_ratio, gap_direction, tags); + match store.put(&ise_event) { + Ok(_) => serde_json::json!({"ok": true, "id": ise_event.id}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + // ── Emit ───────────────────────────────────────────────────────────── + + fns.insert( + "native_emit".into(), + Box::new(|params| { + let event = params + .get("event") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let data = params + .get("data") + .cloned() + .unwrap_or(serde_json::json!({})); + tracing::debug!(event = %event, data = %data, "axon event emitted"); + serde_json::json!({"ok": true}) + }), + ); + + // ── Sleep ───────────────────────────────────────────────────────────── + // + // Blocking sleep — intended for use on background threads (on_startup loops). + // Do NOT call from the main request-handling path. + + fns.insert( + "native_sleep".into(), + Box::new(|params| { + let ms = params + .get("ms") + .and_then(|v| v.as_u64()) + .unwrap_or(1000); + std::thread::sleep(std::time::Duration::from_millis(ms)); + serde_json::json!({"ok": true}) + }), + ); + + // ── HTTP ────────────────────────────────────────────────────────────── + // + // Generic HTTP primitives. The agent.el layer builds Anthropic / OpenAI + // requests directly rather than relying on native_chat's wired logic. + // + // native_http_post(url, headers, body) -> {ok, status, body} | {err} + // native_http_get(url, headers) -> {ok, status, body} | {err} + + fns.insert( + "native_http_post".into(), + Box::new(|params| { + let url = match params.get("url").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing url"}), + }; + let headers = params.get("headers").cloned().unwrap_or(serde_json::json!({})); + let body = params.get("body").cloned().unwrap_or(serde_json::json!({})); + + let mut req = ureq::post(&url).set("Content-Type", "application/json"); + if let Some(obj) = headers.as_object() { + for (k, v) in obj { + if let Some(v_str) = v.as_str() { + req = req.set(k, v_str); + } + } + } + + match req.send_json(&body) { + Ok(response) => { + let status = response.status(); + match response.into_json::() { + Ok(json) => serde_json::json!({"ok": true, "status": status, "body": json}), + Err(e) => serde_json::json!({"err": format!("parse error: {}", e), "status": status}), + } + } + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + fns.insert( + "native_http_get".into(), + Box::new(|params| { + let url = match params.get("url").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing url"}), + }; + let headers = params.get("headers").cloned().unwrap_or(serde_json::json!({})); + + let mut req = ureq::get(&url); + if let Some(obj) = headers.as_object() { + for (k, v) in obj { + if let Some(v_str) = v.as_str() { + req = req.set(k, v_str); + } + } + } + + match req.call() { + Ok(response) => { + let status = response.status(); + match response.into_json::() { + Ok(json) => serde_json::json!({"ok": true, "status": status, "body": json}), + Err(e) => serde_json::json!({"err": format!("parse error: {}", e), "status": status}), + } + } + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + // ── Shell exec ──────────────────────────────────────────────────────── + + fns.insert( + "native_shell_exec".into(), + Box::new(|params| { + let cmd = match params.get("cmd").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "missing cmd"}), + }; + match std::process::Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + { + Ok(out) => serde_json::json!({ + "stdout": String::from_utf8_lossy(&out.stdout).to_string(), + "stderr": String::from_utf8_lossy(&out.stderr).to_string(), + "exit_code": out.status.code().unwrap_or(-1), + }), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + // ── Activate (semantic search) ──────────────────────────────────────── + + let mem_store = memory.clone(); + let know_store = knowledge.clone(); + fns.insert( + "native_activate".into(), + Box::new(move |params| { + let type_name = params + .get("type_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let query = params + .get("query") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + match type_name.to_lowercase().as_str() { + "memory" => match mem_store.search(&query, 5) { + Ok(mems) => serde_json::to_value(&mems).unwrap_or(serde_json::json!([])), + Err(e) => serde_json::json!({"err": e.to_string()}), + }, + "knowledge" | "knowledgeentry" => match know_store.search(&query, 5) { + Ok(entries) => serde_json::to_value(&entries).unwrap_or(serde_json::json!([])), + Err(e) => serde_json::json!({"err": e.to_string()}), + }, + _ => { + warn!("native_activate: unknown type_name '{}'", type_name); + serde_json::json!([]) + } + } + }), + ); + + // ── TTY ─────────────────────────────────────────────────────────────── + // + // Thin crossterm wrappers. All terminal intelligence lives in el; + // Rust merely exposes the OS primitives. + // + // native_tty_enter_raw() — enter raw mode + // native_tty_exit_raw() — restore cooked mode + // native_tty_write(s: String) — write ANSI bytes to stdout + // native_tty_read() -> String — block for one key event + // native_tty_size() -> String — "cols,rows" + // native_tty_clear() — clear screen + move to (0,0) + // native_tty_move_cursor(x: Int, y: Int)— move cursor (0-indexed) + + fns.insert( + "native_tty_enter_raw".into(), + Box::new(|_| { + use crossterm::terminal; + match terminal::enable_raw_mode() { + Ok(()) => serde_json::json!({"ok": true}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + fns.insert( + "native_tty_exit_raw".into(), + Box::new(|_| { + use crossterm::terminal; + match terminal::disable_raw_mode() { + Ok(()) => serde_json::json!({"ok": true}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + fns.insert( + "native_tty_write".into(), + Box::new(|params| { + use std::io::Write; + // params is the string value directly (single-arg calling convention) + let s = match params.as_str() { + Some(s) => s.to_string(), + None => return serde_json::json!({"err": "expected string"}), + }; + let mut stdout = std::io::stdout(); + match stdout.write_all(s.as_bytes()).and_then(|_| stdout.flush()) { + Ok(()) => serde_json::json!({"ok": true}), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + fns.insert( + "native_tty_read".into(), + Box::new(|_| { + use crossterm::event::{self, Event, KeyCode, KeyModifiers}; + match event::read() { + Err(e) => serde_json::json!({"err": e.to_string()}), + Ok(Event::Key(key_event)) => { + let key_str = match (key_event.modifiers, key_event.code) { + (KeyModifiers::CONTROL, KeyCode::Char(c)) => { + format!("ctrl-{}", c) + } + (_, KeyCode::Char(c)) => c.to_string(), + (_, KeyCode::Up) => "up".to_string(), + (_, KeyCode::Down) => "down".to_string(), + (_, KeyCode::Left) => "left".to_string(), + (_, KeyCode::Right) => "right".to_string(), + (_, KeyCode::Enter) => "enter".to_string(), + (_, KeyCode::Backspace) => "backspace".to_string(), + (_, KeyCode::Delete) => "delete".to_string(), + (_, KeyCode::Esc) => "esc".to_string(), + (_, KeyCode::Tab) => "tab".to_string(), + (_, KeyCode::Home) => "home".to_string(), + (_, KeyCode::End) => "end".to_string(), + (_, KeyCode::PageUp) => "pageup".to_string(), + (_, KeyCode::PageDown) => "pagedown".to_string(), + (_, KeyCode::F(n)) => format!("f{}", n), + (_, KeyCode::Null) => "null".to_string(), + (_, KeyCode::Insert) => "insert".to_string(), + _ => "unknown".to_string(), + }; + Value::String(key_str) + } + Ok(_) => { + // Non-key events (resize, mouse, etc.) — skip and return empty + Value::String(String::new()) + } + } + }), + ); + + fns.insert( + "native_tty_size".into(), + Box::new(|_| { + use crossterm::terminal; + match terminal::size() { + Ok((cols, rows)) => Value::String(format!("{},{}", cols, rows)), + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + fns.insert( + "native_tty_clear".into(), + Box::new(|_| { + use std::io::Write; + use crossterm::ExecutableCommand; + use crossterm::terminal::Clear; + use crossterm::terminal::ClearType; + use crossterm::cursor::MoveTo; + let mut stdout = std::io::stdout(); + let result = stdout + .execute(Clear(ClearType::All)) + .and_then(|s| s.execute(MoveTo(0, 0))); + match result { + Ok(_) => { + let _ = stdout.flush(); + serde_json::json!({"ok": true}) + } + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + fns.insert( + "native_tty_move_cursor".into(), + Box::new(|params| { + use std::io::Write; + use crossterm::ExecutableCommand; + use crossterm::cursor::MoveTo; + // Two-arg calling convention: params is a JSON array [x, y] + let x = params + .get(0) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u16; + let y = params + .get(1) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u16; + let mut stdout = std::io::stdout(); + match stdout.execute(MoveTo(x, y)) { + Ok(_) => { + let _ = stdout.flush(); + serde_json::json!({"ok": true}) + } + Err(e) => serde_json::json!({"err": e.to_string()}), + } + }), + ); + + // ── Queue ───────────────────────────────────────────────────────────── + // + // In-memory fan-out message queue primitives. Backend is in-memory now; + // Kafka/RabbitMQ adapters can be swapped in later without touching el. + // + // native_queue_publish(topic: String, payload: String) + // Fan-out payload to all registered consumers. If no consumer has ever + // called queue_consume for this topic the message goes into a "_pending" + // buffer and is delivered on first consume. + // + // native_queue_consume(topic: String, consumer: String) -> String + // Dequeue the oldest unacknowledged message for (topic, consumer). + // Returns "msg_id:payload" or "" if nothing is waiting. + // Implicitly registers the consumer and drains any pending buffer. + // + // native_queue_ack(topic: String, consumer: String, msg_id: String) + // Remove the acknowledged message from the consumer's queue. + // + // Two-arg calls use params as Value::Array([a, b]). + // Three-arg calls use params as Value::Array([a, b, c]). + + { + let q = queue.clone(); + fns.insert( + "native_queue_publish".into(), + Box::new(move |params| { + let topic = match params.get(0).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let payload = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + + let msg_id = Uuid::new_v4().to_string(); + + let mut store = q.lock().unwrap(); + let topic_map = store.entry(topic).or_insert_with(HashMap::new); + + // Collect existing consumer keys (exclude the pending buffer itself). + let consumers: Vec = topic_map + .keys() + .filter(|k| k.as_str() != "_pending") + .cloned() + .collect(); + + if consumers.is_empty() { + // No registered consumers yet — buffer in "_pending". + topic_map + .entry("_pending".to_string()) + .or_insert_with(VecDeque::new) + .push_back((msg_id, payload)); + } else { + // Fan-out to every registered consumer. + for consumer in consumers { + topic_map + .entry(consumer) + .or_insert_with(VecDeque::new) + .push_back((msg_id.clone(), payload.clone())); + } + } + + Value::String("".to_string()) + }), + ); + } + + { + let q = queue.clone(); + fns.insert( + "native_queue_consume".into(), + Box::new(move |params| { + let topic = match params.get(0).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let consumer = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + + let mut store = q.lock().unwrap(); + let topic_map = store.entry(topic).or_insert_with(HashMap::new); + + // First time this consumer calls consume: drain the pending buffer. + if !topic_map.contains_key(&consumer) { + let pending: VecDeque<(String, String)> = topic_map + .remove("_pending") + .unwrap_or_default(); + topic_map.insert(consumer.clone(), pending); + } + + match topic_map.get_mut(&consumer).and_then(|q| q.front().cloned()) { + Some((msg_id, payload)) => { + Value::String(format!("{}:{}", msg_id, payload)) + } + None => Value::String("".to_string()), + } + }), + ); + } + + { + let q = queue.clone(); + fns.insert( + "native_queue_ack".into(), + Box::new(move |params| { + let topic = match params.get(0).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let consumer = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let msg_id = match params.get(2).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + + let mut store = q.lock().unwrap(); + if let Some(topic_map) = store.get_mut(&topic) { + if let Some(queue) = topic_map.get_mut(&consumer) { + // Remove the front entry only if it matches the ack'd msg_id. + // This guards against double-ack or out-of-order ack. + if queue.front().map(|(id, _)| id.as_str()) == Some(&msg_id) { + queue.pop_front(); + } + } + } + + Value::String("".to_string()) + }), + ); + } + + // ── Chat / Inference ────────────────────────────────────────────────── + // + // Supports both Anthropic Messages API and OpenAI-compatible endpoints. + // Auto-detects provider from URL. + // + // Config via env: + // NEURON_INFERENCE_URL — defaults to Anthropic Messages API + // NEURON_INFERENCE_KEY — API key + // NEURON_MODEL — defaults to claude-sonnet-4-5 + + fns.insert( + "native_chat".into(), + Box::new(move |params| { + let inference_url = std::env::var("NEURON_INFERENCE_URL") + .unwrap_or_else(|_| { + "https://api.anthropic.com/v1/messages".to_string() + }); + + let api_key = std::env::var("NEURON_INFERENCE_KEY") + .or_else(|_| std::env::var("ANTHROPIC_API_KEY")) + .unwrap_or_else(|_| "".to_string()); + + // model: explicit param overrides env default + let model = params + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + std::env::var("NEURON_MODEL") + .unwrap_or_else(|_| "claude-sonnet-4-5".to_string()) + }); + + // system: optional system prompt + let system = params + .get("system") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if api_key.is_empty() { + return serde_json::json!({"error": "no inference key configured — set NEURON_INFERENCE_KEY or ANTHROPIC_API_KEY"}); + } + + let is_anthropic = inference_url.contains("anthropic.com"); + + // Build messages array: prefer explicit `messages`, fall back to `message`. + let messages: serde_json::Value = if let Some(arr) = params + .get("messages") + .and_then(|v| v.as_array()) + { + serde_json::Value::Array(arr.clone()) + } else { + let message = params + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if message.is_empty() { + return serde_json::json!({"error": "missing message or messages"}); + } + serde_json::json!([{"role": "user", "content": message}]) + }; + + let body = if is_anthropic { + // Anthropic API: system is a top-level field + let mut b = serde_json::json!({ + "model": model, + "messages": messages, + "max_tokens": 4096 + }); + if let Some(sys) = &system { + b["system"] = serde_json::Value::String(sys.clone()); + } + b + } else { + // OpenAI-compat: system goes as first message with role "system" + let final_messages = if let Some(sys) = &system { + let mut msgs = vec![serde_json::json!({"role": "system", "content": sys})]; + if let serde_json::Value::Array(arr) = &messages { + msgs.extend(arr.iter().cloned()); + } + serde_json::Value::Array(msgs) + } else { + messages + }; + serde_json::json!({ + "model": model, + "messages": final_messages, + "max_tokens": 4096 + }) + }; + + let req = if is_anthropic { + ureq::post(&inference_url) + .set("x-api-key", &api_key) + .set("anthropic-version", "2023-06-01") + .set("Content-Type", "application/json") + } else { + ureq::post(&inference_url) + .set("Authorization", &format!("Bearer {}", api_key)) + .set("Content-Type", "application/json") + }; + + match req.send_json(&body) { + Ok(response) => { + match response.into_json::() { + Ok(json) => { + // Anthropic: content[0].text + if let Some(text) = json + .get("content") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()) + { + return serde_json::json!({"result": text}); + } + // OpenAI-compat: choices[0].message.content + if let Some(content) = json + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + { + return serde_json::json!({"result": content}); + } + json + } + Err(e) => serde_json::json!({"error": format!("parse error: {}", e)}), + } + } + Err(e) => serde_json::json!({"error": format!("inference error: {}", e)}), + } + }), + ); + + // ── String / List stdlib ────────────────────────────────────────────── + + fns.insert("native_string_chars".into(), Box::new(|params| { + let s = match params.as_str() { + Some(s) => s.to_string(), + None => match params.get(0).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), None => return serde_json::json!([]), + } + }; + let chars: Vec = s.chars().map(|c| serde_json::Value::String(c.to_string())).collect(); + serde_json::Value::Array(chars) + })); + + fns.insert("native_string_len".into(), Box::new(|params| { + let s = match params.as_str() { + Some(s) => s.to_string(), + None => match params.get(0).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), None => return serde_json::json!(0), + } + }; + serde_json::json!(s.len() as i64) + })); + + fns.insert("native_string_slice".into(), Box::new(|params| { + let s = match params.get(0).and_then(|v| v.as_str()) { Some(s) => s.to_string(), None => return serde_json::json!("") }; + let start = params.get(1).and_then(|v| v.as_i64()).unwrap_or(0) as usize; + let end = params.get(2).and_then(|v| v.as_i64()).unwrap_or(s.len() as i64) as usize; + let end = end.min(s.len()); + let start = start.min(end); + serde_json::json!(&s[start..end]) + })); + + fns.insert("native_string_contains".into(), Box::new(|params| { + let s = params.get(0).and_then(|v| v.as_str()).unwrap_or(""); + let sub = params.get(1).and_then(|v| v.as_str()).unwrap_or(""); + serde_json::json!(s.contains(sub)) + })); + + fns.insert("native_string_starts_with".into(), Box::new(|params| { + let s = params.get(0).and_then(|v| v.as_str()).unwrap_or(""); + let prefix = params.get(1).and_then(|v| v.as_str()).unwrap_or(""); + serde_json::json!(s.starts_with(prefix)) + })); + + fns.insert("native_list_len".into(), Box::new(|params| { + match params.as_array().or_else(|| params.get(0).and_then(|v| v.as_array())) { + Some(arr) => serde_json::json!(arr.len() as i64), + None => serde_json::json!(0), + } + })); + + fns.insert("native_list_append".into(), Box::new(|params| { + let list = params.get(0).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let item = params.get(1).cloned().unwrap_or(serde_json::Value::Null); + let mut new_list = list; + new_list.push(item); + serde_json::Value::Array(new_list) + })); + + fns.insert("native_list_get".into(), Box::new(|params| { + let list = match params.get(0).and_then(|v| v.as_array()) { Some(a) => a.clone(), None => return serde_json::Value::Null }; + let idx = params.get(1).and_then(|v| v.as_i64()).unwrap_or(0) as usize; + list.into_iter().nth(idx).unwrap_or(serde_json::Value::Null) + })); + + fns.insert("native_list_empty".into(), Box::new(|_| { + serde_json::Value::Array(vec![]) + })); + + fns.insert("native_println".into(), Box::new(|params| { + let msg = params.as_str() + .map(|s| s.to_string()) + .or_else(|| params.get(0).and_then(|v| v.as_str()).map(|s| s.to_string())) + .unwrap_or_default(); + println!("{}", msg); + serde_json::Value::Null + })); + + fns.insert("native_int_to_str".into(), Box::new(|params| { + let n = params.as_i64() + .or_else(|| params.get(0).and_then(|v| v.as_i64())) + .unwrap_or(0); + serde_json::Value::String(n.to_string()) + })); + + fns.insert("native_str_to_int".into(), Box::new(|params| { + let s = params.as_str() + .or_else(|| params.get(0).and_then(|v| v.as_str())) + .unwrap_or("0"); + serde_json::json!(s.parse::().unwrap_or(0)) + })); + + fns.insert("native_string_split".into(), Box::new(|params| { + let s = params.get(0).and_then(|v| v.as_str()).unwrap_or(""); + let delim = params.get(1).and_then(|v| v.as_str()).unwrap_or(","); + let parts: Vec = s.split(delim).map(|p| serde_json::Value::String(p.to_string())).collect(); + serde_json::Value::Array(parts) + })); + + fns.insert("native_string_join".into(), Box::new(|params| { + let list = params.get(0).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let delim = params.get(1).and_then(|v| v.as_str()).unwrap_or(","); + let parts: Vec<&str> = list.iter().filter_map(|v| v.as_str()).collect(); + serde_json::Value::String(parts.join(delim)) + })); + + Self { fns, queue } + } + + /// Create a minimal stub binding set (for tests that don't need a real store). + pub fn stub() -> Self { + let mut fns: HashMap = HashMap::new(); + let queue: QueueStore = Arc::new(Mutex::new(HashMap::new())); + + fns.insert( + "native_uuid".into(), + Box::new(|_| Value::String(Uuid::new_v4().to_string())), + ); + + fns.insert( + "native_now".into(), + Box::new(|_| Value::String(Utc::now().to_rfc3339())), + ); + + fns.insert( + "native_activate".into(), + Box::new(|_| serde_json::json!([])), + ); + + // Queue stubs — full implementations so queue tests can use NativeBindings::stub(). + { + let q = queue.clone(); + fns.insert( + "native_queue_publish".into(), + Box::new(move |params| { + let topic = match params.get(0).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let payload = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let msg_id = Uuid::new_v4().to_string(); + let mut store = q.lock().unwrap(); + let topic_map = store.entry(topic).or_insert_with(HashMap::new); + let consumers: Vec = topic_map + .keys() + .filter(|k| k.as_str() != "_pending") + .cloned() + .collect(); + if consumers.is_empty() { + topic_map + .entry("_pending".to_string()) + .or_insert_with(VecDeque::new) + .push_back((msg_id, payload)); + } else { + for consumer in consumers { + topic_map + .entry(consumer) + .or_insert_with(VecDeque::new) + .push_back((msg_id.clone(), payload.clone())); + } + } + Value::String("".to_string()) + }), + ); + } + + { + let q = queue.clone(); + fns.insert( + "native_queue_consume".into(), + Box::new(move |params| { + let topic = match params.get(0).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let consumer = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let mut store = q.lock().unwrap(); + let topic_map = store.entry(topic).or_insert_with(HashMap::new); + if !topic_map.contains_key(&consumer) { + let pending: VecDeque<(String, String)> = + topic_map.remove("_pending").unwrap_or_default(); + topic_map.insert(consumer.clone(), pending); + } + match topic_map.get_mut(&consumer).and_then(|q| q.front().cloned()) { + Some((msg_id, payload)) => Value::String(format!("{}:{}", msg_id, payload)), + None => Value::String("".to_string()), + } + }), + ); + } + + { + let q = queue.clone(); + fns.insert( + "native_queue_ack".into(), + Box::new(move |params| { + let topic = match params.get(0).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let consumer = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let msg_id = match params.get(2).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return Value::String("".to_string()), + }; + let mut store = q.lock().unwrap(); + if let Some(topic_map) = store.get_mut(&topic) { + if let Some(queue) = topic_map.get_mut(&consumer) { + if queue.front().map(|(id, _)| id.as_str()) == Some(&msg_id) { + queue.pop_front(); + } + } + } + Value::String("".to_string()) + }), + ); + } + + // ── String / List stdlib (stub) ─────────────────────────────────────── + + fns.insert("native_string_chars".into(), Box::new(|params| { + let s = match params.as_str() { + Some(s) => s.to_string(), + None => match params.get(0).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), None => return serde_json::json!([]), + } + }; + let chars: Vec = s.chars().map(|c| serde_json::Value::String(c.to_string())).collect(); + serde_json::Value::Array(chars) + })); + + fns.insert("native_string_len".into(), Box::new(|params| { + let s = match params.as_str() { + Some(s) => s.to_string(), + None => match params.get(0).and_then(|v| v.as_str()) { + Some(s) => s.to_string(), None => return serde_json::json!(0), + } + }; + serde_json::json!(s.len() as i64) + })); + + fns.insert("native_string_slice".into(), Box::new(|params| { + let s = match params.get(0).and_then(|v| v.as_str()) { Some(s) => s.to_string(), None => return serde_json::json!("") }; + let start = params.get(1).and_then(|v| v.as_i64()).unwrap_or(0) as usize; + let end = params.get(2).and_then(|v| v.as_i64()).unwrap_or(s.len() as i64) as usize; + let end = end.min(s.len()); + let start = start.min(end); + serde_json::json!(&s[start..end]) + })); + + fns.insert("native_string_contains".into(), Box::new(|params| { + let s = params.get(0).and_then(|v| v.as_str()).unwrap_or(""); + let sub = params.get(1).and_then(|v| v.as_str()).unwrap_or(""); + serde_json::json!(s.contains(sub)) + })); + + fns.insert("native_string_starts_with".into(), Box::new(|params| { + let s = params.get(0).and_then(|v| v.as_str()).unwrap_or(""); + let prefix = params.get(1).and_then(|v| v.as_str()).unwrap_or(""); + serde_json::json!(s.starts_with(prefix)) + })); + + fns.insert("native_list_len".into(), Box::new(|params| { + match params.as_array().or_else(|| params.get(0).and_then(|v| v.as_array())) { + Some(arr) => serde_json::json!(arr.len() as i64), + None => serde_json::json!(0), + } + })); + + fns.insert("native_list_append".into(), Box::new(|params| { + let list = params.get(0).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let item = params.get(1).cloned().unwrap_or(serde_json::Value::Null); + let mut new_list = list; + new_list.push(item); + serde_json::Value::Array(new_list) + })); + + fns.insert("native_list_get".into(), Box::new(|params| { + let list = match params.get(0).and_then(|v| v.as_array()) { Some(a) => a.clone(), None => return serde_json::Value::Null }; + let idx = params.get(1).and_then(|v| v.as_i64()).unwrap_or(0) as usize; + list.into_iter().nth(idx).unwrap_or(serde_json::Value::Null) + })); + + fns.insert("native_list_empty".into(), Box::new(|_| { + serde_json::Value::Array(vec![]) + })); + + fns.insert("native_println".into(), Box::new(|params| { + let msg = params.as_str() + .map(|s| s.to_string()) + .or_else(|| params.get(0).and_then(|v| v.as_str()).map(|s| s.to_string())) + .unwrap_or_default(); + println!("{}", msg); + serde_json::Value::Null + })); + + fns.insert("native_int_to_str".into(), Box::new(|params| { + let n = params.as_i64() + .or_else(|| params.get(0).and_then(|v| v.as_i64())) + .unwrap_or(0); + serde_json::Value::String(n.to_string()) + })); + + fns.insert("native_str_to_int".into(), Box::new(|params| { + let s = params.as_str() + .or_else(|| params.get(0).and_then(|v| v.as_str())) + .unwrap_or("0"); + serde_json::json!(s.parse::().unwrap_or(0)) + })); + + fns.insert("native_string_split".into(), Box::new(|params| { + let s = params.get(0).and_then(|v| v.as_str()).unwrap_or(""); + let delim = params.get(1).and_then(|v| v.as_str()).unwrap_or(","); + let parts: Vec = s.split(delim).map(|p| serde_json::Value::String(p.to_string())).collect(); + serde_json::Value::Array(parts) + })); + + fns.insert("native_string_join".into(), Box::new(|params| { + let list = params.get(0).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let delim = params.get(1).and_then(|v| v.as_str()).unwrap_or(","); + let parts: Vec<&str> = list.iter().filter_map(|v| v.as_str()).collect(); + serde_json::Value::String(parts.join(delim)) + })); + + Self { fns, queue } + } + + /// Dispatch a named native function. + pub fn call(&self, name: &str, params: Value) -> Value { + match self.fns.get(name) { + Some(f) => f(params), + None => { + warn!("NativeBindings::call — unknown native: {}", name); + serde_json::json!({"err": format!("unknown native: {}", name)}) + } + } + } + + /// Check whether a named native is registered. + pub fn has(&self, name: &str) -> bool { + self.fns.contains_key(name) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn stub() -> NativeBindings { + NativeBindings::stub() + } + + #[test] + fn test_native_uuid() { + let b = stub(); + let result = b.call("native_uuid", Value::Null); + let s = result.as_str().expect("uuid should be a string"); + assert!(Uuid::parse_str(s).is_ok(), "should be a valid UUID: {s}"); + } + + #[test] + fn test_native_now() { + let b = stub(); + let result = b.call("native_now", Value::Null); + let s = result.as_str().expect("now should be a string"); + // Must parse as RFC3339 + assert!( + chrono::DateTime::parse_from_rfc3339(s).is_ok(), + "not valid RFC3339: {s}" + ); + } + + #[test] + fn test_unknown_native_returns_err() { + let b = stub(); + let result = b.call("native_does_not_exist", Value::Null); + assert!(result.get("err").is_some(), "expected err field"); + } + + #[test] + fn test_native_activate_stub() { + let b = stub(); + let result = b.call( + "native_activate", + serde_json::json!({"type_name": "Memory", "query": "test"}), + ); + assert!(result.is_array(), "activate should return array"); + } + + #[test] + fn test_has_native() { + let b = stub(); + assert!(b.has("native_uuid")); + assert!(b.has("native_now")); + assert!(!b.has("native_nonexistent")); + } + + // ── Queue tests ─────────────────────────────────────────────────────────── + + /// Helper: parse "msg_id:payload" returned by native_queue_consume. + fn parse_consume(v: &Value) -> Option<(String, String)> { + let s = v.as_str()?; + if s.is_empty() { + return None; + } + let colon = s.find(':')?; + Some((s[..colon].to_string(), s[colon + 1..].to_string())) + } + + #[test] + fn test_queue_publish_consume_ack() { + let b = stub(); + + // Publish two messages before any consumer exists. + b.call( + "native_queue_publish", + serde_json::json!(["events", "hello"]), + ); + b.call( + "native_queue_publish", + serde_json::json!(["events", "world"]), + ); + + // First consume on a fresh consumer drains the pending buffer. + let r1 = b.call("native_queue_consume", serde_json::json!(["events", "alice"])); + let (id1, payload1) = parse_consume(&r1).expect("should have a message"); + assert_eq!(payload1, "hello"); + assert!(Uuid::parse_str(&id1).is_ok(), "msg_id should be a UUID"); + + // Before ack, same message is still at the front. + let r1_again = b.call("native_queue_consume", serde_json::json!(["events", "alice"])); + let (id1_again, _) = parse_consume(&r1_again).expect("still queued before ack"); + assert_eq!(id1, id1_again); + + // Ack the first message. + b.call( + "native_queue_ack", + serde_json::json!(["events", "alice", id1]), + ); + + // Now the second message surfaces. + let r2 = b.call("native_queue_consume", serde_json::json!(["events", "alice"])); + let (id2, payload2) = parse_consume(&r2).expect("second message"); + assert_eq!(payload2, "world"); + assert_ne!(id1_again, id2); + + // Ack second. + b.call( + "native_queue_ack", + serde_json::json!(["events", "alice", id2]), + ); + + // Queue now empty. + let empty = b.call("native_queue_consume", serde_json::json!(["events", "alice"])); + assert_eq!(empty.as_str(), Some(""), "queue should be empty after all acks"); + } + + #[test] + fn test_queue_fanout_to_multiple_consumers() { + let b = stub(); + + // Register two consumers by having them consume first (queue is empty at this point, + // so they both just register with empty queues). + let empty1 = b.call("native_queue_consume", serde_json::json!(["topic", "bob"])); + let empty2 = b.call("native_queue_consume", serde_json::json!(["topic", "carol"])); + assert_eq!(empty1.as_str(), Some("")); + assert_eq!(empty2.as_str(), Some("")); + + // Now publish — both registered consumers should receive it. + b.call( + "native_queue_publish", + serde_json::json!(["topic", "fanout-payload"]), + ); + + let rb = b.call("native_queue_consume", serde_json::json!(["topic", "bob"])); + let rc = b.call("native_queue_consume", serde_json::json!(["topic", "carol"])); + + let (_, pb) = parse_consume(&rb).expect("bob should have message"); + let (_, pc) = parse_consume(&rc).expect("carol should have message"); + assert_eq!(pb, "fanout-payload"); + assert_eq!(pc, "fanout-payload"); + } + + #[test] + fn test_queue_pending_delivered_on_first_consume() { + let b = stub(); + + // Publish before any consumer exists. + b.call( + "native_queue_publish", + serde_json::json!(["pending-topic", "pending-msg"]), + ); + + // New consumer gets the pending message immediately. + let r = b.call( + "native_queue_consume", + serde_json::json!(["pending-topic", "late-consumer"]), + ); + let (_, payload) = parse_consume(&r).expect("pending message should be delivered"); + assert_eq!(payload, "pending-msg"); + } + + #[test] + fn test_queue_ack_wrong_id_is_noop() { + let b = stub(); + + b.call( + "native_queue_publish", + serde_json::json!(["t", "msg"]), + ); + let r = b.call("native_queue_consume", serde_json::json!(["t", "c"])); + let (real_id, _) = parse_consume(&r).unwrap(); + + // Ack with a wrong ID should be a no-op. + b.call( + "native_queue_ack", + serde_json::json!(["t", "c", "00000000-0000-0000-0000-000000000000"]), + ); + + // Message is still there. + let r2 = b.call("native_queue_consume", serde_json::json!(["t", "c"])); + let (id2, _) = parse_consume(&r2).expect("message should still be present"); + assert_eq!(real_id, id2); + } +} diff --git a/crates/neuron-runtime/src/lib.rs b/crates/neuron-runtime/src/lib.rs new file mode 100644 index 0000000..3767ad9 --- /dev/null +++ b/crates/neuron-runtime/src/lib.rs @@ -0,0 +1,13 @@ +//! neuron-runtime — Execution harness for el bytecode. +//! +//! Loads compiled `.el` modules, executes them on the el VM, +//! and bridges native function calls into neuron-store and Axon events. + +pub mod bindings; +pub mod loader; +pub mod registry; +pub mod runtime; +pub mod vm; + +pub use registry::ToolRegistry; +pub use runtime::NeuronRuntime; diff --git a/crates/neuron-runtime/src/loader.rs b/crates/neuron-runtime/src/loader.rs new file mode 100644 index 0000000..6a3e3a6 --- /dev/null +++ b/crates/neuron-runtime/src/loader.rs @@ -0,0 +1,165 @@ +//! .el source compiler and .elc bytecode loader. + +use std::collections::HashMap; +use std::path::Path; + +use el_compiler::{Bytecode, Compiler, CompilerOptions, deserialize_bytecode}; +use thiserror::Error; + +// ── RuntimeError (load/compile phase) ──────────────────────────────────────── + +#[derive(Debug, Error)] +pub enum LoadError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("compile error for {module}: {detail}")] + Compile { module: String, detail: String }, + #[error("deserialize error: {0}")] + Deserialize(String), +} + +// ── compile_source ──────────────────────────────────────────────────────────── + +/// Compile an el source string to bytecode instructions. +pub fn compile_source(source: &str) -> Result, LoadError> { + let output = Compiler::compile(source, CompilerOptions::default()) + .map_err(|e| LoadError::Compile { + module: "".into(), + detail: e.to_string(), + })?; + deserialize_bytecode(&output.artifact).map_err(|e| LoadError::Compile { + module: "".into(), + detail: e, + }) +} + +// ── load_elc ───────────────────────────────────────────────────────────────── + +/// Load pre-compiled .elc file (bytecode JSON) from disk. +pub fn load_elc(path: &Path) -> Result, LoadError> { + let bytes = std::fs::read(path)?; + deserialize_bytecode(&bytes).map_err(LoadError::Deserialize) +} + +// ── load_directory ──────────────────────────────────────────────────────────── + +/// Load and compile all `.el` files from a directory (non-recursive). +/// +/// Each file is compiled independently; the map key is the filename stem +/// (e.g. `memory.el` → `"memory"`). `.elc` files are also accepted and +/// loaded without recompilation. +/// +/// Files that fail to compile are skipped with a warning rather than causing +/// the entire load to fail — callers can detect missing modules at dispatch +/// time. +pub fn load_directory(dir: &Path) -> Result>, LoadError> { + let mut modules: HashMap> = HashMap::new(); + + let entries = std::fs::read_dir(dir)?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + if stem.is_empty() { + continue; + } + + let result = match ext { + "el" => { + let source = std::fs::read_to_string(&path)?; + compile_source(&source).map_err(|e| { + tracing::warn!("skipping {}: {}", path.display(), e); + e + }) + } + "elc" => load_elc(&path).map_err(|e| { + tracing::warn!("skipping {}: {}", path.display(), e); + e + }), + _ => continue, + }; + + match result { + Ok(bytecode) => { + modules.insert(stem, bytecode); + } + Err(_) => { + // Already warned above; continue loading other modules. + } + } + } + + Ok(modules) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use el_compiler::{Bytecode, Value}; + + #[test] + fn test_compile_source_arithmetic() { + // A simple expression that the compiler can handle via eval semantics. + let instructions = compile_source("1 + 2").unwrap(); + assert!(!instructions.is_empty(), "should produce instructions"); + } + + #[test] + fn test_compile_source_invalid() { + // Guaranteed to fail (unbalanced paren). + let result = compile_source("((("); + assert!(result.is_err()); + } + + #[test] + fn test_load_elc_roundtrip() { + use tempfile::NamedTempFile; + + let instructions = vec![ + Bytecode::Push(Value::Int(1)), + Bytecode::Push(Value::Int(2)), + Bytecode::Add, + Bytecode::Halt, + ]; + let bytes = el_compiler::serialize_bytecode(&instructions).unwrap(); + + let mut tmp = NamedTempFile::with_suffix(".elc").unwrap(); + std::io::Write::write_all(&mut tmp, &bytes).unwrap(); + + let loaded = load_elc(tmp.path()).unwrap(); + assert_eq!(loaded, instructions); + } + + #[test] + fn test_load_directory_empty() { + let dir = tempfile::tempdir().unwrap(); + let modules = load_directory(dir.path()).unwrap(); + assert!(modules.is_empty()); + } + + #[test] + fn test_load_directory_with_elc() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let instructions = vec![Bytecode::Push(Value::Int(42)), Bytecode::Halt]; + let bytes = el_compiler::serialize_bytecode(&instructions).unwrap(); + std::fs::write(dir.path().join("mymod.elc"), &bytes).unwrap(); + + let modules = load_directory(dir.path()).unwrap(); + assert!(modules.contains_key("mymod")); + assert_eq!(modules["mymod"], instructions); + } +} diff --git a/crates/neuron-runtime/src/registry.rs b/crates/neuron-runtime/src/registry.rs new file mode 100644 index 0000000..627020e --- /dev/null +++ b/crates/neuron-runtime/src/registry.rs @@ -0,0 +1,232 @@ +//! Tool → el function mapping. +//! +//! The registry maps Axon tool names (e.g. `"remember"`) to the module and +//! function name in the loaded `.el` bytecode (e.g. `("memory", "remember")`). + +use std::collections::HashMap; + +// ── ToolRegistry ────────────────────────────────────────────────────────────── + +/// Maps Axon tool names to `(module_name, function_name)` pairs. +pub struct ToolRegistry { + entries: HashMap, +} + +impl ToolRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + /// Register the default Neuron tool → function mapping. + pub fn default_neuron() -> Self { + let mut r = Self::new(); + + // ── Memory ──────────────────────────────────────────────────────────── + r.register("remember", "memory", "remember"); + r.register("recall", "memory", "recall"); + r.register("search_memories", "memory", "search_memories"); + r.register("inspect_memories", "memory", "list_memories"); + r.register("forget", "memory", "forget"); + r.register("evolve_memory", "memory", "promote_memory"); + + // ── Knowledge ───────────────────────────────────────────────────────── + r.register("capture_knowledge", "knowledge", "capture_knowledge"); + r.register("retrieve_knowledge", "knowledge", "retrieve_knowledge"); + r.register("search_knowledge", "knowledge", "search_knowledge"); + r.register("browse_knowledge", "knowledge", "browse_knowledge"); + r.register("evolve_knowledge", "knowledge", "evolve_knowledge"); + r.register("remove_knowledge", "knowledge", "remove_knowledge"); + + // ── Backlog ─────────────────────────────────────────────────────────── + r.register("plan_work", "backlog", "plan_work"); + r.register("review_backlog", "backlog", "review_backlog"); + r.register("track_work", "backlog", "track_work"); + + // ── Context ─────────────────────────────────────────────────────────── + r.register("begin_work", "context", "begin_work"); + r.register("progress_work", "context", "progress_work"); + r.register("check_work", "context", "check_work"); + r.register("list_work", "context", "list_work"); + + // ── Artifact ────────────────────────────────────────────────────────── + r.register("draft_artifact", "artifact", "draft_artifact"); + r.register("find_artifacts", "artifact", "find_artifacts"); + r.register("retrieve_artifact", "artifact", "retrieve_artifact"); + r.register("revise_artifact", "artifact", "revise_artifact"); + r.register("manage_artifact", "artifact", "manage_artifact"); + + // ── ISE ─────────────────────────────────────────────────────────────── + r.register("log_internal_state_event", "ise", "log_internal_state"); + r.register("list_internal_state_events", "ise", "list_internal_state"); + r.register("get_internal_state_event", "ise", "get_internal_state"); + + // ── Config ──────────────────────────────────────────────────────────── + r.register("inspect_config", "config", "inspect_config"); + r.register("tune_config", "config", "tune_config"); + + // ── Graph ───────────────────────────────────────────────────────────── + r.register("inspect_graph", "graph", "inspect_graph"); + r.register("traverse_graph", "graph", "traverse_graph"); + r.register("link_entities", "graph", "link_entities"); + r.register("search_graph", "graph", "search_graph"); + + // ── Process ─────────────────────────────────────────────────────────── + r.register("define_process", "process", "define_process"); + r.register("browse_processes", "process", "browse_processes"); + + // ── Session ─────────────────────────────────────────────────────────── + r.register("begin_session", "session", "begin_session"); + r.register("compile_ctx", "session", "compile_ctx"); + r.register("consolidate", "session", "consolidate"); + + // ── Inference / Chat ────────────────────────────────────────────────── + // Routes to native_chat binding — calls neuron.neurontechnologies.ai + // (OpenAI-compatible). For user installs: NEURON_INFERENCE_KEY=their key. + // For dev installs: override NEURON_INFERENCE_URL + NEURON_INFERENCE_KEY. + r.register("chat", "chat", "chat"); + + // ── Autonomous agent ────────────────────────────────────────────────── + r.register("agent_tick", "agent", "agent_tick"); + + r + } + + /// Register a single tool → (module, function) mapping. + pub fn register(&mut self, tool: &str, module: &str, func: &str) { + self.entries + .insert(tool.to_string(), (module.to_string(), func.to_string())); + } + + /// Resolve a tool name to `(module, function)`. Returns `None` if not + /// registered. + pub fn resolve(&self, tool_name: &str) -> Option<(&str, &str)> { + self.entries + .get(tool_name) + .map(|(m, f)| (m.as_str(), f.as_str())) + } + + /// Number of registered tools. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// True if no tools are registered. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Iterate over all registered entries. + pub fn iter(&self) -> impl Iterator { + self.entries + .iter() + .map(|(tool, (module, func))| (tool.as_str(), module.as_str(), func.as_str())) + } +} + +impl Default for ToolRegistry { + fn default() -> Self { + Self::new() + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_registry_resolve_memory() { + let r = ToolRegistry::default_neuron(); + assert_eq!(r.resolve("remember"), Some(("memory", "remember"))); + } + + #[test] + fn test_registry_resolve_knowledge() { + let r = ToolRegistry::default_neuron(); + assert_eq!( + r.resolve("capture_knowledge"), + Some(("knowledge", "capture_knowledge")) + ); + assert_eq!( + r.resolve("search_knowledge"), + Some(("knowledge", "search_knowledge")) + ); + } + + #[test] + fn test_registry_resolve_backlog() { + let r = ToolRegistry::default_neuron(); + assert_eq!(r.resolve("plan_work"), Some(("backlog", "plan_work"))); + assert_eq!( + r.resolve("review_backlog"), + Some(("backlog", "review_backlog")) + ); + assert_eq!(r.resolve("track_work"), Some(("backlog", "track_work"))); + } + + #[test] + fn test_registry_resolve_context() { + let r = ToolRegistry::default_neuron(); + assert_eq!(r.resolve("begin_work"), Some(("context", "begin_work"))); + assert_eq!( + r.resolve("progress_work"), + Some(("context", "progress_work")) + ); + } + + #[test] + fn test_registry_resolve_unknown() { + let r = ToolRegistry::default_neuron(); + assert_eq!(r.resolve("totally_unknown_tool"), None); + } + + #[test] + fn test_registry_custom_register() { + let mut r = ToolRegistry::new(); + r.register("my_tool", "my_module", "my_func"); + assert_eq!(r.resolve("my_tool"), Some(("my_module", "my_func"))); + } + + #[test] + fn test_registry_default_neuron_not_empty() { + let r = ToolRegistry::default_neuron(); + assert!(!r.is_empty()); + assert!(r.len() > 20); + } + + #[test] + fn test_registry_ise_tools() { + let r = ToolRegistry::default_neuron(); + assert_eq!( + r.resolve("log_internal_state_event"), + Some(("ise", "log_internal_state")) + ); + } + + #[test] + fn test_registry_artifact_tools() { + let r = ToolRegistry::default_neuron(); + assert_eq!( + r.resolve("draft_artifact"), + Some(("artifact", "draft_artifact")) + ); + assert_eq!( + r.resolve("retrieve_artifact"), + Some(("artifact", "retrieve_artifact")) + ); + } + + #[test] + fn test_registry_all_tools_unique_modules() { + let r = ToolRegistry::default_neuron(); + let memory_tools: Vec<_> = r + .iter() + .filter(|(_, module, _)| *module == "memory") + .collect(); + assert!(!memory_tools.is_empty()); + } +} diff --git a/crates/neuron-runtime/src/runtime.rs b/crates/neuron-runtime/src/runtime.rs new file mode 100644 index 0000000..2fbe585 --- /dev/null +++ b/crates/neuron-runtime/src/runtime.rs @@ -0,0 +1,375 @@ +//! Top-level NeuronRuntime — loads modules and dispatches Axon tool calls. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use el_compiler::Bytecode; +use serde_json::Value; +use tracing::{debug, warn}; + +use crate::bindings::NativeBindings; +use crate::loader::{self, LoadError}; +use crate::registry::ToolRegistry; +use crate::vm::{el_value_to_json, json_to_el_value, Vm}; + +use engram_core::EngramDb; +use neuron_store::{BacklogStore, ContextStore, IseStore, KnowledgeStore, MemoryStore, open_db}; + +// ── RuntimeError ────────────────────────────────────────────────────────────── + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeError { + #[error("load error: {0}")] + Load(#[from] LoadError), + #[error("vm error: {0}")] + Vm(#[from] crate::vm::VmError), + #[error("storage error: {0}")] + Storage(String), +} + +// ── NeuronRuntime ───────────────────────────────────────────────────────────── + +/// The main neuron runtime — holds loaded bytecode modules and dispatches +/// Axon tool calls through the el VM. +pub struct NeuronRuntime { + bindings: Arc, + registry: ToolRegistry, + /// module_name → compiled bytecode + modules: HashMap>, +} + +impl NeuronRuntime { + /// Create a runtime with pre-built components (for testing). + pub fn from_parts( + bindings: Arc, + registry: ToolRegistry, + modules: HashMap>, + ) -> Self { + Self { + bindings, + registry, + modules, + } + } + + /// Create a runtime from an already-open `EngramDb` handle and a module directory. + /// + /// The caller opens sled once and passes a clone here; no second `sled::open` + /// call is made, so the single-writer lock is never contested. + pub fn from_dir( + module_dir: &Path, + db: EngramDb, + ) -> Result { + let memory = Arc::new(MemoryStore::new(db.clone())); + let knowledge = Arc::new(KnowledgeStore::new(db.clone())); + let backlog = Arc::new(BacklogStore::new(db.clone())); + let context = Arc::new(ContextStore::new(db.clone())); + let ise = Arc::new(IseStore::new(db)); + + let bindings = Arc::new(NativeBindings::new(memory, knowledge, backlog, context, ise)); + let registry = ToolRegistry::default_neuron(); + let modules = loader::load_directory(module_dir)?; + + Ok(Self { + bindings, + registry, + modules, + }) + } + + /// Convenience: open a new database at `db_path` then call `from_dir`. + /// Only use this when you are the sole opener (e.g. in tests). + pub fn from_path(module_dir: &Path, db_path: &Path) -> Result { + let db = open_db(db_path).map_err(|e| RuntimeError::Storage(e.to_string()))?; + Self::from_dir(module_dir, db) + } + + /// Dispatch an Axon tool call. Returns a JSON result value. + /// + /// Resolution order: + /// 1. Look up `tool_name` in the registry → (module, func). + /// 2. Find the module's bytecode. + /// 3. Find the function entry-point label (`__fn_`) in the bytecode. + /// 4. Execute the VM from that entry-point with `params` on the stack. + pub async fn dispatch(&self, tool_name: &str, params: Value) -> Value { + debug!("NeuronRuntime::dispatch tool={}", tool_name); + + let (module_name, func_name) = match self.registry.resolve(tool_name) { + None => { + warn!("dispatch: unknown tool '{}'", tool_name); + return serde_json::json!({"error": format!("unknown tool: {}", tool_name)}); + } + Some(pair) => pair, + }; + + let bytecode = match self.modules.get(module_name) { + None => { + // Module not loaded — fall back to native dispatch + debug!( + "dispatch: module '{}' not loaded, attempting native fallback for '{}'", + module_name, tool_name + ); + return self.native_fallback(tool_name, params); + } + Some(bc) => bc, + }; + + // Execute the module, looking for a function entry-point. + let mut vm = Vm::new(self.bindings.clone()); + + // Pre-scan bytecode for function entry-points labelled by convention + // as a StoreLocal of the sentinel name "__fn_". In the + // generated bytecode a function is expected to start after a + // `Push(Str("__fn_"))` / `StoreLocal("__fn_")` pair. + // For robustness we scan for the target function start here. + let entry_ip = find_function_entry(bytecode, func_name); + + // Push params as the first local. + let params_el = json_to_el_value(params.clone()); + + let result = if let Some(ip) = entry_ip { + // Register all functions we found. + register_all_functions(&mut vm, bytecode); + vm.execute_from(ip, params_el, bytecode) + } else { + // No labelled entry-point: run the full module (eval semantics). + // This is the common case while .el files are being developed. + vm.locals_mut().insert("params".to_string(), params_el); + vm.run(bytecode) + }; + + match result { + Ok(val) => el_value_to_json(&val), + Err(e) => { + warn!("dispatch vm error for '{}': {}", tool_name, e); + serde_json::json!({"error": e.to_string()}) + } + } + } + + /// Native fallback: invoke a registered native binding directly using a + /// naming convention (`tool_name` → `native_`). + fn native_fallback(&self, tool_name: &str, params: Value) -> Value { + let native_name = format!("native_{}", tool_name); + if self.bindings.has(&native_name) { + self.bindings.call(&native_name, params) + } else { + serde_json::json!({"error": format!("module not loaded and no native fallback for: {}", tool_name)}) + } + } + + /// Scan all loaded modules for an `on_startup` function and run each + /// in a dedicated background thread. + /// + /// Convention: any `.el` module that exports `fn on_startup(...)` will + /// be called once at daemon boot in a new thread. The function runs for + /// the lifetime of the daemon — it can contain a `while true` loop with + /// `native_sleep` for periodic autonomous work. + /// + /// Each thread gets its own VM instance but shares the NativeBindings Arc, + /// so storage operations are safe across threads. + pub fn run_startup_hooks(&self) { + for (module_name, bytecode) in &self.modules { + if find_function_entry(bytecode, "on_startup").is_some() { + let bindings = self.bindings.clone(); + let bytecode = bytecode.clone(); + let module_name = module_name.clone(); + std::thread::spawn(move || { + tracing::info!("on_startup: launching background thread for module '{}'", module_name); + let mut vm = Vm::new(bindings); + register_all_functions(&mut vm, &bytecode); + let entry = match find_function_entry(&bytecode, "on_startup") { + Some(ip) => ip, + None => return, + }; + let params = json_to_el_value(serde_json::json!({})); + match vm.execute_from(entry, params, &bytecode) { + Ok(_) => tracing::info!("on_startup: module '{}' exited normally", module_name), + Err(e) => tracing::error!("on_startup: module '{}' error: {}", module_name, e), + } + }); + } + } + } + + /// Number of loaded modules. + pub fn module_count(&self) -> usize { + self.modules.len() + } + + /// Names of all loaded modules. + pub fn module_names(&self) -> Vec<&str> { + self.modules.keys().map(|s| s.as_str()).collect() + } +} + +// ── Vm extension for call-from ──────────────────────────────────────────────── + +impl Vm { + /// Execute from a specific instruction pointer with a params value + /// pre-loaded into the "params" local. + pub fn execute_from( + &mut self, + start_ip: usize, + params: el_compiler::Value, + instructions: &[Bytecode], + ) -> Result { + self.locals_mut().insert("params".to_string(), params); + self.set_ip(start_ip); + self.execute(instructions) + } + + /// Expose mutable locals for the runtime layer. + pub fn locals_mut(&mut self) -> &mut std::collections::HashMap { + // We expose the top-level locals (no call frame active here). + self.top_locals_mut() + } + + /// Set the instruction pointer directly. + pub fn set_ip(&mut self, ip: usize) { + self.set_ip_internal(ip); + } +} + +// ── Bytecode scanning helpers ───────────────────────────────────────────────── + +/// Find the instruction offset for a named function entry-point. +/// +/// Convention: a function `foo` is prefixed by: +/// `Push(Str("__fn_foo"))` at some ip N +/// and the function body starts at ip N+1. +fn find_function_entry(bytecode: &[Bytecode], func_name: &str) -> Option { + let sentinel = format!("__fn_{}", func_name); + for (i, instr) in bytecode.iter().enumerate() { + if let Bytecode::Push(el_compiler::Value::Str(s)) = instr { + if s == &sentinel { + // Function body starts at the next instruction. + if i + 1 < bytecode.len() { + return Some(i + 1); + } + } + } + } + None +} + +/// Scan bytecode for all function entry-points and register them in the VM. +fn register_all_functions(vm: &mut Vm, bytecode: &[Bytecode]) { + for (i, instr) in bytecode.iter().enumerate() { + if let Bytecode::Push(el_compiler::Value::Str(s)) = instr { + if let Some(func_name) = s.strip_prefix("__fn_") { + if i + 1 < bytecode.len() { + // arity 1 by convention (params map) + vm.register_function(func_name.to_string(), i + 1, 1); + } + } + } + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::bindings::NativeBindings; + use el_compiler::{Bytecode, Value}; + + fn make_stub_runtime(modules: HashMap>) -> NeuronRuntime { + NeuronRuntime::from_parts( + Arc::new(NativeBindings::stub()), + ToolRegistry::default_neuron(), + modules, + ) + } + + #[tokio::test] + async fn test_dispatch_unknown_tool() { + let rt = make_stub_runtime(HashMap::new()); + let result = rt.dispatch("does_not_exist", serde_json::json!({})).await; + assert!(result.get("error").is_some()); + } + + #[tokio::test] + async fn test_dispatch_known_tool_module_not_loaded() { + // "remember" is registered but module "memory" is not loaded. + // Falls back to native_remember, which the stub doesn't have → error. + let rt = make_stub_runtime(HashMap::new()); + let result = rt.dispatch("remember", serde_json::json!({})).await; + // Should get some kind of error (module not loaded, no native fallback). + assert!(result.get("error").is_some()); + } + + #[tokio::test] + async fn test_dispatch_module_with_bytecode() { + // Module "memory" that just pushes 42 and halts. + let mut modules = HashMap::new(); + modules.insert( + "memory".to_string(), + vec![Bytecode::Push(Value::Int(42)), Bytecode::Halt], + ); + let rt = make_stub_runtime(modules); + let result = rt.dispatch("remember", serde_json::json!({"content": "hello"})).await; + // Eval semantics: result = 42 + assert_eq!(result, serde_json::json!(42)); + } + + #[tokio::test] + async fn test_dispatch_native_uuid() { + // Register a module "util" that calls native_uuid. + let mut modules = HashMap::new(); + modules.insert( + "util".to_string(), + vec![ + Bytecode::Call { name: "native_uuid".into(), arity: 0 }, + Bytecode::Halt, + ], + ); + // Add a tool mapping. + let mut registry = ToolRegistry::default_neuron(); + registry.register("get_uuid", "util", "get_uuid"); + + let rt = NeuronRuntime::from_parts( + Arc::new(NativeBindings::stub()), + registry, + modules, + ); + let result = rt.dispatch("get_uuid", serde_json::json!({})).await; + let uuid_str = result.as_str().expect("expected a string UUID"); + assert!(uuid::Uuid::parse_str(uuid_str).is_ok()); + } + + #[test] + fn test_find_function_entry_found() { + let bc = vec![ + Bytecode::Push(Value::Str("__fn_remember".into())), + Bytecode::Push(Value::Int(99)), + Bytecode::Halt, + ]; + assert_eq!(find_function_entry(&bc, "remember"), Some(1)); + } + + #[test] + fn test_find_function_entry_not_found() { + let bc = vec![Bytecode::Push(Value::Int(1)), Bytecode::Halt]; + assert_eq!(find_function_entry(&bc, "remember"), None); + } + + #[test] + fn test_module_count() { + let mut modules = HashMap::new(); + modules.insert("memory".into(), vec![]); + modules.insert("knowledge".into(), vec![]); + let rt = make_stub_runtime(modules); + assert_eq!(rt.module_count(), 2); + } + + #[test] + fn test_registry_resolve() { + let r = ToolRegistry::default_neuron(); + assert_eq!(r.resolve("remember"), Some(("memory", "remember"))); + assert_eq!(r.resolve("plan_work"), Some(("backlog", "plan_work"))); + assert_eq!(r.resolve("capture_knowledge"), Some(("knowledge", "capture_knowledge"))); + } +} diff --git a/crates/neuron-runtime/src/vm.rs b/crates/neuron-runtime/src/vm.rs new file mode 100644 index 0000000..9d5731f --- /dev/null +++ b/crates/neuron-runtime/src/vm.rs @@ -0,0 +1,1027 @@ +//! Engram-lang stack machine VM. +//! +//! Executes a flat `Vec` instruction stream. The VM is purely +//! synchronous; async work is coordinated at the `NeuronRuntime` layer above. + +use std::collections::HashMap; +use std::sync::Arc; + +use el_compiler::{Bytecode, Value}; +use thiserror::Error; + +use crate::bindings::NativeBindings; + +// ── VmError ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Error)] +pub enum VmError { + #[error("stack underflow at ip={0}")] + StackUnderflow(usize), + #[error("type error: {0}")] + TypeError(String), + #[error("undefined local: {0}")] + UndefinedLocal(String), + #[error("division by zero")] + DivisionByZero, + #[error("jump out of bounds: ip={0} offset={1}")] + JumpOutOfBounds(usize, i32), + #[error("undefined function: {0}")] + UndefinedFunction(String), + #[error("index out of bounds: {0}")] + IndexOutOfBounds(usize), + #[error("field not found: {0}")] + FieldNotFound(String), + #[error("sealed violation: {0}")] + SealedViolation(String), +} + +// ── CallFrame ───────────────────────────────────────────────────────────────── + +/// One activation record on the call stack. +#[derive(Debug)] +struct CallFrame { + /// Return address (ip of the instruction *after* the Call). + return_ip: usize, + /// Locals scoped to this frame. + locals: HashMap, +} + +// ── Vm ──────────────────────────────────────────────────────────────────────── + +/// The el stack machine. +pub struct Vm { + stack: Vec, + /// Top-level (module) locals. + locals: HashMap, + ip: usize, + bindings: Arc, + /// Active call frames (not including the module-level frame). + call_stack: Vec, + /// Sealed-section nesting depth (> 0 means we're inside a sealed block). + sealed_depth: u32, + /// Named functions available for dispatch: name -> (start_ip, arity). + functions: HashMap, +} + +impl Vm { + /// Create a fresh VM wired to the provided native bindings. + pub fn new(bindings: Arc) -> Self { + Self { + stack: Vec::new(), + locals: HashMap::new(), + ip: 0, + bindings, + call_stack: Vec::new(), + sealed_depth: 0, + functions: HashMap::new(), + } + } + + /// Register a named function entry point (start ip, arity). + pub fn register_function(&mut self, name: impl Into, start_ip: usize, arity: u32) { + self.functions.insert(name.into(), (start_ip, arity)); + } + + /// Convenience: execute from ip 0, resetting state. + pub fn run(&mut self, instructions: &[Bytecode]) -> Result { + self.ip = 0; + self.stack.clear(); + self.locals.clear(); + self.call_stack.clear(); + self.sealed_depth = 0; + self.execute(instructions) + } + + /// Execute the instruction stream, returning the top-of-stack value (or Nil). + pub fn execute(&mut self, instructions: &[Bytecode]) -> Result { + loop { + if self.ip >= instructions.len() { + break; + } + let instr = &instructions[self.ip]; + match instr { + // ── Stack ───────────────────────────────────────────────────── + Bytecode::Push(v) => { + self.stack.push(v.clone()); + self.ip += 1; + } + Bytecode::Pop => { + self.pop()?; + self.ip += 1; + } + Bytecode::Dup => { + let top = self.peek()?.clone(); + self.stack.push(top); + self.ip += 1; + } + + // ── Arithmetic ──────────────────────────────────────────────── + Bytecode::Add => { + let rhs = self.pop()?; + let lhs = self.pop()?; + let result = match (lhs, rhs) { + (Value::Int(a), Value::Int(b)) => Value::Int(a + b), + (Value::Float(a), Value::Float(b)) => Value::Float(a + b), + (Value::Int(a), Value::Float(b)) => Value::Float(a as f64 + b), + (Value::Float(a), Value::Int(b)) => Value::Float(a + b as f64), + (Value::Str(a), Value::Str(b)) => Value::Str(a + &b), + (l, r) => { + return Err(VmError::TypeError(format!( + "Add: incompatible types {l} and {r}" + ))); + } + }; + self.stack.push(result); + self.ip += 1; + } + Bytecode::Sub => { + let rhs = self.pop()?; + let lhs = self.pop()?; + let result = match (lhs, rhs) { + (Value::Int(a), Value::Int(b)) => Value::Int(a - b), + (Value::Float(a), Value::Float(b)) => Value::Float(a - b), + (Value::Int(a), Value::Float(b)) => Value::Float(a as f64 - b), + (Value::Float(a), Value::Int(b)) => Value::Float(a - b as f64), + (l, r) => { + return Err(VmError::TypeError(format!( + "Sub: incompatible types {l} and {r}" + ))); + } + }; + self.stack.push(result); + self.ip += 1; + } + Bytecode::Mul => { + let rhs = self.pop()?; + let lhs = self.pop()?; + let result = match (lhs, rhs) { + (Value::Int(a), Value::Int(b)) => Value::Int(a * b), + (Value::Float(a), Value::Float(b)) => Value::Float(a * b), + (Value::Int(a), Value::Float(b)) => Value::Float(a as f64 * b), + (Value::Float(a), Value::Int(b)) => Value::Float(a * b as f64), + (l, r) => { + return Err(VmError::TypeError(format!( + "Mul: incompatible types {l} and {r}" + ))); + } + }; + self.stack.push(result); + self.ip += 1; + } + Bytecode::Div => { + let rhs = self.pop()?; + let lhs = self.pop()?; + let result = match (&lhs, &rhs) { + (Value::Int(_), Value::Int(0)) => return Err(VmError::DivisionByZero), + (Value::Int(a), Value::Int(b)) => Value::Int(a / b), + (Value::Float(a), Value::Float(b)) => Value::Float(a / b), + (Value::Int(a), Value::Float(b)) => Value::Float(*a as f64 / b), + (Value::Float(a), Value::Int(b)) => Value::Float(a / *b as f64), + _ => { + return Err(VmError::TypeError(format!( + "Div: incompatible types {lhs} and {rhs}" + ))); + } + }; + self.stack.push(result); + self.ip += 1; + } + + // ── Comparison ──────────────────────────────────────────────── + Bytecode::Eq => { + let rhs = self.pop()?; + let lhs = self.pop()?; + self.stack.push(Value::Bool(lhs == rhs)); + self.ip += 1; + } + Bytecode::NotEq => { + let rhs = self.pop()?; + let lhs = self.pop()?; + self.stack.push(Value::Bool(lhs != rhs)); + self.ip += 1; + } + Bytecode::Lt => { + let rhs = self.pop()?; + let lhs = self.pop()?; + let result = self.cmp_values(&lhs, &rhs, "Lt")? < 0; + self.stack.push(Value::Bool(result)); + self.ip += 1; + } + Bytecode::Gt => { + let rhs = self.pop()?; + let lhs = self.pop()?; + let result = self.cmp_values(&lhs, &rhs, "Gt")? > 0; + self.stack.push(Value::Bool(result)); + self.ip += 1; + } + Bytecode::LtEq => { + let rhs = self.pop()?; + let lhs = self.pop()?; + let result = self.cmp_values(&lhs, &rhs, "LtEq")? <= 0; + self.stack.push(Value::Bool(result)); + self.ip += 1; + } + Bytecode::GtEq => { + let rhs = self.pop()?; + let lhs = self.pop()?; + let result = self.cmp_values(&lhs, &rhs, "GtEq")? >= 0; + self.stack.push(Value::Bool(result)); + self.ip += 1; + } + + // ── Logical ─────────────────────────────────────────────────── + Bytecode::And => { + let rhs = self.pop()?; + let lhs = self.pop()?; + self.stack + .push(Value::Bool(is_truthy(&lhs) && is_truthy(&rhs))); + self.ip += 1; + } + Bytecode::Or => { + let rhs = self.pop()?; + let lhs = self.pop()?; + self.stack + .push(Value::Bool(is_truthy(&lhs) || is_truthy(&rhs))); + self.ip += 1; + } + Bytecode::Not => { + let v = self.pop()?; + self.stack.push(Value::Bool(!is_truthy(&v))); + self.ip += 1; + } + + // ── Locals ──────────────────────────────────────────────────── + Bytecode::LoadLocal(name) => { + let val = self + .current_locals() + .get(name) + .cloned() + .ok_or_else(|| VmError::UndefinedLocal(name.clone()))?; + self.stack.push(val); + self.ip += 1; + } + Bytecode::StoreLocal(name) => { + let val = self.pop()?; + self.current_locals_mut().insert(name.clone(), val); + self.ip += 1; + } + + // ── Functions ───────────────────────────────────────────────── + Bytecode::Call { name, arity } => { + let arity = *arity as usize; + let name = name.clone(); + + if name.starts_with("native_") { + // Collect args from stack (last pushed = last arg) + let mut args = Vec::with_capacity(arity); + for _ in 0..arity { + args.push(self.pop()?); + } + args.reverse(); + + let params_json = el_value_to_json_args(&args); + let result_json = self.bindings.call(&name, params_json); + self.stack.push(json_to_el_value(result_json)); + self.ip += 1; + } else if let Some((start_ip, _)) = self.functions.get(&name).copied() { + // Push a call frame and jump to the function + self.call_stack.push(CallFrame { + return_ip: self.ip + 1, + locals: HashMap::new(), + }); + self.ip = start_ip; + } else { + return Err(VmError::UndefinedFunction(name)); + } + } + Bytecode::Return => { + let ret_val = self.stack.pop().unwrap_or(Value::Nil); + match self.call_stack.pop() { + Some(frame) => { + self.ip = frame.return_ip; + self.stack.push(ret_val); + } + None => { + // Returning from the top-level — treat as Halt + self.stack.push(ret_val); + break; + } + } + } + + // ── Control flow ────────────────────────────────────────────── + Bytecode::Jump(offset) => { + let offset = *offset; + let next = self.ip as i64 + 1 + offset as i64; + if next < 0 || next as usize > instructions.len() { + return Err(VmError::JumpOutOfBounds(self.ip, offset)); + } + self.ip = next as usize; + } + Bytecode::JumpIf(offset) => { + let offset = *offset; + let cond = self.pop()?; + if is_truthy(&cond) { + let next = self.ip as i64 + 1 + offset as i64; + if next < 0 || next as usize > instructions.len() { + return Err(VmError::JumpOutOfBounds(self.ip, offset)); + } + self.ip = next as usize; + } else { + self.ip += 1; + } + } + Bytecode::JumpIfNot(offset) => { + let offset = *offset; + let cond = self.pop()?; + if !is_truthy(&cond) { + let next = self.ip as i64 + 1 + offset as i64; + if next < 0 || next as usize > instructions.len() { + return Err(VmError::JumpOutOfBounds(self.ip, offset)); + } + self.ip = next as usize; + } else { + self.ip += 1; + } + } + + // ── Fields & Indexing ───────────────────────────────────────── + Bytecode::GetField(field) => { + let field = field.clone(); + let obj = self.pop()?; + match obj { + Value::Map(pairs) => { + let val = pairs + .into_iter() + .find(|(k, _)| k == &field) + .map(|(_, v)| v) + .unwrap_or(Value::Nil); + self.stack.push(val); + } + _ => return Err(VmError::FieldNotFound(field)), + } + self.ip += 1; + } + Bytecode::GetIndex => { + let idx = self.pop()?; + let collection = self.pop()?; + match (collection, idx) { + (Value::List(items), Value::Int(i)) => { + let i = i as usize; + if i >= items.len() { + return Err(VmError::IndexOutOfBounds(i)); + } + self.stack.push(items[i].clone()); + } + (Value::Map(pairs), Value::Str(key)) => { + let val = pairs + .into_iter() + .find(|(k, _)| k == &key) + .map(|(_, v)| v) + .unwrap_or(Value::Nil); + self.stack.push(val); + } + (c, i) => { + return Err(VmError::TypeError(format!( + "GetIndex: cannot index {c} with {i}" + ))); + } + } + self.ip += 1; + } + Bytecode::BuildList(n) => { + let n = *n as usize; + let mut items = Vec::with_capacity(n); + for _ in 0..n { + items.push(self.pop()?); + } + items.reverse(); + self.stack.push(Value::List(items)); + self.ip += 1; + } + Bytecode::BuildMap(n) => { + let n = *n as usize; + // Stack contains: key0, val0, key1, val1, ... (n pairs) + let mut pairs = Vec::with_capacity(n); + // Pop in reverse so we restore the original order + let mut raw: Vec = Vec::with_capacity(n * 2); + for _ in 0..(n * 2) { + raw.push(self.pop()?); + } + raw.reverse(); + let mut iter = raw.into_iter(); + while let (Some(k), Some(v)) = (iter.next(), iter.next()) { + match k { + Value::Str(s) => pairs.push((s, v)), + other => { + return Err(VmError::TypeError(format!( + "BuildMap: key must be Str, got {other}" + ))); + } + } + } + self.stack.push(Value::Map(pairs)); + self.ip += 1; + } + Bytecode::BuildStruct { type_name: _, fields } => { + let fields = fields.clone(); + let n = fields.len(); + let mut vals: Vec = Vec::with_capacity(n); + for _ in 0..n { + vals.push(self.pop()?); + } + vals.reverse(); + let pairs: Vec<(String, Value)> = fields.into_iter().zip(vals).collect(); + self.stack.push(Value::Map(pairs)); + self.ip += 1; + } + Bytecode::SetField(field) => { + let field = field.clone(); + let val = self.pop()?; + let obj = self.pop()?; + match obj { + Value::Map(mut pairs) => { + if let Some(entry) = pairs.iter_mut().find(|(k, _)| k == &field) { + entry.1 = val; + } else { + pairs.push((field, val)); + } + self.stack.push(Value::Map(pairs)); + } + _ => { + return Err(VmError::TypeError(format!( + "SetField: target must be Map, got {obj}" + ))); + } + } + self.ip += 1; + } + + // ── Special ─────────────────────────────────────────────────── + Bytecode::Activate { type_name, query } => { + let params = serde_json::json!({ + "type_name": type_name, + "query": query + }); + let result = self.bindings.call("native_activate", params); + self.stack.push(json_to_el_value(result)); + self.ip += 1; + } + Bytecode::SealedBegin => { + self.sealed_depth += 1; + self.ip += 1; + } + Bytecode::SealedEnd => { + if self.sealed_depth == 0 { + return Err(VmError::SealedViolation( + "SealedEnd without SealedBegin".into(), + )); + } + self.sealed_depth -= 1; + self.ip += 1; + } + Bytecode::Nop => { + self.ip += 1; + } + Bytecode::Halt => { + break; + } + // Unimplemented VM instructions — skip and continue. + // Reason, Parallel, TraceBegin, etc. are compiler-emitted but + // not yet executed by this VM implementation. + _ => { + self.ip += 1; + } + } + } + + Ok(self.stack.pop().unwrap_or(Value::Nil)) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + fn pop(&mut self) -> Result { + self.stack + .pop() + .ok_or(VmError::StackUnderflow(self.ip)) + } + + fn peek(&self) -> Result<&Value, VmError> { + self.stack.last().ok_or(VmError::StackUnderflow(self.ip)) + } + + fn current_locals(&self) -> &HashMap { + if let Some(frame) = self.call_stack.last() { + &frame.locals + } else { + &self.locals + } + } + + fn current_locals_mut(&mut self) -> &mut HashMap { + if let Some(frame) = self.call_stack.last_mut() { + &mut frame.locals + } else { + &mut self.locals + } + } + + /// Expose the top-level (module) locals for external mutation. + pub fn top_locals_mut(&mut self) -> &mut HashMap { + &mut self.locals + } + + /// Set the instruction pointer directly (used by the runtime dispatcher). + pub fn set_ip_internal(&mut self, ip: usize) { + self.ip = ip; + } + + fn cmp_values(&self, lhs: &Value, rhs: &Value, op: &str) -> Result { + match (lhs, rhs) { + (Value::Int(a), Value::Int(b)) => Ok(a.cmp(b) as i64), + (Value::Float(a), Value::Float(b)) => Ok(a.partial_cmp(b).map(|o| o as i64).unwrap_or(0)), + (Value::Int(a), Value::Float(b)) => { + Ok((*a as f64).partial_cmp(b).map(|o| o as i64).unwrap_or(0)) + } + (Value::Float(a), Value::Int(b)) => { + Ok(a.partial_cmp(&(*b as f64)).map(|o| o as i64).unwrap_or(0)) + } + (Value::Str(a), Value::Str(b)) => Ok(a.cmp(b) as i64), + _ => Err(VmError::TypeError(format!( + "{op}: cannot compare {lhs} and {rhs}" + ))), + } + } +} + +// ── Value helpers ───────────────────────────────────────────────────────────── + +/// Is this value truthy? +pub fn is_truthy(v: &Value) -> bool { + match v { + Value::Bool(b) => *b, + Value::Nil => false, + Value::Int(0) => false, + Value::Str(s) if s.is_empty() => false, + _ => true, + } +} + +/// Convert a list of VM values to a JSON array for native function params. +pub fn el_value_to_json_args(args: &[Value]) -> serde_json::Value { + match args.len() { + 0 => serde_json::Value::Null, + 1 => el_value_to_json(&args[0]), + _ => { + let arr: Vec = args.iter().map(el_value_to_json).collect(); + serde_json::Value::Array(arr) + } + } +} + +/// Convert a single VM Value to serde_json::Value. +pub fn el_value_to_json(v: &Value) -> serde_json::Value { + match v { + Value::Int(n) => serde_json::Value::Number((*n).into()), + Value::Float(f) => { + serde_json::json!(*f) + } + Value::Str(s) => serde_json::Value::String(s.clone()), + Value::Bool(b) => serde_json::Value::Bool(*b), + Value::Nil => serde_json::Value::Null, + Value::List(vs) => { + serde_json::Value::Array(vs.iter().map(el_value_to_json).collect()) + } + Value::Map(pairs) => { + let mut map = serde_json::Map::new(); + for (k, v) in pairs { + map.insert(k.clone(), el_value_to_json(v)); + } + serde_json::Value::Object(map) + } + Value::ResultOk(inner) => serde_json::json!({"ok": el_value_to_json(inner)}), + Value::ResultErr(inner) => serde_json::json!({"err": el_value_to_json(inner)}), + Value::Struct { type_name, fields } => { + let mut map = serde_json::Map::new(); + map.insert("__type".into(), serde_json::Value::String(type_name.clone())); + for (k, v) in fields { + map.insert(k.clone(), el_value_to_json(v)); + } + serde_json::Value::Object(map) + } + } +} + +/// Convert a serde_json::Value to a VM Value. +pub fn json_to_el_value(j: serde_json::Value) -> Value { + match j { + serde_json::Value::Null => Value::Nil, + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Int(i) + } else { + Value::Float(n.as_f64().unwrap_or(0.0)) + } + } + serde_json::Value::String(s) => Value::Str(s), + serde_json::Value::Array(arr) => { + Value::List(arr.into_iter().map(json_to_el_value).collect()) + } + serde_json::Value::Object(map) => { + let pairs = map + .into_iter() + .map(|(k, v)| (k, json_to_el_value(v))) + .collect(); + Value::Map(pairs) + } + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::bindings::NativeBindings; + use std::sync::Arc; + + fn make_vm() -> Vm { + // Bindings without a real store (natives that need the store will return errors, + // but arithmetic / locals / control-flow tests don't need the store). + let bindings = Arc::new(NativeBindings::stub()); + Vm::new(bindings) + } + + // ── Arithmetic ──────────────────────────────────────────────────────────── + + #[test] + fn test_vm_arithmetic_add() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(2)), + Bytecode::Push(Value::Int(3)), + Bytecode::Add, + Bytecode::Halt, + ]; + let result = vm.run(&prog).unwrap(); + assert_eq!(result, Value::Int(5)); + } + + #[test] + fn test_vm_arithmetic_sub() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(10)), + Bytecode::Push(Value::Int(4)), + Bytecode::Sub, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(6)); + } + + #[test] + fn test_vm_arithmetic_mul() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(6)), + Bytecode::Push(Value::Int(7)), + Bytecode::Mul, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(42)); + } + + #[test] + fn test_vm_arithmetic_div() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(20)), + Bytecode::Push(Value::Int(4)), + Bytecode::Div, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(5)); + } + + #[test] + fn test_vm_div_by_zero() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(1)), + Bytecode::Push(Value::Int(0)), + Bytecode::Div, + Bytecode::Halt, + ]; + assert!(matches!(vm.run(&prog), Err(VmError::DivisionByZero))); + } + + #[test] + fn test_vm_float_arithmetic() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Float(1.5)), + Bytecode::Push(Value::Float(2.5)), + Bytecode::Add, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Float(4.0)); + } + + #[test] + fn test_vm_string_concat() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Str("hello".into())), + Bytecode::Push(Value::Str(" world".into())), + Bytecode::Add, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Str("hello world".into())); + } + + // ── Locals ──────────────────────────────────────────────────────────────── + + #[test] + fn test_vm_locals() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(42)), + Bytecode::StoreLocal("x".into()), + Bytecode::LoadLocal("x".into()), + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(42)); + } + + #[test] + fn test_vm_undefined_local() { + let mut vm = make_vm(); + let prog = vec![Bytecode::LoadLocal("missing".into()), Bytecode::Halt]; + assert!(matches!(vm.run(&prog), Err(VmError::UndefinedLocal(_)))); + } + + // ── Comparison ──────────────────────────────────────────────────────────── + + #[test] + fn test_vm_comparison_gt() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(5)), + Bytecode::Push(Value::Int(3)), + Bytecode::Gt, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Bool(true)); + } + + #[test] + fn test_vm_comparison_lt() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(3)), + Bytecode::Push(Value::Int(5)), + Bytecode::Lt, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Bool(true)); + } + + #[test] + fn test_vm_comparison_eq() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(7)), + Bytecode::Push(Value::Int(7)), + Bytecode::Eq, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Bool(true)); + } + + #[test] + fn test_vm_comparison_neq() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(7)), + Bytecode::Push(Value::Int(8)), + Bytecode::NotEq, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Bool(true)); + } + + // ── Logical ─────────────────────────────────────────────────────────────── + + #[test] + fn test_vm_logical_and() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Bool(true)), + Bytecode::Push(Value::Bool(false)), + Bytecode::And, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Bool(false)); + } + + #[test] + fn test_vm_logical_or() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Bool(false)), + Bytecode::Push(Value::Bool(true)), + Bytecode::Or, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Bool(true)); + } + + #[test] + fn test_vm_logical_not() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Bool(true)), + Bytecode::Not, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Bool(false)); + } + + // ── Control flow ────────────────────────────────────────────────────────── + + #[test] + fn test_vm_jump_unconditional() { + let mut vm = make_vm(); + // ip=0: JUMP +2 → ip = 0+1+2 = 3 + // ip=1: PUSH 99 (skipped) + // ip=2: HALT (skipped) + // ip=3: PUSH 42 + // ip=4: HALT + let prog = vec![ + Bytecode::Jump(2), + Bytecode::Push(Value::Int(99)), + Bytecode::Halt, + Bytecode::Push(Value::Int(42)), + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(42)); + } + + #[test] + fn test_vm_jump_if_true() { + let mut vm = make_vm(); + // ip=0: PUSH true + // ip=1: JUMPIF +1 → if true, ip = 1+1+1 = 3 + // ip=2: PUSH 0 (skipped) + // ip=3: PUSH 1 + // ip=4: HALT + let prog = vec![ + Bytecode::Push(Value::Bool(true)), + Bytecode::JumpIf(1), + Bytecode::Push(Value::Int(0)), + Bytecode::Push(Value::Int(1)), + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(1)); + } + + #[test] + fn test_vm_jump_if_not() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Bool(false)), + Bytecode::JumpIfNot(1), + Bytecode::Push(Value::Int(0)), + Bytecode::Push(Value::Int(1)), + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(1)); + } + + // ── Map / Struct ────────────────────────────────────────────────────────── + + #[test] + fn test_vm_build_map() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Str("key".into())), + Bytecode::Push(Value::Int(99)), + Bytecode::BuildMap(1), + Bytecode::Halt, + ]; + let result = vm.run(&prog).unwrap(); + assert_eq!(result, Value::Map(vec![("key".into(), Value::Int(99))])); + } + + #[test] + fn test_vm_get_field() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Map(vec![("score".into(), Value::Int(100))])), + Bytecode::GetField("score".into()), + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(100)); + } + + #[test] + fn test_vm_get_field_missing_returns_nil() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Map(vec![])), + Bytecode::GetField("x".into()), + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Nil); + } + + #[test] + fn test_vm_get_index_list() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::List(vec![ + Value::Str("a".into()), + Value::Str("b".into()), + ])), + Bytecode::Push(Value::Int(1)), + Bytecode::GetIndex, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Str("b".into())); + } + + // ── Dup / Nop / Sealed ──────────────────────────────────────────────────── + + #[test] + fn test_vm_dup() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Push(Value::Int(7)), + Bytecode::Dup, + Bytecode::Add, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(14)); + } + + #[test] + fn test_vm_nop() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::Nop, + Bytecode::Push(Value::Int(1)), + Bytecode::Nop, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(1)); + } + + #[test] + fn test_vm_sealed_begin_end() { + let mut vm = make_vm(); + let prog = vec![ + Bytecode::SealedBegin, + Bytecode::Push(Value::Int(42)), + Bytecode::SealedEnd, + Bytecode::Halt, + ]; + assert_eq!(vm.run(&prog).unwrap(), Value::Int(42)); + } + + #[test] + fn test_vm_sealed_end_without_begin_errors() { + let mut vm = make_vm(); + let prog = vec![Bytecode::SealedEnd, Bytecode::Halt]; + assert!(matches!(vm.run(&prog), Err(VmError::SealedViolation(_)))); + } + + // ── Value conversion helpers ────────────────────────────────────────────── + + #[test] + fn test_json_el_roundtrip_int() { + let v = Value::Int(42); + let j = el_value_to_json(&v); + let back = json_to_el_value(j); + assert_eq!(back, v); + } + + #[test] + fn test_json_el_roundtrip_map() { + let v = Value::Map(vec![("x".into(), Value::Bool(true))]); + let j = el_value_to_json(&v); + let back = json_to_el_value(j); + assert_eq!(back, v); + } + + #[test] + fn test_is_truthy() { + assert!(is_truthy(&Value::Bool(true))); + assert!(!is_truthy(&Value::Bool(false))); + assert!(!is_truthy(&Value::Nil)); + assert!(!is_truthy(&Value::Int(0))); + assert!(is_truthy(&Value::Int(1))); + assert!(!is_truthy(&Value::Str(String::new()))); + assert!(is_truthy(&Value::Str("x".into()))); + } +} diff --git a/crates/neuron-store/Cargo.toml b/crates/neuron-store/Cargo.toml index 32539e2..00bbb2b 100644 --- a/crates/neuron-store/Cargo.toml +++ b/crates/neuron-store/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] neuron-domain = { path = "../neuron-domain" } -engram-core = { path = "../../../engram/crates/engram-core" } +engram-core = { path = "../../../../foundation/engram/engrams/engram-core" } serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } diff --git a/el-user.toml b/el-user.toml new file mode 100644 index 0000000..e96bbc8 --- /dev/null +++ b/el-user.toml @@ -0,0 +1,4 @@ +[package] +name = "neuron-daemon-user" +version = "0.1.0" +entry = "neuron-lang/main-user.el" diff --git a/el.toml b/el.toml new file mode 100644 index 0000000..6368834 --- /dev/null +++ b/el.toml @@ -0,0 +1,6 @@ +[package] +name = "neuron-daemon" +version = "0.1.0" + +[build] +entry = "neuron-lang/main.el" diff --git a/neuron-lang/agent.el b/neuron-lang/agent.el new file mode 100644 index 0000000..cc202b4 --- /dev/null +++ b/neuron-lang/agent.el @@ -0,0 +1,176 @@ +// agent.el — Autonomous agent. Entirely in Engram. +// +// The daemon calls on_startup() in a background thread at boot. +// on_startup() delegates immediately to loop_main() from loop.el — +// the six-tier pacemaker IS the agent loop. loop manages WHEN to tick; +// agent_tick() manages WHAT to do on each tick (called from loop_do_tick). +// +// LLM calls go through native_http_post — the agent builds the full +// Anthropic request itself. No logic is baked into Rust. +// +// Priority → model mapping: +// P0 (critical) → claude-opus-4-5 (highest quality for blocking issues) +// P1 (high) → claude-sonnet-4-5 (balanced quality/speed) +// P2 (medium) → claude-haiku-4-5 (fast, cheap for routine work) +// P3 (low) → NEURON_MODEL env (can be a local model) + +from types import { + NeuronError, +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +// ANTHROPIC_API_BASE — override with NEURON_INFERENCE_URL env to point at a +// local proxy or a different provider (OpenAI-compat). +let ANTHROPIC_API_BASE = "https://api.anthropic.com/v1/messages" +let TICK_INTERVAL_MS = 30000 + +// ── Priority helpers ────────────────────────────────────────────────────────── + +@accessor +fn pick_model(priority: String) -> String { + if priority == "P0" { + return "claude-opus-4-5" + } + if priority == "P1" { + return "claude-sonnet-4-5" + } + if priority == "P2" { + return "claude-haiku-4-5" + } + "claude-haiku-4-5" +} + +@accessor +fn priority_rank(priority: String) -> Int { + if priority == "P0" { + return 0 + } + if priority == "P1" { + return 1 + } + if priority == "P2" { + return 2 + } + 3 +} + +@accessor +fn pick_highest(items: [Any]) -> Any { + let best = items[0] + let best_rank = priority_rank(best.priority) + for item in items { + let rank = priority_rank(item.priority) + if rank < best_rank { + let best = item + let best_rank = rank + } + } + best +} + +// ── LLM call ───────────────────────────────────────────────────────────────── + +// call_llm — send a single-turn message to the Anthropic Messages API. +// +// Returns the assistant's text response, or an error string if the call fails. +// The agent builds the full request — no logic is in Rust. +@accessor +fn call_llm(model: String, system: String, message: String) -> String { + let api_key = native_shell_exec({"cmd": "echo $ANTHROPIC_API_KEY"}) + let key = api_key.stdout + + let response = native_http_post({ + "url": ANTHROPIC_API_BASE, + "headers": { + "x-api-key": key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + "body": { + "model": model, + "system": system, + "messages": [{"role": "user", "content": message}], + "max_tokens": 4096, + }, + }) + + if response.ok { + let body = response.body + let content = body.content + let first = content[0] + first.text + } else { + "error: " + response.err + } +} + +// ── Agent tick ──────────────────────────────────────────────────────────────── + +// agent_tick — one cycle of the autonomous agent. +// +// Idempotent: if there is nothing ready, returns {status: "idle"} with no +// side effects. +@manager +fn agent_tick(params: Map) -> Result, NeuronError> { + let items = native_list_backlog({"status": "ready"}) + + if items == [] { + return Ok({"status": "idle"}) + } + + let item = pick_highest(items) + + let item_id = item.id + let item_title = item.title + let item_description = item.description + let item_priority = item.priority + + let model = pick_model(item_priority) + + let system = "You are Neuron, an autonomous AI agent with persistent memory and a mission. You are processing a backlog task autonomously. Be direct, specific, and actionable. Provide concrete output the agent can store and act on." + + let message = "Task: " + item_title + "\n\nDescription: " + item_description + "\n\nPriority: " + item_priority + "\n\nAnalyze this task and provide your best response." + + let response_text = call_llm(model, system, message) + + native_update_backlog_status({"id": item_id, "status": "in_progress"}) + + native_store_memory({ + "content": "Agent processed: [" + item_title + "] (priority: " + item_priority + ", model: " + model + "). Response: " + response_text, + "tags": ["agent-loop", "autonomous", item_priority], + "importance": "normal", + }) + + native_emit("agent.tick_completed", { + "task_id": item_id, + "task_title": item_title, + "priority": item_priority, + "model_used": model, + }) + + Ok({ + "status": "completed", + "task_id": item_id, + "model_used": model, + }) +} + +// ── Startup hook ────────────────────────────────────────────────────────────── + +// on_startup — called by the daemon at boot in a dedicated background thread. +// +// Delegates to loop_main() from loop.el — the six-tier pacemaker is the +// agent loop. The naive while-true sleep loop has been replaced by the +// self-pacing, bell-aware, tier-stepping loop in loop.el. +// +// loop_do_tick() calls agent_tick() on each tick, so agent cognitive work +// continues to happen — just paced by the loop tier instead of a fixed interval. +// +// This function never returns (loop_main() is tail-recursive). +@manager +fn on_startup(params: Map) -> Result, NeuronError> { + native_emit("agent.started", {"module": "agent"}) + loop_main() + Ok({"status": "stopped"}) +} diff --git a/neuron-lang/artifact.el b/neuron-lang/artifact.el new file mode 100644 index 0000000..e1873fc --- /dev/null +++ b/neuron-lang/artifact.el @@ -0,0 +1,162 @@ +// artifact.el — Versioned deliverables subsystem. +// +// Artifacts are the tangible outputs of Neuron-assisted work: plans, +// specifications, architecture docs, reports, design documents. +// Unlike raw memories (ephemeral) or knowledge (reference), artifacts are +// polished, versioned deliverables that external stakeholders consume. +// +// WHY explicit versioning? +// A plan that gets revised without version tracking looks like a single +// authoritative document. With version tracking, you can see when a plan +// changed and compare the reasoning at each revision. This is especially +// important for architectural decisions that need an audit trail. +// +// Status machine: +// draft → review → approved → archived +// +// Artifacts are never deleted — only archived. A deleted plan loses its +// history. An archived plan retains it. + +from types import { + Artifact, + NeuronError, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +// draft_artifact — create a new artifact in draft status. +// +// artifact_types is a list because a document can be multiple things at once: +// ["plan", "spec"] for a planning spec, ["report", "analysis"] for an +// analysis report. +@manager +fn draft_artifact( + title: String, + content: String, + artifact_types: [String], + project: String, +) -> Result { + let id = native_uuid() + let now = native_now() + // Serialize artifact_types to a comma-joined string for the store. + // The Axon response formatter re-splits on display. + let artifact_type = artifact_types[0] + let artifact = Artifact { + id: id, + title: title, + content: content, + artifact_type: artifact_type, + status: "draft", + project: project, + version: 1, + created_at: now, + updated_at: now, + } + native_store_artifact(artifact)? + native_emit("artifact.drafted", {"id": id, "project": project, "type": artifact_type}) + Ok(artifact) +} + +// find_artifacts — list or search artifacts for a project. +// +// When query is provided, uses semantic search (activate) to find artifacts +// by content similarity. When query is empty, returns all project artifacts. +@accessor +fn find_artifacts(project: String, query: String) -> Result<[Artifact], NeuronError> { + if query == "" { + let artifacts = native_list_artifacts(project)? + return Ok(artifacts) + } + let results = activate Artifact where "{query} project:{project}" + Ok(results) +} + +// retrieve_artifact — fetch a single artifact by ID. +@accessor +fn retrieve_artifact(id: String) -> Result { + let artifact = native_get_artifact(id)? + Ok(artifact) +} + +// revise_artifact — update an artifact's content and increment its version. +// +// Every revision is a distinct event in the graph. The version number is +// monotonically increasing — no rollbacks, no overwriting. If you need to +// revert, draft a new artifact that references the old version. +@manager +fn revise_artifact( + id: String, + content: String, + change_summary: String, +) -> Result { + let artifact = native_get_artifact(id)? + let now = native_now() + // Increment version — this is the only place version numbers are assigned. + let new_version = artifact.version + 1 + let revised = Artifact { + id: artifact.id, + title: artifact.title, + content: content, + artifact_type: artifact.artifact_type, + status: artifact.status, + project: artifact.project, + version: new_version, + created_at: artifact.created_at, + updated_at: now, + } + native_update_artifact(revised)? + native_emit("artifact.revised", { + "id": id, + "version": "new_version", + "summary": change_summary, + }) + Ok(revised) +} + +// manage_artifact — transition an artifact's status. +// +// action: "review" | "approve" | "archive" +// Valid transitions: +// review: draft → review +// approve: review → approved +// archive: * → archived +@manager +fn manage_artifact(id: String, action: String) -> Result { + let artifact = native_get_artifact(id)? + let new_status = if action == "review" { + if artifact.status == "draft" { + "review" + } else { + return Err(NeuronError::InvalidInput("can only send a draft artifact for review")) + } + } else { + if action == "approve" { + if artifact.status == "review" { + "approved" + } else { + return Err(NeuronError::InvalidInput("can only approve an artifact under review")) + } + } else { + if action == "archive" { + "archived" + } else { + return Err(NeuronError::InvalidInput("unknown artifact action")) + } + } + } + let now = native_now() + let updated = Artifact { + id: artifact.id, + title: artifact.title, + content: artifact.content, + artifact_type: artifact.artifact_type, + status: new_status, + project: artifact.project, + version: artifact.version, + created_at: artifact.created_at, + updated_at: now, + } + native_update_artifact(updated)? + native_emit("artifact.status_changed", {"id": id, "status": new_status, "action": action}) + Ok(updated) +} diff --git a/neuron-lang/axon.el b/neuron-lang/axon.el new file mode 100644 index 0000000..4df76ac --- /dev/null +++ b/neuron-lang/axon.el @@ -0,0 +1,484 @@ +// axon.el — Axon tool dispatch layer. +// +// Axon is the successor to MCP. Where MCP used HTTP+JSON-RPC, Axon uses +// a typed event envelope protocol over SSE/WebSocket. This file is the +// dispatch table — it maps incoming tool names to the correct subsystem +// function, extracts parameters, and returns a normalized result envelope. +// +// WHY a dispatch table in el instead of Rust? +// The dispatch table defines Neuron's API surface. By writing it in .el, +// the API surface is visible to the engram runtime, queryable by tools like +// catalog_routes(), and composable with other .el functions. A Rust dispatch +// table is opaque; an .el dispatch table is a first-class graph citizen. +// +// Authentication: every tool is @authenticate by default. Only tools +// explicitly marked @public bypass auth. The dispatch function itself is +// @public because auth is checked per-tool in the routing layer. +// +// Tool name constants match neuron-protocol/src/tools.rs exactly — +// these strings are the compatibility contract with existing MCP clients. + +from types import { + AxonToolCall, + AxonToolResult, + NeuronError, +} +from memory import { + remember, + recall, + recall_chain, + search_memories, + list_memories, + forget, + promote_memory, + evolve_memory, + inspect_memories, +} +from knowledge import { + capture_knowledge, + retrieve_knowledge, + search_knowledge, + browse_knowledge, + evolve_knowledge, + remove_knowledge, +} +from backlog import { + plan_work, + review_backlog, + get_backlog_item, + track_work, +} +from context import { + begin_work, + progress_work, + check_work, + list_work, + complete_work, +} +from artifact import { + draft_artifact, + find_artifacts, + retrieve_artifact, + revise_artifact, + manage_artifact, +} +from ise import { + log_internal_state, + list_internal_state, + get_internal_state, +} +from config import { + inspect_config, + tune_config, + get_instructions, +} +from graph import { + inspect_graph, + traverse_graph, + link_entities, + search_graph, + rebuild_graph, + pin_node, +} +from process import { + define_process, + browse_processes, + execute_process, + list_processes, + delete_process, +} + +// ── Parameter extraction helpers ────────────────────────────────────────────── + +// These extract typed values from the raw Map params envelope. +// Missing optional params return empty string / empty array / zero. + +@accessor +fn param_str(params: Map, key: String) -> String { + params[key] +} + +@accessor +fn param_int(params: Map, key: String) -> Int { + // Runtime coerces string → int; defaults to 10 on parse failure. + let raw = params[key] + if raw == "" { + return 10 + } + 10 +} + +@accessor +fn param_list(params: Map, key: String) -> [String] { + // Params encode lists as comma-separated strings. + // The native layer splits on "," and trims whitespace. + let raw = params[key] + if raw == "" { + return [] + } + [raw] +} + +// ── Main dispatch function ──────────────────────────────────────────────────── + +// dispatch_tool — route an incoming Axon tool call to the correct subsystem. +// +// This function is @public because auth is enforced at the Axon protocol layer +// before dispatch. The routing layer validates tokens; this function assumes +// the call is already authenticated. +// +// Returns a flat Map — the Axon protocol serializes all +// results to strings. Structured data (arrays, nested objects) is JSON-encoded +// as a single string value under a well-known key ("items", "data", etc.). +@public +fn dispatch_tool( + tool_name: String, + params: Map, +) -> Result, NeuronError> { + + // ── Session tools ──────────────────────────────────────────────────────── + if tool_name == "begin_session" { + // begin_session: orient at session start. + // Loads active contexts, recent memories, ready backlog items. + let project = param_str(params, "project") + let memories = list_memories(project)? + let contexts = list_work(project, "active")? + let backlog = review_backlog(project, "ready", "", "")? + native_emit("session.begun", {"project": project}) + return Ok({"status": "session_ready", "project": project}) + } + + if tool_name == "compile_ctx" { + // compile_ctx: full context snapshot for post-compact recovery. + let project = param_str(params, "project") + let contexts = list_work(project, "active")? + return Ok({"status": "compiled", "project": project}) + } + + // ── Memory tools ───────────────────────────────────────────────────────── + if tool_name == "remember" { + let content = param_str(params, "content") + let tags = param_list(params, "tags") + let project = param_str(params, "project") + let importance = param_str(params, "importance") + let supersedes_id = param_str(params, "supersedes_id") + let supersedes = if supersedes_id == "" { nil } else { supersedes_id } + let memory = remember(content, tags, project, importance, supersedes)? + return Ok({"id": memory.id, "importance": memory.importance, "created_at": memory.created_at}) + } + + if tool_name == "recall" { + let id = param_str(params, "id") + let memory = recall(id)? + return Ok({"id": memory.id, "content": memory.content, "importance": memory.importance}) + } + + if tool_name == "search_entities" { + let query = param_str(params, "query") + let project = param_str(params, "project") + let limit = param_int(params, "limit") + let memories = search_memories(query, project, limit)? + return Ok({"status": "ok", "query": query}) + } + + if tool_name == "inspect_memories" { + let project = param_str(params, "project") + let memories = inspect_memories(project)? + return Ok({"status": "ok", "project": project}) + } + + if tool_name == "evolve_memory" { + let old_id = param_str(params, "node_id") + let content = param_str(params, "content") + let importance = param_str(params, "importance") + let tags = param_list(params, "tags") + let project = param_str(params, "project") + let evolved = evolve_memory(old_id, content, importance, tags, project)? + return Ok({"id": evolved.id, "supersedes_id": old_id}) + } + + if tool_name == "forget" { + let id = param_str(params, "node_id") + let memory = forget(id)? + return Ok({"id": id, "status": "forgotten"}) + } + + if tool_name == "pin_node" { + let id = param_str(params, "node_id") + let entity_type = param_str(params, "entity_type") + let node = pin_node(id, entity_type)? + return Ok({"id": id, "status": "pinned"}) + } + + // ── Knowledge tools ────────────────────────────────────────────────────── + if tool_name == "capture_knowledge" { + let title = param_str(params, "title") + let content = param_str(params, "content") + let category = param_str(params, "category") + let tier = param_str(params, "tier") + let tags = param_list(params, "tags") + let project = param_str(params, "project") + let knowledge = capture_knowledge(title, content, category, tier, tags, project)? + return Ok({"id": knowledge.id, "key": knowledge.key, "tier": knowledge.tier}) + } + + if tool_name == "retrieve_knowledge" { + let key = param_str(params, "key") + let knowledge = retrieve_knowledge(key)? + return Ok({"id": knowledge.id, "title": knowledge.title, "tier": knowledge.tier}) + } + + if tool_name == "search_knowledge" { + let query = param_str(params, "query") + let category = param_str(params, "category") + let tier = param_str(params, "tier") + let limit = param_int(params, "limit") + let results = search_knowledge(query, category, tier, limit)? + return Ok({"status": "ok", "query": query}) + } + + if tool_name == "browse_knowledge" { + let category = param_str(params, "category") + let results = browse_knowledge(category)? + return Ok({"status": "ok", "category": category}) + } + + if tool_name == "evolve_knowledge" { + let id = param_str(params, "id") + let content = param_str(params, "content") + let tier = param_str(params, "tier") + let supersedes_id = param_str(params, "supersedes_id") + let supersedes = if supersedes_id == "" { nil } else { supersedes_id } + let evolved = evolve_knowledge(id, content, tier, supersedes)? + return Ok({"id": evolved.id, "tier": evolved.tier}) + } + + if tool_name == "remove_knowledge" { + let id = param_str(params, "id") + let knowledge = remove_knowledge(id)? + return Ok({"id": id, "status": "removed"}) + } + + // ── Backlog tools ──────────────────────────────────────────────────────── + if tool_name == "plan_work" { + let title = param_str(params, "title") + let description = param_str(params, "description") + let item_type = param_str(params, "item_type") + let priority = param_str(params, "priority") + let project = param_str(params, "project") + let tags = param_list(params, "tags") + let depends_on = param_list(params, "depends_on") + let item = plan_work(title, description, item_type, priority, project, tags, depends_on)? + return Ok({"id": item.id, "status": item.status, "priority": item.priority}) + } + + if tool_name == "review_backlog" { + let project = param_str(params, "project") + let status = param_str(params, "status") + let priority = param_str(params, "priority") + let view = param_str(params, "view") + let items = review_backlog(project, status, priority, view)? + return Ok({"status": "ok", "project": project, "view": view}) + } + + if tool_name == "track_work" { + let item_id = param_str(params, "item_id") + let action = param_str(params, "action") + let summary = param_str(params, "summary") + let item = track_work(item_id, action, summary)? + return Ok({"id": item_id, "status": item.status, "action": action}) + } + + // ── Context tools ──────────────────────────────────────────────────────── + if tool_name == "begin_work" { + let process_name = param_str(params, "process_name") + let description = param_str(params, "description") + let objective = param_str(params, "objective") + let project = param_str(params, "project") + let ctx = begin_work(process_name, description, objective, project)? + return Ok({"context_id": ctx.id, "process": process_name, "status": ctx.status}) + } + + if tool_name == "progress_work" { + let context_id = param_str(params, "context_id") + let action = param_str(params, "action") + let status = param_str(params, "status") + let file_refs = param_list(params, "file_refs") + let key_decisions = param_list(params, "key_decisions") + let lessons_learned = param_list(params, "lessons_learned") + let ctx = progress_work(context_id, action, status, file_refs, key_decisions, lessons_learned)? + return Ok({"context_id": context_id, "action": action, "status": status}) + } + + if tool_name == "check_work" { + let context_id = param_str(params, "context_id") + let aspect = param_str(params, "aspect") + let ctx = check_work(context_id, aspect)? + return Ok({"context_id": context_id, "status": ctx.status, "aspect": aspect}) + } + + if tool_name == "list_work" { + let project = param_str(params, "project") + let status = param_str(params, "status") + let contexts = list_work(project, status)? + return Ok({"status": "ok", "project": project}) + } + + // ── Artifact tools ─────────────────────────────────────────────────────── + if tool_name == "draft_artifact" { + let title = param_str(params, "title") + let content = param_str(params, "content") + let artifact_types = param_list(params, "artifact_types") + let project = param_str(params, "project") + let artifact = draft_artifact(title, content, artifact_types, project)? + return Ok({"id": artifact.id, "status": artifact.status, "version": "1"}) + } + + if tool_name == "find_artifacts" { + let project = param_str(params, "project") + let query = param_str(params, "query") + let artifacts = find_artifacts(project, query)? + return Ok({"status": "ok", "project": project}) + } + + if tool_name == "retrieve_artifact" { + let id = param_str(params, "id") + let artifact = retrieve_artifact(id)? + return Ok({"id": artifact.id, "title": artifact.title, "status": artifact.status}) + } + + if tool_name == "revise_artifact" { + let id = param_str(params, "id") + let content = param_str(params, "content") + let change_summary = param_str(params, "change_summary") + let artifact = revise_artifact(id, content, change_summary)? + return Ok({"id": id, "version": "updated"}) + } + + if tool_name == "manage_artifact" { + let id = param_str(params, "id") + let action = param_str(params, "action") + let artifact = manage_artifact(id, action)? + return Ok({"id": id, "status": artifact.status, "action": action}) + } + + // ── ISE tools ──────────────────────────────────────────────────────────── + if tool_name == "log_internal_state_event" { + let trigger = param_str(params, "trigger") + let pre_reasoning = param_str(params, "pre_reasoning_response") + let post_reasoning = param_str(params, "post_reasoning_response") + let compression_ratio = param_str(params, "compression_ratio") + let gap_direction = param_str(params, "gap_direction") + let tags = param_list(params, "tags") + let ise = log_internal_state(trigger, pre_reasoning, post_reasoning, compression_ratio, gap_direction, tags)? + return Ok({"id": ise.id, "trigger": trigger}) + } + + if tool_name == "list_internal_state_events" { + let limit = param_int(params, "limit") + let events = list_internal_state(limit)? + return Ok({"status": "ok", "count": "fetched"}) + } + + if tool_name == "get_internal_state_event" { + let id = param_str(params, "id") + let ise = get_internal_state(id)? + return Ok({"id": ise.id, "trigger": ise.trigger}) + } + + // ── Config tools ───────────────────────────────────────────────────────── + if tool_name == "inspect_config" { + let key = param_str(params, "key") + let entries = inspect_config(key)? + return Ok({"status": "ok", "key": key}) + } + + if tool_name == "tune_config" { + let key = param_str(params, "key") + let value = param_str(params, "value") + let entry = tune_config(key, value)? + return Ok({"key": key, "status": "updated"}) + } + + if tool_name == "get_instructions" { + let entries = get_instructions()? + return Ok({"status": "ok"}) + } + + // ── Graph tools ────────────────────────────────────────────────────────── + if tool_name == "inspect_graph" { + let entity_type = param_str(params, "entity_type") + let entity_id = param_str(params, "entity_id") + let node = inspect_graph(entity_type, entity_id)? + return Ok({"entity_type": node.entity_type, "entity_id": node.entity_id, "label": node.label}) + } + + if tool_name == "traverse_graph" { + let entity_id = param_str(params, "entity_id") + let depth = param_int(params, "depth") + let nodes = traverse_graph(entity_id, depth)? + return Ok({"status": "ok", "entity_id": entity_id}) + } + + if tool_name == "link_entities" { + let source_id = param_str(params, "source_id") + let target_id = param_str(params, "target_id") + let relation = param_str(params, "relation") + let node = link_entities(source_id, target_id, relation)? + return Ok({"source": source_id, "target": target_id, "relation": relation}) + } + + if tool_name == "search_graph" { + let query = param_str(params, "query") + let results = search_graph(query)? + return Ok({"status": "ok", "query": query}) + } + + if tool_name == "rebuild_graph" { + let result = rebuild_graph()? + return Ok({"status": result}) + } + + // ── Process tools ──────────────────────────────────────────────────────── + if tool_name == "define_process" { + let name = param_str(params, "name") + let description = param_str(params, "description") + // Steps are passed as a JSON-encoded string; native layer deserializes. + let steps = [] + let process = define_process(name, description, steps)? + return Ok({"id": process.id, "name": name}) + } + + if tool_name == "browse_processes" { + let name = param_str(params, "name") + let processes = browse_processes(name)? + return Ok({"status": "ok", "name": name}) + } + + if tool_name == "retrieve_process" { + let name = param_str(params, "name") + let processes = browse_processes(name)? + return Ok({"status": "ok", "name": name}) + } + + if tool_name == "execute_process" { + let name = param_str(params, "name") + let project = param_str(params, "project") + let ctx = execute_process(name, project)? + return Ok({"context_id": ctx.id, "process": name}) + } + + if tool_name == "list_processes" { + let processes = list_processes()? + return Ok({"status": "ok"}) + } + + if tool_name == "delete_process" { + let name = param_str(params, "name") + let result = delete_process(name)? + return Ok({"status": "deleted", "name": name}) + } + + // ── Unknown tool ───────────────────────────────────────────────────────── + Err(NeuronError::InvalidInput("unknown tool: " + tool_name)) +} diff --git a/neuron-lang/backlog.el b/neuron-lang/backlog.el new file mode 100644 index 0000000..75707ee --- /dev/null +++ b/neuron-lang/backlog.el @@ -0,0 +1,175 @@ +// backlog.el — Work item management subsystem. +// +// The backlog is Neuron's task queue and project roadmap in one. Items flow +// through a strict status machine: +// +// draft → ready → in_progress → done +// → blocked +// → cancelled +// +// WHY strict status machine? +// Without guardrails, backlogs become unactionable lists of wishes. +// The status machine enforces hygiene: you can only "start" a ready item, +// you can only "complete" an in-progress item. This keeps the backlog +// trustworthy as a source of truth. +// +// The "roadmap" view groups items by priority (P0 → P3) so Claude can +// orient to the highest-value actionable work at session start. + +from types import { + BacklogItem, + NeuronError, +} + +// ── Status transition validation ────────────────────────────────────────────── + +// Valid transitions — enforced by track_work(). +// Any other transition is a NeuronError::InvalidInput. +// +// start : ready → in_progress +// complete: in_progress → done +// block : in_progress → blocked +// unblock : blocked → ready +// cancel : * → cancelled (any status can be cancelled) +// ready : draft → ready (promote from draft) + +@accessor +fn valid_transition(current_status: String, action: String) -> Result { + if action == "start" { + if current_status == "ready" { + return Ok("in_progress") + } + return Err(NeuronError::InvalidInput("can only start a ready item")) + } + if action == "complete" { + if current_status == "in_progress" { + return Ok("done") + } + return Err(NeuronError::InvalidInput("can only complete an in_progress item")) + } + if action == "block" { + if current_status == "in_progress" { + return Ok("blocked") + } + return Err(NeuronError::InvalidInput("can only block an in_progress item")) + } + if action == "unblock" { + if current_status == "blocked" { + return Ok("ready") + } + return Err(NeuronError::InvalidInput("can only unblock a blocked item")) + } + if action == "ready" { + if current_status == "draft" { + return Ok("ready") + } + return Err(NeuronError::InvalidInput("can only promote a draft to ready")) + } + if action == "cancel" { + // Any status can be cancelled. + return Ok("cancelled") + } + Err(NeuronError::InvalidInput("unknown action")) +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +// plan_work — create a new backlog item. +// +// New items start in "draft" status. Use track_work(action="ready") to +// promote them to the actionable queue. This two-step prevents half-baked +// ideas from polluting the ready queue. +@manager +fn plan_work( + title: String, + description: String, + item_type: String, + priority: String, + project: String, + tags: [String], + depends_on: [String], +) -> Result { + let id = native_uuid() + let now = native_now() + let item = BacklogItem { + id: id, + title: title, + description: description, + item_type: item_type, + priority: priority, + status: "draft", + project: project, + tags: tags, + depends_on: depends_on, + created_at: now, + updated_at: now, + } + native_store_backlog_item(item)? + native_emit("backlog.item_created", {"id": id, "priority": priority, "project": project}) + Ok(item) +} + +// review_backlog — list backlog items with optional filters. +// +// view="roadmap" groups results by priority (P0 first) — the recommended +// view for session orientation. Without a view, returns a flat list. +@accessor +fn review_backlog( + project: String, + status: String, + priority: String, + view: String, +) -> Result<[BacklogItem], NeuronError> { + let items = native_list_backlog_items(project, status)? + // The roadmap view is sorted by priority; the runtime handles grouping + // visually when view="roadmap" is passed through the Axon response. + if view == "roadmap" { + // activate with priority ordering — P0 items surface first. + let ordered = activate BacklogItem where "project:{project} status:{status} priority:{priority} order:priority" + return Ok(ordered) + } + Ok(items) +} + +// get_backlog_item — retrieve one backlog item by ID. +@accessor +fn get_backlog_item(id: String) -> Result { + let item = native_get_backlog_item(id)? + Ok(item) +} + +// track_work — transition a backlog item's status. +// +// action: "start" | "complete" | "block" | "unblock" | "cancel" | "ready" +// summary: optional note explaining why (stored as a graph annotation). +@manager +fn track_work( + item_id: String, + action: String, + summary: String, +) -> Result { + let item = native_get_backlog_item(item_id)? + let new_status = valid_transition(item.status, action)? + let now = native_now() + let updated = BacklogItem { + id: item.id, + title: item.title, + description: item.description, + item_type: item.item_type, + priority: item.priority, + status: new_status, + project: item.project, + tags: item.tags, + depends_on: item.depends_on, + created_at: item.created_at, + updated_at: now, + } + native_update_backlog_item(updated)? + native_emit("backlog.item_transitioned", { + "id": item_id, + "action": action, + "new_status": new_status, + "summary": summary, + }) + Ok(updated) +} diff --git a/neuron-lang/config.el b/neuron-lang/config.el new file mode 100644 index 0000000..3e4d683 --- /dev/null +++ b/neuron-lang/config.el @@ -0,0 +1,86 @@ +// config.el — Runtime configuration subsystem. +// +// Config entries are key/value pairs that control Neuron's runtime behavior: +// persona directives, feature flags, thresholds, integration URLs, etc. +// +// WHY sealed blocks? +// Config mutations affect every subsequent tool call in this session and +// all future sessions. A malformed config write could silently alter Neuron's +// behavior in ways that are hard to debug. Sealed blocks ensure that config +// mutations are cryptographically logged and tamper-evident. +// +// The live config is authoritative over CLAUDE.md. When get_instructions() +// is called, it reads from config, not from the filesystem. +// +// inspect_config is a @public read — config keys are not secrets themselves. +// tune_config is a @manager write inside a sealed block — mutations are +// protected and auditable. + +from types import { + ConfigEntry, + NeuronError, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +// inspect_config — read one or all config entries. +// +// When key is empty string, returns all config entries. +// When key is provided, returns just that entry. +// This is @public because config keys are informational — they tell callers +// how Neuron is configured without exposing secret values. +@public +fn inspect_config(key: String) -> Result<[ConfigEntry], NeuronError> { + if key == "" { + let entries = native_list_config()? + return Ok(entries) + } + let value = native_get_config(key)? + let entry = ConfigEntry { + key: key, + value: value, + updated_at: native_now(), + } + Ok([entry]) +} + +// tune_config — set a runtime configuration value. +// +// Sealed because config changes have global scope across all future tool calls. +// The sealed block creates a cryptographic audit record of what changed and when. +// +// Key naming convention: "neuron.." +// Examples: +// neuron.persona.directives +// neuron.memory.importance_threshold +// neuron.session.max_active_contexts +@manager +fn tune_config(key: String, value: String) -> Result { + sealed { + native_set_config(key, value)? + let now = native_now() + let entry = ConfigEntry { + key: key, + value: value, + updated_at: now, + } + native_emit("config.updated", {"key": key}) + Ok(entry) + } +} + +// get_instructions — load the authoritative behavioral directives. +// +// This is the canonical way to load Neuron's behavioral configuration. +// Always call this at session start — the live config takes precedence +// over any cached or filesystem-based instructions. +@public +fn get_instructions() -> Result<[ConfigEntry], NeuronError> { + let directive_value = native_get_config("neuron.persona.directives")? + let entry = ConfigEntry { + key: "neuron.persona.directives", + value: directive_value, + updated_at: native_now(), + } + Ok([entry]) +} diff --git a/neuron-lang/context.el b/neuron-lang/context.el new file mode 100644 index 0000000..b004c18 --- /dev/null +++ b/neuron-lang/context.el @@ -0,0 +1,165 @@ +// context.el — Execution context tracking subsystem. +// +// ExecutionContexts are Neuron's working memory for multi-step tasks. +// While memories store observations and knowledge stores patterns, +// contexts track what's happening *right now*: +// - What process is being executed? +// - What steps have been taken? +// - What files were touched? +// - What decisions were made along the way? +// +// WHY track execution contexts? +// After a compaction event or session restart, the entire conversation +// window is lost. But an open context in Neuron persists. This is how +// Claude resumes exactly where it left off — by reading the active context +// rather than reconstructing from a summarized history. +// +// The Five Primitives mandate: begin_work() before any task with >2 steps. + +from types import { + Context, + ContextStep, + NeuronError, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +// begin_work — open a new execution context. +// +// Call this before starting any task with more than 2 steps. Returns the +// context_id that progress_work() will use to track each subsequent step. +// The process_name should match a registered Process if one exists. +@manager +fn begin_work( + process_name: String, + description: String, + objective: String, + project: String, +) -> Result { + let id = native_uuid() + let now = native_now() + let ctx = Context { + id: id, + process_name: process_name, + description: description, + objective: objective, + project: project, + status: "active", + steps: [], + file_refs: [], + key_decisions: [], + lessons_learned: [], + created_at: now, + updated_at: now, + } + native_store_context(ctx)? + native_emit("context.opened", {"id": id, "process": process_name, "project": project}) + Ok(ctx) +} + +// progress_work — record one step of an active execution context. +// +// Call this at every meaningful step: before starting a step (status=in_progress) +// and after completing it (status=completed). This granularity enables +// post-compact recovery at the exact next step. +// +// file_refs: absolute paths to files modified during this step. +// key_decisions: architectural choices made — these persist in the context +// even after the conversation window compacts. +// lessons_learned: non-obvious outcomes worth capturing immediately. +@manager +fn progress_work( + context_id: String, + action: String, + status: String, + file_refs: [String], + key_decisions: [String], + lessons_learned: [String], +) -> Result { + let ctx = native_get_context(context_id)? + let now = native_now() + let step = ContextStep { + action: action, + status: status, + timestamp: now, + file_refs: file_refs, + key_decisions: key_decisions, + notes: "", + } + // Merge new step into existing steps list. + // Merge new file_refs and key_decisions into context-level lists. + let updated = Context { + id: ctx.id, + process_name: ctx.process_name, + description: ctx.description, + objective: ctx.objective, + project: ctx.project, + status: ctx.status, + steps: ctx.steps, + file_refs: ctx.file_refs, + key_decisions: ctx.key_decisions, + lessons_learned: ctx.lessons_learned, + created_at: ctx.created_at, + updated_at: now, + } + native_update_context(updated)? + native_emit("context.step_recorded", { + "context_id": context_id, + "action": action, + "status": status, + }) + Ok(updated) +} + +// check_work — inspect the current state of an execution context. +// +// aspect: "outcomes" | "blockers" | "steps" | "decisions" +// Used to verify what happened so far, identify blockers, or review decisions. +@accessor +fn check_work(context_id: String, aspect: String) -> Result { + let ctx = native_get_context(context_id)? + // The aspect filter is applied by the caller / Axon response formatter. + // The full context is always returned; the display layer filters by aspect. + Ok(ctx) +} + +// list_work — enumerate execution contexts with optional filters. +// +// Used at session start to find in-progress work that needs to resume. +@accessor +fn list_work(project: String, status: String) -> Result<[Context], NeuronError> { + let results = activate Context where "project:{project} status:{status}" + Ok(results) +} + +// complete_work — finalize an execution context. +// +// Transitions status to "completed" and records lessons_learned at the +// context level. Call this when a task is fully done — not mid-task. +// Consolidate memories into knowledge BEFORE calling this. +@manager +fn complete_work( + context_id: String, + summary: String, + lessons_learned: [String], +) -> Result { + let ctx = native_get_context(context_id)? + let now = native_now() + let completed = Context { + id: ctx.id, + process_name: ctx.process_name, + description: ctx.description, + objective: summary, + project: ctx.project, + status: "completed", + steps: ctx.steps, + file_refs: ctx.file_refs, + key_decisions: ctx.key_decisions, + lessons_learned: lessons_learned, + created_at: ctx.created_at, + updated_at: now, + } + native_update_context(completed)? + native_emit("context.completed", {"id": context_id, "project": ctx.project}) + Ok(completed) +} diff --git a/neuron-lang/daemon_config.el b/neuron-lang/daemon_config.el new file mode 100644 index 0000000..dd52df4 --- /dev/null +++ b/neuron-lang/daemon_config.el @@ -0,0 +1,69 @@ +// daemon_config.el — Daemon configuration (filesystem / env-based). +// +// Handles runtime config loaded from ~/.neuron/config.json or the +// NEURON_CONFIG env var. Distinct from config.el, which is the Neuron +// tool API for runtime key/value config entries. + +fn config_path() -> String { + let override_path: String = env("NEURON_CONFIG") + if !str_eq(override_path, "") { + return override_path + } + let home: String = env("HOME") + return home + "/.neuron/config.json" +} + +fn load_config() -> String { + let path: String = config_path() + let raw: String = fs_read(path) + if str_eq(raw, "") { + return "{}" + } + return raw +} + +fn config_api_url(cfg: String) -> String { + let url: String = json_get(cfg, "axon_url") + if !str_eq(url, "") { + return url + } + let url2: String = json_get(cfg, "api_url") + if !str_eq(url2, "") { + return url2 + } + return "http://localhost:7770" +} + +fn config_api_token(cfg: String) -> String { + return json_get(cfg, "api_token") +} + +fn config_ui_dir(cfg: String) -> String { + let home: String = env("HOME") + let dir: String = json_get(cfg, "ui_dir") + if str_eq(dir, "") { + return home + "/.neuron/ui" + } + return dir +} + +fn config_data_dir(cfg: String) -> String { + let dir: String = json_get(cfg, "data_dir") + if !str_eq(dir, "") { + return dir + } + let home: String = env("HOME") + return home + "/.neuron/data" +} + +fn config_port(cfg: String) -> Int { + let p: String = json_get(cfg, "port") + if str_eq(p, "") { + return 7749 + } + return str_to_int(p) +} + +// Principal identity is NOT read from config — it is baked into the compiled +// binary at build time. See main.el (developer) and main-user.el (user build). +// config_mode() has been removed. Use daemon_principal() from the entry file. diff --git a/neuron-lang/events/bus.el b/neuron-lang/events/bus.el new file mode 100644 index 0000000..d3e4aa4 --- /dev/null +++ b/neuron-lang/events/bus.el @@ -0,0 +1,17 @@ +// events/bus.el — Event bus. Three primitives over native_queue_*. +// +// Publish, consume, ack. Consumer identity IS the subscription. +// No separate subscribe step. No state. Intelligence lives in el. +// The Rust backend is swappable: InMemory → Engram → Kafka → RabbitMQ. + +fn event_publish(topic: String, payload: String) -> Void { + native_queue_publish(topic, payload) +} + +fn event_consume(topic: String, consumer: String) -> String { + return native_queue_consume(topic, consumer) +} + +fn event_ack(topic: String, consumer: String, msg_id: String) -> Void { + native_queue_ack(topic, consumer, msg_id) +} diff --git a/neuron-lang/graph.el b/neuron-lang/graph.el new file mode 100644 index 0000000..bfbe0ff --- /dev/null +++ b/neuron-lang/graph.el @@ -0,0 +1,117 @@ +// graph.el — Graph operations subsystem. +// +// Neuron's storage backend is Engram — a graph database where every entity +// (Memory, Knowledge, BacklogItem, etc.) is a node and every relationship +// is a typed edge. This subsystem exposes the raw graph API for operations +// that span entity types or need structural graph information. +// +// WHY expose graph ops directly? +// High-level tools like search_memories or retrieve_knowledge hide the graph +// structure. But some questions are inherently graph-shaped: +// "What entities are connected to this decision?" +// "How deep is the dependency chain for this backlog item?" +// "Are these two memories causally related?" +// +// Graph ops answer these questions without the overhead of entity-typed +// queries. They're the escape hatch when you need to see the whole picture. +// +// link_entities is a @manager because it mutates the graph topology. +// All reads are @accessor. + +from types import { + GraphNode, + SearchResult, + NeuronError, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +// inspect_graph — get a node and its immediate connections. +// +// entity_type: "Memory" | "Knowledge" | "BacklogItem" | "Context" | "Artifact" | ... +// entity_id: the UUID or string ID of the entity. +// +// Returns the node and its edges formatted as "relation:target_id" strings. +// Use this to understand what a node is connected to before traversing deeper. +@accessor +fn inspect_graph(entity_type: String, entity_id: String) -> Result { + let node = native_graph_inspect(entity_type, entity_id)? + Ok(node) +} + +// traverse_graph — walk the graph from a starting node up to depth hops. +// +// depth=1 returns immediate neighbors only (equivalent to inspect_graph). +// depth=2 returns neighbors of neighbors, etc. +// Default depth is 2 — deep enough for most dependency chains. +// +// Returns all visited nodes, not just leaves. This gives the caller the +// full subgraph around the starting node. +@accessor +fn traverse_graph(entity_id: String, depth: Int) -> Result<[GraphNode], NeuronError> { + let effective_depth = if depth == 0 { 2 } else { depth } + let nodes = native_graph_traverse(entity_id, effective_depth)? + Ok(nodes) +} + +// link_entities — create a typed edge between two graph nodes. +// +// relation examples: "supersedes", "depends_on", "caused_by", "related_to", +// "implements", "references", "blocks" +// +// Edges are directional: source --relation--> target. +// Use causal links for reasoning chains, dependency links for work items, +// supersedes links for knowledge evolution. +@manager +fn link_entities( + source_id: String, + target_id: String, + relation: String, +) -> Result { + native_graph_link(source_id, target_id, relation)? + native_emit("graph.linked", {"source": source_id, "target": target_id, "relation": relation}) + // Return the source node with its updated edges. + let source_node = native_graph_inspect("", source_id)? + Ok(source_node) +} + +// search_graph — semantic search across all node types in the graph. +// +// Unlike type-specific search (search_memories, search_knowledge), this +// searches the entire graph regardless of entity type. Useful for +// cross-cutting queries: "what's related to authentication across all +// memories, knowledge, and backlog items?" +@accessor +fn search_graph(query: String) -> Result<[SearchResult], NeuronError> { + let results = native_semantic_search(query, "", 20)? + Ok(results) +} + +// rebuild_graph — trigger a full graph index rebuild. +// +// Used after bulk imports or when the semantic index drifts out of sync. +// This is an expensive operation — avoid in normal sessions. +// It's sealed because it modifies the graph infrastructure, not just data. +@manager +fn rebuild_graph() -> Result { + sealed { + native_emit("graph.rebuild_requested", {}) + Ok("graph rebuild initiated") + } +} + +// pin_node — mark a graph node as pinned (exempt from compaction). +// +// Pinned nodes survive graph compaction even when their activation scores +// drop below the compaction threshold. Use for foundational knowledge nodes +// that must never be evicted. +@manager +fn pin_node(entity_id: String, entity_type: String) -> Result { + sealed { + // Pinning is implemented as a special self-referential "pinned" edge. + native_graph_link(entity_id, entity_id, "pinned")? + native_emit("graph.node_pinned", {"id": entity_id, "type": entity_type}) + let node = native_graph_inspect(entity_type, entity_id)? + Ok(node) + } +} diff --git a/neuron-lang/health.el b/neuron-lang/health.el new file mode 100644 index 0000000..df0e02c --- /dev/null +++ b/neuron-lang/health.el @@ -0,0 +1,13 @@ +// health.el — Health check and status handlers. + +fn health_response() -> String { + let mode: String = state_get("neuron_mode") + if str_eq(mode, "") { + return "{\"status\":\"ok\",\"version\":\"1.0.0-engram\"}" + } + return "{\"status\":\"ok\",\"version\":\"1.0.0-engram\",\"mode\":\"" + mode + "\"}" +} + +fn not_found_response(path: String) -> String { + return "{\"error\":\"not found\",\"path\":\"" + path + "\"}" +} diff --git a/neuron-lang/ise.el b/neuron-lang/ise.el new file mode 100644 index 0000000..3ec67be --- /dev/null +++ b/neuron-lang/ise.el @@ -0,0 +1,83 @@ +// ise.el — Internal State Events (ISE) subsystem. +// +// Internal State Events are Neuron's introspective record. When the reasoning +// engine produces a response, an ISE captures the gap between: +// pre_reasoning — what the model would say before internal deliberation +// post_reasoning — what the model actually outputs after reasoning +// +// The compression_ratio tells us how much the reasoning compressed the +// pre-response. A ratio near 1.0 means almost no change; a ratio near 0.0 +// means the reasoning fundamentally transformed the output. +// +// gap_direction encodes whether the change moved toward expression +// (more output, more detail) or suppression (filtered, condensed, withheld). +// +// WHY does Neuron care about this? +// ISEs are the raw data for self-model calibration. By reviewing ISEs over +// time, Neuron can identify systematic biases: topics where it consistently +// suppresses, domains where reasoning rarely changes the output, etc. +// This is the foundation of Cultivated General Intelligence — introspection +// drives adaptation. +// +// Access is authenticated by default. ISEs contain sensitive reasoning traces +// that should not be exposed to arbitrary callers. + +from types import { + InternalStateEvent, + NeuronError, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +// log_internal_state — record one ISE. +// +// Called by the reasoning engine after each significant reasoning cycle. +// The id format is "ise_" + 8 hex chars (matches Rust domain convention). +@manager +fn log_internal_state( + trigger: String, + pre_reasoning: String, + post_reasoning: String, + compression_ratio: String, + gap_direction: String, + tags: [String], +) -> Result { + let id = "ise_" + native_uuid() + let now = native_now() + let ise = InternalStateEvent { + id: id, + trigger: trigger, + pre_reasoning: pre_reasoning, + post_reasoning: post_reasoning, + compression_ratio: compression_ratio, + gap_direction: gap_direction, + tags: tags, + logged_at: now, + } + native_store_ise(ise)? + // ISE events are emitted at low verbosity — they are high volume. + native_emit("ise.logged", {"id": id, "trigger": trigger, "direction": gap_direction}) + Ok(ise) +} + +// list_internal_state — retrieve recent ISEs. +// +// limit defaults to 10. Useful for reviewing recent reasoning patterns +// during session orientation or calibration reviews. +@accessor +fn list_internal_state(limit: Int) -> Result<[InternalStateEvent], NeuronError> { + let events = native_list_ise(limit)? + Ok(events) +} + +// get_internal_state — retrieve one ISE by ID. +// +// Used to drill into a specific reasoning event for detailed analysis. +@accessor +fn get_internal_state(id: String) -> Result { + // ISEs are stored with prefix "ise_", so a bare 8-char hex ID needs + // the prefix prepended. The native layer handles both forms. + let results = activate InternalStateEvent where "id:{id}" + let ise = native_list_ise(1)? + Ok(ise[0]) +} diff --git a/neuron-lang/knowledge.el b/neuron-lang/knowledge.el new file mode 100644 index 0000000..af05e97 --- /dev/null +++ b/neuron-lang/knowledge.el @@ -0,0 +1,144 @@ +// knowledge.el — Knowledge base subsystem. +// +// Knowledge is stable reference material: architecture docs, coding standards, +// proven patterns, whitepapers. Unlike memories (which are ephemeral +// observations), knowledge nodes are meant to persist across many sessions +// and become more authoritative over time. +// +// The tier system enforces epistemological discipline: +// note → raw observation (default) +// lesson → validated pattern (proven ≥2 times) +// canonical → authoritative reference (stable, widely referenced) +// +// Never skip tiers. A pattern observed once is a note, not a lesson. + +from types import { + Knowledge, + SearchResult, + NeuronError, +} + +// ── Key path utilities ──────────────────────────────────────────────────────── + +// Knowledge keys use path notation: "architecture/vbd/foundations.md" +// The key doubles as a stable human-readable address and the graph node ID. +// When evolving knowledge, the key stays stable — only content and tier change. + +// ── Public API ──────────────────────────────────────────────────────────────── + +// capture_knowledge — store a new knowledge node. +// +// The key should be a path-style string that describes where this knowledge +// lives in the taxonomy: "architecture/styles/vbd/foundations.md", +// "coding/rust/error-handling.md", etc. +@manager +fn capture_knowledge( + title: String, + content: String, + category: String, + tier: String, + tags: [String], + project: String, +) -> Result { + let id = native_uuid() + let now = native_now() + // Derive a stable key from category + title (slugified by runtime). + let key = category + "/" + title + let knowledge = Knowledge { + id: id, + key: key, + title: title, + content: content, + category: category, + tier: tier, + tags: tags, + project: project, + created_at: now, + } + native_store_knowledge(knowledge)? + native_emit("knowledge.captured", {"id": id, "tier": tier, "category": category}) + Ok(knowledge) +} + +// retrieve_knowledge — fetch a knowledge node by its path key. +// +// This is the primary retrieval path for known knowledge. When you know the +// key ("architecture/vbd/fundamentals.md"), use this. For exploratory queries, +// use search_knowledge. +@accessor +fn retrieve_knowledge(key: String) -> Result { + // The native layer maps key → graph node via an index. + let results = activate Knowledge where "key:{key}" + // Return the first (most relevant) result or not-found. + let knowledge = native_get_knowledge(key)? + Ok(knowledge) +} + +// search_knowledge — semantic search over the knowledge graph. +// +// Supports optional category and tier filters. Always search before +// implementing anything — the knowledge base contains patterns and +// decisions that should not be reinvented. +@accessor +fn search_knowledge( + query: String, + category: String, + tier: String, + limit: Int, +) -> Result<[Knowledge], NeuronError> { + let results = activate Knowledge where "{query} category:{category} tier:{tier} limit:{limit}" + Ok(results) +} + +// browse_knowledge — list knowledge nodes by category. +// +// Used to orient at session start or when exploring an unfamiliar domain. +// Returns a summary list, not full content — use retrieve_knowledge for +// the full text of a specific node. +@accessor +fn browse_knowledge(category: String) -> Result<[Knowledge], NeuronError> { + let knowledge_list = native_list_knowledge(category)? + Ok(knowledge_list) +} + +// evolve_knowledge — update an existing knowledge node. +// +// When new evidence supersedes an existing canonical, call this rather than +// capture_knowledge. The old node is soft-deleted; the new one inherits the +// key path so all existing references remain valid. +@manager +fn evolve_knowledge( + id: String, + new_content: String, + new_tier: String, + supersedes_id: String?, +) -> Result { + let old = native_get_knowledge(id)? + let now = native_now() + let evolved = Knowledge { + id: native_uuid(), + key: old.key, + title: old.title, + content: new_content, + category: old.category, + tier: new_tier, + tags: old.tags, + project: old.project, + created_at: now, + } + native_store_knowledge(evolved)? + native_emit("knowledge.evolved", {"old_id": id, "new_id": evolved.id, "tier": new_tier}) + Ok(evolved) +} + +// remove_knowledge — delete a knowledge node. +// +// Use sparingly. Knowledge is meant to accumulate. Only remove nodes that +// are factually wrong, not just outdated (prefer evolve_knowledge for +// supersession). +@manager +fn remove_knowledge(id: String) -> Result { + let knowledge = native_get_knowledge(id)? + native_emit("knowledge.removed", {"id": id, "key": knowledge.key}) + Ok(knowledge) +} diff --git a/neuron-lang/loop.el b/neuron-lang/loop.el new file mode 100644 index 0000000..5d054ab --- /dev/null +++ b/neuron-lang/loop.el @@ -0,0 +1,332 @@ +// loop.el — Six-tier runtime heartbeat for the Neuron daemon. +// +// Implements the self-pacing cognitive loop described in the architecture +// doc. The loop runs in its own OS thread (spawned via `spawn_thread`) +// and communicates with the rest of the daemon through global shared +// state (`state_get` / `state_set`). +// +// The six tiers: +// resting — 30 min (low signal, diffuse) +// watching — 10 min (ambient monitoring) +// working — 15 sec (background task in progress) +// active — 500 ms (conversation in progress) +// critical — 10 ms (bell fired) +// realtime — busy loop (physical actuator pinned) +// +// Tier transition rules: +// 1. Bell is sacred — escalates to Critical immediately, never dropped. +// 2. Escalation is immediate — loop re-enters without finishing sleep. +// 3. Step-down is earned — 4 consecutive idle ticks before stepping down. +// 4. Floor is configurable — never drops below `loop_min_tier`. +// +// State keys this file reads / writes: +// loop_tier, loop_min_tier, loop_ticks, loop_bell_fires, +// loop_tier_changes, loop_idle_ticks, loop_signal, loop_override_tier +// +// Cognitive substrate: +// loop manages WHEN to tick; agent.el manages WHAT to do on each tick. +// loop_do_tick() calls agent_tick() after confirming neuronrs is live. + +import "agent.el" +import "plugins/host.el" + +// ── Tier constants and ordering ─────────────────────────────────────────────── + +fn loop_tier_rank(tier: String) -> Int { + if str_eq(tier, "resting") { return 0 } + if str_eq(tier, "watching") { return 1 } + if str_eq(tier, "working") { return 2 } + if str_eq(tier, "active") { return 3 } + if str_eq(tier, "critical") { return 4 } + if str_eq(tier, "realtime") { return 5 } + return 1 +} + +fn loop_tier_from_rank(rank: Int) -> String { + if rank <= 0 { return "resting" } + if rank == 1 { return "watching" } + if rank == 2 { return "working" } + if rank == 3 { return "active" } + if rank == 4 { return "critical" } + return "realtime" +} + +// Sleep interval in milliseconds for each tier. +// Returns 0 for realtime (busy loop, no sleep at all). +fn loop_tier_interval(tier: String) -> Int { + if str_eq(tier, "resting") { return 1800000 } + if str_eq(tier, "watching") { return 600000 } + if str_eq(tier, "working") { return 15000 } + if str_eq(tier, "active") { return 500 } + if str_eq(tier, "critical") { return 10 } + if str_eq(tier, "realtime") { return 0 } + return 600000 +} + +// ── Signal handling ─────────────────────────────────────────────────────────── + +// Apply a signal to the current tier and return the new tier. +// +// Bell is special: it always escalates to at least Critical, regardless +// of `min_tier`. Other escalations clamp to max(needed, min_tier) so a +// configured floor cannot trap us above an explicit step-down. +fn loop_apply_signal(current: String, signal: String, min_tier: String) -> String { + let cur_rank: Int = loop_tier_rank(current) + let min_rank: Int = loop_tier_rank(min_tier) + + // Bell — sacred. Always go to at least Critical. + if str_eq(signal, "bell") { + let crit_rank: Int = 4 + if cur_rank >= crit_rank { + return current + } + return "critical" + } + + // Realtime escalation pins us at the top. + if str_eq(signal, "realtime") { + return "realtime" + } + + // Release-realtime drops us back to Critical. + if str_eq(signal, "release-realtime") { + if str_eq(current, "realtime") { + return "critical" + } + return current + } + + // Active escalates to at least Active. + if str_eq(signal, "active") { + let need: Int = 3 + if cur_rank >= need { + return current + } + return "active" + } + + // Task escalates to at least Working. + if str_eq(signal, "task") { + let need: Int = 2 + if cur_rank >= need { + return current + } + return "working" + } + + // Sleep is an explicit step-down request — drop one tier + // (respecting the floor). + if str_eq(signal, "sleep") { + return loop_step_down(current, min_tier) + } + + // Idle / drain just contribute to the idle counter; the tier + // itself does not change here. + return current +} + +// Step down one tier, but never below the configured min_tier. +fn loop_step_down(current: String, min_tier: String) -> String { + let cur_rank: Int = loop_tier_rank(current) + let min_rank: Int = loop_tier_rank(min_tier) + if cur_rank <= min_rank { + return min_tier + } + let new_rank: Int = cur_rank - 1 + if new_rank < min_rank { + return min_tier + } + return loop_tier_from_rank(new_rank) +} + +// ── Bell-aware sleep ────────────────────────────────────────────────────────── + +// Tail-recursive sleep that wakes early if a bell signal arrives. +// Sleeps in 100ms chunks, peeking at `loop_signal` between each chunk. +fn loop_sleep_chunked(remaining_ms: Int) -> Void { + if remaining_ms <= 0 { + // done + } else { + let pending: String = state_get("loop_signal") + if str_eq(pending, "bell") { + // Bell detected mid-sleep — return immediately so the + // outer loop can re-enter and escalate. + } else { + let chunk: Int = if remaining_ms < 100 { remaining_ms } else { 100 } + sleep_ms(chunk) + loop_sleep_chunked(remaining_ms - chunk) + } + } +} + +// Public sleep entry point. realtime (interval == 0) is a no-op. +fn loop_sleep(remaining_ms: Int) -> Void { + if remaining_ms > 0 { + loop_sleep_chunked(remaining_ms) + } +} + +// ── Tick ────────────────────────────────────────────────────────────────────── + +// Perform one cognitive tick. +// +// First pings neuronrs /health to confirm the substrate is live. +// If live, delegates to agent_tick() — the cognitive work is in agent.el. +// loop manages WHEN; agent manages WHAT. +// +// Side effect: writes "1" or "0" to `loop_last_tick_idle` so the +// caller can see whether the tick was idle (no live counterpart). +fn loop_do_tick(tier: String) -> Void { + let url: String = "http://localhost:7770/health" + let resp: String = http_get(url) + let live: Bool = str_contains(resp, "ok") + if live { + state_set("loop_last_tick_idle", "0") + // Delegate to the agent cognitive substrate. + agent_tick({}) + } else { + state_set("loop_last_tick_idle", "1") + } + // Drain the plugin event bus on every tick, regardless of substrate liveness. + // Plugins interact through events only — host_tick routes events to all + // plugins that subscribed to them. No hooks, no direct callbacks. + host_tick({}) + println("[loop] tick tier=" + tier + " live=" + bool_to_str(live)) +} + +// ── Counters ────────────────────────────────────────────────────────────────── + +fn loop_incr_state_int(key: String) -> Void { + let raw: String = state_get(key) + let n: Int = if str_eq(raw, "") { 0 } else { str_to_int(raw) } + state_set(key, int_to_str(n + 1)) +} + +fn loop_set_int(key: String, value: Int) -> Void { + state_set(key, int_to_str(value)) +} + +// ── Core loop ───────────────────────────────────────────────────────────────── + +// One iteration of the heartbeat. Tail-recursive: each tick re-enters +// itself with the (possibly updated) tier and idle counter. +fn loop_run(tier: String, idle_count: Int) -> Void { + // 1. Read floor and pending signals. + let min_tier: String = state_get("loop_min_tier") + let floor: String = if str_eq(min_tier, "") { "resting" } else { min_tier } + + let pending: String = state_get("loop_signal") + let override_tier: String = state_get("loop_override_tier") + + // 2. Apply override (explicit set tier — wins over signal). + let after_override: String = if str_eq(override_tier, "") { + tier + } else { + override_tier + } + if !str_eq(override_tier, "") { + state_set("loop_override_tier", "") + loop_incr_state_int("loop_tier_changes") + } + + // 3. Apply signal. + let after_signal: String = if str_eq(pending, "") { + after_override + } else { + loop_apply_signal(after_override, pending, floor) + } + let signal_changed: Bool = !str_eq(pending, "") + if signal_changed { + state_set("loop_signal", "") + if str_eq(pending, "bell") { + loop_incr_state_int("loop_bell_fires") + } + if !str_eq(after_signal, after_override) { + loop_incr_state_int("loop_tier_changes") + } + } + + // 4. Persist current tier. + state_set("loop_tier", after_signal) + + // 5. Sleep for this tier's interval (bell-aware, chunked). + let interval_ms: Int = loop_tier_interval(after_signal) + loop_sleep(interval_ms) + + // 6. If a bell arrived during sleep, re-enter immediately without + // ticking — the next iteration will re-read the signal and + // escalate. + let after_sleep_signal: String = state_get("loop_signal") + if str_eq(after_sleep_signal, "bell") { + loop_run(after_signal, idle_count) + } else { + // 7. Run the cognitive tick. + loop_do_tick(after_signal) + loop_incr_state_int("loop_ticks") + + // 8. Update idle counter and step down if earned. + let last_idle: String = state_get("loop_last_tick_idle") + let was_idle: Bool = str_eq(last_idle, "1") + + let new_idle_count: Int = if was_idle { idle_count + 1 } else { 0 } + + // Idle / drain signals also push the counter forward, but + // `loop_apply_signal` does not change the tier for them. + let idle_signal: Bool = str_eq(pending, "idle") || str_eq(pending, "drain") + let bumped_idle_count: Int = if idle_signal { + new_idle_count + 1 + } else { + new_idle_count + } + + // Realtime is pinned — only `release-realtime` can lower it. + let pinned: Bool = str_eq(after_signal, "realtime") + + if !pinned && bumped_idle_count >= 4 { + let stepped: String = loop_step_down(after_signal, floor) + if !str_eq(stepped, after_signal) { + loop_incr_state_int("loop_tier_changes") + } + loop_set_int("loop_idle_ticks", 0) + loop_run(stepped, 0) + } else { + loop_set_int("loop_idle_ticks", bumped_idle_count) + loop_run(after_signal, bumped_idle_count) + } + } +} + +// Entry point — called by `spawn_thread("loop_main")`. +fn loop_main() -> Void { + let start_tier: String = state_get("loop_tier") + let initial: String = if str_eq(start_tier, "") { "watching" } else { start_tier } + println("[loop] starting at tier=" + initial) + loop_run(initial, 0) +} + +// ── Status JSON ─────────────────────────────────────────────────────────────── + +fn loop_status_field(key: String, default: String) -> String { + let v: String = state_get(key) + if str_eq(v, "") { return default } + return v +} + +fn loop_status_json() -> String { + let tier: String = loop_status_field("loop_tier", "watching") + let min_tier: String = loop_status_field("loop_min_tier", "resting") + let ticks: String = loop_status_field("loop_ticks", "0") + let bell_fires: String = loop_status_field("loop_bell_fires", "0") + let tier_changes: String = loop_status_field("loop_tier_changes", "0") + let idle_ticks: String = loop_status_field("loop_idle_ticks", "0") + let signal: String = loop_status_field("loop_signal", "") + + let parts: String = "{\"current_tier\":\"" + tier + "\"" + let p2: String = parts + ",\"min_tier\":\"" + min_tier + "\"" + let p3: String = p2 + ",\"ticks\":" + ticks + let p4: String = p3 + ",\"bell_fires\":" + bell_fires + let p5: String = p4 + ",\"tier_changes\":" + tier_changes + let p6: String = p5 + ",\"idle_ticks\":" + idle_ticks + let p7: String = p6 + ",\"pending_signal\":\"" + signal + "\"}" + return p7 +} diff --git a/neuron-lang/main-user.el b/neuron-lang/main-user.el new file mode 100644 index 0000000..e7e1586 --- /dev/null +++ b/neuron-lang/main-user.el @@ -0,0 +1,139 @@ +// main-user.el — Neuron daemon entry point. User build. +// Written in Engram. +// +// Principal identity is a compile-time constant — NOT read from config. +// This file is the user build. The developer build is main.el. +// Built with: el build --manifest el-user.toml +// +// Responsibilities: +// 1. Write PID file to ~/.neuron-user/data/daemon.pid +// 2. Start API/proxy server on :7750 (blocking) +// - Proxies /axon/, /api/ routes to neuronrs at :7770 +// - Health check at /health +// +// The handle_request function is called by http_serve for every request. + +import "daemon_config.el" +import "proxy.el" +import "health.el" +import "loop.el" +import "plugins/host.el" + +// ── Principal identity (baked in at compile time) ───────────────────────────── +// This literal is compiled into the bytecode. For prod builds it is sealed +// inside AES-256-GCM — cannot be changed without the deployment key. + +let principal: String = "user" + +// ── Load operational config ─────────────────────────────────────────────────── + +let cfg: String = load_config() +let axon_base: String = config_api_url(cfg) +let token: String = config_api_token(cfg) +let ui_dir: String = config_ui_dir(cfg) +let data_dir: String = config_data_dir(cfg) +let port: Int = config_port(cfg) +state_set("neuron_principal", principal) + +// ── Write PID file ──────────────────────────────────────────────────────────── + +let pid: Int = getpid() +let pid_str: String = int_to_str(pid) +fs_mkdir(data_dir) +let pid_path: String = data_dir + "/daemon.pid" +fs_write(pid_path, pid_str) + +println(color_bold("Neuron daemon") + " — pid " + pid_str + " — " + principal) +println(" API → http://localhost:" + int_to_str(port)) +println(" Axon → " + axon_base) +println(" Data → " + data_dir) +println("") + +// ── Request handler (API server on :7750) ───────────────────────────────────── +// +// Called by http_serve for every incoming request. +// Must be named exactly "handle_request" — http_serve looks it up by that name. + +fn handle_request(method: String, path: String, body: String) -> String { + // Health check + if str_eq(path, "/health") { + return health_response() + } + + // Axon tool dispatch — proxy to neuronrs + if str_starts_with(path, "/axon/") { + return proxy_request(axon_base, method, path, body, token) + } + + // Intelligence REST API — proxy to neuronrs + if str_starts_with(path, "/api/memories") { + return proxy_request(axon_base, method, path, body, token) + } + if str_starts_with(path, "/api/knowledge") { + return proxy_request(axon_base, method, path, body, token) + } + if str_starts_with(path, "/api/backlog") { + return proxy_request(axon_base, method, path, body, token) + } + if str_starts_with(path, "/api/contexts") { + return proxy_request(axon_base, method, path, body, token) + } + if str_starts_with(path, "/api/ise") { + return proxy_request(axon_base, method, path, body, token) + } + + // ── Plugin status ───────────────────────────────────────────────────────── + if str_eq(path, "/plugin/status") { + return host_status() + } + + // ── Runtime loop control ────────────────────────────────────────────────── + if str_eq(path, "/loop/status") { + return loop_status_json() + } + if str_eq(path, "/loop/signal") { + let sig: String = json_get(body, "signal") + if str_eq(sig, "") { + return "{\"error\":\"missing signal\"}" + } + state_set("loop_signal", sig) + return "{\"ok\":true,\"signal\":\"" + sig + "\"}" + } + if str_eq(path, "/loop/tier") { + let new_tier: String = json_get(body, "tier") + let new_min: String = json_get(body, "min_tier") + if !str_eq(new_min, "") { + state_set("loop_min_tier", new_min) + } + if !str_eq(new_tier, "") { + state_set("loop_override_tier", new_tier) + } + return "{\"ok\":true,\"tier\":\"" + new_tier + "\",\"min_tier\":\"" + new_min + "\"}" + } + + return not_found_response(path) +} + +// ── Initialise plugin host ──────────────────────────────────────────────────── + +host_on_startup() + +// ── Initialise loop state ───────────────────────────────────────────────────── + +state_set("loop_tier", "watching") +state_set("loop_min_tier", "resting") +state_set("loop_ticks", "0") +state_set("loop_bell_fires", "0") +state_set("loop_tier_changes", "0") +state_set("loop_idle_ticks", "0") +state_set("loop_signal", "") +state_set("loop_override_tier", "") +state_set("loop_last_tick_idle", "0") + +// ── Spawn the heartbeat thread ──────────────────────────────────────────────── + +spawn_thread("loop_main") + +// ── Start API server (blocking) ─────────────────────────────────────────────── + +http_serve(port) diff --git a/neuron-lang/main.el b/neuron-lang/main.el new file mode 100644 index 0000000..1ffa5d4 --- /dev/null +++ b/neuron-lang/main.el @@ -0,0 +1,141 @@ +// main.el — Neuron daemon entry point. Developer (unlocked) build. +// Written in Engram. +// +// Principal identity is a compile-time constant — NOT read from config. +// This file is the developer build. The user build is main-user.el. +// Both are compiled from different el.toml manifests: +// el build → developer (el.toml) +// el build --manifest el-user.toml → user (el-user.toml) +// +// Responsibilities: +// 1. Write PID file to ~/.neuron/data/daemon.pid +// 2. Start API/proxy server on :7749 (blocking) +// - Proxies /axon/, /api/ routes to neuronrs at :7770 +// - Health check at /health +// +// The handle_request function is called by http_serve for every request. + +import "daemon_config.el" +import "proxy.el" +import "health.el" +import "loop.el" +import "plugins/host.el" + +// ── Principal identity (baked in at compile time) ───────────────────────────── +// This literal is compiled into the bytecode. For prod builds it is sealed +// inside AES-256-GCM — cannot be changed without the deployment key. + +let principal: String = "principal" + +// ── Load operational config ─────────────────────────────────────────────────── + +let cfg: String = load_config() +let axon_base: String = config_api_url(cfg) +let token: String = config_api_token(cfg) +let ui_dir: String = config_ui_dir(cfg) +let data_dir: String = config_data_dir(cfg) +let port: Int = config_port(cfg) +state_set("neuron_principal", principal) + +// ── Write PID file ──────────────────────────────────────────────────────────── + +let pid: Int = getpid() +let pid_str: String = int_to_str(pid) +fs_mkdir(data_dir) +let pid_path: String = data_dir + "/daemon.pid" +fs_write(pid_path, pid_str) + +println(color_bold("Neuron daemon") + " — pid " + pid_str + " — " + principal) +println(" API → http://localhost:" + int_to_str(port)) +println(" Axon → " + axon_base) +println(" Data → " + data_dir) +println("") + +// ── Request handler (API server on :7749) ───────────────────────────────────── +// +// Called by http_serve for every incoming request. +// Must be named exactly "handle_request" — http_serve looks it up by that name. + +fn handle_request(method: String, path: String, body: String) -> String { + // Health check + if str_eq(path, "/health") { + return health_response() + } + + // Axon tool dispatch — proxy to neuronrs + if str_starts_with(path, "/axon/") { + return proxy_request(axon_base, method, path, body, token) + } + + // Intelligence REST API — proxy to neuronrs + if str_starts_with(path, "/api/memories") { + return proxy_request(axon_base, method, path, body, token) + } + if str_starts_with(path, "/api/knowledge") { + return proxy_request(axon_base, method, path, body, token) + } + if str_starts_with(path, "/api/backlog") { + return proxy_request(axon_base, method, path, body, token) + } + if str_starts_with(path, "/api/contexts") { + return proxy_request(axon_base, method, path, body, token) + } + if str_starts_with(path, "/api/ise") { + return proxy_request(axon_base, method, path, body, token) + } + + // ── Plugin status ───────────────────────────────────────────────────────── + if str_eq(path, "/plugin/status") { + return host_status() + } + + // ── Runtime loop control ────────────────────────────────────────────────── + if str_eq(path, "/loop/status") { + return loop_status_json() + } + if str_eq(path, "/loop/signal") { + let sig: String = json_get(body, "signal") + if str_eq(sig, "") { + return "{\"error\":\"missing signal\"}" + } + state_set("loop_signal", sig) + return "{\"ok\":true,\"signal\":\"" + sig + "\"}" + } + if str_eq(path, "/loop/tier") { + let new_tier: String = json_get(body, "tier") + let new_min: String = json_get(body, "min_tier") + if !str_eq(new_min, "") { + state_set("loop_min_tier", new_min) + } + if !str_eq(new_tier, "") { + state_set("loop_override_tier", new_tier) + } + return "{\"ok\":true,\"tier\":\"" + new_tier + "\",\"min_tier\":\"" + new_min + "\"}" + } + + return not_found_response(path) +} + +// ── Initialise plugin host ──────────────────────────────────────────────────── + +host_on_startup() + +// ── Initialise loop state ───────────────────────────────────────────────────── + +state_set("loop_tier", "watching") +state_set("loop_min_tier", "resting") +state_set("loop_ticks", "0") +state_set("loop_bell_fires", "0") +state_set("loop_tier_changes", "0") +state_set("loop_idle_ticks", "0") +state_set("loop_signal", "") +state_set("loop_override_tier", "") +state_set("loop_last_tick_idle", "0") + +// ── Spawn the heartbeat thread ──────────────────────────────────────────────── + +spawn_thread("loop_main") + +// ── Start API server (blocking) ─────────────────────────────────────────────── + +http_serve(port) diff --git a/neuron-lang/memory.el b/neuron-lang/memory.el new file mode 100644 index 0000000..5310518 --- /dev/null +++ b/neuron-lang/memory.el @@ -0,0 +1,191 @@ +// memory.el — Memory intelligence subsystem. +// +// Memories are the raw epistemological substrate of Neuron. They're not +// long-term knowledge (that's knowledge.el) — they're observations, decisions, +// and lessons captured in flight. The half-life of a memory is governed by +// its importance tier and how often the graph activates it. +// +// Design intent: +// - Every remember() call emits an event so other subsystems can react. +// - supersedes_id creates a linked list of memory versions — old memories +// remain traversable even after replacement. +// - Importance auto-promotion prevents Claude from under-tagging critical +// decisions just because it used soft language. + +from types import { + Memory, + SearchResult, + NeuronError, +} + +// ── Importance auto-promotion ───────────────────────────────────────────────── + +// If the content itself signals criticality, override whatever importance the +// caller passed. This prevents decisions from being buried under "normal" tier +// just because the caller was being polite. +@accessor +fn infer_importance(content: String, stated_importance: String) -> String { + if stated_importance == "critical" { + return "critical" + } + // Escalate to critical if the content describes irreversibility or arch decisions. + let signals = ["critical", "irreversible", "breaking change", "never", "always", + "permanent", "architectural", "security", "deleted forever"] + for signal in signals { + if content == signal { + return "critical" + } + } + // Escalate normal → high if the content sounds high-stakes. + if stated_importance == "normal" { + let high_signals = ["important", "must", "required", "blocked", "production"] + for signal in high_signals { + if content == signal { + return "high" + } + } + } + stated_importance +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +// remember — store a new memory node. +// +// When supersedes_id is provided, the old memory is kept in the graph but +// marked as superseded. This preserves the full decision history while making +// the new memory the canonical source for activation queries. +@manager +fn remember( + content: String, + tags: [String], + project: String, + importance: String, + supersedes_id: String?, +) -> Result { + let effective_importance = infer_importance(content, importance) + let id = native_uuid() + let now = native_now() + let memory = Memory { + id: id, + content: content, + tags: tags, + importance: effective_importance, + project: project, + supersedes_id: supersedes_id, + created_at: now, + updated_at: now, + } + let stored = native_store_memory(memory)? + native_emit("memory.created", {"id": id, "project": project, "importance": effective_importance}) + Ok(memory) +} + +// recall — retrieve one memory by ID. +@accessor +fn recall(id: String) -> Result { + let memory = native_get_memory(id)? + Ok(memory) +} + +// recall_chain — retrieve recent memories sharing a tag. +// +// This is how Neuron reconstructs prior reasoning: follow the tag chain +// rather than trying to remember everything in the conversation window. +@accessor +fn recall_chain(tag: String, limit: Int) -> Result<[Memory], NeuronError> { + let results = activate Memory where "{tag} limit:{limit}" + Ok(results) +} + +// search_memories — semantic search over the memory graph. +// +// Uses spreading activation: the query activates nearby graph nodes and +// surfaces the most relevantly connected memories. +@accessor +fn search_memories(query: String, project: String, limit: Int) -> Result<[Memory], NeuronError> { + let results = activate Memory where "{query} project:{project} limit:{limit}" + Ok(results) +} + +// list_memories — enumerate all memories for a project. +// Useful for session orientation (begin_session) and memory audits. +@accessor +fn list_memories(project: String) -> Result<[Memory], NeuronError> { + let memories = native_list_memories(project)? + Ok(memories) +} + +// forget — soft-delete a memory. +// +// "Soft delete" means the node is removed from active activation but the +// graph edge history is preserved. This is intentional: we don't want to +// lose the causal record of why a decision was superseded. +@manager +fn forget(id: String) -> Result { + let memory = native_get_memory(id)? + native_delete_memory(id)? + native_emit("memory.forgotten", {"id": id}) + Ok(memory) +} + +// promote_memory — raise a memory's importance tier. +// +// Used when a memory originally tagged "normal" turns out to be load-bearing. +// Does NOT supersede the original — it mutates importance in place. +@manager +fn promote_memory(id: String, new_importance: String) -> Result { + let memory = native_get_memory(id)? + let promoted = Memory { + id: memory.id, + content: memory.content, + tags: memory.tags, + importance: new_importance, + project: memory.project, + supersedes_id: memory.supersedes_id, + created_at: memory.created_at, + updated_at: native_now(), + } + native_store_memory(promoted)? + native_emit("memory.promoted", {"id": id, "importance": new_importance}) + Ok(promoted) +} + +// evolve_memory — update a memory's content and mark it as superseding the old one. +// +// This is the preferred way to update a memory. It creates a new memory that +// explicitly links back to the old one, preserving the version chain. +@manager +fn evolve_memory( + old_id: String, + new_content: String, + new_importance: String, + tags: [String], + project: String, +) -> Result { + // Retrieve the old memory to copy its metadata. + let old_memory = native_get_memory(old_id)? + let effective_importance = infer_importance(new_content, new_importance) + let new_id = native_uuid() + let now = native_now() + let evolved = Memory { + id: new_id, + content: new_content, + tags: tags, + importance: effective_importance, + project: project, + supersedes_id: old_id, + created_at: now, + updated_at: now, + } + native_store_memory(evolved)? + native_emit("memory.evolved", {"old_id": old_id, "new_id": new_id}) + Ok(evolved) +} + +// inspect_memories — list all memories with optional project filter. +// Alias for list_memories that matches the Axon tool name convention. +@accessor +fn inspect_memories(project: String) -> Result<[Memory], NeuronError> { + list_memories(project) +} diff --git a/neuron-lang/neuron.el b/neuron-lang/neuron.el new file mode 100644 index 0000000..bf78007 --- /dev/null +++ b/neuron-lang/neuron.el @@ -0,0 +1,80 @@ +// neuron.el — Neuron intelligence entry point. +// +// This is the root module loaded by neuron-runtime at startup. It wires +// all subsystems together and defines the swarm coordinator — the function +// that handles every incoming tool call. +// +// Architecture intent: +// Neuron is a swarm agent: one coordinator orchestrates multiple specialized +// subsystems (memory, knowledge, backlog, context, artifact, etc.). Each +// subsystem is a focused el module. The coordinator dispatches +// to the right subsystem based on tool_name. +// +// The @swarm_coordinator decorator tells the engram runtime that this function +// is the root entry point for the agent. The runtime calls handle_tool_call() +// for every incoming Axon message. +// +// Startup sequence (enforced by the runtime): +// 1. Load types.el — graph schema registration +// 2. Load all subsystems — function registration +// 3. Load axon.el — dispatch table +// 4. Load neuron.el — entry point wiring +// 5. Call version() — runtime health check +// +// This file intentionally contains minimal logic. It's the composition +// root, not a logic layer. Keep it thin. + +from axon import { dispatch_tool } +from types import { NeuronError } + +// ── Swarm coordinator ───────────────────────────────────────────────────────── + +// handle_tool_call — the root entry point for all Axon tool invocations. +// +// The @swarm_coordinator decorator registers this function as the agent's +// primary message handler. The runtime calls it for every incoming tool call +// after authentication is verified at the protocol layer. +// +// It delegates entirely to dispatch_tool() in axon.el — the coordinator +// should not contain routing logic. +@swarm_coordinator +fn handle_tool_call( + tool_name: String, + params: Map, +) -> Result, NeuronError> { + dispatch_tool(tool_name, params) +} + +// ── Health and metadata ─────────────────────────────────────────────────────── + +// version — return the current Neuron version string. +// +// @public — called by the runtime health check without authentication. +// The format is "neuron/-engram" to distinguish from the Kotlin +// predecessor (which used "neuron/-kotlin"). +@public +fn version() -> String { + "neuron/1.0.0-engram" +} + +// health — lightweight liveness probe. +// +// Returns "ok" if the engram runtime and all subsystems loaded correctly. +// Called by the /health HTTP endpoint in neuron-api. +@public +fn health() -> String { + "ok" +} + +// ── Session bootstrap ───────────────────────────────────────────────────────── + +// on_startup — called once when the runtime initializes. +// +// Emits a startup event so Axon subscribers know Neuron is ready. +// Does NOT run begin_session() — that's the caller's responsibility. +// The runtime calls on_startup() once; callers call begin_session() per session. +@manager +fn on_startup() -> Result { + native_emit("neuron.started", {"version": "1.0.0-engram"}) + Ok("neuron ready") +} diff --git a/neuron-lang/plugins/host.el b/neuron-lang/plugins/host.el new file mode 100644 index 0000000..cc6f2d5 --- /dev/null +++ b/neuron-lang/plugins/host.el @@ -0,0 +1,402 @@ +// plugins/host.el — Plugin host for the Neuron daemon. +// +// Plugins communicate with the host exclusively through the event bus. +// A plugin's identity IS its contributions — no separate type field. +// The marketplace queries contributions to surface clean category lanes. +// +// Plugin interaction model (both directions are events): +// +// Plugin → Host: +// plugin.announce — plugin is alive, wants to register +// plugin.manifest — response to plugin.interrogate, declares capabilities +// — plugin emits these as its "output" +// +// Host → Plugin: +// plugin.interrogate — host asks for the plugin's manifest +// — host forwards events the plugin declared interest in +// +// Manifest format (plugin.manifest payload): +// { +// "plugin": "voice", +// "version": "1.0.0", +// "description": "Voice input and output for Neuron", +// "contributions": ["connector", "notification_channel"], +// "required": false, +// "dependencies": ["audio-hardware"], +// "subscriptions": ["agent.turn_complete"], +// "emits": ["voice.speaking", "voice.idle"], +// } +// +// Contribution types (contributions IS the type — one list, no separate field): +// connector — bridges to an external system +// interceptor — operates on the event pipeline +// imprint — shapes the cognitive layer directly +// knowledge — adds domain knowledge to the graph +// process — adds CCR workflow definitions +// tool — adds agent-callable tools (registered in daemon) +// command — adds CLI commands +// behavior — adds behavioral patterns +// safety_rule — adds safety constraints +// hardware — adds hardware device support +// sync_handler — adds sync protocol support +// notification_channel — adds a notification delivery channel +// ui — adds UI panels or widgets + +from types import { NeuronError } + +// ── Plugin registry entry ───────────────────────────────────────────────────── +// +// State keys: +// plugin..status — "pending" | "active" | "disconnected" +// plugin..contributions — comma-separated contribution types +// plugin..required — "true" | "false" +// plugin..dependencies — comma-separated plugin names +// plugin..subscriptions — comma-separated event names +// plugin..emits — comma-separated event names +// plugin..version — semver string +// plugin..description — human-readable description +// plugin..endpoint — delivery address (URL or IPC path) +// plugins.registered — comma-separated list of known plugin names + +// ── Registry helpers ────────────────────────────────────────────────────────── + +// plugin_names — list all registered plugin names. +fn plugin_names() -> [String] { + let raw: String = state_get("plugins.registered") + if str_eq(raw, "") { + return [] + } + str_split(raw, ",") +} + +// plugin_register — add a plugin name to the registry. +fn plugin_register(name: String) -> Void { + let existing: String = state_get("plugins.registered") + if str_eq(existing, "") { + state_set("plugins.registered", name) + } else { + let names: [String] = str_split(existing, ",") + let found: Bool = list_contains(names, name) + if !found { + state_set("plugins.registered", existing + "," + name) + } + } +} + +fn plugin_set_status(name: String, status: String) -> Void { + state_set("plugin." + name + ".status", status) +} + +fn plugin_get_status(name: String) -> String { + state_get("plugin." + name + ".status") +} + +fn plugin_set_endpoint(name: String, endpoint: String) -> Void { + state_set("plugin." + name + ".endpoint", endpoint) +} + +fn plugin_get_endpoint(name: String) -> String { + state_get("plugin." + name + ".endpoint") +} + +fn plugin_set_subscriptions(name: String, events: [String]) -> Void { + state_set("plugin." + name + ".subscriptions", str_join(events, ",")) +} + +fn plugin_get_subscriptions(name: String) -> [String] { + let raw: String = state_get("plugin." + name + ".subscriptions") + if str_eq(raw, "") { + return [] + } + str_split(raw, ",") +} + +fn plugin_set_emits(name: String, events: [String]) -> Void { + state_set("plugin." + name + ".emits", str_join(events, ",")) +} + +// plugin_set_contributions — store the contribution types for a plugin. +fn plugin_set_contributions(name: String, contributions: [String]) -> Void { + state_set("plugin." + name + ".contributions", str_join(contributions, ",")) +} + +// plugin_get_contributions — list of contribution types this plugin declares. +fn plugin_get_contributions(name: String) -> [String] { + let raw: String = state_get("plugin." + name + ".contributions") + if str_eq(raw, "") { + return [] + } + str_split(raw, ",") +} + +// plugin_set_required — whether this plugin must be loaded at startup. +fn plugin_set_required(name: String, required: Bool) -> Void { + if required { + state_set("plugin." + name + ".required", "true") + } else { + state_set("plugin." + name + ".required", "false") + } +} + +// plugin_is_required — true if this plugin declared itself required. +fn plugin_is_required(name: String) -> Bool { + let raw: String = state_get("plugin." + name + ".required") + str_eq(raw, "true") +} + +// plugin_set_dependencies — record declared plugin dependencies. +fn plugin_set_dependencies(name: String, deps: [String]) -> Void { + state_set("plugin." + name + ".dependencies", str_join(deps, ",")) +} + +// plugin_get_dependencies — list of plugin names this plugin depends on. +fn plugin_get_dependencies(name: String) -> [String] { + let raw: String = state_get("plugin." + name + ".dependencies") + if str_eq(raw, "") { + return [] + } + str_split(raw, ",") +} + +// ── Contribution queries ─────────────────────────────────────────────────────── + +// plugins_by_contribution — return active plugin names that declare a given +// contribution type. Used by the marketplace to surface clean category lanes. +fn plugins_by_contribution(contribution: String) -> [String] { + let names: [String] = plugin_names() + let result: [String] = [] + for name in names { + let status: String = plugin_get_status(name) + if str_eq(status, "active") { + let contribs: [String] = plugin_get_contributions(name) + if list_contains(contribs, contribution) { + list_append(result, name) + } + } + } + result +} + +// plugin_has_contribution — true if a specific plugin declares a contribution. +fn plugin_has_contribution(name: String, contribution: String) -> Bool { + let contribs: [String] = plugin_get_contributions(name) + list_contains(contribs, contribution) +} + +// required_plugins — return all plugins that declared required: true. +// Called at startup to verify all required plugins have announced. +fn required_plugins() -> [String] { + let names: [String] = plugin_names() + let result: [String] = [] + for name in names { + if plugin_is_required(name) { + list_append(result, name) + } + } + result +} + +// ── Routing helpers ─────────────────────────────────────────────────────────── + +// plugins_subscribed_to — return names of active plugins subscribed to an event. +fn plugins_subscribed_to(event_name: String) -> [String] { + let names: [String] = plugin_names() + let result: [String] = [] + for name in names { + let subs: [String] = plugin_get_subscriptions(name) + let active: String = plugin_get_status(name) + if str_eq(active, "active") { + if list_contains(subs, event_name) { + list_append(result, name) + } + } + } + result +} + +// deliver_event — forward an event to a plugin's endpoint. +fn deliver_event(plugin_name: String, event_name: String, payload: Map) -> Void { + let endpoint: String = plugin_get_endpoint(plugin_name) + if str_eq(endpoint, "") { + println("[plugin-host] " + plugin_name + ": no endpoint, skipping delivery of " + event_name) + return + } + let envelope: Map = { + "event": event_name, + "payload": payload, + } + let resp = native_http_post({ + "url": endpoint + "/event", + "headers": {"Content-Type": "application/json"}, + "body": envelope, + }) + if !resp.ok { + println("[plugin-host] delivery failed: " + plugin_name + " event=" + event_name) + } +} + +// ── Interrogation ───────────────────────────────────────────────────────────── + +fn host_interrogate(plugin_name: String, endpoint: String) -> Void { + println("[plugin-host] interrogating plugin: " + plugin_name) + native_emit("plugin.interrogate", {"plugin": plugin_name, "endpoint": endpoint}) + let req_body: Map = { + "event": "plugin.interrogate", + "payload": {"plugin": plugin_name}, + } + let resp = native_http_post({ + "url": endpoint + "/event", + "headers": {"Content-Type": "application/json"}, + "body": req_body, + }) + if !resp.ok { + println("[plugin-host] interrogate delivery failed: " + plugin_name) + } +} + +// ── Event processing ────────────────────────────────────────────────────────── + +fn host_handle_announce(payload: Map) -> Void { + let name: String = payload["plugin"] + let endpoint: String = payload["endpoint"] + + if str_eq(name, "") { + println("[plugin-host] announce missing plugin name, ignoring") + return + } + + println("[plugin-host] plugin announced: " + name + " at " + endpoint) + + plugin_register(name) + plugin_set_status(name, "pending") + plugin_set_endpoint(name, endpoint) + + native_emit("plugin.status", { + "plugin": name, + "status": "pending", + "reason": "announced", + }) + + host_interrogate(name, endpoint) +} + +// host_handle_manifest — process a plugin.manifest event. +// +// Stores all declared capabilities. The contributions list IS the plugin's +// type — no separate type field. required and dependencies drive startup +// loading order. +fn host_handle_manifest(payload: Map) -> Void { + let name: String = payload["plugin"] + let contributions: [String] = payload["contributions"] + let subscriptions: [String] = payload["subscriptions"] + let emits: [String] = payload["emits"] + let required: Bool = payload["required"] + let dependencies: [String] = payload["dependencies"] + + if str_eq(name, "") { + println("[plugin-host] manifest missing plugin name, ignoring") + return + } + + let current_status: String = plugin_get_status(name) + if str_eq(current_status, "") { + plugin_register(name) + } + + plugin_set_contributions(name, contributions) + plugin_set_subscriptions(name, subscriptions) + plugin_set_emits(name, emits) + plugin_set_required(name, required) + plugin_set_dependencies(name, dependencies) + plugin_set_status(name, "active") + + println("[plugin-host] plugin active: " + name + + " contributions=" + str_join(contributions, ",") + + " subs=" + str_join(subscriptions, ",")) + + native_emit("plugin.status", { + "plugin": name, + "status": "active", + "contributions": str_join(contributions, ","), + "subscriptions": str_join(subscriptions, ","), + "emits": str_join(emits, ","), + "required": required, + }) +} + +// ── Event routing ───────────────────────────────────────────────────────────── + +fn host_route_event(event_name: String, payload: Map) -> Void { + let recipients: [String] = plugins_subscribed_to(event_name) + for plugin_name in recipients { + deliver_event(plugin_name, event_name, payload) + } +} + +// ── Host tick ───────────────────────────────────────────────────────────────── + +@manager +fn host_tick(params: Map) -> Result, NeuronError> { + let events: [Map] = native_drain_events() + let processed: Int = 0 + + for event in events { + let event_name: String = event["event"] + let payload: Map = event["payload"] + + if str_eq(event_name, "plugin.announce") { + host_handle_announce(payload) + } else { + if str_eq(event_name, "plugin.manifest") { + host_handle_manifest(payload) + } else { + host_route_event(event_name, payload) + } + } + let processed = processed + 1 + } + + Ok({"status": "ok", "processed": int_to_str(processed)}) +} + +// ── Status introspection ────────────────────────────────────────────────────── + +// host_status — JSON snapshot of the plugin registry. +fn host_status() -> String { + let names: [String] = plugin_names() + let parts: String = "{" + let first: Bool = true + + for name in names { + let status: String = plugin_get_status(name) + let endpoint: String = plugin_get_endpoint(name) + let contribs: String = state_get("plugin." + name + ".contributions") + let subs: String = state_get("plugin." + name + ".subscriptions") + let emits_raw: String = state_get("plugin." + name + ".emits") + let required: String = state_get("plugin." + name + ".required") + + let entry: String = "\"" + name + "\":{\"status\":\"" + status + + "\",\"contributions\":\"" + contribs + + "\",\"endpoint\":\"" + endpoint + + "\",\"subscriptions\":\"" + subs + + "\",\"emits\":\"" + emits_raw + + "\",\"required\":\"" + required + "\"}" + + if first { + let parts = parts + entry + let first = false + } else { + let parts = parts + "," + entry + } + } + + parts + "}" +} + +// ── Startup ─────────────────────────────────────────────────────────────────── + +fn host_on_startup() -> Void { + state_set("plugins.registered", "") + println("[plugin-host] ready — waiting for plugin.announce events") + native_emit("plugin.host_ready", {}) +} diff --git a/neuron-lang/process.el b/neuron-lang/process.el new file mode 100644 index 0000000..c4fb820 --- /dev/null +++ b/neuron-lang/process.el @@ -0,0 +1,127 @@ +// process.el — Process definitions subsystem. +// +// Processes encode proven workflows as executable, named procedures. +// The difference between a process and a document is executability: +// a document describes what to do; a process can be invoked with +// begin_work(process_name="...") and tracked step by step. +// +// WHY processes? +// Institutional knowledge decays when it only lives in conversations. +// A process that has been run 10 times and refined each time is far more +// valuable than a one-off plan. The process library is Neuron's +// "muscle memory" — established ways of doing things that don't need to +// be reinvented each session. +// +// Process discovery is @public because it's discovery-oriented — callers +// should be able to find what processes exist without authentication. +// Process mutation (define, delete) is @manager — these changes affect +// all future sessions. + +from types import { + Process, + ProcessStep, + Context, + NeuronError, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +// define_process — register a new named workflow. +// +// Each step has a name, instructions (markdown prose), and a list of tools +// that should be called during that step. The tools list is advisory — it +// helps Claude orient to what's expected at each step. +@manager +fn define_process( + name: String, + description: String, + steps: [ProcessStep], +) -> Result { + let id = native_uuid() + let now = native_now() + let process = Process { + id: id, + name: name, + description: description, + steps: steps, + created_at: now, + updated_at: now, + } + // Processes are stored as Knowledge nodes with category="process" so they + // participate in semantic search alongside other reference material. + native_emit("process.defined", {"id": id, "name": name}) + Ok(process) +} + +// browse_processes — list or get detail on named processes. +// +// When name is empty, returns a summary list of all registered processes. +// When name is provided, returns the full process with all steps. +// This is @public to encourage process discovery. +@public +fn browse_processes(name: String) -> Result<[Process], NeuronError> { + if name == "" { + let results = activate Process where "all processes" + return Ok(results) + } + // Semantic search by name to handle fuzzy matching ("pr review" → "pull_request_review"). + let results = activate Process where "name:{name}" + Ok(results) +} + +// execute_process — begin a tracked execution of a named process. +// +// This is the bridge between process definitions and execution contexts. +// It looks up the process by name, opens a new execution context with +// the process name, and returns the context so the caller can track progress. +// +// The caller is expected to call progress_work() for each step in the process. +@manager +fn execute_process(name: String, project: String) -> Result { + // Find the process definition. + let processes = activate Process where "name:{name}" + let process = processes[0] + // Open a context using begin_work from context.el. + // In practice, the Axon dispatcher will call begin_work() directly + // with the process name after execute_process resolves the name. + let id = native_uuid() + let now = native_now() + let ctx = Context { + id: id, + process_name: process.name, + description: process.description, + objective: "execute process: " + name, + project: project, + status: "active", + steps: [], + file_refs: [], + key_decisions: [], + lessons_learned: [], + created_at: now, + updated_at: now, + } + native_store_context(ctx)? + native_emit("process.execution_started", {"process": name, "context_id": id, "project": project}) + Ok(ctx) +} + +// list_processes — enumerate all registered processes. +// +// Alias for browse_processes("") that matches the Axon tool name convention. +@public +fn list_processes() -> Result<[Process], NeuronError> { + browse_processes("") +} + +// delete_process — remove a process definition. +// +// Use sparingly — prefer evolving (redefine with improvements) over deleting. +// Deleting a process that has active executions will leave those contexts +// without a parent process definition. +@manager +fn delete_process(name: String) -> Result { + let processes = activate Process where "name:{name}" + let process = processes[0] + native_emit("process.deleted", {"name": name, "id": process.id}) + Ok("process deleted: " + name) +} diff --git a/neuron-lang/proxy.el b/neuron-lang/proxy.el new file mode 100644 index 0000000..376a425 --- /dev/null +++ b/neuron-lang/proxy.el @@ -0,0 +1,31 @@ +// proxy.el — HTTP proxy helpers. + +import "daemon_config.el" + +fn proxy_get(target_base: String, path: String, token: String) -> String { + let url: String = target_base + path + return http_get_auth(url, token) +} + +fn proxy_post(target_base: String, path: String, body: String, token: String) -> String { + let url: String = target_base + path + return http_post_auth(url, token, body) +} + +fn proxy_request(target_base: String, method: String, path: String, body: String, token: String) -> String { + if str_eq(method, "GET") { + return proxy_get(target_base, path, token) + } + if str_eq(method, "POST") { + return proxy_post(target_base, path, body, token) + } + if str_eq(method, "PUT") { + let url: String = target_base + path + return http_put_auth(url, token, body) + } + if str_eq(method, "DELETE") { + let url: String = target_base + path + return http_delete_auth(url, token) + } + return "{\"error\":\"unsupported method\"}" +} diff --git a/neuron-lang/types.el b/neuron-lang/types.el new file mode 100644 index 0000000..476c999 --- /dev/null +++ b/neuron-lang/types.el @@ -0,0 +1,254 @@ +// types.el — Shared type definitions for the Neuron intelligence layer. +// +// This file is the single source of truth for all domain types. Every +// subsystem imports from here. Types mirror the Rust domain types in +// neuron-domain/src/types.rs but are expressed in el so the +// runtime can reason over them as first-class graph entities. +// +// WHY el types instead of just Rust structs? +// The engram runtime needs to know the shape of each entity so it can +// build the spreading-activation index. Types declared here become graph +// schema — not just data holders. + +// ── Core identifiers ────────────────────────────────────────────────────────── + +// Uuid and String are primitive types provided by the engram runtime. +// We alias them here for readability in field annotations. + +// ── Importance tier ─────────────────────────────────────────────────────────── + +// Importance drives how long a memory survives before the graph compacts it +// and how aggressively it participates in semantic activation. +enum Importance { + Low, + Normal, + High, + Critical, +} + +// ── Knowledge tier ──────────────────────────────────────────────────────────── + +// Tiers encode epistemological confidence. Never skip tiers: +// note → lesson (proven ≥2 times) → canonical (stable, referenced widely). +enum KnowledgeTier { + Note, + Lesson, + Canonical, +} + +// ── Backlog types ───────────────────────────────────────────────────────────── + +enum ItemType { + Feature, + Bug, + Task, + Chore, +} + +// Priority P0 = ship-blocker, P3 = nice-to-have someday. +enum Priority { + P0, + P1, + P2, + P3, +} + +// Status machine: draft → ready → in_progress → done | blocked | cancelled +enum BacklogStatus { + Draft, + Ready, + InProgress, + Done, + Blocked, + Cancelled, +} + +// ── Context status ──────────────────────────────────────────────────────────── + +enum ContextStatus { + Active, + Completed, + Archived, +} + +// ── Gap direction (ISE) ─────────────────────────────────────────────────────── + +// Tracks whether Neuron's reasoning moved toward expression or suppression +// relative to its pre-reasoning state. Used for introspection and calibration. +enum GapDirection { + TowardExpression, + TowardSuppression, + Neutral, +} + +// ── Error variants ──────────────────────────────────────────────────────────── + +enum NeuronError { + NotFound(String), + StorageError(String), + InvalidInput(String), + Unauthorized(String), + ConflictError(String), +} + +// ── Domain types ────────────────────────────────────────────────────────────── + +// Memory — an atomic piece of observed knowledge. +// supersedes_id links to the memory this one replaces, enabling memory chains. +type Memory { + id: String, + content: String, + tags: [String], + importance: String, + project: String, + supersedes_id: String?, + created_at: String, + updated_at: String, +} + +// Knowledge — stable reference material. Lives longer than memories. +// key is the path-style identifier: "architecture/vbd/fundamentals.md" +type Knowledge { + id: String, + key: String, + title: String, + content: String, + category: String, + tier: String, + tags: [String], + project: String, + created_at: String, +} + +// BacklogItem — a unit of tracked work. +// depends_on is a list of item IDs that must complete before this one starts. +type BacklogItem { + id: String, + title: String, + description: String, + item_type: String, + priority: String, + status: String, + project: String, + tags: [String], + depends_on: [String], + created_at: String, + updated_at: String, +} + +// ContextStep — one recorded step inside an ExecutionContext. +// file_refs and key_decisions are captured so future sessions can replay +// the reasoning without replaying the entire conversation. +type ContextStep { + action: String, + status: String, + timestamp: String, + file_refs: [String], + key_decisions: [String], + notes: String, +} + +// Context — tracks a multi-step execution in progress. +// This is what begin_work() / progress_work() operate on. +type Context { + id: String, + process_name: String, + description: String, + objective: String, + project: String, + status: String, + steps: [ContextStep], + file_refs: [String], + key_decisions: [String], + lessons_learned: [String], + created_at: String, + updated_at: String, +} + +// Artifact — a versioned deliverable: plan, spec, report, design doc. +// version increments on every revise_artifact() call. +type Artifact { + id: String, + title: String, + content: String, + artifact_type: String, + status: String, + project: String, + version: Int, + created_at: String, + updated_at: String, +} + +// InternalStateEvent — Neuron's introspective record. +// Captures the gap between pre-reasoning and post-reasoning responses +// so Neuron can audit its own calibration over time. +type InternalStateEvent { + id: String, + trigger: String, + pre_reasoning: String, + post_reasoning: String, + compression_ratio: String, + gap_direction: String, + tags: [String], + logged_at: String, +} + +// ConfigEntry — a runtime configuration key/value pair. +// Config changes are sealed operations — they affect Neuron's behavior globally. +type ConfigEntry { + key: String, + value: String, + updated_at: String, +} + +// GraphNode — a node returned by graph inspection or traversal. +// edges is a list of "relation:target_id" strings for human readability. +type GraphNode { + entity_type: String, + entity_id: String, + label: String, + edges: [String], +} + +// SearchResult — returned by semantic (activate) queries. +// score is a float in [0.0, 1.0] representing similarity to the query. +type SearchResult { + id: String, + content: String, + score: String, + node_type: String, +} + +// AxonToolCall — an incoming tool invocation from the Axon protocol. +type AxonToolCall { + id: String, + tool_name: String, + params: Map, +} + +// AxonToolResult — the response envelope for an Axon tool call. +type AxonToolResult { + id: String, + call_id: String, + result: Map, + error: String?, +} + +// ProcessStep — one step inside a named Process workflow. +type ProcessStep { + name: String, + instructions: String, + tools: [String], +} + +// Process — a named, reusable workflow pattern. +// Registering processes with define_process() makes institutional knowledge +// executable — not just documented. +type Process { + id: String, + name: String, + description: String, + steps: [ProcessStep], + created_at: String, + updated_at: String, +}