feat(clients): mental models api (#172)

* feat(clients): mental models api

* fixes

* more tests

* fixes
This commit is contained in:
Nicolò Boschi 2026-01-19 14:49:49 +01:00 committed by GitHub
parent bac4b24e30
commit fe4ed1db73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 3533 additions and 12 deletions

View file

@ -45,6 +45,10 @@ chrono = "0.4"
walkdir = "2.5" walkdir = "2.5"
dirs = "5.0" dirs = "5.0"
[dev-dependencies]
# For integration tests with blocking HTTP client
reqwest = { version = "0.12", features = ["blocking"] }
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
lto = true lto = true

View file

@ -67,7 +67,7 @@ run_test_output() {
cleanup() { cleanup() {
echo "" echo ""
echo "Cleaning up test bank..." 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 trap cleanup EXIT
@ -115,8 +115,32 @@ run_test "list documents" "$HINDSIGHT_CLI" document list "$TEST_BANK" || FAILED=
# Test 14: Clear memories # Test 14: Clear memories
run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1 run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1
# Test 15: Delete bank # Test 15: Health check
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" || FAILED=1 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 "" echo ""
if [ $FAILED -eq 0 ]; then if [ $FAILED -eq 0 ]; then

View file

@ -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<Vec<String>>,
tags_match: Option<&str>,
_verbose: bool,
) -> Result<types::MentalModelListResponse> {
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<types::MentalModelResponse> {
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<types::MentalModelResponse> {
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<types::DeleteResponse> {
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<types::MentalModelResponse> {
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<Vec<String>>,
_verbose: bool,
) -> Result<types::AsyncOperationSubmitResponse> {
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<types::AsyncOperationSubmitResponse> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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<types::BankProfileResponse> {
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<types::BankProfileResponse> {
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<types::BankProfileResponse> {
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<i64>,
_verbose: bool,
) -> Result<types::GraphDataResponse> {
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<i64>,
offset: Option<i64>,
_verbose: bool,
) -> Result<types::ListTagsResponse> {
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<types::ChunkResponse> {
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<types::OperationStatusResponse> {
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<serde_json::Value> {
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<serde_json::Value> {
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 // Re-export types from the generated client for use in commands
pub use types::{ pub use types::{
BankProfileResponse, BankProfileResponse,

View file

@ -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<String>,
mission_text: Option<String>,
skepticism: Option<i64>,
literalism: Option<i64>,
empathy: Option<i64>,
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<String>,
mission_text: Option<String>,
skepticism: Option<i64>,
literalism: Option<i64>,
empathy: Option<i64>,
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<String>,
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( pub fn delete(
client: &ApiClient, client: &ApiClient,
bank_id: &str, bank_id: &str,

View file

@ -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);
}
}

View file

@ -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<String>,
version: Option<String>,
}
/// 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()));
}
}

View file

@ -10,8 +10,30 @@ use crate::ui;
// Import types from generated client // Import types from generated client
use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch}; use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch};
use serde::Deserialize;
use serde_json; use serde_json;
// Local types for serde_json::Value deserialization
#[derive(Debug, Deserialize)]
struct MemoryUnitDetail {
id: String,
text: String,
#[serde(rename = "type")]
type_: Option<String>,
document_id: Option<String>,
context: Option<String>,
occurred_start: Option<String>,
occurred_end: Option<String>,
entities: Option<Vec<EntityRef>>,
tags: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct EntityRef {
id: String,
name: String,
}
// Helper function to parse budget string to Budget enum // Helper function to parse budget string to Budget enum
fn parse_budget(budget: &str) -> Budget { fn parse_budget(budget: &str) -> Budget {
match budget.to_lowercase().as_str() { 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<String>,
query: Option<String>,
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 // Helper function to check if a file has a text-based extension
fn is_text_file(path: &std::path::Path) -> bool { fn is_text_file(path: &std::path::Path) -> bool {
const TEXT_EXTENSIONS: &[&str] = &[ const TEXT_EXTENSIONS: &[&str] = &[

View file

@ -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<VersionItem>,
}
#[derive(Debug, Deserialize)]
struct VersionItem {
version: i64,
created_at: String,
observations_count: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct VersionDetailResponse {
version: i64,
created_at: String,
observations: Option<Vec<ObservationData>>,
}
#[derive(Debug, Deserialize)]
struct ObservationData {
title: String,
content: String,
trend: Option<String>,
evidence: Option<Vec<EvidenceData>>,
}
#[derive(Debug, Deserialize)]
struct EvidenceData {
quote: String,
}
/// List mental models for a bank
pub fn list(
client: &ApiClient,
bank_id: &str,
subtype: Option<String>,
tags: Option<Vec<String>>,
tags_match: Option<String>,
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<String>,
tags: Option<Vec<String>>,
observations_file: Option<PathBuf>,
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<types::ObservationInput> = 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<String>,
description: Option<String>,
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<String>,
tags: Option<Vec<String>>,
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");
}
}

View file

@ -1,6 +1,10 @@
pub mod bank; pub mod bank;
pub mod memory; pub mod chunk;
pub mod document; pub mod document;
pub mod entity; pub mod entity;
pub mod operation;
pub mod explore; pub mod explore;
pub mod health;
pub mod memory;
pub mod mental_model;
pub mod operation;
pub mod tag;

View file

@ -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( pub fn cancel(
client: &ApiClient, client: &ApiClient,
agent_id: &str, agent_id: &str,

View file

@ -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<String>,
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);
}
}

View file

@ -67,14 +67,18 @@ fn get_before_help() -> &'static str {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
/// Manage banks (list, profile, stats) /// Manage banks (list, create, update, profile, stats, mission, graph, delete)
#[command(subcommand)] #[command(subcommand)]
Bank(BankCommands), Bank(BankCommands),
/// Manage memories (recall, reflect, retain, delete) /// Manage memories (list, get, recall, reflect, retain, clear)
#[command(subcommand)] #[command(subcommand)]
Memory(MemoryCommands), Memory(MemoryCommands),
/// Manage mental models (list, get, create, update, delete, refresh, versions)
#[command(subcommand)]
MentalModel(MentalModelCommands),
/// Manage documents (list, get, delete) /// Manage documents (list, get, delete)
#[command(subcommand)] #[command(subcommand)]
Document(DocumentCommands), Document(DocumentCommands),
@ -83,10 +87,24 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
Entity(EntityCommands), 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)] #[command(subcommand)]
Operation(OperationCommands), 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 /// Interactive TUI explorer (k9s-style) for navigating banks, memories, entities, and performing recall/reflect
#[command(alias = "tui")] #[command(alias = "tui")]
Explore, Explore,
@ -111,7 +129,59 @@ enum BankCommands {
/// List all banks /// List all banks
List, List,
/// Get bank disposition and background /// Create a new bank
Create {
/// Bank ID
bank_id: String,
/// Bank name
#[arg(short = 'n', long)]
name: Option<String>,
/// Mission statement
#[arg(short = 'm', long)]
mission: Option<String>,
/// Skepticism trait (1-5)
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
skepticism: Option<i64>,
/// Literalism trait (1-5)
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
literalism: Option<i64>,
/// Empathy trait (1-5)
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
empathy: Option<i64>,
},
/// Update bank properties (partial update)
Update {
/// Bank ID
bank_id: String,
/// Bank name
#[arg(short = 'n', long)]
name: Option<String>,
/// Mission statement
#[arg(short = 'm', long)]
mission: Option<String>,
/// Skepticism trait (1-5)
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
skepticism: Option<i64>,
/// Literalism trait (1-5)
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
literalism: Option<i64>,
/// Empathy trait (1-5)
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
empathy: Option<i64>,
},
/// Get bank disposition and profile
Disposition { Disposition {
/// Bank ID /// Bank ID
bank_id: String, bank_id: String,
@ -132,7 +202,17 @@ enum BankCommands {
name: String, 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 { Background {
/// Bank ID /// Bank ID
bank_id: String, bank_id: String,
@ -145,6 +225,20 @@ enum BankCommands {
no_update_disposition: bool, 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<String>,
/// Maximum nodes to return
#[arg(short = 'l', long, default_value = "1000")]
limit: i64,
},
/// Delete a bank and all its data /// Delete a bank and all its data
Delete { Delete {
/// Bank ID /// Bank ID
@ -158,6 +252,37 @@ enum BankCommands {
#[derive(Subcommand)] #[derive(Subcommand)]
enum MemoryCommands { 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<String>,
/// Full-text search query
#[arg(short = 'q', long)]
query: Option<String>,
/// 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 memories using semantic search
Recall { Recall {
/// Bank ID /// Bank ID
@ -360,6 +485,15 @@ enum OperationCommands {
bank_id: String, 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 a pending async operation
Cancel { Cancel {
/// Bank ID /// 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<String>,
/// Filter by tags
#[arg(long, value_delimiter = ',')]
tags: Option<Vec<String>>,
/// Tag matching mode (any, all, any_strict, all_strict)
#[arg(long, default_value = "any")]
tags_match: Option<String>,
},
/// 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<String>,
/// Tags for the model
#[arg(long, value_delimiter = ',')]
tags: Option<Vec<String>>,
/// Path to JSON file containing initial observations
#[arg(long)]
observations: Option<PathBuf>,
},
/// 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<String>,
/// New description
#[arg(long)]
description: Option<String>,
},
/// 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<String>,
/// Filter by tags
#[arg(long, value_delimiter = ',')]
tags: Option<Vec<String>>,
},
/// 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<String>,
/// 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() { fn main() {
if let Err(_) = run() { if let Err(_) = run() {
std::process::exit(1); std::process::exit(1);
@ -412,20 +704,45 @@ fn run() -> Result<()> {
Commands::Configure { .. } => unreachable!(), // Handled above Commands::Configure { .. } => unreachable!(), // Handled above
Commands::Ui => unreachable!(), // Handled above Commands::Ui => unreachable!(), // Handled above
Commands::Explore => commands::explore::run(&client), 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 { Commands::Bank(bank_cmd) => match bank_cmd {
BankCommands::List => commands::bank::list(&client, verbose, output_format), 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::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::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::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 } => { BankCommands::Background { bank_id, content, no_update_disposition } => {
commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format) 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 } => { BankCommands::Delete { bank_id, yes } => {
commands::bank::delete(&client, &bank_id, yes, verbose, output_format) commands::bank::delete(&client, &bank_id, yes, verbose, output_format)
} }
}, },
// Memory commands
Commands::Memory(memory_cmd) => match memory_cmd { 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 } => { 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) 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 { Commands::Document(doc_cmd) => match doc_cmd {
DocumentCommands::List { bank_id, query, limit, offset } => { DocumentCommands::List { bank_id, query, limit, offset } => {
commands::document::list(&client, &bank_id, query, limit, offset, verbose, output_format) 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 { Commands::Entity(entity_cmd) => match entity_cmd {
EntityCommands::List { bank_id, limit } => { EntityCommands::List { bank_id, limit } => {
commands::entity::list(&client, &bank_id, limit, verbose, output_format) 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 { Commands::Operation(op_cmd) => match op_cmd {
OperationCommands::List { bank_id } => { OperationCommands::List { bank_id } => {
commands::operation::list(&client, &bank_id, verbose, output_format) 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 } => { OperationCommands::Cancel { bank_id, operation_id } => {
commands::operation::cancel(&client, &bank_id, &operation_id, verbose, output_format) commands::operation::cancel(&client, &bank_id, &operation_id, verbose, output_format)
} }

View file

@ -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::<serde_json::Value>(&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");
}
}

View file

@ -6,11 +6,11 @@ easy-to-use interface on top of the auto-generated OpenAPI client.
""" """
import asyncio import asyncio
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any, Literal
from datetime import datetime from datetime import datetime
import hindsight_client_api 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 ( from hindsight_client_api.models import (
recall_request, recall_request,
retain_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.reflect_response import ReflectResponse
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse 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.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): def _run_async(coro):
@ -78,6 +81,7 @@ class Hindsight:
self._api_client.set_default_header("Authorization", f"Bearer {api_key}") self._api_client.set_default_header("Authorization", f"Bearer {api_key}")
self._memory_api = memory_api.MemoryApi(self._api_client) self._memory_api = memory_api.MemoryApi(self._api_client)
self._banks_api = banks_api.BanksApi(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): def __enter__(self):
"""Context manager entry.""" """Context manager entry."""
@ -332,6 +336,256 @@ class Hindsight:
return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj)) 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 methods (native async, no _run_async wrapper)
async def aretain_batch( async def aretain_batch(

View file

@ -544,3 +544,205 @@ class TestDeleteBank:
# Verify bank data is deleted - memories should be gone # Verify bank data is deleted - memories should be gone
memories = client.list_memories(bank_id=bank_id) memories = client.list_memories(bank_id=bank_id)
assert memories.total == 0 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

View file

@ -40,6 +40,10 @@ import type {
BankProfileResponse, BankProfileResponse,
CreateBankRequest, CreateBankRequest,
Budget, Budget,
MentalModelResponse,
MentalModelListResponse,
AsyncOperationSubmitResponse,
ObservationInput,
} from '../generated/types.gen'; } from '../generated/types.gen';
export interface HindsightClientOptions { export interface HindsightClientOptions {
@ -308,6 +312,176 @@ export class HindsightClient {
return this.validateResponse(response, 'getBankProfile'); return this.validateResponse(response, 'getBankProfile');
} }
/**
* Set or update the mission for a memory bank.
*/
async setMission(bankId: string, mission: string): Promise<BankProfileResponse> {
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<MentalModelListResponse> {
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<MentalModelResponse> {
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<MentalModelResponse> {
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<MentalModelResponse> {
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<void> {
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<AsyncOperationSubmitResponse> {
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<AsyncOperationSubmitResponse> {
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<unknown> {
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<unknown> {
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 // Re-export types for convenience
@ -323,6 +497,10 @@ export type {
BankProfileResponse, BankProfileResponse,
CreateBankRequest, CreateBankRequest,
Budget, Budget,
MentalModelResponse,
MentalModelListResponse,
AsyncOperationSubmitResponse,
ObservationInput,
}; };
// Also export low-level SDK functions for advanced usage // Also export low-level SDK functions for advanced usage

View file

@ -412,3 +412,186 @@ describe('TestDeleteBank', () => {
expect(memories.total).toBe(0); 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();
});
});