diff --git a/hindsight-cli/Cargo.toml b/hindsight-cli/Cargo.toml index a1dcfab1..f52d584d 100644 --- a/hindsight-cli/Cargo.toml +++ b/hindsight-cli/Cargo.toml @@ -45,6 +45,10 @@ chrono = "0.4" walkdir = "2.5" dirs = "5.0" +[dev-dependencies] +# For integration tests with blocking HTTP client +reqwest = { version = "0.12", features = ["blocking"] } + [profile.release] opt-level = "z" lto = true diff --git a/hindsight-cli/smoke-test.sh b/hindsight-cli/smoke-test.sh index 85dd9857..8b4fd171 100755 --- a/hindsight-cli/smoke-test.sh +++ b/hindsight-cli/smoke-test.sh @@ -67,7 +67,7 @@ run_test_output() { cleanup() { echo "" echo "Cleaning up test bank..." - "$HINDSIGHT_CLI" bank delete "$TEST_BANK" 2>/dev/null || true + "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y 2>/dev/null || true } trap cleanup EXIT @@ -115,8 +115,32 @@ run_test "list documents" "$HINDSIGHT_CLI" document list "$TEST_BANK" || FAILED= # Test 14: Clear memories run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1 -# Test 15: Delete bank -run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" || FAILED=1 +# Test 15: Health check +run_test_output "health check" "healthy" "$HINDSIGHT_CLI" health || FAILED=1 + +# Test 16: List memories (new command) +run_test "list memories" "$HINDSIGHT_CLI" memory list "$TEST_BANK" || FAILED=1 + +# Test 17: List tags +run_test "list tags" "$HINDSIGHT_CLI" tag list "$TEST_BANK" || FAILED=1 + +# Test 18: List mental models +run_test "list mental models" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1 + +# Test 19: Create mental model +run_test "create mental model" "$HINDSIGHT_CLI" mental-model create "$TEST_BANK" "Test Model" "A test mental model" || FAILED=1 + +# Test 20: List mental models (should have one now) +run_test_output "list mental models with model" "Test Model" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1 + +# Test 21: Bank graph +run_test "bank graph" "$HINDSIGHT_CLI" bank graph "$TEST_BANK" || FAILED=1 + +# Test 22: List operations +run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1 + +# Test 23: Delete bank +run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1 echo "" if [ $FAILED -eq 0 ]; then diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index 518a4eff..78d1b42f 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -316,6 +316,266 @@ impl ApiClient { } } +// ============================================================================ +// Additional API methods for complete CLI coverage +// ============================================================================ + +impl ApiClient { + // --- Mental Model Methods --- + + pub fn list_mental_models( + &self, + bank_id: &str, + subtype: Option<&str>, + tags: Option>, + tags_match: Option<&str>, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let tags_match_enum = match tags_match { + Some("all") => Some(types::TagsMatch::All), + Some("any_strict") => Some(types::TagsMatch::AnyStrict), + Some("all_strict") => Some(types::TagsMatch::AllStrict), + _ => Some(types::TagsMatch::Any), + }; + let response = self.client.list_mental_models( + bank_id, + subtype, + tags.as_ref(), + tags_match_enum, + None, + ).await?; + Ok(response.into_inner()) + }) + } + + pub fn get_mental_model( + &self, + bank_id: &str, + model_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.get_mental_model(bank_id, model_id, None).await?; + Ok(response.into_inner()) + }) + } + + pub fn create_mental_model( + &self, + bank_id: &str, + request: &types::CreateMentalModelRequest, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.create_mental_model(bank_id, None, request).await?; + Ok(response.into_inner()) + }) + } + + pub fn delete_mental_model( + &self, + bank_id: &str, + model_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.delete_mental_model(bank_id, model_id, None).await?; + Ok(response.into_inner()) + }) + } + + pub fn update_mental_model( + &self, + bank_id: &str, + model_id: &str, + request: &types::UpdateMentalModelRequest, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.update_mental_model(bank_id, model_id, None, request).await?; + Ok(response.into_inner()) + }) + } + + pub fn refresh_mental_models( + &self, + bank_id: &str, + subtype: Option<&str>, + tags: Option>, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let subtype_enum = match subtype { + Some("structural") => Some(types::Subtype::Structural), + Some("emergent") => Some(types::Subtype::Emergent), + Some("pinned") => Some(types::Subtype::Pinned), + Some("learned") => Some(types::Subtype::Learned), + _ => None, + }; + let request = types::RefreshMentalModelsRequest { + subtype: subtype_enum, + tags, + }; + let response = self.client.refresh_mental_models(bank_id, None, &request).await?; + Ok(response.into_inner()) + }) + } + + pub fn refresh_mental_model( + &self, + bank_id: &str, + model_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.refresh_mental_model(bank_id, model_id, None).await?; + Ok(response.into_inner()) + }) + } + + pub fn list_mental_model_versions( + &self, + bank_id: &str, + model_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.list_mental_model_versions(bank_id, model_id, None).await?; + Ok(response.into_inner()) + }) + } + + pub fn get_mental_model_version( + &self, + bank_id: &str, + model_id: &str, + version: i64, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.get_mental_model_version(bank_id, model_id, version, None).await?; + Ok(response.into_inner()) + }) + } + + // --- Memory Methods --- + + pub fn get_memory(&self, bank_id: &str, memory_id: &str, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.get_memory(bank_id, memory_id, None).await?; + Ok(response.into_inner()) + }) + } + + // --- Bank Methods --- + + pub fn create_bank( + &self, + bank_id: &str, + request: &types::CreateBankRequest, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.create_or_update_bank(bank_id, None, request).await?; + Ok(response.into_inner()) + }) + } + + pub fn update_bank( + &self, + bank_id: &str, + request: &types::CreateBankRequest, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.update_bank(bank_id, None, request).await?; + Ok(response.into_inner()) + }) + } + + pub fn set_mission( + &self, + bank_id: &str, + mission: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let request = types::CreateBankRequest { + name: None, + mission: Some(mission.to_string()), + background: None, + disposition: None, + }; + let response = self.client.update_bank(bank_id, None, &request).await?; + Ok(response.into_inner()) + }) + } + + pub fn get_graph( + &self, + bank_id: &str, + type_filter: Option<&str>, + limit: Option, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.get_graph(bank_id, limit, type_filter, None).await?; + Ok(response.into_inner()) + }) + } + + // --- Tag Methods --- + + pub fn list_tags( + &self, + bank_id: &str, + q: Option<&str>, + limit: Option, + offset: Option, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.list_tags(bank_id, limit, offset, q, None).await?; + Ok(response.into_inner()) + }) + } + + // --- Chunk Methods --- + + pub fn get_chunk(&self, chunk_id: &str, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.get_chunk(chunk_id, None).await?; + Ok(response.into_inner()) + }) + } + + // --- Operation Methods --- + + pub fn get_operation(&self, bank_id: &str, operation_id: &str, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.get_operation_status(bank_id, operation_id, None).await?; + Ok(response.into_inner()) + }) + } + + // --- Health Methods --- + + pub fn health(&self, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.health_endpoint_health_get().await?; + Ok(response.into_inner()) + }) + } + + pub fn metrics(&self, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.metrics_endpoint_metrics_get().await?; + Ok(response.into_inner()) + }) + } +} + // Re-export types from the generated client for use in commands pub use types::{ BankProfileResponse, diff --git a/hindsight-cli/src/commands/bank.rs b/hindsight-cli/src/commands/bank.rs index d44c381b..bc324d46 100644 --- a/hindsight-cli/src/commands/bank.rs +++ b/hindsight-cli/src/commands/bank.rs @@ -222,6 +222,226 @@ pub fn update_background( } } +/// Set bank mission +pub fn mission( + client: &ApiClient, + bank_id: &str, + mission_text: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Setting mission...")) + } else { + None + }; + + let response = client.set_mission(bank_id, mission_text, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(profile) => { + if output_format == OutputFormat::Pretty { + ui::print_success("Mission updated successfully"); + println!(); + println!("{}", profile.mission); + } else { + output::print_output(&profile, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Create a new bank +pub fn create( + client: &ApiClient, + bank_id: &str, + name: Option, + mission_text: Option, + skepticism: Option, + literalism: Option, + empathy: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Creating bank...")) + } else { + None + }; + + use hindsight_client::types; + use std::num::NonZeroU64; + + let disposition = if skepticism.is_some() || literalism.is_some() || empathy.is_some() { + Some(types::DispositionTraits { + skepticism: NonZeroU64::new(skepticism.unwrap_or(3) as u64).unwrap(), + literalism: NonZeroU64::new(literalism.unwrap_or(3) as u64).unwrap(), + empathy: NonZeroU64::new(empathy.unwrap_or(3) as u64).unwrap(), + }) + } else { + None + }; + + let request = types::CreateBankRequest { + name, + mission: mission_text, + background: None, + disposition, + }; + + let response = client.create_bank(bank_id, &request, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(profile) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Bank '{}' created successfully", bank_id)); + println!(); + ui::print_disposition(&profile); + } else { + output::print_output(&profile, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Update bank properties (partial update) +pub fn update( + client: &ApiClient, + bank_id: &str, + name: Option, + mission_text: Option, + skepticism: Option, + literalism: Option, + empathy: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + if name.is_none() && mission_text.is_none() && skepticism.is_none() && literalism.is_none() && empathy.is_none() { + anyhow::bail!("At least one field must be provided (--name, --mission, --skepticism, --literalism, --empathy)"); + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Updating bank...")) + } else { + None + }; + + use hindsight_client::types; + use std::num::NonZeroU64; + + let disposition = if skepticism.is_some() || literalism.is_some() || empathy.is_some() { + Some(types::DispositionTraits { + skepticism: NonZeroU64::new(skepticism.unwrap_or(3) as u64).unwrap(), + literalism: NonZeroU64::new(literalism.unwrap_or(3) as u64).unwrap(), + empathy: NonZeroU64::new(empathy.unwrap_or(3) as u64).unwrap(), + }) + } else { + None + }; + + let request = types::CreateBankRequest { + name, + mission: mission_text, + background: None, + disposition, + }; + + let response = client.update_bank(bank_id, &request, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(profile) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Bank '{}' updated successfully", bank_id)); + println!(); + ui::print_disposition(&profile); + } else { + output::print_output(&profile, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Get memory graph data +pub fn graph( + client: &ApiClient, + bank_id: &str, + type_filter: Option, + limit: i64, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching graph data...")) + } else { + None + }; + + let response = client.get_graph(bank_id, type_filter.as_deref(), Some(limit), verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Memory Graph: {}", bank_id)); + + println!(" {} {}", ui::dim("Nodes:"), ui::gradient_start(&result.nodes.len().to_string())); + println!(" {} {}", ui::dim("Edges:"), ui::gradient_end(&result.edges.len().to_string())); + println!(); + + // Show sample of nodes + if !result.nodes.is_empty() { + println!("{}", ui::gradient_text("─── Sample Nodes ───")); + for node in result.nodes.iter().take(5) { + let fact_type = node.get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let id = node.get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + println!(" {} [{}]", ui::dim(id), fact_type); + if let Some(text) = node.get("text").and_then(|v| v.as_str()) { + let preview: String = text.chars().take(60).collect(); + let ellipsis = if text.len() > 60 { "..." } else { "" }; + println!(" {}{}", preview, ellipsis); + } + } + if result.nodes.len() > 5 { + println!(" {} more...", ui::dim(&format!("+ {}", result.nodes.len() - 5))); + } + println!(); + } + + println!("{}", ui::dim("Use JSON output for full graph data: -o json")); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + pub fn delete( client: &ApiClient, bank_id: &str, diff --git a/hindsight-cli/src/commands/chunk.rs b/hindsight-cli/src/commands/chunk.rs new file mode 100644 index 00000000..1de86f9a --- /dev/null +++ b/hindsight-cli/src/commands/chunk.rs @@ -0,0 +1,96 @@ +//! Chunk commands for retrieving document chunks. + +use anyhow::Result; + +use crate::api::ApiClient; +use crate::output::{self, OutputFormat}; +use crate::ui; + +/// Get a specific chunk by ID +pub fn get( + client: &ApiClient, + chunk_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching chunk...")) + } else { + None + }; + + let response = client.get_chunk(chunk_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Chunk: {}", chunk_id)); + + println!(" {} {}", ui::dim("ID:"), result.chunk_id); + println!(" {} {}", ui::dim("Index:"), result.chunk_index); + println!(" {} {}", ui::dim("Document:"), result.document_id); + println!(" {} {}", ui::dim("Bank:"), result.bank_id); + println!(" {} {}", ui::dim("Created:"), result.created_at); + + println!(); + println!("{}", ui::gradient_text("─── Content ───")); + println!(); + println!("{}", result.chunk_text); + + println!(); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use hindsight_client::types::ChunkResponse; + + #[test] + fn test_chunk_response_deserialization() { + let json = r#"{ + "chunk_id": "chunk-123", + "bank_id": "test-bank", + "document_id": "doc-456", + "chunk_index": 0, + "chunk_text": "This is the chunk content.", + "created_at": "2024-01-15T10:00:00Z" + }"#; + + let result: ChunkResponse = serde_json::from_str(json).unwrap(); + + assert_eq!(result.chunk_id, "chunk-123"); + assert_eq!(result.bank_id, "test-bank"); + assert_eq!(result.document_id, "doc-456"); + assert_eq!(result.chunk_index, 0); + assert_eq!(result.chunk_text, "This is the chunk content."); + assert_eq!(result.created_at, "2024-01-15T10:00:00Z"); + } + + #[test] + fn test_chunk_response_multiline_content() { + let json = r#"{ + "chunk_id": "chunk-456", + "bank_id": "test-bank", + "document_id": "doc-789", + "chunk_index": 5, + "chunk_text": "Line 1\nLine 2\nLine 3", + "created_at": "2024-01-15T11:00:00Z" + }"#; + + let result: ChunkResponse = serde_json::from_str(json).unwrap(); + + assert_eq!(result.chunk_index, 5); + assert!(result.chunk_text.contains('\n')); + assert_eq!(result.chunk_text.lines().count(), 3); + } +} diff --git a/hindsight-cli/src/commands/health.rs b/hindsight-cli/src/commands/health.rs new file mode 100644 index 00000000..e57566d2 --- /dev/null +++ b/hindsight-cli/src/commands/health.rs @@ -0,0 +1,157 @@ +//! Health and metrics commands. + +use anyhow::Result; +use serde::Deserialize; + +use crate::api::ApiClient; +use crate::output::{self, OutputFormat}; +use crate::ui; + +// Local type for health response +#[derive(Debug, Deserialize)] +struct HealthResponse { + status: String, + database: Option, + version: Option, +} + +/// Check API health +pub fn health( + client: &ApiClient, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Checking health...")) + } else { + None + }; + + let response = client.health(verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(value) => { + if output_format == OutputFormat::Pretty { + let result: HealthResponse = serde_json::from_value(value.clone()) + .unwrap_or(HealthResponse { + status: "unknown".to_string(), + database: None, + version: None, + }); + + let status_str = if result.status == "healthy" { + ui::gradient_start(&result.status) + } else { + ui::gradient_end(&result.status) + }; + + ui::print_section_header("Health Check"); + println!(" {} {}", ui::dim("Status:"), status_str); + + if let Some(db_status) = &result.database { + let db_str = if db_status == "connected" { + ui::gradient_start(db_status) + } else { + ui::gradient_end(db_status) + }; + println!(" {} {}", ui::dim("Database:"), db_str); + } + + if let Some(version) = &result.version { + println!(" {} {}", ui::dim("Version:"), version); + } + + println!(); + } else { + output::print_output(&value, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Get Prometheus metrics +pub fn metrics( + client: &ApiClient, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching metrics...")) + } else { + None + }; + + let response = client.metrics(verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_section_header("Prometheus Metrics"); + println!("{}", result); + } else { + // For JSON/YAML, wrap in an object + let wrapped = serde_json::json!({ "metrics": result }); + output::print_output(&wrapped, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_health_response_deserialization() { + let json = r#"{ + "status": "healthy", + "database": "connected", + "version": "0.3.0" + }"#; + + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + let result: HealthResponse = serde_json::from_value(value).unwrap(); + + assert_eq!(result.status, "healthy"); + assert_eq!(result.database, Some("connected".to_string())); + assert_eq!(result.version, Some("0.3.0".to_string())); + } + + #[test] + fn test_health_response_minimal() { + let json = r#"{"status": "healthy"}"#; + + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + let result: HealthResponse = serde_json::from_value(value).unwrap(); + + assert_eq!(result.status, "healthy"); + assert_eq!(result.database, None); + assert_eq!(result.version, None); + } + + #[test] + fn test_health_response_unhealthy() { + let json = r#"{ + "status": "unhealthy", + "database": "disconnected" + }"#; + + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + let result: HealthResponse = serde_json::from_value(value).unwrap(); + + assert_eq!(result.status, "unhealthy"); + assert_eq!(result.database, Some("disconnected".to_string())); + } +} diff --git a/hindsight-cli/src/commands/memory.rs b/hindsight-cli/src/commands/memory.rs index 3cfb4909..524e443b 100644 --- a/hindsight-cli/src/commands/memory.rs +++ b/hindsight-cli/src/commands/memory.rs @@ -10,8 +10,30 @@ use crate::ui; // Import types from generated client use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch}; +use serde::Deserialize; use serde_json; +// Local types for serde_json::Value deserialization +#[derive(Debug, Deserialize)] +struct MemoryUnitDetail { + id: String, + text: String, + #[serde(rename = "type")] + type_: Option, + document_id: Option, + context: Option, + occurred_start: Option, + occurred_end: Option, + entities: Option>, + tags: Option>, +} + +#[derive(Debug, Deserialize)] +struct EntityRef { + id: String, + name: String, +} + // Helper function to parse budget string to Budget enum fn parse_budget(budget: &str) -> Budget { match budget.to_lowercase().as_str() { @@ -21,6 +43,183 @@ fn parse_budget(budget: &str) -> Budget { } } +/// List memory units with pagination and optional filters +pub fn list( + client: &ApiClient, + bank_id: &str, + type_filter: Option, + query: Option, + limit: i64, + offset: i64, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching memories...")) + } else { + None + }; + + let response = client.list_memories( + bank_id, + type_filter.as_deref(), + query.as_deref(), + Some(limit), + Some(offset), + verbose, + ); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Memories: {} (showing {}-{})", bank_id, offset + 1, offset + result.items.len() as i64)); + + if result.items.is_empty() { + println!(" {}", ui::dim("No memories found.")); + } else { + for item in &result.items { + let fact_type = item.get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let type_t = match fact_type { + "world" => 0.0, + "experience" => 0.5, + "opinion" => 1.0, + _ => 0.5, + }; + + let id = item.get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + println!( + " {} {}", + ui::gradient(&format!("[{}]", fact_type.to_uppercase()), type_t), + ui::dim(id) + ); + + // Truncate text if too long + if let Some(text) = item.get("text").and_then(|v| v.as_str()) { + let text_preview: String = text.chars().take(100).collect(); + let ellipsis = if text.len() > 100 { "..." } else { "" }; + println!(" {}{}", text_preview, ellipsis); + } + + if let Some(doc_id) = item.get("document_id").and_then(|v| v.as_str()) { + println!(" {} {}", ui::dim("doc:"), ui::dim(doc_id)); + } + println!(); + } + + println!(" {} {} total", ui::dim("Total:"), result.total); + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Get a specific memory unit by ID +pub fn get( + client: &ApiClient, + bank_id: &str, + memory_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching memory...")) + } else { + None + }; + + let response = client.get_memory(bank_id, memory_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(value) => { + if output_format == OutputFormat::Pretty { + let result: MemoryUnitDetail = serde_json::from_value(value) + .with_context(|| "Failed to parse memory response")?; + + let fact_type = result.type_.as_deref().unwrap_or("unknown"); + let type_t = match fact_type { + "world" => 0.0, + "experience" => 0.5, + "opinion" => 1.0, + _ => 0.5, + }; + + ui::print_section_header(&format!("Memory: {}", memory_id)); + + println!(" {} {}", ui::dim("Type:"), ui::gradient(&fact_type.to_uppercase(), type_t)); + println!(" {} {}", ui::dim("ID:"), result.id); + + if let Some(doc_id) = &result.document_id { + println!(" {} {}", ui::dim("Document:"), doc_id); + } + + if let Some(context) = &result.context { + println!(" {} {}", ui::dim("Context:"), context); + } + + println!(); + println!("{}", ui::gradient_text("─── Content ───")); + println!(); + println!("{}", result.text); + + // Show temporal info if available + if result.occurred_start.is_some() || result.occurred_end.is_some() { + println!(); + println!("{}", ui::gradient_text("─── Temporal ───")); + if let Some(start) = &result.occurred_start { + println!(" {} {}", ui::dim("Start:"), start); + } + if let Some(end) = &result.occurred_end { + println!(" {} {}", ui::dim("End:"), end); + } + } + + // Show entities if available + if let Some(entities) = &result.entities { + if !entities.is_empty() { + println!(); + println!("{}", ui::gradient_text("─── Entities ───")); + for entity in entities { + println!(" • {} ({})", entity.name, entity.id); + } + } + } + + // Show tags if available + if let Some(tags) = &result.tags { + if !tags.is_empty() { + println!(); + println!("{}", ui::gradient_text("─── Tags ───")); + println!(" {}", tags.join(", ")); + } + } + + println!(); + } else { + output::print_output(&value, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + // Helper function to check if a file has a text-based extension fn is_text_file(path: &std::path::Path) -> bool { const TEXT_EXTENSIONS: &[&str] = &[ diff --git a/hindsight-cli/src/commands/mental_model.rs b/hindsight-cli/src/commands/mental_model.rs new file mode 100644 index 00000000..384aab19 --- /dev/null +++ b/hindsight-cli/src/commands/mental_model.rs @@ -0,0 +1,721 @@ +//! Mental model commands for managing structured knowledge containers. + +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; + +use crate::api::ApiClient; +use crate::output::{self, OutputFormat}; +use crate::ui; + +use hindsight_client::types; +use serde::Deserialize; + +// Local types for serde_json::Value deserialization +#[derive(Debug, Deserialize)] +struct VersionListResponse { + versions: Vec, +} + +#[derive(Debug, Deserialize)] +struct VersionItem { + version: i64, + created_at: String, + observations_count: Option, +} + +#[derive(Debug, Deserialize)] +struct VersionDetailResponse { + version: i64, + created_at: String, + observations: Option>, +} + +#[derive(Debug, Deserialize)] +struct ObservationData { + title: String, + content: String, + trend: Option, + evidence: Option>, +} + +#[derive(Debug, Deserialize)] +struct EvidenceData { + quote: String, +} + +/// List mental models for a bank +pub fn list( + client: &ApiClient, + bank_id: &str, + subtype: Option, + tags: Option>, + tags_match: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching mental models...")) + } else { + None + }; + + let response = client.list_mental_models( + bank_id, + subtype.as_deref(), + tags, + tags_match.as_deref(), + verbose, + ); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Mental Models: {}", bank_id)); + + if result.items.is_empty() { + println!(" {}", ui::dim("No mental models found.")); + } else { + for model in &result.items { + let subtype_str = &model.subtype; + let obs_count = model.observations.len(); + + println!( + " {} {} {}", + ui::gradient_start(&model.id), + ui::dim(&format!("[{}]", subtype_str)), + model.name + ); + + if !model.description.is_empty() { + println!(" {}", ui::dim(&model.description)); + } + + println!( + " {} observations, v{}", + obs_count, + model.version + ); + + // Show freshness status + if let Some(freshness) = &model.freshness { + let status = if freshness.is_up_to_date { + ui::gradient_start("up to date") + } else { + ui::gradient_end("needs refresh") + }; + println!(" {}", status); + } + + println!(); + } + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Get a specific mental model +pub fn get( + client: &ApiClient, + bank_id: &str, + model_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching mental model...")) + } else { + None + }; + + let response = client.get_mental_model(bank_id, model_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(model) => { + if output_format == OutputFormat::Pretty { + print_mental_model_detail(&model); + } else { + output::print_output(&model, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Create a new mental model +pub fn create( + client: &ApiClient, + bank_id: &str, + name: &str, + description: &str, + subtype: Option, + tags: Option>, + observations_file: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Creating mental model...")) + } else { + None + }; + + // Parse observations from file if provided + let observations = if let Some(path) = observations_file { + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read observations file: {}", path.display()))?; + let obs: Vec = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse observations JSON from: {}", path.display()))?; + Some(obs) + } else { + None + }; + + let request = types::CreateMentalModelRequest { + name: name.to_string(), + description: description.to_string(), + subtype: subtype.unwrap_or_else(|| "pinned".to_string()), + tags: tags.unwrap_or_default(), + observations, + }; + + let response = client.create_mental_model(bank_id, &request, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(model) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Mental model '{}' created successfully", model.id)); + println!(); + print_mental_model_detail(&model); + } else { + output::print_output(&model, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Delete a mental model +pub fn delete( + client: &ApiClient, + bank_id: &str, + model_id: &str, + yes: bool, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + // Confirmation prompt unless -y flag is used + if !yes && output_format == OutputFormat::Pretty { + let message = format!( + "Are you sure you want to delete mental model '{}'? This cannot be undone.", + model_id + ); + + let confirmed = ui::prompt_confirmation(&message)?; + + if !confirmed { + ui::print_info("Operation cancelled"); + return Ok(()); + } + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Deleting mental model...")) + } else { + None + }; + + let response = client.delete_mental_model(bank_id, model_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + if result.success { + ui::print_success(&format!("Mental model '{}' deleted successfully", model_id)); + } else { + ui::print_error("Failed to delete mental model"); + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Update a mental model's name or description +pub fn update( + client: &ApiClient, + bank_id: &str, + model_id: &str, + name: Option, + description: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + if name.is_none() && description.is_none() { + anyhow::bail!("At least one of --name or --description must be provided"); + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Updating mental model...")) + } else { + None + }; + + let request = types::UpdateMentalModelRequest { name, description }; + + let response = client.update_mental_model(bank_id, model_id, &request, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(model) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Mental model '{}' updated successfully", model_id)); + println!(); + print_mental_model_detail(&model); + } else { + output::print_output(&model, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Refresh all mental models (or filtered by subtype) +pub fn refresh_all( + client: &ApiClient, + bank_id: &str, + subtype: Option, + tags: Option>, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Submitting refresh request...")) + } else { + None + }; + + let response = client.refresh_mental_models(bank_id, subtype.as_deref(), tags, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_success("Refresh operation submitted"); + println!(" Operation ID: {}", result.operation_id); + println!(" Status: {}", result.status); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Refresh a specific mental model +pub fn refresh( + client: &ApiClient, + bank_id: &str, + model_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Submitting refresh request...")) + } else { + None + }; + + let response = client.refresh_mental_model(bank_id, model_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Refresh submitted for model '{}'", model_id)); + println!(" Operation ID: {}", result.operation_id); + println!(" Status: {}", result.status); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// List version history for a mental model +pub fn versions( + client: &ApiClient, + bank_id: &str, + model_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching versions...")) + } else { + None + }; + + let response = client.list_mental_model_versions(bank_id, model_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(value) => { + if output_format == OutputFormat::Pretty { + let result: VersionListResponse = serde_json::from_value(value) + .with_context(|| "Failed to parse version list response")?; + + ui::print_section_header(&format!("Version History: {}", model_id)); + + if result.versions.is_empty() { + println!(" {}", ui::dim("No versions found.")); + } else { + for version in &result.versions { + let obs_count = version.observations_count.unwrap_or(0); + println!( + " {} v{} - {} observations", + ui::gradient_start(&format!("v{}", version.version)), + version.version, + obs_count + ); + println!(" {}", ui::dim(&version.created_at)); + } + } + } else { + output::print_output(&value, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Get a specific version of a mental model +pub fn version( + client: &ApiClient, + bank_id: &str, + model_id: &str, + version_num: i64, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching version...")) + } else { + None + }; + + let response = client.get_mental_model_version(bank_id, model_id, version_num, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(value) => { + if output_format == OutputFormat::Pretty { + let result: VersionDetailResponse = serde_json::from_value(value) + .with_context(|| "Failed to parse version response")?; + + ui::print_section_header(&format!("{} v{}", model_id, version_num)); + + println!(" {} {}", ui::dim("Created:"), result.created_at); + println!(); + + if let Some(observations) = &result.observations { + if observations.is_empty() { + println!(" {}", ui::dim("No observations in this version.")); + } else { + for (i, obs) in observations.iter().enumerate() { + print_observation_data(i + 1, obs); + } + } + } + } else { + output::print_output(&value, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +// Helper function to print mental model details +fn print_mental_model_detail(model: &types::MentalModelResponse) { + ui::print_section_header(&model.name); + + let subtype_str = &model.subtype; + println!(" {} {}", ui::dim("ID:"), ui::gradient_start(&model.id)); + println!(" {} {}", ui::dim("Subtype:"), subtype_str); + println!(" {} v{}", ui::dim("Version:"), model.version); + + if !model.description.is_empty() { + println!(" {} {}", ui::dim("Description:"), &model.description); + } + + if !model.tags.is_empty() { + println!(" {} {}", ui::dim("Tags:"), model.tags.join(", ")); + } + + // Freshness status + if let Some(freshness) = &model.freshness { + println!(); + println!("{}", ui::gradient_text("─── Freshness ───")); + let status = if freshness.is_up_to_date { + ui::gradient_start("Up to date") + } else { + ui::gradient_end("Needs refresh") + }; + println!(" {} {}", ui::dim("Status:"), status); + + if let Some(last_refresh) = &freshness.last_refresh_at { + println!(" {} {}", ui::dim("Last refresh:"), last_refresh); + } + + if freshness.memories_since_refresh > 0 { + println!(" {} {}", ui::dim("New memories:"), freshness.memories_since_refresh); + } + + if !freshness.reasons.is_empty() { + println!(" {} {}", ui::dim("Reasons:"), freshness.reasons.join(", ")); + } + } + + // Observations + println!(); + println!("{}", ui::gradient_text("─── Observations ───")); + println!(); + + if model.observations.is_empty() { + println!(" {}", ui::dim("No observations yet.")); + } else { + for (i, obs) in model.observations.iter().enumerate() { + print_observation(i + 1, obs); + } + } + + println!(); +} + +fn print_observation(index: usize, obs: &types::MentalModelObservationResponse) { + let trend_str = &obs.trend; + let trend_colored = match trend_str.as_str() { + "strengthening" => ui::gradient_start(trend_str), + "stable" => ui::gradient_mid(trend_str), + "weakening" | "stale" => ui::gradient_end(trend_str), + _ => trend_str.to_string(), + }; + + println!(" {}. {} {}", index, ui::gradient_mid(&obs.title), ui::dim(&format!("[{}]", trend_colored))); + println!(" {}", obs.content); + + // Show evidence if available + if !obs.evidence.is_empty() { + println!(" {} evidence items:", ui::dim(&obs.evidence.len().to_string())); + for ev in obs.evidence.iter().take(2) { + // Show first 2 evidence items + let quote_preview: String = ev.quote.chars().take(60).collect(); + let ellipsis = if ev.quote.len() > 60 { "..." } else { "" }; + println!(" • \"{}{}\"", quote_preview, ellipsis); + } + if obs.evidence.len() > 2 { + println!(" {} more...", ui::dim(&format!("+ {}", obs.evidence.len() - 2))); + } + } + + println!(); +} + +fn print_observation_data(index: usize, obs: &ObservationData) { + let trend_str = obs.trend.as_deref().unwrap_or("unknown"); + let trend_colored = match trend_str { + "strengthening" => ui::gradient_start(trend_str), + "stable" => ui::gradient_mid(trend_str), + "weakening" | "stale" => ui::gradient_end(trend_str), + _ => trend_str.to_string(), + }; + + println!(" {}. {} {}", index, ui::gradient_mid(&obs.title), ui::dim(&format!("[{}]", trend_colored))); + println!(" {}", obs.content); + + // Show evidence if available + if let Some(evidence) = &obs.evidence { + if !evidence.is_empty() { + println!(" {} evidence items:", ui::dim(&evidence.len().to_string())); + for ev in evidence.iter().take(2) { + // Show first 2 evidence items + let quote_preview: String = ev.quote.chars().take(60).collect(); + let ellipsis = if ev.quote.len() > 60 { "..." } else { "" }; + println!(" • \"{}{}\"", quote_preview, ellipsis); + } + if evidence.len() > 2 { + println!(" {} more...", ui::dim(&format!("+ {}", evidence.len() - 2))); + } + } + } + + println!(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_observation_input_serialization() { + let obs = types::ObservationInput { + title: "Test observation".to_string(), + content: "Test content".to_string(), + }; + let json = serde_json::to_string(&obs).unwrap(); + assert!(json.contains("Test observation")); + assert!(json.contains("Test content")); + } + + #[test] + fn test_version_list_response_deserialization() { + let json = r#"{ + "versions": [ + {"version": 1, "created_at": "2024-01-10T10:00:00Z", "observations_count": 5}, + {"version": 2, "created_at": "2024-01-15T10:00:00Z", "observations_count": 8} + ] + }"#; + + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + let result: VersionListResponse = serde_json::from_value(value).unwrap(); + + assert_eq!(result.versions.len(), 2); + assert_eq!(result.versions[0].version, 1); + assert_eq!(result.versions[1].version, 2); + assert_eq!(result.versions[1].observations_count, Some(8)); + } + + #[test] + fn test_version_detail_response_deserialization() { + let json = r#"{ + "version": 1, + "created_at": "2024-01-10T10:00:00Z", + "observations": [ + { + "title": "Test observation", + "content": "Test content", + "trend": "stable", + "evidence": [{"quote": "test evidence"}] + } + ] + }"#; + + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + let result: VersionDetailResponse = serde_json::from_value(value).unwrap(); + + assert_eq!(result.created_at, "2024-01-10T10:00:00Z"); + let observations = result.observations.unwrap(); + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].title, "Test observation"); + assert_eq!(observations[0].trend, Some("stable".to_string())); + } + + #[test] + fn test_observation_data_deserialization() { + let json = r#"{ + "title": "Test Title", + "content": "Test Content", + "trend": "strengthening", + "evidence": [ + {"quote": "Evidence 1"}, + {"quote": "Evidence 2"} + ] + }"#; + + let result: ObservationData = serde_json::from_str(json).unwrap(); + + assert_eq!(result.title, "Test Title"); + assert_eq!(result.content, "Test Content"); + assert_eq!(result.trend, Some("strengthening".to_string())); + let evidence = result.evidence.unwrap(); + assert_eq!(evidence.len(), 2); + assert_eq!(evidence[0].quote, "Evidence 1"); + } + + #[test] + fn test_create_mental_model_request() { + let request = types::CreateMentalModelRequest { + name: "Test Model".to_string(), + description: "A test model".to_string(), + subtype: "pinned".to_string(), + tags: vec!["test".to_string()], + observations: None, + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("Test Model")); + assert!(json.contains("pinned")); + assert!(json.contains("test")); + } + + #[test] + fn test_update_mental_model_request() { + let request = types::UpdateMentalModelRequest { + name: Some("Updated Name".to_string()), + description: None, + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("Updated Name")); + } + + #[test] + fn test_async_operation_submit_response_deserialization() { + let json = r#"{ + "operation_id": "op-123", + "status": "pending" + }"#; + + let result: types::AsyncOperationSubmitResponse = serde_json::from_str(json).unwrap(); + + assert_eq!(result.operation_id, "op-123"); + assert_eq!(result.status, "pending"); + } +} diff --git a/hindsight-cli/src/commands/mod.rs b/hindsight-cli/src/commands/mod.rs index 2f548caa..e72bb527 100644 --- a/hindsight-cli/src/commands/mod.rs +++ b/hindsight-cli/src/commands/mod.rs @@ -1,6 +1,10 @@ pub mod bank; -pub mod memory; +pub mod chunk; pub mod document; pub mod entity; -pub mod operation; pub mod explore; +pub mod health; +pub mod memory; +pub mod mental_model; +pub mod operation; +pub mod tag; diff --git a/hindsight-cli/src/commands/operation.rs b/hindsight-cli/src/commands/operation.rs index 241a6c02..80aa7806 100644 --- a/hindsight-cli/src/commands/operation.rs +++ b/hindsight-cli/src/commands/operation.rs @@ -47,6 +47,55 @@ pub fn list( } } +/// Get the status of a specific operation +pub fn get( + client: &ApiClient, + agent_id: &str, + operation_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching operation status...")) + } else { + None + }; + + let response = client.get_operation(agent_id, operation_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Operation: {}", operation_id)); + + use hindsight_client::types::Status; + let status_str = match &result.status { + Status::Completed => ui::gradient_start("completed"), + Status::Pending => ui::gradient_mid("pending"), + Status::Failed => ui::gradient_end("failed"), + Status::NotFound => ui::gradient_end("not_found"), + }; + + println!(" {} {}", ui::dim("Status:"), status_str); + + if let Some(error) = &result.error_message { + println!(" {} {}", ui::dim("Error:"), ui::gradient_end(error)); + } + + println!(); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + pub fn cancel( client: &ApiClient, agent_id: &str, diff --git a/hindsight-cli/src/commands/tag.rs b/hindsight-cli/src/commands/tag.rs new file mode 100644 index 00000000..e74b8cab --- /dev/null +++ b/hindsight-cli/src/commands/tag.rs @@ -0,0 +1,119 @@ +//! Tag commands for listing tags in a memory bank. + +use anyhow::Result; + +use crate::api::ApiClient; +use crate::output::{self, OutputFormat}; +use crate::ui; + +/// List tags in a bank +pub fn list( + client: &ApiClient, + bank_id: &str, + query: Option, + limit: i64, + offset: i64, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching tags...")) + } else { + None + }; + + let response = client.list_tags( + bank_id, + query.as_deref(), + Some(limit), + Some(offset), + verbose, + ); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Tags: {}", bank_id)); + + if result.items.is_empty() { + println!(" {}", ui::dim("No tags found.")); + } else { + for (i, tag) in result.items.iter().enumerate() { + let t = i as f32 / result.items.len().max(1) as f32; + println!( + " {} {}", + ui::gradient(&tag.tag, t), + ui::dim(&format!("({})", tag.count)) + ); + } + println!(); + println!(" {} {} total", ui::dim("Total:"), result.total); + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use hindsight_client::types::{ListTagsResponse, TagItem}; + + #[test] + fn test_tag_item_fields() { + // Verify TagItem has the expected fields + let tag = TagItem { + tag: "test-tag".to_string(), + count: 5, + }; + + assert_eq!(tag.tag, "test-tag"); + assert_eq!(tag.count, 5); + } + + #[test] + fn test_list_tags_response_deserialization() { + let json = r#"{ + "items": [ + {"tag": "user", "count": 10}, + {"tag": "system", "count": 5} + ], + "limit": 100, + "offset": 0, + "total": 2 + }"#; + + let result: ListTagsResponse = serde_json::from_str(json).unwrap(); + + assert_eq!(result.items.len(), 2); + assert_eq!(result.items[0].tag, "user"); + assert_eq!(result.items[0].count, 10); + assert_eq!(result.items[1].tag, "system"); + assert_eq!(result.items[1].count, 5); + assert_eq!(result.total, 2); + assert_eq!(result.limit, 100); + assert_eq!(result.offset, 0); + } + + #[test] + fn test_empty_tags_response() { + let json = r#"{ + "items": [], + "limit": 100, + "offset": 0, + "total": 0 + }"#; + + let result: ListTagsResponse = serde_json::from_str(json).unwrap(); + + assert!(result.items.is_empty()); + assert_eq!(result.total, 0); + } +} diff --git a/hindsight-cli/src/main.rs b/hindsight-cli/src/main.rs index 250b9f3c..75d190a1 100644 --- a/hindsight-cli/src/main.rs +++ b/hindsight-cli/src/main.rs @@ -67,14 +67,18 @@ fn get_before_help() -> &'static str { #[derive(Subcommand)] enum Commands { - /// Manage banks (list, profile, stats) + /// Manage banks (list, create, update, profile, stats, mission, graph, delete) #[command(subcommand)] Bank(BankCommands), - /// Manage memories (recall, reflect, retain, delete) + /// Manage memories (list, get, recall, reflect, retain, clear) #[command(subcommand)] Memory(MemoryCommands), + /// Manage mental models (list, get, create, update, delete, refresh, versions) + #[command(subcommand)] + MentalModel(MentalModelCommands), + /// Manage documents (list, get, delete) #[command(subcommand)] Document(DocumentCommands), @@ -83,10 +87,24 @@ enum Commands { #[command(subcommand)] Entity(EntityCommands), - /// Manage async operations (list, cancel) + /// Manage tags (list) + #[command(subcommand)] + Tag(TagCommands), + + /// Manage chunks (get) + #[command(subcommand)] + Chunk(ChunkCommands), + + /// Manage async operations (list, get, cancel) #[command(subcommand)] Operation(OperationCommands), + /// Check API health status + Health, + + /// Get Prometheus metrics + Metrics, + /// Interactive TUI explorer (k9s-style) for navigating banks, memories, entities, and performing recall/reflect #[command(alias = "tui")] Explore, @@ -111,7 +129,59 @@ enum BankCommands { /// List all banks List, - /// Get bank disposition and background + /// Create a new bank + Create { + /// Bank ID + bank_id: String, + + /// Bank name + #[arg(short = 'n', long)] + name: Option, + + /// Mission statement + #[arg(short = 'm', long)] + mission: Option, + + /// Skepticism trait (1-5) + #[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))] + skepticism: Option, + + /// Literalism trait (1-5) + #[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))] + literalism: Option, + + /// Empathy trait (1-5) + #[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))] + empathy: Option, + }, + + /// Update bank properties (partial update) + Update { + /// Bank ID + bank_id: String, + + /// Bank name + #[arg(short = 'n', long)] + name: Option, + + /// Mission statement + #[arg(short = 'm', long)] + mission: Option, + + /// Skepticism trait (1-5) + #[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))] + skepticism: Option, + + /// Literalism trait (1-5) + #[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))] + literalism: Option, + + /// Empathy trait (1-5) + #[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))] + empathy: Option, + }, + + /// Get bank disposition and profile Disposition { /// Bank ID bank_id: String, @@ -132,7 +202,17 @@ enum BankCommands { name: String, }, - /// Set or merge bank background + /// Set bank mission + Mission { + /// Bank ID + bank_id: String, + + /// Mission statement + mission: String, + }, + + /// Set or merge bank background (deprecated: use mission instead) + #[command(hide = true)] Background { /// Bank ID bank_id: String, @@ -145,6 +225,20 @@ enum BankCommands { no_update_disposition: bool, }, + /// Get memory graph data + Graph { + /// Bank ID + bank_id: String, + + /// Filter by fact type (world, experience, opinion) + #[arg(short = 't', long)] + fact_type: Option, + + /// Maximum nodes to return + #[arg(short = 'l', long, default_value = "1000")] + limit: i64, + }, + /// Delete a bank and all its data Delete { /// Bank ID @@ -158,6 +252,37 @@ enum BankCommands { #[derive(Subcommand)] enum MemoryCommands { + /// List memory units with pagination + List { + /// Bank ID + bank_id: String, + + /// Filter by fact type (world, experience, opinion) + #[arg(short = 't', long)] + fact_type: Option, + + /// Full-text search query + #[arg(short = 'q', long)] + query: Option, + + /// Maximum number of results + #[arg(short = 'l', long, default_value = "100")] + limit: i64, + + /// Offset for pagination + #[arg(short = 's', long, default_value = "0")] + offset: i64, + }, + + /// Get a specific memory unit by ID + Get { + /// Bank ID + bank_id: String, + + /// Memory unit ID + memory_id: String, + }, + /// Recall memories using semantic search Recall { /// Bank ID @@ -360,6 +485,15 @@ enum OperationCommands { bank_id: String, }, + /// Get the status of a specific operation + Get { + /// Bank ID + bank_id: String, + + /// Operation ID + operation_id: String, + }, + /// Cancel a pending async operation Cancel { /// Bank ID @@ -370,6 +504,164 @@ enum OperationCommands { }, } +#[derive(Subcommand)] +enum MentalModelCommands { + /// List mental models for a bank + List { + /// Bank ID + bank_id: String, + + /// Filter by subtype (structural, emergent, pinned, learned, directive) + #[arg(long)] + subtype: Option, + + /// Filter by tags + #[arg(long, value_delimiter = ',')] + tags: Option>, + + /// Tag matching mode (any, all, any_strict, all_strict) + #[arg(long, default_value = "any")] + tags_match: Option, + }, + + /// Get a specific mental model + Get { + /// Bank ID + bank_id: String, + + /// Mental model ID + model_id: String, + }, + + /// Create a new mental model (pinned or directive subtype) + Create { + /// Bank ID + bank_id: String, + + /// Model name + name: String, + + /// Model description + description: String, + + /// Subtype (pinned or directive) + #[arg(long, default_value = "pinned")] + subtype: Option, + + /// Tags for the model + #[arg(long, value_delimiter = ',')] + tags: Option>, + + /// Path to JSON file containing initial observations + #[arg(long)] + observations: Option, + }, + + /// Update a mental model's name or description + Update { + /// Bank ID + bank_id: String, + + /// Mental model ID + model_id: String, + + /// New name + #[arg(long)] + name: Option, + + /// New description + #[arg(long)] + description: Option, + }, + + /// Delete a mental model + Delete { + /// Bank ID + bank_id: String, + + /// Mental model ID + model_id: String, + + /// Skip confirmation prompt + #[arg(short = 'y', long)] + yes: bool, + }, + + /// Refresh all mental models (async operation) + RefreshAll { + /// Bank ID + bank_id: String, + + /// Filter by subtype + #[arg(long)] + subtype: Option, + + /// Filter by tags + #[arg(long, value_delimiter = ',')] + tags: Option>, + }, + + /// Refresh a specific mental model (async operation) + Refresh { + /// Bank ID + bank_id: String, + + /// Mental model ID + model_id: String, + }, + + /// List version history for a mental model + Versions { + /// Bank ID + bank_id: String, + + /// Mental model ID + model_id: String, + }, + + /// Get a specific version of a mental model + Version { + /// Bank ID + bank_id: String, + + /// Mental model ID + model_id: String, + + /// Version number + version: i64, + }, +} + +#[derive(Subcommand)] +enum TagCommands { + /// List tags in a bank + List { + /// Bank ID + bank_id: String, + + /// Wildcard search query (e.g., 'user:*') + #[arg(short = 'q', long)] + query: Option, + + /// Maximum number of results + #[arg(short = 'l', long, default_value = "100")] + limit: i64, + + /// Offset for pagination + #[arg(short = 's', long, default_value = "0")] + offset: i64, + }, +} + +#[derive(Subcommand)] +enum ChunkCommands { + /// Get a specific chunk by ID + Get { + /// Chunk ID + chunk_id: String, + }, +} + fn main() { if let Err(_) = run() { std::process::exit(1); @@ -412,20 +704,45 @@ fn run() -> Result<()> { Commands::Configure { .. } => unreachable!(), // Handled above Commands::Ui => unreachable!(), // Handled above Commands::Explore => commands::explore::run(&client), + + // Health and Metrics + Commands::Health => commands::health::health(&client, verbose, output_format), + Commands::Metrics => commands::health::metrics(&client, verbose, output_format), + + // Bank commands Commands::Bank(bank_cmd) => match bank_cmd { BankCommands::List => commands::bank::list(&client, verbose, output_format), + BankCommands::Create { bank_id, name, mission, skepticism, literalism, empathy } => { + commands::bank::create(&client, &bank_id, name, mission, skepticism, literalism, empathy, verbose, output_format) + } + BankCommands::Update { bank_id, name, mission, skepticism, literalism, empathy } => { + commands::bank::update(&client, &bank_id, name, mission, skepticism, literalism, empathy, verbose, output_format) + } BankCommands::Disposition { bank_id } => commands::bank::disposition(&client, &bank_id, verbose, output_format), BankCommands::Stats { bank_id } => commands::bank::stats(&client, &bank_id, verbose, output_format), BankCommands::Name { bank_id, name } => commands::bank::update_name(&client, &bank_id, &name, verbose, output_format), + BankCommands::Mission { bank_id, mission } => { + commands::bank::mission(&client, &bank_id, &mission, verbose, output_format) + } BankCommands::Background { bank_id, content, no_update_disposition } => { commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format) } + BankCommands::Graph { bank_id, fact_type, limit } => { + commands::bank::graph(&client, &bank_id, fact_type, limit, verbose, output_format) + } BankCommands::Delete { bank_id, yes } => { commands::bank::delete(&client, &bank_id, yes, verbose, output_format) } }, + // Memory commands Commands::Memory(memory_cmd) => match memory_cmd { + MemoryCommands::List { bank_id, fact_type, query, limit, offset } => { + commands::memory::list(&client, &bank_id, fact_type, query, limit, offset, verbose, output_format) + } + MemoryCommands::Get { bank_id, memory_id } => { + commands::memory::get(&client, &bank_id, &memory_id, verbose, output_format) + } MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens } => { commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, verbose, output_format) } @@ -446,6 +763,38 @@ fn run() -> Result<()> { } }, + // Mental Model commands + Commands::MentalModel(mm_cmd) => match mm_cmd { + MentalModelCommands::List { bank_id, subtype, tags, tags_match } => { + commands::mental_model::list(&client, &bank_id, subtype, tags, tags_match, verbose, output_format) + } + MentalModelCommands::Get { bank_id, model_id } => { + commands::mental_model::get(&client, &bank_id, &model_id, verbose, output_format) + } + MentalModelCommands::Create { bank_id, name, description, subtype, tags, observations } => { + commands::mental_model::create(&client, &bank_id, &name, &description, subtype, tags, observations, verbose, output_format) + } + MentalModelCommands::Update { bank_id, model_id, name, description } => { + commands::mental_model::update(&client, &bank_id, &model_id, name, description, verbose, output_format) + } + MentalModelCommands::Delete { bank_id, model_id, yes } => { + commands::mental_model::delete(&client, &bank_id, &model_id, yes, verbose, output_format) + } + MentalModelCommands::RefreshAll { bank_id, subtype, tags } => { + commands::mental_model::refresh_all(&client, &bank_id, subtype, tags, verbose, output_format) + } + MentalModelCommands::Refresh { bank_id, model_id } => { + commands::mental_model::refresh(&client, &bank_id, &model_id, verbose, output_format) + } + MentalModelCommands::Versions { bank_id, model_id } => { + commands::mental_model::versions(&client, &bank_id, &model_id, verbose, output_format) + } + MentalModelCommands::Version { bank_id, model_id, version } => { + commands::mental_model::version(&client, &bank_id, &model_id, version, verbose, output_format) + } + }, + + // Document commands Commands::Document(doc_cmd) => match doc_cmd { DocumentCommands::List { bank_id, query, limit, offset } => { commands::document::list(&client, &bank_id, query, limit, offset, verbose, output_format) @@ -458,6 +807,7 @@ fn run() -> Result<()> { } }, + // Entity commands Commands::Entity(entity_cmd) => match entity_cmd { EntityCommands::List { bank_id, limit } => { commands::entity::list(&client, &bank_id, limit, verbose, output_format) @@ -470,10 +820,28 @@ fn run() -> Result<()> { } }, + // Tag commands + Commands::Tag(tag_cmd) => match tag_cmd { + TagCommands::List { bank_id, query, limit, offset } => { + commands::tag::list(&client, &bank_id, query, limit, offset, verbose, output_format) + } + }, + + // Chunk commands + Commands::Chunk(chunk_cmd) => match chunk_cmd { + ChunkCommands::Get { chunk_id } => { + commands::chunk::get(&client, &chunk_id, verbose, output_format) + } + }, + + // Operation commands Commands::Operation(op_cmd) => match op_cmd { OperationCommands::List { bank_id } => { commands::operation::list(&client, &bank_id, verbose, output_format) } + OperationCommands::Get { bank_id, operation_id } => { + commands::operation::get(&client, &bank_id, &operation_id, verbose, output_format) + } OperationCommands::Cancel { bank_id, operation_id } => { commands::operation::cancel(&client, &bank_id, &operation_id, verbose, output_format) } diff --git a/hindsight-cli/tests/cli_integration.rs b/hindsight-cli/tests/cli_integration.rs new file mode 100644 index 00000000..3bf222da --- /dev/null +++ b/hindsight-cli/tests/cli_integration.rs @@ -0,0 +1,483 @@ +//! Integration tests for the hindsight CLI commands. +//! +//! These tests require a running hindsight API server. +//! Set HINDSIGHT_API_URL environment variable to point to the server. +//! Tests will be skipped if the server is not available. + +use std::env; +use std::process::Command; + +/// Check if the API server is available +fn server_available() -> bool { + let api_url = env::var("HINDSIGHT_API_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); + let health_url = format!("{}/health", api_url); + + match reqwest::blocking::get(&health_url) { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } +} + +/// Helper macro to skip tests when server is not available +macro_rules! skip_if_no_server { + () => { + if !server_available() { + eprintln!("Skipping test: API server not available"); + return; + } + }; +} + +/// Get the path to the hindsight binary +fn hindsight_binary() -> String { + env::var("CARGO_BIN_EXE_hindsight") + .unwrap_or_else(|_| { + // Try common locations + let target_debug = "./target/debug/hindsight"; + let target_release = "./target/release/hindsight"; + if std::path::Path::new(target_debug).exists() { + target_debug.to_string() + } else if std::path::Path::new(target_release).exists() { + target_release.to_string() + } else { + "hindsight".to_string() + } + }) +} + +/// Test bank ID for integration tests - each test needs a unique bank ID +/// to avoid parallel test interference +fn test_bank_id(test_name: &str) -> String { + format!("cli-test-{}-{}", test_name, std::process::id()) +} + +/// Run a hindsight CLI command +fn run_hindsight(args: &[&str]) -> std::process::Output { + let api_url = env::var("HINDSIGHT_API_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); + + Command::new(hindsight_binary()) + .env("HINDSIGHT_API_URL", &api_url) + .args(args) + .output() + .expect("Failed to execute hindsight command") +} + +#[test] +fn test_health_check() { + skip_if_no_server!(); + + let output = run_hindsight(&["health"]); + + // Should succeed or fail gracefully + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Either succeeded with "healthy" output or has a reasonable error + if output.status.success() { + // Note: output may contain ANSI color codes, so check for key text + assert!( + stdout.contains("healthy") || stdout.contains("Health") || stdout.contains("status"), + "Expected health check output, got: {} / {}", + stdout, + stderr + ); + } +} + +#[test] +fn test_health_check_json_output() { + skip_if_no_server!(); + + let output = run_hindsight(&["health", "-o", "json"]); + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Should be valid JSON + let result: serde_json::Value = serde_json::from_str(&stdout) + .expect(&format!("Expected valid JSON output, got: {}", stdout)); + + // Should have status field + assert!(result.get("status").is_some(), "Expected status field in health response"); + } +} + +#[test] +fn test_bank_list() { + skip_if_no_server!(); + + let output = run_hindsight(&["bank", "list"]); + + // Should succeed (even if no banks exist) + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "Bank list command failed: {} / {}", + stdout, + stderr + ); +} + +#[test] +fn test_bank_list_json_output() { + skip_if_no_server!(); + + let output = run_hindsight(&["bank", "list", "-o", "json"]); + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Should be valid JSON array + let _result: serde_json::Value = serde_json::from_str(&stdout) + .expect(&format!("Expected valid JSON output, got: {}", stdout)); + } +} + +#[test] +fn test_bank_create_and_delete() { + skip_if_no_server!(); + + let bank_id = test_bank_id("create-delete"); + + // Create a bank + let output = run_hindsight(&[ + "bank", "create", + &bank_id, + "--name", "Test Bank", + "--mission", "A test bank for CLI integration tests", + ]); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Bank might already exist, which is OK + let created = output.status.success(); + + // Get bank disposition + let output = run_hindsight(&["bank", "disposition", &bank_id]); + if created { + assert!( + output.status.success(), + "Bank disposition command failed: {} / {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + // Clean up: delete the bank + let output = run_hindsight(&["bank", "delete", &bank_id, "-y"]); + // Deletion should succeed + if created { + assert!( + output.status.success(), + "Bank delete command failed: {} / {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn test_memory_list() { + skip_if_no_server!(); + + let bank_id = test_bank_id("memory-list"); + + // Create the bank first + let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]); + + // List memories (should be empty for new bank) + let output = run_hindsight(&["memory", "list", &bank_id]); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Should succeed (even if empty) + assert!( + output.status.success(), + "Memory list command failed: {} / {}", + stdout, + stderr + ); + + // Clean up + let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]); +} + +#[test] +fn test_mental_model_list() { + skip_if_no_server!(); + + let bank_id = test_bank_id("mm-list"); + + // Create the bank first + let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]); + + // List mental models + let output = run_hindsight(&["mental-model", "list", &bank_id]); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Should succeed + assert!( + output.status.success(), + "Mental model list command failed: {} / {}", + stdout, + stderr + ); + + // Clean up + let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]); +} + +#[test] +fn test_mental_model_create_and_delete() { + skip_if_no_server!(); + + let bank_id = test_bank_id("mm-create"); + + // Create the bank first + let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]); + + // Create a mental model + let output = run_hindsight(&[ + "mental-model", "create", + &bank_id, + "Test Model", + "A test mental model", + ]); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // The create command should succeed + assert!( + output.status.success(), + "Mental model create failed: stdout={}, stderr={}", + stdout, + stderr + ); + + // Verify it's in the list + let output = run_hindsight(&["mental-model", "list", &bank_id, "-o", "json"]); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + output.status.success(), + "Mental model list failed: {}", + stdout + ); + + // Parse JSON and verify model exists + if let Ok(result) = serde_json::from_str::(&stdout) { + if let Some(items) = result.get("items").and_then(|v| v.as_array()) { + // Check if any model has the name "Test Model" + let found = items.iter().any(|item| { + item.get("name").and_then(|v| v.as_str()) == Some("Test Model") + }); + assert!(found, "Expected to find 'Test Model' in mental models list: {}", stdout); + } + } + + // Clean up + let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]); +} + +#[test] +fn test_tag_list() { + skip_if_no_server!(); + + let bank_id = test_bank_id("tag-list"); + + // Create the bank first + let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]); + + // List tags + let output = run_hindsight(&["tag", "list", &bank_id]); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Should succeed (even if no tags) + assert!( + output.status.success(), + "Tag list command failed: {} / {}", + stdout, + stderr + ); + + // Clean up + let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]); +} + +#[test] +fn test_entity_list() { + skip_if_no_server!(); + + let bank_id = test_bank_id("entity-list"); + + // Create the bank first + let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]); + + // List entities + let output = run_hindsight(&["entity", "list", &bank_id]); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Should succeed (even if no entities) + assert!( + output.status.success(), + "Entity list command failed: {} / {}", + stdout, + stderr + ); + + // Clean up + let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]); +} + +#[test] +fn test_operation_list() { + skip_if_no_server!(); + + let bank_id = test_bank_id("op-list"); + + // Create the bank first + let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]); + + // List operations + let output = run_hindsight(&["operation", "list", &bank_id]); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Should succeed (even if no operations) + assert!( + output.status.success(), + "Operation list command failed: {} / {}", + stdout, + stderr + ); + + // Clean up + let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]); +} + +#[test] +fn test_bank_stats() { + skip_if_no_server!(); + + let bank_id = test_bank_id("bank-stats"); + + // Create the bank first + let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]); + + // Get stats + let output = run_hindsight(&["bank", "stats", &bank_id]); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Should succeed + assert!( + output.status.success(), + "Bank stats command failed: {} / {}", + stdout, + stderr + ); + + // Clean up + let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]); +} + +#[test] +fn test_bank_graph() { + skip_if_no_server!(); + + let bank_id = test_bank_id("bank-graph"); + + // Create the bank first + let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]); + + // Get graph + let output = run_hindsight(&["bank", "graph", &bank_id]); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Should succeed (even if empty graph) + assert!( + output.status.success(), + "Bank graph command failed: {} / {}", + stdout, + stderr + ); + + // Clean up + let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]); +} + +#[test] +fn test_bank_update() { + skip_if_no_server!(); + + let bank_id = test_bank_id("bank-update"); + + // Create the bank first + let output = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]); + + if output.status.success() { + // Update the bank + let output = run_hindsight(&[ + "bank", "update", &bank_id, + "--name", "Updated Test Bank", + ]); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "Bank update command failed: {} / {}", + stdout, + stderr + ); + + // Verify the update + let output = run_hindsight(&["bank", "disposition", &bank_id, "-o", "json"]); + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + result.get("name").and_then(|v| v.as_str()), + Some("Updated Test Bank") + ); + } + } + + // Clean up + let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]); +} + +#[test] +fn test_json_yaml_output_formats() { + skip_if_no_server!(); + + // Test JSON output for bank list + let output = run_hindsight(&["bank", "list", "-o", "json"]); + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let _: serde_json::Value = serde_json::from_str(&stdout) + .expect("Expected valid JSON for bank list"); + } + + // Test YAML output for bank list + let output = run_hindsight(&["bank", "list", "-o", "yaml"]); + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let _: serde_yaml::Value = serde_yaml::from_str(&stdout) + .expect("Expected valid YAML for bank list"); + } +} diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py index 47cb9e00..1269aaad 100644 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ b/hindsight-clients/python/hindsight_client/hindsight_client.py @@ -6,11 +6,11 @@ easy-to-use interface on top of the auto-generated OpenAPI client. """ import asyncio -from typing import Optional, List, Dict, Any +from typing import Optional, List, Dict, Any, Literal from datetime import datetime import hindsight_client_api -from hindsight_client_api.api import memory_api, banks_api +from hindsight_client_api.api import memory_api, banks_api, mental_models_api from hindsight_client_api.models import ( recall_request, retain_request, @@ -23,6 +23,9 @@ from hindsight_client_api.models.recall_result import RecallResult from hindsight_client_api.models.reflect_response import ReflectResponse from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse from hindsight_client_api.models.bank_profile_response import BankProfileResponse +from hindsight_client_api.models.mental_model_response import MentalModelResponse +from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse +from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse def _run_async(coro): @@ -78,6 +81,7 @@ class Hindsight: self._api_client.set_default_header("Authorization", f"Bearer {api_key}") self._memory_api = memory_api.MemoryApi(self._api_client) self._banks_api = banks_api.BanksApi(self._api_client) + self._mental_models_api = mental_models_api.MentalModelsApi(self._api_client) def __enter__(self): """Context manager entry.""" @@ -332,6 +336,256 @@ class Hindsight: return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj)) + def set_mission( + self, + bank_id: str, + mission: str, + ) -> BankProfileResponse: + """ + Set or update the mission for a memory bank. + + Args: + bank_id: The memory bank ID + mission: The mission text describing the agent's purpose + + Returns: + BankProfileResponse with updated bank profile + """ + from hindsight_client_api.models import create_bank_request + + request_obj = create_bank_request.CreateBankRequest(mission=mission) + return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj)) + + def list_mental_models( + self, + bank_id: str, + subtype: Optional[Literal["structural", "emergent", "pinned", "learned", "directive"]] = None, + tags: Optional[List[str]] = None, + tags_match: Optional[Literal["any", "all", "exact"]] = None, + ) -> MentalModelListResponse: + """ + List mental models for a bank. + + Args: + bank_id: The memory bank ID + subtype: Optional filter by subtype (structural, emergent, pinned, learned, directive) + tags: Optional list of tags to filter by + tags_match: How to match tags - 'any' (OR), 'all' (AND), or 'exact' + + Returns: + MentalModelListResponse with list of mental models + """ + return _run_async(self._mental_models_api.list_mental_models( + bank_id=bank_id, + subtype=subtype, + tags=tags, + tags_match=tags_match, + )) + + def get_mental_model( + self, + bank_id: str, + model_id: str, + ) -> MentalModelResponse: + """ + Get a specific mental model by ID. + + Args: + bank_id: The memory bank ID + model_id: The mental model ID + + Returns: + MentalModelResponse with full mental model details including observations + """ + return _run_async(self._mental_models_api.get_mental_model( + bank_id=bank_id, + model_id=model_id, + )) + + def create_mental_model( + self, + bank_id: str, + name: str, + description: str, + subtype: Literal["pinned", "directive"] = "pinned", + observations: Optional[List[Dict[str, str]]] = None, + tags: Optional[List[str]] = None, + ) -> MentalModelResponse: + """ + Create a mental model. + + Args: + bank_id: The memory bank ID + name: Human-readable name for the mental model + description: One-liner description for quick scanning + subtype: Type of mental model - 'pinned' (LLM-generated observations) or 'directive' (user-provided observations) + observations: For directives only - list of observations with 'title' and 'content' keys + tags: Optional list of tags for scoped visibility + + Returns: + MentalModelResponse with created mental model + """ + from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest + from hindsight_client_api.models.observation_input import ObservationInput + + obs_list = None + if observations: + obs_list = [ObservationInput(title=o.get("title", ""), content=o.get("content", "")) for o in observations] + + request_obj = CreateMentalModelRequest( + name=name, + description=description, + subtype=subtype, + observations=obs_list, + tags=tags or [], + ) + return _run_async(self._mental_models_api.create_mental_model( + bank_id=bank_id, + create_mental_model_request=request_obj, + )) + + def update_mental_model( + self, + bank_id: str, + model_id: str, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> MentalModelResponse: + """ + Update a mental model's name and/or description. + + Args: + bank_id: The memory bank ID + model_id: The mental model ID + name: Optional new name + description: Optional new description + + Returns: + MentalModelResponse with updated mental model + """ + from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest + + request_obj = UpdateMentalModelRequest( + name=name, + description=description, + ) + return _run_async(self._mental_models_api.update_mental_model( + bank_id=bank_id, + model_id=model_id, + update_mental_model_request=request_obj, + )) + + def delete_mental_model( + self, + bank_id: str, + model_id: str, + ): + """ + Delete a mental model. + + Args: + bank_id: The memory bank ID + model_id: The mental model ID + + Returns: + DeleteResponse confirming deletion + """ + return _run_async(self._mental_models_api.delete_mental_model( + bank_id=bank_id, + model_id=model_id, + )) + + def refresh_mental_models( + self, + bank_id: str, + subtype: Optional[Literal["structural", "emergent", "pinned", "learned"]] = None, + tags: Optional[List[str]] = None, + ) -> AsyncOperationSubmitResponse: + """ + Submit a background job to refresh mental models for a bank. + + Args: + bank_id: The memory bank ID + subtype: Optional - only refresh models of this subtype + tags: Optional - tags to apply to newly created mental models + + Returns: + AsyncOperationSubmitResponse with operation_id to track progress + """ + from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest + + request_obj = RefreshMentalModelsRequest( + subtype=subtype, + tags=tags, + ) + return _run_async(self._mental_models_api.refresh_mental_models( + bank_id=bank_id, + refresh_mental_models_request=request_obj, + )) + + def refresh_mental_model( + self, + bank_id: str, + model_id: str, + ) -> AsyncOperationSubmitResponse: + """ + Submit a background job to refresh content for a specific mental model. + + Args: + bank_id: The memory bank ID + model_id: The mental model ID to refresh + + Returns: + AsyncOperationSubmitResponse with operation_id to track progress + """ + return _run_async(self._mental_models_api.refresh_mental_model( + bank_id=bank_id, + model_id=model_id, + )) + + def list_mental_model_versions( + self, + bank_id: str, + model_id: str, + ): + """ + List all saved versions of a mental model's observations. + + Args: + bank_id: The memory bank ID + model_id: The mental model ID + + Returns: + List of version objects ordered by version descending + """ + return _run_async(self._mental_models_api.list_mental_model_versions( + bank_id=bank_id, + model_id=model_id, + )) + + def get_mental_model_version( + self, + bank_id: str, + model_id: str, + version: int, + ): + """ + Get observations from a specific version of a mental model. + + Args: + bank_id: The memory bank ID + model_id: The mental model ID + version: The version number + + Returns: + Version object with observations at that version + """ + return _run_async(self._mental_models_api.get_mental_model_version( + bank_id=bank_id, + model_id=model_id, + version=version, + )) + # Async methods (native async, no _run_async wrapper) async def aretain_batch( diff --git a/hindsight-clients/python/tests/test_main_operations.py b/hindsight-clients/python/tests/test_main_operations.py index 2a9865c8..e728dd99 100644 --- a/hindsight-clients/python/tests/test_main_operations.py +++ b/hindsight-clients/python/tests/test_main_operations.py @@ -544,3 +544,205 @@ class TestDeleteBank: # Verify bank data is deleted - memories should be gone memories = client.list_memories(bank_id=bank_id) assert memories.total == 0 + + +class TestMentalModels: + """Tests for mental model operations.""" + + def test_set_mission(self, client, bank_id): + """Test setting a bank's mission.""" + response = client.set_mission( + bank_id=bank_id, + mission="Be a helpful PM tracking sprint progress and team capacity", + ) + + assert response is not None + assert response.bank_id == bank_id + assert response.mission == "Be a helpful PM tracking sprint progress and team capacity" + + def test_create_pinned_mental_model(self, client, bank_id): + """Test creating a pinned mental model.""" + # Create bank first (required for mental models) + client.create_bank(bank_id=bank_id) + + response = client.create_mental_model( + bank_id=bank_id, + name="Product Roadmap", + description="Track product priorities and feature decisions", + subtype="pinned", + tags=["test"], + ) + + assert response is not None + assert response.name == "Product Roadmap" + assert response.description == "Track product priorities and feature decisions" + assert response.subtype == "pinned" + + def test_create_directive_mental_model(self, client, bank_id): + """Test creating a directive mental model with observations.""" + # Create bank first (required for mental models) + client.create_bank(bank_id=bank_id) + + response = client.create_mental_model( + bank_id=bank_id, + name="Response Guidelines", + description="Rules for responding to users", + subtype="directive", + observations=[ + {"title": "Always be polite", "content": "All responses must be courteous and professional"}, + {"title": "Never share private info", "content": "Do not reveal internal details or user data"}, + ], + tags=["test"], + ) + + assert response is not None + assert response.name == "Response Guidelines" + assert response.subtype == "directive" + assert response.observations is not None + assert len(response.observations) == 2 + + def test_list_mental_models(self, client, bank_id): + """Test listing mental models.""" + # Create bank first (required for mental models) + client.create_bank(bank_id=bank_id) + + # Create a model first + client.create_mental_model( + bank_id=bank_id, + name="Test Model", + description="A test mental model", + subtype="pinned", + ) + + response = client.list_mental_models(bank_id=bank_id) + + assert response is not None + assert response.items is not None + assert len(response.items) >= 1 + + def test_get_mental_model(self, client, bank_id): + """Test getting a specific mental model.""" + # Create bank first (required for mental models) + client.create_bank(bank_id=bank_id) + + # Create a model first + created = client.create_mental_model( + bank_id=bank_id, + name="Retrieve Test Model", + description="A model to retrieve", + subtype="pinned", + ) + + response = client.get_mental_model( + bank_id=bank_id, + model_id=created.id, + ) + + assert response is not None + assert response.id == created.id + assert response.name == "Retrieve Test Model" + + def test_update_mental_model(self, client, bank_id): + """Test updating a mental model.""" + # Create bank first (required for mental models) + client.create_bank(bank_id=bank_id) + + # Create a model first + created = client.create_mental_model( + bank_id=bank_id, + name="Update Test Model", + description="Original description", + subtype="pinned", + ) + + response = client.update_mental_model( + bank_id=bank_id, + model_id=created.id, + name="Updated Model Name", + description="Updated description", + ) + + assert response is not None + assert response.name == "Updated Model Name" + assert response.description == "Updated description" + + def test_delete_mental_model(self, client, bank_id): + """Test deleting a mental model.""" + # Create bank first (required for mental models) + client.create_bank(bank_id=bank_id) + + # Create a model first + created = client.create_mental_model( + bank_id=bank_id, + name="Delete Test Model", + description="A model to delete", + subtype="pinned", + ) + + response = client.delete_mental_model( + bank_id=bank_id, + model_id=created.id, + ) + + assert response is not None + assert response.success is True + + def test_refresh_mental_models(self, client, bank_id): + """Test refreshing all mental models (async operation).""" + # Set mission first (required for refresh) - this also creates the bank + client.set_mission( + bank_id=bank_id, + mission="Track team progress and decisions", + ) + + response = client.refresh_mental_models( + bank_id=bank_id, + tags=["test"], + ) + + assert response is not None + assert response.operation_id is not None + assert response.status == "queued" + + def test_refresh_mental_model(self, client, bank_id): + """Test refreshing a single mental model (async operation).""" + # Create bank first (required for mental models) + client.create_bank(bank_id=bank_id) + + # Create a model first + created = client.create_mental_model( + bank_id=bank_id, + name="Refresh Single Test", + description="A model to refresh individually", + subtype="pinned", + ) + + response = client.refresh_mental_model( + bank_id=bank_id, + model_id=created.id, + ) + + assert response is not None + assert response.operation_id is not None + assert response.status == "queued" + + def test_list_mental_model_versions(self, client, bank_id): + """Test listing mental model versions.""" + # Create bank first (required for mental models) + client.create_bank(bank_id=bank_id) + + # Create a model first + created = client.create_mental_model( + bank_id=bank_id, + name="Versions Test Model", + description="A model to test version history", + subtype="pinned", + ) + + response = client.list_mental_model_versions( + bank_id=bank_id, + model_id=created.id, + ) + + # Newly created model should have version history + assert response is not None diff --git a/hindsight-clients/typescript/src/index.ts b/hindsight-clients/typescript/src/index.ts index 3a4db684..9a0ef223 100644 --- a/hindsight-clients/typescript/src/index.ts +++ b/hindsight-clients/typescript/src/index.ts @@ -40,6 +40,10 @@ import type { BankProfileResponse, CreateBankRequest, Budget, + MentalModelResponse, + MentalModelListResponse, + AsyncOperationSubmitResponse, + ObservationInput, } from '../generated/types.gen'; export interface HindsightClientOptions { @@ -308,6 +312,176 @@ export class HindsightClient { return this.validateResponse(response, 'getBankProfile'); } + + /** + * Set or update the mission for a memory bank. + */ + async setMission(bankId: string, mission: string): Promise { + const response = await sdk.createOrUpdateBank({ + client: this.client, + path: { bank_id: bankId }, + body: { mission }, + }); + + return this.validateResponse(response, 'setMission'); + } + + /** + * List mental models for a bank. + */ + async listMentalModels( + bankId: string, + options?: { + subtype?: 'structural' | 'emergent' | 'pinned' | 'learned' | 'directive'; + tags?: string[]; + tagsMatch?: 'any' | 'all' | 'exact'; + } + ): Promise { + const response = await sdk.listMentalModels({ + client: this.client, + path: { bank_id: bankId }, + query: { + subtype: options?.subtype, + tags: options?.tags, + tags_match: options?.tagsMatch, + }, + }); + + return this.validateResponse(response, 'listMentalModels'); + } + + /** + * Get a specific mental model by ID. + */ + async getMentalModel(bankId: string, modelId: string): Promise { + const response = await sdk.getMentalModel({ + client: this.client, + path: { bank_id: bankId, model_id: modelId }, + }); + + return this.validateResponse(response, 'getMentalModel'); + } + + /** + * Create a mental model. + */ + async createMentalModel( + bankId: string, + options: { + name: string; + description: string; + subtype?: 'pinned' | 'directive'; + observations?: Array<{ title: string; content: string }>; + tags?: string[]; + } + ): Promise { + const response = await sdk.createMentalModel({ + client: this.client, + path: { bank_id: bankId }, + body: { + name: options.name, + description: options.description, + subtype: options.subtype, + observations: options.observations, + tags: options.tags, + }, + }); + + return this.validateResponse(response, 'createMentalModel'); + } + + /** + * Update a mental model's name and/or description. + */ + async updateMentalModel( + bankId: string, + modelId: string, + options: { + name?: string; + description?: string; + } + ): Promise { + const response = await sdk.updateMentalModel({ + client: this.client, + path: { bank_id: bankId, model_id: modelId }, + body: { + name: options.name, + description: options.description, + }, + }); + + return this.validateResponse(response, 'updateMentalModel'); + } + + /** + * Delete a mental model. + */ + async deleteMentalModel(bankId: string, modelId: string): Promise { + const response = await sdk.deleteMentalModel({ + client: this.client, + path: { bank_id: bankId, model_id: modelId }, + }); + + this.validateResponse(response, 'deleteMentalModel'); + } + + /** + * Submit a background job to refresh mental models for a bank. + */ + async refreshMentalModels( + bankId: string, + options?: { + subtype?: 'structural' | 'emergent' | 'pinned' | 'learned'; + tags?: string[]; + } + ): Promise { + const response = await sdk.refreshMentalModels({ + client: this.client, + path: { bank_id: bankId }, + body: { + subtype: options?.subtype, + tags: options?.tags, + }, + }); + + return this.validateResponse(response, 'refreshMentalModels'); + } + + /** + * Submit a background job to refresh content for a specific mental model. + */ + async refreshMentalModel(bankId: string, modelId: string): Promise { + const response = await sdk.refreshMentalModel({ + client: this.client, + path: { bank_id: bankId, model_id: modelId }, + }); + + return this.validateResponse(response, 'refreshMentalModel'); + } + + /** + * List all saved versions of a mental model's observations. + */ + async listMentalModelVersions(bankId: string, modelId: string): Promise { + const response = await sdk.listMentalModelVersions({ + client: this.client, + path: { bank_id: bankId, model_id: modelId }, + }); + + return this.validateResponse(response, 'listMentalModelVersions'); + } + + /** + * Get observations from a specific version of a mental model. + */ + async getMentalModelVersion(bankId: string, modelId: string, version: number): Promise { + const response = await sdk.getMentalModelVersion({ + client: this.client, + path: { bank_id: bankId, model_id: modelId, version }, + }); + + return this.validateResponse(response, 'getMentalModelVersion'); + } } // Re-export types for convenience @@ -323,6 +497,10 @@ export type { BankProfileResponse, CreateBankRequest, Budget, + MentalModelResponse, + MentalModelListResponse, + AsyncOperationSubmitResponse, + ObservationInput, }; // Also export low-level SDK functions for advanced usage diff --git a/hindsight-clients/typescript/tests/main_operations.test.ts b/hindsight-clients/typescript/tests/main_operations.test.ts index cc65ce6c..4d7b2255 100644 --- a/hindsight-clients/typescript/tests/main_operations.test.ts +++ b/hindsight-clients/typescript/tests/main_operations.test.ts @@ -412,3 +412,186 @@ describe('TestDeleteBank', () => { expect(memories.total).toBe(0); }); }); + +describe('TestMentalModels', () => { + test('set mission', async () => { + const bankId = randomBankId(); + const response = await client.setMission( + bankId, + 'Be a helpful PM tracking sprint progress and team capacity' + ); + + expect(response).not.toBeNull(); + expect(response.bank_id).toBe(bankId); + expect(response.mission).toBe('Be a helpful PM tracking sprint progress and team capacity'); + }); + + test('create pinned mental model', async () => { + const bankId = randomBankId(); + // Create bank first (required for mental models) + await client.createBank(bankId, {}); + + const response = await client.createMentalModel(bankId, { + name: 'Product Roadmap', + description: 'Track product priorities and feature decisions', + subtype: 'pinned', + tags: ['test'], + }); + + expect(response).not.toBeNull(); + expect(response.name).toBe('Product Roadmap'); + expect(response.description).toBe('Track product priorities and feature decisions'); + expect(response.subtype).toBe('pinned'); + }); + + test('create directive mental model', async () => { + const bankId = randomBankId(); + // Create bank first (required for mental models) + await client.createBank(bankId, {}); + + const response = await client.createMentalModel(bankId, { + name: 'Response Guidelines', + description: 'Rules for responding to users', + subtype: 'directive', + observations: [ + { title: 'Always be polite', content: 'All responses must be courteous and professional' }, + { title: 'Never share private info', content: 'Do not reveal internal details or user data' }, + ], + tags: ['test'], + }); + + expect(response).not.toBeNull(); + expect(response.name).toBe('Response Guidelines'); + expect(response.subtype).toBe('directive'); + expect(response.observations).toBeDefined(); + expect(response.observations!.length).toBe(2); + }); + + test('list mental models', async () => { + const bankId = randomBankId(); + // Create bank first (required for mental models) + await client.createBank(bankId, {}); + + // Create a model first + await client.createMentalModel(bankId, { + name: 'Test Model', + description: 'A test mental model', + subtype: 'pinned', + }); + + const response = await client.listMentalModels(bankId); + + expect(response).not.toBeNull(); + expect(response.items).toBeDefined(); + expect(response.items!.length).toBeGreaterThanOrEqual(1); + }); + + test('get mental model', async () => { + const bankId = randomBankId(); + // Create bank first (required for mental models) + await client.createBank(bankId, {}); + + // Create a model first + const created = await client.createMentalModel(bankId, { + name: 'Retrieve Test Model', + description: 'A model to retrieve', + subtype: 'pinned', + }); + + const response = await client.getMentalModel(bankId, created.id); + + expect(response).not.toBeNull(); + expect(response.id).toBe(created.id); + expect(response.name).toBe('Retrieve Test Model'); + }); + + test('update mental model', async () => { + const bankId = randomBankId(); + // Create bank first (required for mental models) + await client.createBank(bankId, {}); + + // Create a model first + const created = await client.createMentalModel(bankId, { + name: 'Update Test Model', + description: 'Original description', + subtype: 'pinned', + }); + + const response = await client.updateMentalModel(bankId, created.id, { + name: 'Updated Model Name', + description: 'Updated description', + }); + + expect(response).not.toBeNull(); + expect(response.name).toBe('Updated Model Name'); + expect(response.description).toBe('Updated description'); + }); + + test('delete mental model', async () => { + const bankId = randomBankId(); + // Create bank first (required for mental models) + await client.createBank(bankId, {}); + + // Create a model first + const created = await client.createMentalModel(bankId, { + name: 'Delete Test Model', + description: 'A model to delete', + subtype: 'pinned', + }); + + // Delete should not throw + await expect(client.deleteMentalModel(bankId, created.id)).resolves.not.toThrow(); + }); + + test('refresh mental models', async () => { + const bankId = randomBankId(); + + // Set mission first (required for refresh) - this also creates the bank + await client.setMission(bankId, 'Track team progress and decisions'); + + const response = await client.refreshMentalModels(bankId, { + tags: ['test'], + }); + + expect(response).not.toBeNull(); + expect(response.operation_id).toBeDefined(); + expect(response.status).toBe('queued'); + }); + + test('refresh mental model', async () => { + const bankId = randomBankId(); + // Create bank first (required for mental models) + await client.createBank(bankId, {}); + + // Create a model first + const created = await client.createMentalModel(bankId, { + name: 'Refresh Single Test', + description: 'A model to refresh individually', + subtype: 'pinned', + }); + + const response = await client.refreshMentalModel(bankId, created.id); + + expect(response).not.toBeNull(); + expect(response.operation_id).toBeDefined(); + expect(response.status).toBe('queued'); + }); + + test('list mental model versions', async () => { + const bankId = randomBankId(); + // Create bank first (required for mental models) + await client.createBank(bankId, {}); + + // Create a model first + const created = await client.createMentalModel(bankId, { + name: 'Versions Test Model', + description: 'A model to test version history', + subtype: 'pinned', + }); + + const response = await client.listMentalModelVersions(bankId, created.id); + + // Newly created model should have version history + expect(response).not.toBeNull(); + }); +});