diff --git a/crates/cortex-core/src/briefing/engine.rs b/crates/cortex-core/src/briefing/engine.rs index 9cec875c..754d6b55 100644 --- a/crates/cortex-core/src/briefing/engine.rs +++ b/crates/cortex-core/src/briefing/engine.rs @@ -4,8 +4,8 @@ use super::{Briefing, BriefingScope, BriefingSection}; use crate::error::Result; use crate::graph::{GraphEngine, TraversalDirection, TraversalRequest}; use crate::storage::{NodeFilter, Storage}; -use crate::types::{Node, NodeId, NodeKind, Relation}; use crate::trust::{TrustConfig, TrustEngine}; +use crate::types::{Node, NodeId, NodeKind, Relation}; use crate::vector::{EmbeddingService, HybridQuery, HybridSearch, VectorIndex}; use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -352,11 +352,7 @@ where /// - `Agent` — identical to `generate(agent_id)`. /// - `Shared` — agent's briefing plus a cross-agent context section. /// - `Unified(agents)` — multi-agent briefing for orchestrators. - pub fn generate_with_scope( - &self, - agent_id: &str, - scope: BriefingScope, - ) -> Result { + pub fn generate_with_scope(&self, agent_id: &str, scope: BriefingScope) -> Result { match scope { BriefingScope::Agent => self.generate(agent_id), BriefingScope::Shared => self.generate_shared(agent_id), @@ -517,11 +513,9 @@ where } // Last resort: scan agent/entity nodes for title/source match - let all_agents = self.storage.list_nodes( - NodeFilter::new() - .with_kinds(agent_kinds) - .with_limit(50), - )?; + let all_agents = self + .storage + .list_nodes(NodeFilter::new().with_kinds(agent_kinds).with_limit(50))?; for node in &all_agents { if !Self::is_agent_entity(node) { @@ -548,11 +542,7 @@ where return true; } if node.kind.as_str() == "entity" { - return node - .data - .tags - .iter() - .any(|t| t == "entity-type-agent"); + return node.data.tags.iter().any(|t| t == "entity-type-agent"); } false } @@ -852,11 +842,7 @@ where }) } - fn generate_temporal( - &self, - agent_id: &str, - seen: &HashSet, - ) -> Result { + fn generate_temporal(&self, agent_id: &str, seen: &HashSet) -> Result { let title = self.role_section_title("temporal", &self.config.roles.temporal); let kind_filters: Vec = self .config @@ -1258,9 +1244,7 @@ where for edge in &outbound { if edge.relation.as_str() == "contradicts" { if let Ok(Some(other)) = self.storage.get_node(edge.to) { - if agent_set.contains(other.source.agent.as_str()) - && !other.deleted - { + if agent_set.contains(other.source.agent.as_str()) && !other.deleted { // Verify they're from *different* agents if let Ok(Some(this)) = self.storage.get_node(*node_id) { if this.source.agent != other.source.agent { @@ -2170,8 +2154,7 @@ mod tests { let dir = TempDir::new().unwrap(); let storage = Arc::new(RedbStorage::open(dir.path().join("t.redb")).unwrap()); - let mut experiment = - make_node(NodeKind::new("experiment").unwrap(), "Test A/B", "kai"); + let mut experiment = make_node(NodeKind::new("experiment").unwrap(), "Test A/B", "kai"); experiment.importance = 0.8; storage.put_node(&experiment).unwrap(); @@ -2195,8 +2178,7 @@ mod tests { let storage = Arc::new(RedbStorage::open(dir.path().join("t.redb")).unwrap()); // Low importance kind - let mut insight = - make_node(NodeKind::new("insight").unwrap(), "Small insight", "kai"); + let mut insight = make_node(NodeKind::new("insight").unwrap(), "Small insight", "kai"); insight.importance = 0.4; storage.put_node(&insight).unwrap(); @@ -2241,8 +2223,7 @@ mod tests { let dir = TempDir::new().unwrap(); let storage = Arc::new(RedbStorage::open(dir.path().join("t.redb")).unwrap()); - let mut experiment = - make_node(NodeKind::new("experiment").unwrap(), "Low exp", "kai"); + let mut experiment = make_node(NodeKind::new("experiment").unwrap(), "Low exp", "kai"); experiment.importance = 0.1; // Below default min_importance of 0.3 storage.put_node(&experiment).unwrap(); @@ -2250,10 +2231,7 @@ mod tests { let briefing = engine.generate("kai").unwrap(); assert!( - !briefing - .sections - .iter() - .any(|s| s.title == "Experiments"), + !briefing.sections.iter().any(|s| s.title == "Experiments"), "Low-importance novel kind should not produce a section" ); } @@ -2264,8 +2242,7 @@ mod tests { let dir = TempDir::new().unwrap(); let storage = Arc::new(RedbStorage::open(dir.path().join("t.redb")).unwrap()); - let mut experiment = - make_node(NodeKind::new("experiment").unwrap(), "Test A/B", "kai"); + let mut experiment = make_node(NodeKind::new("experiment").unwrap(), "Test A/B", "kai"); experiment.importance = 0.8; storage.put_node(&experiment).unwrap(); @@ -2275,16 +2252,12 @@ mod tests { }; let graph = Arc::new(GraphEngineImpl::new(storage.clone())); let gv = Arc::new(AtomicU64::new(0)); - let engine = - BriefingEngine::new(storage, graph, MockVectorIndex, MockEmbedder, gv, config); + let engine = BriefingEngine::new(storage, graph, MockVectorIndex, MockEmbedder, gv, config); let briefing = engine.generate("kai").unwrap(); assert!( - !briefing - .sections - .iter() - .any(|s| s.title == "Experiments"), + !briefing.sections.iter().any(|s| s.title == "Experiments"), "Excluded kind should not produce a section" ); } @@ -2330,8 +2303,7 @@ mod tests { let dir = TempDir::new().unwrap(); let storage = Arc::new(RedbStorage::open(dir.path().join("t.redb")).unwrap()); - let mut experiment = - make_node(NodeKind::new("experiment").unwrap(), "Shared exp", "kai"); + let mut experiment = make_node(NodeKind::new("experiment").unwrap(), "Shared exp", "kai"); experiment.importance = 0.8; storage.put_node(&experiment).unwrap(); @@ -2360,8 +2332,7 @@ mod tests { let storage = Arc::new(RedbStorage::open(dir.path().join("t.redb")).unwrap()); // Create a novel-kind node and a fact (to populate Active Context) - let mut experiment = - make_node(NodeKind::new("experiment").unwrap(), "Novel exp", "kai"); + let mut experiment = make_node(NodeKind::new("experiment").unwrap(), "Novel exp", "kai"); experiment.importance = 0.8; storage.put_node(&experiment).unwrap(); @@ -2400,8 +2371,7 @@ mod tests { let fact = make_node(NodeKind::new("fact").unwrap(), "A fact", "kai"); storage.put_node(&fact).unwrap(); - let experiment = - make_node(NodeKind::new("experiment").unwrap(), "An exp", "kai"); + let experiment = make_node(NodeKind::new("experiment").unwrap(), "An exp", "kai"); storage.put_node(&experiment).unwrap(); // Add a second fact — should not duplicate @@ -2479,10 +2449,7 @@ mod tests { .find(|s| s.title == "Tasks & Milestones") .expect("Trackable section with multi-kind title missing"); - assert!(section - .nodes - .iter() - .any(|n| n.data.title == "Fix bug #42")); + assert!(section.nodes.iter().any(|n| n.data.title == "Fix bug #42")); } // Test 34: kinds not in any role appear in auto-discovered sections @@ -2492,8 +2459,7 @@ mod tests { let storage = Arc::new(RedbStorage::open(dir.path().join("t.redb")).unwrap()); // Use a config where "experiment" is NOT mapped to any role - let mut experiment = - make_node(NodeKind::new("experiment").unwrap(), "Novel exp", "kai"); + let mut experiment = make_node(NodeKind::new("experiment").unwrap(), "Novel exp", "kai"); experiment.importance = 0.8; storage.put_node(&experiment).unwrap(); @@ -2502,10 +2468,7 @@ mod tests { let briefing = engine.generate("kai").unwrap(); assert!( - briefing - .sections - .iter() - .any(|s| s.title == "Experiments"), + briefing.sections.iter().any(|s| s.title == "Experiments"), "Unmapped kind should appear as auto-discovered section" ); } @@ -2551,7 +2514,15 @@ mod tests { let config = BriefingRoleConfig::default(); let mapped = config.mapped_kinds(); - let legacy = ["agent", "preference", "fact", "pattern", "goal", "event", "decision"]; + let legacy = [ + "agent", + "preference", + "fact", + "pattern", + "goal", + "event", + "decision", + ]; for kind in &legacy { assert!( mapped.contains(*kind), @@ -2615,8 +2586,7 @@ mod tests { storage.put_node(&agent).unwrap(); // "experiment" is mapped to reviewable — should NOT auto-discover - let mut experiment = - make_node(NodeKind::new("experiment").unwrap(), "Test A/B", "kai"); + let mut experiment = make_node(NodeKind::new("experiment").unwrap(), "Test A/B", "kai"); experiment.importance = 0.8; storage.put_node(&experiment).unwrap(); @@ -2695,11 +2665,7 @@ mod tests { // Kai's fact references the entity let kai_fact = make_node(NodeKind::new("fact").unwrap(), "Kai's observation", "kai"); // Scout's fact also references the entity - let scout_fact = make_node( - NodeKind::new("fact").unwrap(), - "Scout's analysis", - "scout", - ); + let scout_fact = make_node(NodeKind::new("fact").unwrap(), "Scout's analysis", "scout"); storage.put_node(&agent_kai).unwrap(); storage.put_node(&agent_scout).unwrap(); @@ -2801,23 +2767,14 @@ mod tests { let briefing = engine .generate_with_scope( "kai", - super::super::BriefingScope::Unified(vec![ - "kai".to_string(), - "scout".to_string(), - ]), + super::super::BriefingScope::Unified(vec!["kai".to_string(), "scout".to_string()]), ) .unwrap(); assert_eq!(briefing.agent_id, "kai,scout"); - let kai_section = briefing - .sections - .iter() - .find(|s| s.title == "Agent: kai"); - let scout_section = briefing - .sections - .iter() - .find(|s| s.title == "Agent: scout"); + let kai_section = briefing.sections.iter().find(|s| s.title == "Agent: kai"); + let scout_section = briefing.sections.iter().find(|s| s.title == "Agent: scout"); assert!( kai_section.is_some(), @@ -2835,7 +2792,11 @@ mod tests { let dir = TempDir::new().unwrap(); let storage = Arc::new(RedbStorage::open(dir.path().join("t.redb")).unwrap()); - let entity = make_node(NodeKind::new("entity").unwrap(), "Shared Resource", "system"); + let entity = make_node( + NodeKind::new("entity").unwrap(), + "Shared Resource", + "system", + ); let kai_fact = make_node(NodeKind::new("fact").unwrap(), "Kai ref", "kai"); let scout_fact = make_node(NodeKind::new("fact").unwrap(), "Scout ref", "scout"); @@ -2863,10 +2824,7 @@ mod tests { let briefing = engine .generate_with_scope( "kai", - super::super::BriefingScope::Unified(vec![ - "kai".to_string(), - "scout".to_string(), - ]), + super::super::BriefingScope::Unified(vec!["kai".to_string(), "scout".to_string()]), ) .unwrap(); @@ -2914,10 +2872,7 @@ mod tests { let briefing = engine .generate_with_scope( "kai", - super::super::BriefingScope::Unified(vec![ - "kai".to_string(), - "scout".to_string(), - ]), + super::super::BriefingScope::Unified(vec!["kai".to_string(), "scout".to_string()]), ) .unwrap(); diff --git a/crates/cortex-core/src/conventions.rs b/crates/cortex-core/src/conventions.rs new file mode 100644 index 00000000..ac51b6f9 --- /dev/null +++ b/crates/cortex-core/src/conventions.rs @@ -0,0 +1,296 @@ +//! Well-known metadata conventions for Cortex nodes and edges. +//! +//! These are **documented conventions**, not schema constraints. Validation +//! helpers return warnings (never errors) and never block storage. + +use crate::types::Node; +use serde_json::Value; + +/// Well-known entity types. Not an enum — just constants for documentation +/// and programmatic use. Custom entity types are allowed. +pub mod entity_types { + pub const AGENT: &str = "agent"; + pub const COMPANY: &str = "company"; + pub const PERSON: &str = "person"; + pub const TECHNOLOGY: &str = "technology"; + pub const PROJECT: &str = "project"; + pub const LOCATION: &str = "location"; + pub const PRODUCT: &str = "product"; + + /// All well-known entity type values. + pub const ALL: &[&str] = &[ + AGENT, COMPANY, PERSON, TECHNOLOGY, PROJECT, LOCATION, PRODUCT, + ]; +} + +/// Well-known node metadata keys. +pub mod node_keys { + pub const ENTITY_TYPE: &str = "entity_type"; + pub const ALIASES: &str = "aliases"; + pub const PARENT_AGENT: &str = "parent_agent"; + pub const TASK_ID: &str = "task_id"; + pub const SOURCE_URL: &str = "source_url"; + pub const CONTENT_TYPE: &str = "content_type"; + pub const LANGUAGE: &str = "language"; + pub const EXPIRES_REASON: &str = "expires_reason"; +} + +/// Well-known edge metadata keys. +pub mod edge_keys { + pub const ENTITY: &str = "entity"; + pub const SIMILARITY_CONTEXT: &str = "similarity_context"; + pub const RULE_VERSION: &str = "rule_version"; +} + +/// Check whether a node's metadata uses well-known keys correctly. +/// Returns warnings, not errors. Never blocks storage. +pub fn check_conventions(node: &Node) -> Vec { + let mut warnings = Vec::new(); + let meta = &node.data.metadata; + + // entity_type should be a string + if let Some(val) = meta.get(node_keys::ENTITY_TYPE) { + if !val.is_string() { + warnings.push("entity_type should be a string".into()); + } + } else if node.kind.as_str() == "entity" { + warnings.push("entity nodes should have an entity_type metadata key".into()); + } + + // aliases should be an array of strings + if let Some(val) = meta.get(node_keys::ALIASES) { + match val { + Value::Array(arr) => { + if !arr.iter().all(|v| v.is_string()) { + warnings.push("aliases should be an array of strings".into()); + } + } + _ => warnings.push("aliases should be an array of strings".into()), + } + } + + // parent_agent should be a string + if let Some(val) = meta.get(node_keys::PARENT_AGENT) { + if !val.is_string() { + warnings.push("parent_agent should be a string".into()); + } + } + + // task_id should be a string + if let Some(val) = meta.get(node_keys::TASK_ID) { + if !val.is_string() { + warnings.push("task_id should be a string".into()); + } + } + + // source_url should look like a URL + if let Some(val) = meta.get(node_keys::SOURCE_URL) { + match val { + Value::String(url) => { + if !url.starts_with("http://") && !url.starts_with("https://") { + warnings.push("source_url should be an HTTP(S) URL".into()); + } + } + _ => warnings.push("source_url should be a string".into()), + } + } + + // content_type should be a string + if let Some(val) = meta.get(node_keys::CONTENT_TYPE) { + if !val.is_string() { + warnings.push("content_type should be a string".into()); + } + } + + // language should be a short string (ISO 639-1 codes are 2-3 chars) + if let Some(val) = meta.get(node_keys::LANGUAGE) { + match val { + Value::String(lang) => { + if lang.len() > 5 { + warnings + .push("language should be an ISO 639-1 code (e.g. \"en\", \"zh\")".into()); + } + } + _ => warnings.push("language should be a string".into()), + } + } + + // expires_reason should be a string + if let Some(val) = meta.get(node_keys::EXPIRES_REASON) { + if !val.is_string() { + warnings.push("expires_reason should be a string".into()); + } + } + + warnings +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{Node, NodeKind, Source}; + use serde_json::json; + use std::collections::HashMap; + + fn make_node(kind: &str, metadata: HashMap) -> Node { + let mut node = Node::new( + NodeKind::new(kind).unwrap(), + "Test node".into(), + "Test body".into(), + Source { + agent: "test".into(), + session: None, + channel: None, + }, + 0.5, + ); + node.data.metadata = metadata; + node + } + + #[test] + fn test_no_warnings_for_valid_entity() { + let mut meta = HashMap::new(); + meta.insert("entity_type".into(), json!("company")); + meta.insert("aliases".into(), json!(["CompanyX", "company-x"])); + let node = make_node("entity", meta); + + let warnings = check_conventions(&node); + assert!( + warnings.is_empty(), + "Expected no warnings, got: {:?}", + warnings + ); + } + + #[test] + fn test_warning_entity_type_not_string() { + let mut meta = HashMap::new(); + meta.insert("entity_type".into(), json!(42)); + let node = make_node("entity", meta); + + let warnings = check_conventions(&node); + assert!(warnings + .iter() + .any(|w| w.contains("entity_type should be a string"))); + } + + #[test] + fn test_warning_missing_entity_type_on_entity_node() { + let node = make_node("entity", HashMap::new()); + + let warnings = check_conventions(&node); + assert!(warnings + .iter() + .any(|w| w.contains("entity nodes should have an entity_type"))); + } + + #[test] + fn test_no_warning_missing_entity_type_on_fact_node() { + let node = make_node("fact", HashMap::new()); + + let warnings = check_conventions(&node); + assert!(warnings.is_empty()); + } + + #[test] + fn test_warning_aliases_not_array() { + let mut meta = HashMap::new(); + meta.insert("aliases".into(), json!("single-string")); + let node = make_node("fact", meta); + + let warnings = check_conventions(&node); + assert!(warnings + .iter() + .any(|w| w.contains("aliases should be an array"))); + } + + #[test] + fn test_warning_aliases_contains_non_string() { + let mut meta = HashMap::new(); + meta.insert("aliases".into(), json!(["valid", 42])); + let node = make_node("fact", meta); + + let warnings = check_conventions(&node); + assert!(warnings + .iter() + .any(|w| w.contains("aliases should be an array of strings"))); + } + + #[test] + fn test_warning_source_url_not_http() { + let mut meta = HashMap::new(); + meta.insert("source_url".into(), json!("ftp://example.com/file")); + let node = make_node("fact", meta); + + let warnings = check_conventions(&node); + assert!(warnings + .iter() + .any(|w| w.contains("source_url should be an HTTP(S) URL"))); + } + + #[test] + fn test_valid_source_url_no_warning() { + let mut meta = HashMap::new(); + meta.insert("source_url".into(), json!("https://example.com/doc")); + let node = make_node("fact", meta); + + let warnings = check_conventions(&node); + assert!(warnings.is_empty()); + } + + #[test] + fn test_warning_source_url_not_string() { + let mut meta = HashMap::new(); + meta.insert("source_url".into(), json!(42)); + let node = make_node("fact", meta); + + let warnings = check_conventions(&node); + assert!(warnings + .iter() + .any(|w| w.contains("source_url should be a string"))); + } + + #[test] + fn test_warning_language_too_long() { + let mut meta = HashMap::new(); + meta.insert("language".into(), json!("english-full-name")); + let node = make_node("fact", meta); + + let warnings = check_conventions(&node); + assert!(warnings.iter().any(|w| w.contains("ISO 639-1"))); + } + + #[test] + fn test_valid_language_no_warning() { + let mut meta = HashMap::new(); + meta.insert("language".into(), json!("en")); + let node = make_node("fact", meta); + + let warnings = check_conventions(&node); + assert!(warnings.is_empty()); + } + + #[test] + fn test_entity_types_all_constant() { + assert_eq!(entity_types::ALL.len(), 7); + assert!(entity_types::ALL.contains(&"agent")); + assert!(entity_types::ALL.contains(&"product")); + } + + #[test] + fn test_multiple_warnings_returned() { + let mut meta = HashMap::new(); + meta.insert("entity_type".into(), json!(42)); + meta.insert("aliases".into(), json!("not-array")); + meta.insert("source_url".into(), json!(false)); + let node = make_node("entity", meta); + + let warnings = check_conventions(&node); + assert!( + warnings.len() >= 3, + "Expected at least 3 warnings, got {}", + warnings.len() + ); + } +} diff --git a/crates/cortex-core/src/lib.rs b/crates/cortex-core/src/lib.rs index 2c672f66..8d171f22 100644 --- a/crates/cortex-core/src/lib.rs +++ b/crates/cortex-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod api; pub mod briefing; +pub mod conventions; pub mod error; pub mod gate; pub mod graph; @@ -17,6 +18,7 @@ pub mod types; pub mod vector; pub use api::{Cortex, LibraryConfig}; +pub use conventions::check_conventions; pub use error::{CortexError, Result}; pub use gate::schema::{FieldSchema, FieldType, KindSchema, SchemaValidator, SchemaViolation}; pub use gate::{ diff --git a/crates/cortex-core/src/linker/config.rs b/crates/cortex-core/src/linker/config.rs index 5a162406..300bcf0c 100644 --- a/crates/cortex-core/src/linker/config.rs +++ b/crates/cortex-core/src/linker/config.rs @@ -309,9 +309,7 @@ impl ConfigRule { /// Validate this rule's configuration. pub fn validate(&self) -> Result<()> { if self.name.is_empty() { - return Err(CortexError::Validation( - "Rule name cannot be empty".into(), - )); + return Err(CortexError::Validation("Rule name cannot be empty".into())); } // Validate kinds and relation using the same rules as NodeKind/Relation. // "*" is a wildcard meaning "any kind". diff --git a/crates/cortex-core/src/linker/entity.rs b/crates/cortex-core/src/linker/entity.rs index a12e5571..128da9d2 100644 --- a/crates/cortex-core/src/linker/entity.rs +++ b/crates/cortex-core/src/linker/entity.rs @@ -546,9 +546,7 @@ mod tests { // Verify entity node was created let entity_nodes = storage - .list_nodes( - NodeFilter::new().with_kinds(vec![NodeKind::new("entity").unwrap()]), - ) + .list_nodes(NodeFilter::new().with_kinds(vec![NodeKind::new("entity").unwrap()])) .unwrap(); assert_eq!(entity_nodes.len(), 1); assert_eq!(entity_nodes[0].data.title, "company x"); @@ -594,9 +592,7 @@ mod tests { assert_eq!(promoted, 0); let entity_nodes = storage - .list_nodes( - NodeFilter::new().with_kinds(vec![NodeKind::new("entity").unwrap()]), - ) + .list_nodes(NodeFilter::new().with_kinds(vec![NodeKind::new("entity").unwrap()])) .unwrap(); assert!(entity_nodes.is_empty()); } @@ -640,9 +636,7 @@ mod tests { assert_eq!(promoted2, 0); let entity_nodes = storage - .list_nodes( - NodeFilter::new().with_kinds(vec![NodeKind::new("entity").unwrap()]), - ) + .list_nodes(NodeFilter::new().with_kinds(vec![NodeKind::new("entity").unwrap()])) .unwrap(); assert_eq!(entity_nodes.len(), 1); } diff --git a/crates/cortex-core/src/policies/retention.rs b/crates/cortex-core/src/policies/retention.rs index f3081125..f0e49233 100644 --- a/crates/cortex-core/src/policies/retention.rs +++ b/crates/cortex-core/src/policies/retention.rs @@ -183,9 +183,7 @@ impl RetentionEngine { let now = Utc::now(); // 0. Explicit expiry: soft-delete nodes past their expires_at - let expired_nodes = storage.list_nodes( - NodeFilter::new().expires_before(now), - )?; + let expired_nodes = storage.list_nodes(NodeFilter::new().expires_before(now))?; for node in expired_nodes { self.cleanup_outbound_edges(node.id, storage)?; storage.delete_node(node.id)?; diff --git a/crates/cortex-core/src/query/mod.rs b/crates/cortex-core/src/query/mod.rs index ea729825..dda4ffcc 100644 --- a/crates/cortex-core/src/query/mod.rs +++ b/crates/cortex-core/src/query/mod.rs @@ -32,7 +32,10 @@ pub enum FieldFilter { Kind(Vec), Tags(Vec), Agent(String), - Importance { op: CmpOp, value: f32 }, + Importance { + op: CmpOp, + value: f32, + }, CreatedAfter(DateTime), CreatedBefore(DateTime), Deleted(bool), diff --git a/crates/cortex-core/src/storage/redb_storage.rs b/crates/cortex-core/src/storage/redb_storage.rs index 0854fb3d..99ab72ab 100644 --- a/crates/cortex-core/src/storage/redb_storage.rs +++ b/crates/cortex-core/src/storage/redb_storage.rs @@ -2017,8 +2017,8 @@ mod query_filter_tests { let tomorrow = Utc::now() + chrono::Duration::days(1); - let future = make_node(NodeKind::new("fact").unwrap(), "Future fact") - .with_valid_from(tomorrow); + let future = + make_node(NodeKind::new("fact").unwrap(), "Future fact").with_valid_from(tomorrow); storage.put_node(&future).unwrap(); let results = storage @@ -2059,8 +2059,7 @@ mod query_filter_tests { .metadata .insert("entity_type".to_string(), serde_json::json!("person")); - let filter = - NodeFilter::new().with_metadata("entity_type", serde_json::json!("company")); + let filter = NodeFilter::new().with_metadata("entity_type", serde_json::json!("company")); assert!(RedbStorage::node_matches_filter(&company, &filter)); assert!(!RedbStorage::node_matches_filter(&person, &filter)); diff --git a/crates/cortex-core/src/trust/cache.rs b/crates/cortex-core/src/trust/cache.rs index 1379d177..688e68a7 100644 --- a/crates/cortex-core/src/trust/cache.rs +++ b/crates/cortex-core/src/trust/cache.rs @@ -8,9 +8,8 @@ use crate::storage::{NodeFilter, Storage}; /// reliability = 1.0 - (superseded + contradicted) / max(total, 1) /// Clamped to [0.2, 1.0] — even unreliable sources aren't zero. pub fn compute_source_reliability(storage: &S, agent_id: &str) -> Result { - let agent_nodes = storage.list_nodes( - NodeFilter::new().with_source_agent(agent_id.to_string()), - )?; + let agent_nodes = + storage.list_nodes(NodeFilter::new().with_source_agent(agent_id.to_string()))?; let total = agent_nodes.len(); if total == 0 { diff --git a/crates/cortex-core/src/trust/mod.rs b/crates/cortex-core/src/trust/mod.rs index 4991f592..0d7a5d7d 100644 --- a/crates/cortex-core/src/trust/mod.rs +++ b/crates/cortex-core/src/trust/mod.rs @@ -138,8 +138,7 @@ impl TrustEngine { pub fn score_batch(&self, nodes: &[Node]) -> Result> { // Pre-warm source cache for all unique agents in this batch. let unique_agents: Vec = { - let mut agents: Vec = - nodes.iter().map(|n| n.source.agent.clone()).collect(); + let mut agents: Vec = nodes.iter().map(|n| n.source.agent.clone()).collect(); agents.sort(); agents.dedup(); agents @@ -229,7 +228,10 @@ mod tests { fn test_default_config_weights_sum_to_one() { let w = TrustWeights::default(); let sum = w.corroboration + w.contradiction + w.source + w.access + w.freshness; - assert!((sum - 1.0).abs() < 0.001, "Weights should sum to 1.0, got {sum}"); + assert!( + (sum - 1.0).abs() < 0.001, + "Weights should sum to 1.0, got {sum}" + ); } #[test] @@ -257,7 +259,10 @@ mod tests { let engine = TrustEngine::new(storage, TrustConfig::default()); let s1 = engine.score(&node).unwrap(); let s2 = engine.score(&node).unwrap(); - assert_eq!(s1.total, s2.total, "Same graph state should produce same scores"); + assert_eq!( + s1.total, s2.total, + "Same graph state should produce same scores" + ); } #[test] diff --git a/crates/cortex-core/src/trust/signals.rs b/crates/cortex-core/src/trust/signals.rs index 89c6a1dc..61399ffe 100644 --- a/crates/cortex-core/src/trust/signals.rs +++ b/crates/cortex-core/src/trust/signals.rs @@ -249,7 +249,11 @@ mod tests { assert!((access_reinforcement(&node, &config) - 1.0).abs() < 0.001); node.access_count = 100; - assert_eq!(access_reinforcement(&node, &config), 1.0, "Should cap at 1.0"); + assert_eq!( + access_reinforcement(&node, &config), + 1.0, + "Should cap at 1.0" + ); } #[test] diff --git a/crates/cortex-server/src/cli/mod.rs b/crates/cortex-server/src/cli/mod.rs index 926360af..4e019e2d 100644 --- a/crates/cortex-server/src/cli/mod.rs +++ b/crates/cortex-server/src/cli/mod.rs @@ -443,6 +443,9 @@ pub struct NodeCreateArgs { /// Read body from stdin #[arg(long)] pub stdin: bool, + /// JSON metadata (e.g. '{"entity_type": "company", "source_url": "https://..."}') + #[arg(long)] + pub metadata: Option, /// When this fact became true (ISO 8601) #[arg(long)] pub valid_from: Option, @@ -452,6 +455,9 @@ pub struct NodeCreateArgs { /// When to auto-delete this node (ISO 8601) #[arg(long)] pub expires_at: Option, + /// Print warnings if metadata doesn't follow conventions (still creates the node) + #[arg(long)] + pub check_conventions: bool, /// Output format: table (default), json #[arg(long, default_value = "table")] pub format: String, diff --git a/crates/cortex-server/src/cli/node.rs b/crates/cortex-server/src/cli/node.rs index f368c087..265e0690 100644 --- a/crates/cortex-server/src/cli/node.rs +++ b/crates/cortex-server/src/cli/node.rs @@ -28,7 +28,60 @@ async fn create(args: NodeCreateArgs, server: &str) -> Result<()> { args.body.unwrap_or_else(|| args.title.clone()) }; - let req = CreateNodeRequest { + // Parse --metadata JSON into key-value pairs + let metadata: std::collections::HashMap = match &args.metadata { + Some(json_str) => { + let val: serde_json::Value = serde_json::from_str(json_str) + .map_err(|e| anyhow::anyhow!("Invalid --metadata JSON: {}", e))?; + match val { + serde_json::Value::Object(map) => map + .into_iter() + .map(|(k, v)| { + let s = match v { + serde_json::Value::String(s) => s, + other => other.to_string(), + }; + (k, s) + }) + .collect(), + _ => return Err(anyhow::anyhow!("--metadata must be a JSON object")), + } + } + None => std::collections::HashMap::new(), + }; + + // Check conventions if requested (before creating, but never blocks) + if args.check_conventions { + use cortex_core::conventions; + use cortex_core::types::{Node, NodeKind, Source}; + + let kind = NodeKind::new(&args.kind).unwrap_or_else(|_| NodeKind::new("fact").unwrap()); + let mut node = Node::new( + kind, + args.title.clone(), + body.clone(), + Source { + agent: "cli".into(), + session: None, + channel: None, + }, + args.importance, + ); + // Convert string metadata to serde_json::Value for convention checking + for (k, v) in &metadata { + // Try to parse as JSON first (for arrays, numbers, etc.) + let json_val = + serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.clone())); + node.data.metadata.insert(k.clone(), json_val); + } + + let warnings = conventions::check_conventions(&node); + for w in &warnings { + eprintln!("Warning: {}", w); + } + } + + let mut req = CreateNodeRequest { kind: args.kind, title: args.title, body, @@ -40,6 +93,7 @@ async fn create(args: NodeCreateArgs, server: &str) -> Result<()> { expires_at: parse_optional_timestamp(&args.expires_at)?, ..Default::default() }; + req.metadata = metadata; let resp = client.create_node(req).await?.into_inner(); diff --git a/crates/cortex-server/src/cli/trust.rs b/crates/cortex-server/src/cli/trust.rs index 29e08483..5c511aff 100644 --- a/crates/cortex-server/src/cli/trust.rs +++ b/crates/cortex-server/src/cli/trust.rs @@ -21,14 +21,11 @@ pub async fn run(args: TrustArgs, config: CortexConfig) -> Result<()> { if let Some(agent_id) = &args.agent { // Show agent reliability - let reliability = cortex_core::trust::cache::compute_source_reliability( - storage.as_ref(), - agent_id, - )?; + let reliability = + cortex_core::trust::cache::compute_source_reliability(storage.as_ref(), agent_id)?; - let node_count = storage.count_nodes( - NodeFilter::new().with_source_agent(agent_id.to_string()), - )?; + let node_count = + storage.count_nodes(NodeFilter::new().with_source_agent(agent_id.to_string()))?; match args.format.as_str() { "json" => { @@ -72,12 +69,18 @@ pub async fn run(args: TrustArgs, config: CortexConfig) -> Result<()> { println!("{}", "─".repeat(50)); println!("Trust: {:.3}", score.total); println!(" Corroboration: {:.3}", score.corroboration); - println!(" Contradiction: {:.3} ({} conflicts)", score.contradiction, score.contradiction_count); + println!( + " Contradiction: {:.3} ({} conflicts)", + score.contradiction, score.contradiction_count + ); println!(" Source: {:.3}", score.source_reliability); println!(" Access: {:.3}", score.access); println!(" Freshness: {:.3}", score.freshness); if !score.corroborating_agents.is_empty() { - println!(" Corroborated by: {}", score.corroborating_agents.join(", ")); + println!( + " Corroborated by: {}", + score.corroborating_agents.join(", ") + ); } } } diff --git a/crates/cortex-server/src/config.rs b/crates/cortex-server/src/config.rs index 25b1ab11..b2423116 100644 --- a/crates/cortex-server/src/config.rs +++ b/crates/cortex-server/src/config.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use cortex_core::briefing::BriefingRoleConfig; -use cortex_core::{AutoLinkerConfig, ConfigRule, NodeKind, Relation, SimilarityConfig, TrustConfig}; +use cortex_core::{ + AutoLinkerConfig, ConfigRule, NodeKind, Relation, SimilarityConfig, TrustConfig, +}; // Re-export from cortex-core so cortex-server code can use them from config #[allow(unused_imports)] @@ -469,7 +471,10 @@ condition = { type = "min_similarity", threshold = 0.85 } assert_eq!(config.auto_linker.rules.len(), 3); assert_eq!(config.auto_linker.legacy_rules_enabled, Some(false)); - assert_eq!(config.auto_linker.rules[0].name, "experiment-targets-function"); + assert_eq!( + config.auto_linker.rules[0].name, + "experiment-targets-function" + ); assert_eq!(config.auto_linker.rules[1].relation, "supersedes"); assert!(config.auto_linker.rules[2].weight_from_score); diff --git a/crates/cortex-server/src/http/routes.rs b/crates/cortex-server/src/http/routes.rs index 2d7c7fae..608ce369 100644 --- a/crates/cortex-server/src/http/routes.rs +++ b/crates/cortex-server/src/http/routes.rs @@ -1118,7 +1118,9 @@ async fn get_briefing( _ => cortex_core::briefing::BriefingScope::Agent, }; - let briefing = state.briefing_engine.generate_with_scope(&agent_id, scope)?; + let briefing = state + .briefing_engine + .generate_with_scope(&agent_id, scope)?; let rendered = state.briefing_engine.render(&briefing, compact); let sections: Vec = briefing @@ -1173,9 +1175,7 @@ async fn get_unified_briefing( let scope = cortex_core::briefing::BriefingScope::Unified(agent_ids.clone()); let primary = agent_ids.first().map(|s| s.as_str()).unwrap_or("default"); - let briefing = state - .briefing_engine - .generate_with_scope(primary, scope)?; + let briefing = state.briefing_engine.generate_with_scope(primary, scope)?; let rendered = state.briefing_engine.render(&briefing, compact); let sections: Vec = briefing @@ -1488,10 +1488,9 @@ async fn trust_node( State(state): State, Path(node_id): Path, ) -> AppResult> { - let trust_engine = state - .trust_engine - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Trust scoring is not enabled. Add [trust] to cortex.toml."))?; + let trust_engine = state.trust_engine.as_ref().ok_or_else(|| { + anyhow::anyhow!("Trust scoring is not enabled. Add [trust] to cortex.toml.") + })?; let id: uuid::Uuid = node_id .parse() @@ -1530,10 +1529,9 @@ async fn trust_batch( State(state): State, Json(body): Json, ) -> AppResult> { - let trust_engine = state - .trust_engine - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Trust scoring is not enabled. Add [trust] to cortex.toml."))?; + let trust_engine = state.trust_engine.as_ref().ok_or_else(|| { + anyhow::anyhow!("Trust scoring is not enabled. Add [trust] to cortex.toml.") + })?; let mut nodes = Vec::new(); let mut ids = Vec::new(); @@ -1575,13 +1573,10 @@ async fn trust_batch( } /// GET /trust/agents — source reliability for all cached agents. -async fn trust_agents( - State(state): State, -) -> AppResult> { - let trust_engine = state - .trust_engine - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Trust scoring is not enabled. Add [trust] to cortex.toml."))?; +async fn trust_agents(State(state): State) -> AppResult> { + let trust_engine = state.trust_engine.as_ref().ok_or_else(|| { + anyhow::anyhow!("Trust scoring is not enabled. Add [trust] to cortex.toml.") + })?; let reliabilities = trust_engine.agent_reliabilities(); diff --git a/crates/cortex-server/src/mcp/mod.rs b/crates/cortex-server/src/mcp/mod.rs index 0f4e171d..636428f9 100644 --- a/crates/cortex-server/src/mcp/mod.rs +++ b/crates/cortex-server/src/mcp/mod.rs @@ -1252,30 +1252,28 @@ async fn remote_tool_call( let resp: Value = if scope == "unified" && !agents.is_empty() { let agents_param = agents.join(","); - http - .get(format!( - "{}/briefing?agents={}&compact={}", - base_url, - urlencoding::encode(&agents_param), - compact - )) - .send() - .await? - .json() - .await? + http.get(format!( + "{}/briefing?agents={}&compact={}", + base_url, + urlencoding::encode(&agents_param), + compact + )) + .send() + .await? + .json() + .await? } else { - http - .get(format!( - "{}/briefing/{}?compact={}&scope={}", - base_url, - urlencoding::encode(agent_id), - compact, - scope - )) - .send() - .await? - .json() - .await? + http.get(format!( + "{}/briefing/{}?compact={}&scope={}", + base_url, + urlencoding::encode(agent_id), + compact, + scope + )) + .send() + .await? + .json() + .await? }; let rendered = resp["data"]["rendered"] .as_str() diff --git a/docs/reference/metadata-conventions.md b/docs/reference/metadata-conventions.md new file mode 100644 index 00000000..f09e391c --- /dev/null +++ b/docs/reference/metadata-conventions.md @@ -0,0 +1,126 @@ +# Metadata Conventions + +Cortex's `data.metadata` field is an open `HashMap`. This flexibility is intentional — the schema doesn't prescribe what agents store. But without conventions, every agent author invents their own keys. + +This document defines **well-known metadata keys**: documented conventions that are not enforced at the schema level but are recognised by Cortex's intelligence layer (auto-linker, briefing engine, trust scoring). Like HTTP headers — anyone can add custom ones, but `Content-Type` means something specific. + +## Node metadata + +| Key | Type | Description | Used by | +|-----|------|-------------|---------| +| `entity_type` | string | What kind of entity this is: `agent`, `company`, `person`, `technology`, `project`, `location`, `product` | Entity layer, briefing engine | +| `aliases` | array of strings | Alternative names for an entity node. Used for entity resolution. | Auto-linker entity matching | +| `parent_agent` | string | Agent ID that spawned the agent that created this node. Provenance chain. | Trust scoring (source reliability), audit | +| `task_id` | string | Orchestrator task ID that prompted this node's creation. | Audit, task-scoped queries | +| `source_url` | string | External URL where this knowledge originated. | Provenance, citation | +| `content_type` | string | MIME-like type of the body content: `text/plain`, `text/markdown`, `application/json`, `code/rust`, `code/python` | Embedding strategy, display | +| `language` | string | ISO 639-1 language code of the content: `en`, `zh`, `ja` | Embedding model selection | +| `expires_reason` | string | Why this node has an `expires_at`. `working-memory`, `ttl-policy`, `gdpr-request` | Audit | + +## Edge metadata + +| Key | Type | Description | Used by | +|-----|------|-------------|---------| +| `entity` | string | Normalised entity name that links these two nodes. | Entity co-occurrence rule, cross-agent discovery | +| `similarity_context` | string | What the similarity was about (title match, body match, tag overlap). | Debugging, trust scoring | +| `rule_version` | string | Version of the rule that created this edge. | Migration, debugging | + +## Entity type + +Use on nodes with `kind: "entity"` to classify the entity. + +```bash +cortex node create --kind entity --title "Company X" \ + --metadata '{"entity_type": "company", "aliases": ["CompanyX", "company-x"]}' +``` + +The auto-linker's entity promotion uses `entity_type` to tag promoted nodes. +The briefing engine uses `entity_type` to group entities in cross-agent sections. + +Well-known entity types: + +| Value | Description | +|-------|-------------| +| `agent` | An AI agent or bot | +| `company` | A company or organisation | +| `person` | A human individual | +| `technology` | A language, framework, or tool | +| `project` | A named project or initiative | +| `location` | A place (city, region, address) | +| `product` | A product or service | + +Custom entity types are allowed — these are conventions, not constraints. + +## Aliases + +Use `aliases` to list alternative names for an entity. The auto-linker's entity matching checks aliases when resolving entity references across nodes. + +```bash +cortex node create --kind entity --title "Anthropic" \ + --metadata '{"entity_type": "company", "aliases": ["anthropic-ai", "Anthropic PBC"]}' +``` + +## Source URL + +Use `source_url` to record where knowledge was sourced from. Must be an HTTP or HTTPS URL. + +```bash +cortex node create --kind fact --title "GPT-4 context window is 128k" \ + --metadata '{"source_url": "https://platform.openai.com/docs/models"}' +``` + +## Content type + +Use `content_type` to hint at the body format. This may influence embedding strategy in future versions. + +```bash +cortex node create --kind fact --title "Rust sort implementation" \ + --body "fn sort(items: &mut [i32]) { items.sort(); }" \ + --metadata '{"content_type": "code/rust"}' +``` + +## Validation + +These conventions are **not enforced** at the storage level. Nodes with non-standard metadata continue to work. However, the `check_conventions()` function (Rust) and the `--check-conventions` CLI flag will emit warnings for metadata that doesn't follow these conventions. + +```bash +cortex node create --kind entity --title "Test" --check-conventions +# Warning: entity_type should be a string (missing on entity node) +# Created node abc-123 +``` + +## SDK helpers + +### Python + +```python +from cortex_memory import Cortex, EntityType + +cx = Cortex("localhost:9090") + +# Helper that sets entity_type metadata automatically +cx.store_entity("Company X", entity_type=EntityType.COMPANY, aliases=["CompanyX"]) + +# Equivalent to: +cx.store("entity", "Company X", metadata={ + "entity_type": "company", + "aliases": "CompanyX", # serialised as string over gRPC +}) +``` + +### TypeScript + +```typescript +import { Cortex, EntityType } from '@cortex-memory/client'; + +const cx = new Cortex('localhost:9090'); + +await cx.storeEntity('Company X', { + entityType: EntityType.Company, + aliases: ['CompanyX'], +}); +``` + +## Backward compatibility + +Entirely additive. Existing nodes without well-known metadata keys continue to work. The intelligence layer (auto-linker, briefing engine) checks for these keys but gracefully handles their absence. diff --git a/sdks/python/cortex_memory/__init__.py b/sdks/python/cortex_memory/__init__.py index 00d27e17..0c258ccb 100644 --- a/sdks/python/cortex_memory/__init__.py +++ b/sdks/python/cortex_memory/__init__.py @@ -1,4 +1,4 @@ from .client import Cortex -from .models import Node, Edge, SearchResult, Briefing +from .models import Node, Edge, SearchResult, Briefing, EntityType -__all__ = ["Cortex", "Node", "Edge", "SearchResult", "Briefing"] +__all__ = ["Cortex", "Node", "Edge", "SearchResult", "Briefing", "EntityType"] diff --git a/sdks/python/cortex_memory/client.py b/sdks/python/cortex_memory/client.py index bd329e2c..1cc0d6ca 100644 --- a/sdks/python/cortex_memory/client.py +++ b/sdks/python/cortex_memory/client.py @@ -71,6 +71,53 @@ def open(cls, path: str) -> "Cortex": # Core write operations # ------------------------------------------------------------------ + def store_entity( + self, + title: str, + *, + entity_type: str = "", + aliases: Optional[List[str]] = None, + body: str = "", + tags: Optional[List[str]] = None, + importance: float = 0.5, + metadata: Optional[Dict[str, str]] = None, + source_agent: str = "", + ) -> str: + """Store an entity node with well-known metadata conventions. + + Convenience wrapper around :meth:`store` that sets ``kind="entity"`` + and populates ``entity_type`` and ``aliases`` metadata automatically. + + Args: + title: Entity name (e.g. ``"Anthropic"``). + entity_type: One of the well-known entity types (``EntityType.*`` + constants) or a custom string. + aliases: Alternative names for entity resolution. + body: Extended description. Defaults to *title*. + tags: Additional tags. + importance: Importance score (0.0-1.0). + metadata: Extra metadata (merged with entity_type/aliases). + source_agent: Agent that created this node. + + Returns: + The new node ID string. + """ + merged: Dict[str, str] = dict(metadata or {}) + if entity_type: + merged["entity_type"] = entity_type + if aliases: + import json + merged["aliases"] = json.dumps(aliases) + return self.store( + "entity", + title, + body=body, + tags=tags, + importance=importance, + metadata=merged, + source_agent=source_agent, + ) + def store( self, kind: str, diff --git a/sdks/python/cortex_memory/models.py b/sdks/python/cortex_memory/models.py index 415e3728..55366d00 100644 --- a/sdks/python/cortex_memory/models.py +++ b/sdks/python/cortex_memory/models.py @@ -3,6 +3,28 @@ from typing import List, Optional +class EntityType: + """Well-known entity type constants for metadata conventions. + + Use with :meth:`~cortex_memory.Cortex.store_entity`:: + + cx.store_entity("Anthropic", entity_type=EntityType.COMPANY) + + Custom entity type strings are also accepted — these are conventions, + not constraints. + """ + + AGENT = "agent" + COMPANY = "company" + PERSON = "person" + TECHNOLOGY = "technology" + PROJECT = "project" + LOCATION = "location" + PRODUCT = "product" + + ALL = (AGENT, COMPANY, PERSON, TECHNOLOGY, PROJECT, LOCATION, PRODUCT) + + class Node: """A knowledge node retrieved from Cortex.""" diff --git a/sdks/python/cortex_memory/testing.py b/sdks/python/cortex_memory/testing.py index b5f1bbbe..a798fb78 100644 --- a/sdks/python/cortex_memory/testing.py +++ b/sdks/python/cortex_memory/testing.py @@ -112,6 +112,36 @@ def store( self._call_log.append(("store", kind, title)) return node_id + def store_entity( + self, + title: str, + *, + entity_type: str = "", + aliases: Optional[List[str]] = None, + body: str = "", + tags: Optional[List[str]] = None, + importance: float = 0.5, + metadata: Optional[dict] = None, + source_agent: str = "", + ) -> str: + """Store an entity node with well-known metadata conventions.""" + import json + + merged = dict(metadata or {}) + if entity_type: + merged["entity_type"] = entity_type + if aliases: + merged["aliases"] = json.dumps(aliases) + return self.store( + "entity", + title, + body=body, + tags=tags, + importance=importance, + metadata=merged, + source_agent=source_agent, + ) + # ------------------------------------------------------------------ # Read / search # ------------------------------------------------------------------ diff --git a/sdks/python/tests/test_client.py b/sdks/python/tests/test_client.py index 5373ad87..d194d6ec 100644 --- a/sdks/python/tests/test_client.py +++ b/sdks/python/tests/test_client.py @@ -4,7 +4,9 @@ All tests use MockCortex via the mock_cortex fixture — no real gRPC server or network connection is required. """ +import json import pytest +from cortex_memory import EntityType from cortex_memory.testing import MockCortex, mock_cortex @@ -217,3 +219,95 @@ def test_raises_when_store_was_called(self, cx): cx.store("fact", "Stored fact") with pytest.raises(AssertionError): cx.assert_not_stored("fact", "Stored fact") + + +# --------------------------------------------------------------------------- +# EntityType constants +# --------------------------------------------------------------------------- + +class TestEntityType: + def test_all_constants_are_strings(self): + for t in EntityType.ALL: + assert isinstance(t, str) + + def test_known_types(self): + assert EntityType.AGENT == "agent" + assert EntityType.COMPANY == "company" + assert EntityType.PERSON == "person" + assert EntityType.TECHNOLOGY == "technology" + assert EntityType.PROJECT == "project" + assert EntityType.LOCATION == "location" + assert EntityType.PRODUCT == "product" + + def test_all_has_seven_entries(self): + assert len(EntityType.ALL) == 7 + + +# --------------------------------------------------------------------------- +# store_entity() +# --------------------------------------------------------------------------- + +class TestStoreEntity: + def test_returns_string_id(self, cx): + node_id = cx.store_entity("Anthropic", entity_type=EntityType.COMPANY) + assert isinstance(node_id, str) + assert len(node_id) > 0 + + def test_creates_entity_kind_node(self, cx): + node_id = cx.store_entity("Anthropic", entity_type=EntityType.COMPANY) + cx.assert_stored("entity", "Anthropic") + + def test_sets_entity_type_metadata(self, cx): + node_id = cx.store_entity("Anthropic", entity_type=EntityType.COMPANY) + node = cx._nodes[node_id] + assert node["metadata"]["entity_type"] == "company" + + def test_sets_aliases_metadata(self, cx): + node_id = cx.store_entity( + "Anthropic", + entity_type=EntityType.COMPANY, + aliases=["anthropic-ai", "Anthropic PBC"], + ) + node = cx._nodes[node_id] + aliases = json.loads(node["metadata"]["aliases"]) + assert aliases == ["anthropic-ai", "Anthropic PBC"] + + def test_no_aliases_when_omitted(self, cx): + node_id = cx.store_entity("Anthropic", entity_type=EntityType.COMPANY) + node = cx._nodes[node_id] + assert "aliases" not in node["metadata"] + + def test_merges_extra_metadata(self, cx): + node_id = cx.store_entity( + "Anthropic", + entity_type=EntityType.COMPANY, + metadata={"source_url": "https://anthropic.com"}, + ) + node = cx._nodes[node_id] + assert node["metadata"]["entity_type"] == "company" + assert node["metadata"]["source_url"] == "https://anthropic.com" + + def test_accepts_custom_entity_type(self, cx): + node_id = cx.store_entity("Cortex", entity_type="framework") + node = cx._nodes[node_id] + assert node["metadata"]["entity_type"] == "framework" + + def test_no_entity_type_when_omitted(self, cx): + node_id = cx.store_entity("Unknown Thing") + node = cx._nodes[node_id] + assert "entity_type" not in node["metadata"] + + def test_passes_through_optional_fields(self, cx): + node_id = cx.store_entity( + "Anthropic", + entity_type=EntityType.COMPANY, + body="AI safety company", + tags=["ai", "safety"], + importance=0.9, + source_agent="kai", + ) + node = cx._nodes[node_id] + assert node["body"] == "AI safety company" + assert node["tags"] == ["ai", "safety"] + assert node["importance"] == 0.9 + assert node["source_agent"] == "kai" diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index 3fba4b01..632092b2 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cortex-memory/client", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cortex-memory/client", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@grpc/grpc-js": "^1.14.3", diff --git a/sdks/typescript/src/__tests__/client.test.ts b/sdks/typescript/src/__tests__/client.test.ts index d1aec470..27d38003 100644 --- a/sdks/typescript/src/__tests__/client.test.ts +++ b/sdks/typescript/src/__tests__/client.test.ts @@ -4,6 +4,7 @@ */ import { describe, it, expect, beforeEach } from '@jest/globals'; import { MockCortex } from '../testing'; +import { EntityType } from '../client'; describe('MockCortex', () => { let cx: MockCortex; @@ -231,4 +232,114 @@ describe('MockCortex', () => { expect(() => cx.assertNotStored('fact', 'Stored fact')).toThrow(); }); }); + + // ----------------------------------------------------------------------- + // EntityType enum + // ----------------------------------------------------------------------- + describe('EntityType', () => { + it('has all well-known values', () => { + expect(EntityType.Agent).toBe('agent'); + expect(EntityType.Company).toBe('company'); + expect(EntityType.Person).toBe('person'); + expect(EntityType.Technology).toBe('technology'); + expect(EntityType.Project).toBe('project'); + expect(EntityType.Location).toBe('location'); + expect(EntityType.Product).toBe('product'); + }); + }); + + // ----------------------------------------------------------------------- + // storeEntity() + // ----------------------------------------------------------------------- + describe('storeEntity()', () => { + it('returns a non-empty string ID', async () => { + const id = await cx.storeEntity({ + title: 'Anthropic', + entityType: EntityType.Company, + }); + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + }); + + it('creates a node with kind "entity"', async () => { + await cx.storeEntity({ + title: 'Anthropic', + entityType: EntityType.Company, + }); + expect(() => cx.assertStored('entity', 'Anthropic')).not.toThrow(); + }); + + it('sets entity_type in metadata', async () => { + const id = await cx.storeEntity({ + title: 'Anthropic', + entityType: EntityType.Company, + }); + const node = await cx.getNode(id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((node as any).metadata.entity_type).toBe('company'); + }); + + it('sets aliases in metadata as JSON string', async () => { + const id = await cx.storeEntity({ + title: 'Anthropic', + entityType: EntityType.Company, + aliases: ['anthropic-ai', 'Anthropic PBC'], + }); + const node = await cx.getNode(id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const aliases = JSON.parse((node as any).metadata.aliases); + expect(aliases).toEqual(['anthropic-ai', 'Anthropic PBC']); + }); + + it('omits aliases when not provided', async () => { + const id = await cx.storeEntity({ + title: 'Anthropic', + entityType: EntityType.Company, + }); + const node = await cx.getNode(id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((node as any).metadata.aliases).toBeUndefined(); + }); + + it('merges extra metadata with entity fields', async () => { + const id = await cx.storeEntity({ + title: 'Anthropic', + entityType: EntityType.Company, + metadata: { source_url: 'https://anthropic.com' }, + }); + const node = await cx.getNode(id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const meta = (node as any).metadata; + expect(meta.entity_type).toBe('company'); + expect(meta.source_url).toBe('https://anthropic.com'); + }); + + it('accepts custom entity type strings', async () => { + const id = await cx.storeEntity({ + title: 'Cortex', + entityType: 'framework', + }); + const node = await cx.getNode(id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((node as any).metadata.entity_type).toBe('framework'); + }); + + it('passes through optional fields', async () => { + const id = await cx.storeEntity({ + title: 'Anthropic', + entityType: EntityType.Company, + body: 'AI safety company', + tags: ['ai', 'safety'], + importance: 0.9, + source_agent: 'kai', + }); + const node = await cx.getNode(id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const n = node as any; + expect(n.body).toBe('AI safety company'); + expect(n.tags).toEqual(['ai', 'safety']); + expect(n.importance).toBe(0.9); + expect(n.source_agent).toBe('kai'); + }); + }); }); diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index a1cadd3c..dd83db76 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -33,6 +33,33 @@ const LOADER_OPTIONS: protoLoader.Options = { includeDirs: [path.join(__dirname, '..', 'proto')], }; +/** Well-known entity type constants for metadata conventions. */ +export enum EntityType { + Agent = 'agent', + Company = 'company', + Person = 'person', + Technology = 'technology', + Project = 'project', + Location = 'location', + Product = 'product', +} + +/** Options for storing an entity node. */ +export interface StoreEntityOptions { + /** Entity name (title). */ + title: string; + /** Well-known entity type or custom string. */ + entityType?: EntityType | string; + /** Alternative names for entity resolution. */ + aliases?: string[]; + body?: string; + tags?: string[]; + importance?: number; + /** Extra metadata (merged with entityType/aliases). */ + metadata?: Record; + source_agent?: string; +} + /** Options for storing a node. */ export interface StoreOptions { kind: string; @@ -84,6 +111,31 @@ export class Cortex { // Write // ------------------------------------------------------------------ + /** + * Store an entity node with well-known metadata conventions. + * + * Convenience wrapper around {@link store} that sets `kind: "entity"` + * and populates `entity_type` and `aliases` metadata automatically. + */ + async storeEntity(options: StoreEntityOptions): Promise { + const metadata: Record = { ...options.metadata }; + if (options.entityType) { + metadata.entity_type = options.entityType; + } + if (options.aliases && options.aliases.length > 0) { + metadata.aliases = JSON.stringify(options.aliases); + } + return this.store({ + kind: 'entity', + title: options.title, + body: options.body, + tags: options.tags, + importance: options.importance, + metadata, + source_agent: options.source_agent, + }); + } + /** Store a knowledge node. Returns the new node ID. */ async store(options: StoreOptions): Promise { return this._call('CreateNode', { diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 16de4921..dc866213 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -1,3 +1,3 @@ -export { Cortex } from './client'; -export type { StoreOptions, SearchResult, Subgraph } from './client'; +export { Cortex, EntityType } from './client'; +export type { StoreOptions, StoreEntityOptions, SearchResult, Subgraph } from './client'; export { MockCortex } from './testing'; diff --git a/sdks/typescript/src/testing.ts b/sdks/typescript/src/testing.ts index d6bcf345..5add0afc 100644 --- a/sdks/typescript/src/testing.ts +++ b/sdks/typescript/src/testing.ts @@ -20,7 +20,7 @@ * ``` */ -import type { SearchResult, StoreOptions, Subgraph } from './client'; +import type { SearchResult, StoreOptions, StoreEntityOptions, Subgraph } from './client'; interface StoredNode { id: string; @@ -48,6 +48,25 @@ export class MockCortex { // Write // ------------------------------------------------------------------ + async storeEntity(options: StoreEntityOptions): Promise { + const metadata: Record = { ...options.metadata }; + if (options.entityType) { + metadata.entity_type = options.entityType; + } + if (options.aliases && options.aliases.length > 0) { + metadata.aliases = JSON.stringify(options.aliases); + } + return this.store({ + kind: 'entity', + title: options.title, + body: options.body, + tags: options.tags, + importance: options.importance, + metadata, + source_agent: options.source_agent, + }); + } + async store(options: StoreOptions): Promise { const id = _uuid(); const node: StoredNode = {