fix cross-repo path deps; remove el compiler from neuron-lang/
- neuron-runtime, neuron-store, neuron-migrate: fix path deps (products/engram/ → foundation/engram/engrams/, products/el/ → foundation/el/engrams/) - neuron-rs workspace: fix axon-events/axon-protocol paths (../../../platform/ → ../../platform/, paths were off by one level) - neuron-lang/compiler/ removed (el self-hosting compiler now lives in foundation/el/el-compiler/)
This commit is contained in:
+4
-1
@@ -5,13 +5,16 @@ members = [
|
|||||||
"crates/neuron-store",
|
"crates/neuron-store",
|
||||||
"crates/neuron-api",
|
"crates/neuron-api",
|
||||||
"crates/neuron-protocol",
|
"crates/neuron-protocol",
|
||||||
"crates/axon-events",
|
|
||||||
"crates/neuron-runtime",
|
"crates/neuron-runtime",
|
||||||
"crates/neuron-migrate",
|
"crates/neuron-migrate",
|
||||||
"crates/neuron-cli",
|
"crates/neuron-cli",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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<T>`.
|
|
||||||
/// This provides routing metadata (project type, timestamp) without
|
|
||||||
/// coupling the event payload itself to those concerns.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AxonEnvelope<T> {
|
|
||||||
/// 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<T: Serialize + for<'de> Deserialize<'de>> AxonEnvelope<T> {
|
|
||||||
/// 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<String, serde_json::Error> {
|
|
||||||
serde_json::to_string(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialise an envelope from a JSON string.
|
|
||||||
pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
|
|
||||||
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<Ping> = 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<CompileError>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<CompileStarted> = 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<NodeCreated> = 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Diagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<FileOpened> = 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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};
|
|
||||||
@@ -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<String>,
|
|
||||||
/// Semantic version of the emitting service, e.g. `"0.3.1"`.
|
|
||||||
pub version: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<String>) -> Self {
|
|
||||||
self.label = Some(label.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_version(mut self, version: impl Into<String>) -> 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<String> = variants.iter().map(|v| v.to_string()).collect();
|
|
||||||
assert_eq!(strings.len(), variants.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<PublishStarted> = 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Box<dyn Future<Output = T> + 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::<CompileSucceeded>(|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<TypeId, ErasedHandler>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<T>`.
|
|
||||||
/// Only one handler per `T` is supported; registering a second one
|
|
||||||
/// replaces the first.
|
|
||||||
pub fn on<T, F, Fut>(&mut self, handler: F)
|
|
||||||
where
|
|
||||||
T: Serialize + for<'de> Deserialize<'de> + Send + 'static,
|
|
||||||
F: Fn(AxonEnvelope<T>) -> Fut + Send + Sync + 'static,
|
|
||||||
Fut: Future<Output = Result<(), AxonError>> + 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<T> = serde_json::from_value(raw)
|
|
||||||
.map_err(AxonError::Deserialize)?;
|
|
||||||
handler(envelope).await
|
|
||||||
});
|
|
||||||
fut
|
|
||||||
});
|
|
||||||
self.handlers.insert(TypeId::of::<T>(), 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<T>(&self, envelope: AxonEnvelope<T>) -> Result<(), AxonError>
|
|
||||||
where
|
|
||||||
T: Serialize + for<'de> Deserialize<'de> + Send + 'static,
|
|
||||||
{
|
|
||||||
let type_id = TypeId::of::<T>();
|
|
||||||
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<T>` and then routing to the registered handler for `T`.
|
|
||||||
pub async fn dispatch_json<T>(&self, json: &str) -> Result<(), AxonError>
|
|
||||||
where
|
|
||||||
T: Serialize + for<'de> Deserialize<'de> + Send + 'static,
|
|
||||||
{
|
|
||||||
let envelope: AxonEnvelope<T> =
|
|
||||||
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<T: 'static>(&self) -> bool {
|
|
||||||
self.handlers.contains_key(&TypeId::of::<T>())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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<ProjectType>,
|
|
||||||
/// Subscribe only to events whose payload type name is in this list.
|
|
||||||
/// Empty = subscribe to all event types.
|
|
||||||
pub event_types: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
|
|
||||||
let cap = Arc::clone(&captured);
|
|
||||||
|
|
||||||
let mut router = AxonRouter::new();
|
|
||||||
router.on(move |env: AxonEnvelope<SessionStarted>| {
|
|
||||||
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<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
|
||||||
let cap = Arc::clone(&captured);
|
|
||||||
|
|
||||||
let mut router = AxonRouter::new();
|
|
||||||
router.on(move |env: AxonEnvelope<CompileSucceeded>| {
|
|
||||||
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::<CompileSucceeded>(&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<Mutex<u32>> = Arc::new(Mutex::new(0));
|
|
||||||
let c1 = Arc::clone(&counter);
|
|
||||||
let c2 = Arc::clone(&counter);
|
|
||||||
|
|
||||||
let mut router = AxonRouter::new();
|
|
||||||
router.on(move |_: AxonEnvelope<SessionStarted>| {
|
|
||||||
let c = Arc::clone(&c1);
|
|
||||||
async move { *c.lock().unwrap() += 10; Ok(()) }
|
|
||||||
});
|
|
||||||
// replace with a handler that adds 1
|
|
||||||
router.on(move |_: AxonEnvelope<SessionStarted>| {
|
|
||||||
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::<SessionStarted>());
|
|
||||||
router.on(|_: AxonEnvelope<SessionStarted>| async { Ok(()) });
|
|
||||||
assert!(router.has_handler::<SessionStarted>());
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<SessionStarted> = 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<BuildStarted> = 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,6 +34,10 @@ async fn main() {
|
|||||||
let state = AppState::new(&db_path, api_key).expect("failed to initialize app state");
|
let state = AppState::new(&db_path, api_key).expect("failed to initialize app state");
|
||||||
let state = Arc::new(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 app = routes::build_router(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&bind)
|
let listener = tokio::net::TcpListener::bind(&bind)
|
||||||
|
|||||||
@@ -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"] }
|
||||||
@@ -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<Commands>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
},
|
||||||
|
/// 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<Value, String> {
|
||||||
|
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::<Value>()
|
||||||
|
.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 <text> — save a memory");
|
||||||
|
println!(" search <query> — 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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<T: serde::Serialize>(value: &T) -> Vec<u8> {
|
||||||
|
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<f32> {
|
||||||
|
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::<f32>().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<String>.
|
||||||
|
fn parse_tags(raw: &str) -> Vec<String> {
|
||||||
|
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<Uuid>.
|
||||||
|
fn parse_uuid_list(raw: &str) -> Vec<Uuid> {
|
||||||
|
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<String>.
|
||||||
|
fn parse_string_list(raw: &str) -> Vec<String> {
|
||||||
|
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<String>, treating empty strings as None.
|
||||||
|
fn opt_str(s: Option<String>) -> Option<String> {
|
||||||
|
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::<i64>() {
|
||||||
|
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<Stats> {
|
||||||
|
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<String>>(4)?,
|
||||||
|
row.get::<_, i64>(5)?,
|
||||||
|
row.get::<_, i64>(6)?,
|
||||||
|
row.get::<_, Option<String>>(7)?,
|
||||||
|
row.get::<_, Option<String>>(8)?,
|
||||||
|
row.get::<_, Option<i64>>(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<Stats> {
|
||||||
|
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<Stats> {
|
||||||
|
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<String>>(5)?,
|
||||||
|
row.get::<_, String>(6)?,
|
||||||
|
row.get::<_, String>(7)?,
|
||||||
|
row.get::<_, String>(8)?,
|
||||||
|
row.get::<_, i64>(9)?,
|
||||||
|
row.get::<_, i64>(10)?,
|
||||||
|
row.get::<_, Option<String>>(11)?,
|
||||||
|
row.get::<_, Option<String>>(12)?,
|
||||||
|
row.get::<_, Option<String>>(13)?,
|
||||||
|
row.get::<_, Option<String>>(14)?,
|
||||||
|
row.get::<_, Option<String>>(15)?,
|
||||||
|
row.get::<_, Option<String>>(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<Stats> {
|
||||||
|
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<String>>(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<String>>(12)?,
|
||||||
|
row.get::<_, Option<String>>(13)?,
|
||||||
|
row.get::<_, Option<String>>(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<Stats> {
|
||||||
|
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<String>>(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<Stats> {
|
||||||
|
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<ContextStep> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() || trimmed == "[]" { return vec![]; }
|
||||||
|
let arr: Vec<Value> = 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(())
|
||||||
|
}
|
||||||
@@ -2,18 +2,20 @@
|
|||||||
name = "neuron-protocol"
|
name = "neuron-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
description = "Thin re-export shim — delegates to the canonical axon-protocol and axon-events platform crates."
|
||||||
|
|
||||||
[dependencies]
|
[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-domain = { path = "../neuron-domain" }
|
||||||
neuron-store = { path = "../neuron-store" }
|
|
||||||
axon-events = { path = "../axon-events" }
|
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
axum = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
|
||||||
|
|||||||
@@ -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<Self> {
|
|
||||||
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<String>) -> 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<String>, result: Value) -> Self {
|
|
||||||
Self {
|
|
||||||
id: id.into(),
|
|
||||||
success: true,
|
|
||||||
result,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn err(id: impl Into<String>, message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
id: id.into(),
|
|
||||||
success: false,
|
|
||||||
result: serde_json::json!({ "error": message.into() }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// 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<String>, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<dyn Fn(Value) -> Value + Send + Sync>;
|
|
||||||
|
|
||||||
/// Routes Axon method names to handler functions.
|
|
||||||
///
|
|
||||||
/// Each handler takes the `params` field of an AxonMessage and returns
|
|
||||||
/// a JSON Value to be wrapped in an AxonResponse.
|
|
||||||
pub struct AxonHandler {
|
|
||||||
tools: HashMap<String, ToolFn>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AxonHandler {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
tools: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a tool handler by name.
|
|
||||||
pub fn register<F>(&mut self, name: impl Into<String>, f: F)
|
|
||||||
where
|
|
||||||
F: Fn(Value) -> Value + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
self.tools.insert(name.into(), Arc::new(f));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch an AxonMessage to the appropriate handler.
|
|
||||||
pub fn dispatch(&self, msg: AxonMessage) -> AxonResponse {
|
|
||||||
match msg.method {
|
|
||||||
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<String> {
|
|
||||||
let mut names: Vec<_> = self.tools.keys().cloned().collect();
|
|
||||||
names.sort();
|
|
||||||
names
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AxonHandler {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dispatch_ping() {
|
|
||||||
let handler = AxonHandler::new();
|
|
||||||
let msg = AxonMessage {
|
|
||||||
id: "1".into(),
|
|
||||||
method: 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
pub mod axon;
|
//! `neuron-protocol` — re-export shim for the canonical Axon platform crates.
|
||||||
pub mod handler;
|
//!
|
||||||
pub mod sse;
|
//! All types now live in:
|
||||||
pub mod tools;
|
//! - `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};
|
// Re-export wire-protocol types
|
||||||
pub use handler::AxonHandler;
|
pub use axon_protocol::handler::AxonHandler;
|
||||||
pub use tools::ProjectType;
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SseChannel {
|
|
||||||
pub fn new(capacity: usize) -> Self {
|
|
||||||
let (sender, _) = broadcast::channel(capacity);
|
|
||||||
Self { sender }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send an event to all subscribers.
|
|
||||||
pub fn send(&self, event: &AxonEvent) -> Result<(), String> {
|
|
||||||
let json = serde_json::to_string(event).map_err(|e| e.to_string())?;
|
|
||||||
// Ignore errors when no receivers are connected
|
|
||||||
let _ = self.sender.send(json);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscribe to the SSE channel.
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<String> {
|
|
||||||
self.sender.subscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn sse_channel_broadcast() {
|
|
||||||
let ch = SseChannel::new(16);
|
|
||||||
let mut rx = ch.subscribe();
|
|
||||||
|
|
||||||
let event = AxonEvent::new("test", serde_json::json!({ "msg": "hello" }));
|
|
||||||
ch.send(&event).unwrap();
|
|
||||||
|
|
||||||
let received = rx.recv().await.unwrap();
|
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&received).unwrap();
|
|
||||||
assert_eq!(parsed["event_type"], "test");
|
|
||||||
assert_eq!(parsed["payload"]["msg"], "hello");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
@@ -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<Vec<Bytecode>, LoadError> {
|
||||||
|
let output = Compiler::compile(source, CompilerOptions::default())
|
||||||
|
.map_err(|e| LoadError::Compile {
|
||||||
|
module: "<source>".into(),
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?;
|
||||||
|
deserialize_bytecode(&output.artifact).map_err(|e| LoadError::Compile {
|
||||||
|
module: "<source>".into(),
|
||||||
|
detail: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── load_elc ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Load pre-compiled .elc file (bytecode JSON) from disk.
|
||||||
|
pub fn load_elc(path: &Path) -> Result<Vec<Bytecode>, 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<HashMap<String, Vec<Bytecode>>, LoadError> {
|
||||||
|
let mut modules: HashMap<String, Vec<Bytecode>> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, (String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Item = (&str, &str, &str)> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NativeBindings>,
|
||||||
|
registry: ToolRegistry,
|
||||||
|
/// module_name → compiled bytecode
|
||||||
|
modules: HashMap<String, Vec<Bytecode>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NeuronRuntime {
|
||||||
|
/// Create a runtime with pre-built components (for testing).
|
||||||
|
pub fn from_parts(
|
||||||
|
bindings: Arc<NativeBindings>,
|
||||||
|
registry: ToolRegistry,
|
||||||
|
modules: HashMap<String, Vec<Bytecode>>,
|
||||||
|
) -> 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<Self, RuntimeError> {
|
||||||
|
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<Self, RuntimeError> {
|
||||||
|
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_<func>`) 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_<func>". In the
|
||||||
|
// generated bytecode a function is expected to start after a
|
||||||
|
// `Push(Str("__fn_<name>"))` / `StoreLocal("__fn_<name>")` 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_<tool_name>`).
|
||||||
|
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<el_compiler::Value, crate::vm::VmError> {
|
||||||
|
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<String, el_compiler::Value> {
|
||||||
|
// 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<usize> {
|
||||||
|
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<String, Vec<Bytecode>>) -> 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
neuron-domain = { path = "../neuron-domain" }
|
neuron-domain = { path = "../neuron-domain" }
|
||||||
engram-core = { path = "../../../engram/crates/engram-core" }
|
engram-core = { path = "../../../../foundation/engram/engrams/engram-core" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[package]
|
||||||
|
name = "neuron-daemon-user"
|
||||||
|
version = "0.1.0"
|
||||||
|
entry = "neuron-lang/main-user.el"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[package]
|
||||||
|
name = "neuron-daemon"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
entry = "neuron-lang/main.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<String, Any>) -> Result<Map<String, Any>, 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<String, Any>) -> Result<Map<String, Any>, NeuronError> {
|
||||||
|
native_emit("agent.started", {"module": "agent"})
|
||||||
|
loop_main()
|
||||||
|
Ok({"status": "stopped"})
|
||||||
|
}
|
||||||
@@ -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<Artifact, NeuronError> {
|
||||||
|
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<Artifact, NeuronError> {
|
||||||
|
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<Artifact, NeuronError> {
|
||||||
|
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<Artifact, NeuronError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<String,String> params envelope.
|
||||||
|
// Missing optional params return empty string / empty array / zero.
|
||||||
|
|
||||||
|
@accessor
|
||||||
|
fn param_str(params: Map<String, String>, key: String) -> String {
|
||||||
|
params[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
@accessor
|
||||||
|
fn param_int(params: Map<String, String>, 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<String, String>, 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<String, String> — 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<String, String>,
|
||||||
|
) -> Result<Map<String, String>, 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))
|
||||||
|
}
|
||||||
@@ -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<String, NeuronError> {
|
||||||
|
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<BacklogItem, NeuronError> {
|
||||||
|
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<BacklogItem, NeuronError> {
|
||||||
|
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<BacklogItem, NeuronError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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.<subsystem>.<setting>"
|
||||||
|
// Examples:
|
||||||
|
// neuron.persona.directives
|
||||||
|
// neuron.memory.importance_threshold
|
||||||
|
// neuron.session.max_active_contexts
|
||||||
|
@manager
|
||||||
|
fn tune_config(key: String, value: String) -> Result<ConfigEntry, NeuronError> {
|
||||||
|
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])
|
||||||
|
}
|
||||||
@@ -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<Context, NeuronError> {
|
||||||
|
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<Context, NeuronError> {
|
||||||
|
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<Context, NeuronError> {
|
||||||
|
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<Context, NeuronError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<GraphNode, NeuronError> {
|
||||||
|
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<GraphNode, NeuronError> {
|
||||||
|
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<String, NeuronError> {
|
||||||
|
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<GraphNode, NeuronError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + "\"}"
|
||||||
|
}
|
||||||
@@ -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<InternalStateEvent, NeuronError> {
|
||||||
|
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<InternalStateEvent, NeuronError> {
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
@@ -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<Knowledge, NeuronError> {
|
||||||
|
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<Knowledge, NeuronError> {
|
||||||
|
// 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<Knowledge, NeuronError> {
|
||||||
|
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<Knowledge, NeuronError> {
|
||||||
|
let knowledge = native_get_knowledge(id)?
|
||||||
|
native_emit("knowledge.removed", {"id": id, "key": knowledge.key})
|
||||||
|
Ok(knowledge)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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<Memory, NeuronError> {
|
||||||
|
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<Memory, NeuronError> {
|
||||||
|
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<Memory, NeuronError> {
|
||||||
|
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<Memory, NeuronError> {
|
||||||
|
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<Memory, NeuronError> {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -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<String, String>,
|
||||||
|
) -> Result<Map<String, String>, 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/<version>-engram" to distinguish from the Kotlin
|
||||||
|
// predecessor (which used "neuron/<version>-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<String, NeuronError> {
|
||||||
|
native_emit("neuron.started", {"version": "1.0.0-engram"})
|
||||||
|
Ok("neuron ready")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// <any event> — plugin emits these as its "output"
|
||||||
|
//
|
||||||
|
// Host → Plugin:
|
||||||
|
// plugin.interrogate — host asks for the plugin's manifest
|
||||||
|
// <subscribed events> — 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.<name>.status — "pending" | "active" | "disconnected"
|
||||||
|
// plugin.<name>.contributions — comma-separated contribution types
|
||||||
|
// plugin.<name>.required — "true" | "false"
|
||||||
|
// plugin.<name>.dependencies — comma-separated plugin names
|
||||||
|
// plugin.<name>.subscriptions — comma-separated event names
|
||||||
|
// plugin.<name>.emits — comma-separated event names
|
||||||
|
// plugin.<name>.version — semver string
|
||||||
|
// plugin.<name>.description — human-readable description
|
||||||
|
// plugin.<name>.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<String, Any>) -> 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<String, Any> = {
|
||||||
|
"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<String, Any> = {
|
||||||
|
"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<String, Any>) -> 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<String, Any>) -> 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<String, Any>) -> 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<String, Any>) -> Result<Map<String, Any>, NeuronError> {
|
||||||
|
let events: [Map<String, Any>] = native_drain_events()
|
||||||
|
let processed: Int = 0
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let event_name: String = event["event"]
|
||||||
|
let payload: Map<String, Any> = 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", {})
|
||||||
|
}
|
||||||
@@ -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<Process, NeuronError> {
|
||||||
|
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<Context, NeuronError> {
|
||||||
|
// 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<String, NeuronError> {
|
||||||
|
let processes = activate Process where "name:{name}"
|
||||||
|
let process = processes[0]
|
||||||
|
native_emit("process.deleted", {"name": name, "id": process.id})
|
||||||
|
Ok("process deleted: " + name)
|
||||||
|
}
|
||||||
@@ -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\"}"
|
||||||
|
}
|
||||||
@@ -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<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AxonToolResult — the response envelope for an Axon tool call.
|
||||||
|
type AxonToolResult {
|
||||||
|
id: String,
|
||||||
|
call_id: String,
|
||||||
|
result: Map<String, String>,
|
||||||
|
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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user