Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48144fb15f | ||
|
|
48abd36519 | ||
|
|
583d5c2e18 | ||
|
|
1987985c2e |
@@ -45,6 +45,10 @@ chrono = "0.4"
|
||||
walkdir = "2.5"
|
||||
dirs = "5.0"
|
||||
|
||||
[dev-dependencies]
|
||||
# For integration tests with blocking HTTP client
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
|
||||
@@ -67,7 +67,7 @@ run_test_output() {
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Cleaning up test bank..."
|
||||
"$HINDSIGHT_CLI" bank delete "$TEST_BANK" 2>/dev/null || true
|
||||
"$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -115,8 +115,32 @@ run_test "list documents" "$HINDSIGHT_CLI" document list "$TEST_BANK" || FAILED=
|
||||
# Test 14: Clear memories
|
||||
run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 15: Delete bank
|
||||
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" || FAILED=1
|
||||
# Test 15: Health check
|
||||
run_test_output "health check" "healthy" "$HINDSIGHT_CLI" health || FAILED=1
|
||||
|
||||
# Test 16: List memories (new command)
|
||||
run_test "list memories" "$HINDSIGHT_CLI" memory list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 17: List tags
|
||||
run_test "list tags" "$HINDSIGHT_CLI" tag list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 18: List mental models
|
||||
run_test "list mental models" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 19: Create mental model
|
||||
run_test "create mental model" "$HINDSIGHT_CLI" mental-model create "$TEST_BANK" "Test Model" "A test mental model" || FAILED=1
|
||||
|
||||
# Test 20: List mental models (should have one now)
|
||||
run_test_output "list mental models with model" "Test Model" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 21: Bank graph
|
||||
run_test "bank graph" "$HINDSIGHT_CLI" bank graph "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 22: List operations
|
||||
run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 23: Delete bank
|
||||
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1
|
||||
|
||||
echo ""
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
|
||||
@@ -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
|
||||
pub use types::{
|
||||
BankProfileResponse,
|
||||
|
||||
@@ -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(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,30 @@ use crate::ui;
|
||||
|
||||
// Import types from generated client
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch};
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
|
||||
// Local types for serde_json::Value deserialization
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MemoryUnitDetail {
|
||||
id: String,
|
||||
text: String,
|
||||
#[serde(rename = "type")]
|
||||
type_: Option<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
|
||||
fn parse_budget(budget: &str) -> Budget {
|
||||
match budget.to_lowercase().as_str() {
|
||||
@@ -21,6 +43,183 @@ fn parse_budget(budget: &str) -> Budget {
|
||||
}
|
||||
}
|
||||
|
||||
/// List memory units with pagination and optional filters
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
type_filter: Option<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
|
||||
fn is_text_file(path: &std::path::Path) -> bool {
|
||||
const TEXT_EXTENSIONS: &[&str] = &[
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
pub mod bank;
|
||||
pub mod memory;
|
||||
pub mod chunk;
|
||||
pub mod document;
|
||||
pub mod entity;
|
||||
pub mod operation;
|
||||
pub mod explore;
|
||||
pub mod health;
|
||||
pub mod memory;
|
||||
pub mod mental_model;
|
||||
pub mod operation;
|
||||
pub mod tag;
|
||||
|
||||
@@ -47,6 +47,55 @@ pub fn list(
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the status of a specific operation
|
||||
pub fn get(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
operation_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching operation status..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_operation(agent_id, operation_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Operation: {}", operation_id));
|
||||
|
||||
use hindsight_client::types::Status;
|
||||
let status_str = match &result.status {
|
||||
Status::Completed => ui::gradient_start("completed"),
|
||||
Status::Pending => ui::gradient_mid("pending"),
|
||||
Status::Failed => ui::gradient_end("failed"),
|
||||
Status::NotFound => ui::gradient_end("not_found"),
|
||||
};
|
||||
|
||||
println!(" {} {}", ui::dim("Status:"), status_str);
|
||||
|
||||
if let Some(error) = &result.error_message {
|
||||
println!(" {} {}", ui::dim("Error:"), ui::gradient_end(error));
|
||||
}
|
||||
|
||||
println!();
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+373
-5
@@ -67,14 +67,18 @@ fn get_before_help() -> &'static str {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Manage banks (list, profile, stats)
|
||||
/// Manage banks (list, create, update, profile, stats, mission, graph, delete)
|
||||
#[command(subcommand)]
|
||||
Bank(BankCommands),
|
||||
|
||||
/// Manage memories (recall, reflect, retain, delete)
|
||||
/// Manage memories (list, get, recall, reflect, retain, clear)
|
||||
#[command(subcommand)]
|
||||
Memory(MemoryCommands),
|
||||
|
||||
/// Manage mental models (list, get, create, update, delete, refresh, versions)
|
||||
#[command(subcommand)]
|
||||
MentalModel(MentalModelCommands),
|
||||
|
||||
/// Manage documents (list, get, delete)
|
||||
#[command(subcommand)]
|
||||
Document(DocumentCommands),
|
||||
@@ -83,10 +87,24 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
Entity(EntityCommands),
|
||||
|
||||
/// Manage async operations (list, cancel)
|
||||
/// Manage tags (list)
|
||||
#[command(subcommand)]
|
||||
Tag(TagCommands),
|
||||
|
||||
/// Manage chunks (get)
|
||||
#[command(subcommand)]
|
||||
Chunk(ChunkCommands),
|
||||
|
||||
/// Manage async operations (list, get, cancel)
|
||||
#[command(subcommand)]
|
||||
Operation(OperationCommands),
|
||||
|
||||
/// Check API health status
|
||||
Health,
|
||||
|
||||
/// Get Prometheus metrics
|
||||
Metrics,
|
||||
|
||||
/// Interactive TUI explorer (k9s-style) for navigating banks, memories, entities, and performing recall/reflect
|
||||
#[command(alias = "tui")]
|
||||
Explore,
|
||||
@@ -111,7 +129,59 @@ enum BankCommands {
|
||||
/// List all banks
|
||||
List,
|
||||
|
||||
/// Get bank disposition and background
|
||||
/// Create a new bank
|
||||
Create {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Bank name
|
||||
#[arg(short = 'n', long)]
|
||||
name: Option<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 {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
@@ -132,7 +202,17 @@ enum BankCommands {
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Set or merge bank background
|
||||
/// Set bank mission
|
||||
Mission {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mission statement
|
||||
mission: String,
|
||||
},
|
||||
|
||||
/// Set or merge bank background (deprecated: use mission instead)
|
||||
#[command(hide = true)]
|
||||
Background {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
@@ -145,6 +225,20 @@ enum BankCommands {
|
||||
no_update_disposition: bool,
|
||||
},
|
||||
|
||||
/// Get memory graph data
|
||||
Graph {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by fact type (world, experience, opinion)
|
||||
#[arg(short = 't', long)]
|
||||
fact_type: Option<String>,
|
||||
|
||||
/// Maximum nodes to return
|
||||
#[arg(short = 'l', long, default_value = "1000")]
|
||||
limit: i64,
|
||||
},
|
||||
|
||||
/// Delete a bank and all its data
|
||||
Delete {
|
||||
/// Bank ID
|
||||
@@ -158,6 +252,37 @@ enum BankCommands {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum MemoryCommands {
|
||||
/// List memory units with pagination
|
||||
List {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by fact type (world, experience, opinion)
|
||||
#[arg(short = 't', long)]
|
||||
fact_type: Option<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 {
|
||||
/// Bank ID
|
||||
@@ -360,6 +485,15 @@ enum OperationCommands {
|
||||
bank_id: String,
|
||||
},
|
||||
|
||||
/// Get the status of a specific operation
|
||||
Get {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Operation ID
|
||||
operation_id: String,
|
||||
},
|
||||
|
||||
/// Cancel a pending async operation
|
||||
Cancel {
|
||||
/// Bank ID
|
||||
@@ -370,6 +504,164 @@ enum OperationCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum MentalModelCommands {
|
||||
/// List mental models for a bank
|
||||
List {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by subtype (structural, emergent, pinned, learned, directive)
|
||||
#[arg(long)]
|
||||
subtype: Option<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() {
|
||||
if let Err(_) = run() {
|
||||
std::process::exit(1);
|
||||
@@ -412,20 +704,45 @@ fn run() -> Result<()> {
|
||||
Commands::Configure { .. } => unreachable!(), // Handled above
|
||||
Commands::Ui => unreachable!(), // Handled above
|
||||
Commands::Explore => commands::explore::run(&client),
|
||||
|
||||
// Health and Metrics
|
||||
Commands::Health => commands::health::health(&client, verbose, output_format),
|
||||
Commands::Metrics => commands::health::metrics(&client, verbose, output_format),
|
||||
|
||||
// Bank commands
|
||||
Commands::Bank(bank_cmd) => match bank_cmd {
|
||||
BankCommands::List => commands::bank::list(&client, verbose, output_format),
|
||||
BankCommands::Create { bank_id, name, mission, skepticism, literalism, empathy } => {
|
||||
commands::bank::create(&client, &bank_id, name, mission, skepticism, literalism, empathy, verbose, output_format)
|
||||
}
|
||||
BankCommands::Update { bank_id, name, mission, skepticism, literalism, empathy } => {
|
||||
commands::bank::update(&client, &bank_id, name, mission, skepticism, literalism, empathy, verbose, output_format)
|
||||
}
|
||||
BankCommands::Disposition { bank_id } => commands::bank::disposition(&client, &bank_id, verbose, output_format),
|
||||
BankCommands::Stats { bank_id } => commands::bank::stats(&client, &bank_id, verbose, output_format),
|
||||
BankCommands::Name { bank_id, name } => commands::bank::update_name(&client, &bank_id, &name, verbose, output_format),
|
||||
BankCommands::Mission { bank_id, mission } => {
|
||||
commands::bank::mission(&client, &bank_id, &mission, verbose, output_format)
|
||||
}
|
||||
BankCommands::Background { bank_id, content, no_update_disposition } => {
|
||||
commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format)
|
||||
}
|
||||
BankCommands::Graph { bank_id, fact_type, limit } => {
|
||||
commands::bank::graph(&client, &bank_id, fact_type, limit, verbose, output_format)
|
||||
}
|
||||
BankCommands::Delete { bank_id, yes } => {
|
||||
commands::bank::delete(&client, &bank_id, yes, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Memory commands
|
||||
Commands::Memory(memory_cmd) => match memory_cmd {
|
||||
MemoryCommands::List { bank_id, fact_type, query, limit, offset } => {
|
||||
commands::memory::list(&client, &bank_id, fact_type, query, limit, offset, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Get { bank_id, memory_id } => {
|
||||
commands::memory::get(&client, &bank_id, &memory_id, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens } => {
|
||||
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, verbose, output_format)
|
||||
}
|
||||
@@ -446,6 +763,38 @@ fn run() -> Result<()> {
|
||||
}
|
||||
},
|
||||
|
||||
// Mental Model commands
|
||||
Commands::MentalModel(mm_cmd) => match mm_cmd {
|
||||
MentalModelCommands::List { bank_id, subtype, tags, tags_match } => {
|
||||
commands::mental_model::list(&client, &bank_id, subtype, tags, tags_match, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Get { bank_id, model_id } => {
|
||||
commands::mental_model::get(&client, &bank_id, &model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Create { bank_id, name, description, subtype, tags, observations } => {
|
||||
commands::mental_model::create(&client, &bank_id, &name, &description, subtype, tags, observations, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Update { bank_id, model_id, name, description } => {
|
||||
commands::mental_model::update(&client, &bank_id, &model_id, name, description, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Delete { bank_id, model_id, yes } => {
|
||||
commands::mental_model::delete(&client, &bank_id, &model_id, yes, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::RefreshAll { bank_id, subtype, tags } => {
|
||||
commands::mental_model::refresh_all(&client, &bank_id, subtype, tags, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Refresh { bank_id, model_id } => {
|
||||
commands::mental_model::refresh(&client, &bank_id, &model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Versions { bank_id, model_id } => {
|
||||
commands::mental_model::versions(&client, &bank_id, &model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Version { bank_id, model_id, version } => {
|
||||
commands::mental_model::version(&client, &bank_id, &model_id, version, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Document commands
|
||||
Commands::Document(doc_cmd) => match doc_cmd {
|
||||
DocumentCommands::List { bank_id, query, limit, offset } => {
|
||||
commands::document::list(&client, &bank_id, query, limit, offset, verbose, output_format)
|
||||
@@ -458,6 +807,7 @@ fn run() -> Result<()> {
|
||||
}
|
||||
},
|
||||
|
||||
// Entity commands
|
||||
Commands::Entity(entity_cmd) => match entity_cmd {
|
||||
EntityCommands::List { bank_id, limit } => {
|
||||
commands::entity::list(&client, &bank_id, limit, verbose, output_format)
|
||||
@@ -470,10 +820,28 @@ fn run() -> Result<()> {
|
||||
}
|
||||
},
|
||||
|
||||
// Tag commands
|
||||
Commands::Tag(tag_cmd) => match tag_cmd {
|
||||
TagCommands::List { bank_id, query, limit, offset } => {
|
||||
commands::tag::list(&client, &bank_id, query, limit, offset, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Chunk commands
|
||||
Commands::Chunk(chunk_cmd) => match chunk_cmd {
|
||||
ChunkCommands::Get { chunk_id } => {
|
||||
commands::chunk::get(&client, &chunk_id, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Operation commands
|
||||
Commands::Operation(op_cmd) => match op_cmd {
|
||||
OperationCommands::List { bank_id } => {
|
||||
commands::operation::list(&client, &bank_id, verbose, output_format)
|
||||
}
|
||||
OperationCommands::Get { bank_id, operation_id } => {
|
||||
commands::operation::get(&client, &bank_id, &operation_id, verbose, output_format)
|
||||
}
|
||||
OperationCommands::Cancel { bank_id, operation_id } => {
|
||||
commands::operation::cancel(&client, &bank_id, &operation_id, verbose, output_format)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ easy-to-use interface on top of the auto-generated OpenAPI client.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict, Any, Literal
|
||||
from datetime import datetime
|
||||
|
||||
import hindsight_client_api
|
||||
from hindsight_client_api.api import memory_api, banks_api
|
||||
from hindsight_client_api.api import memory_api, banks_api, mental_models_api
|
||||
from hindsight_client_api.models import (
|
||||
recall_request,
|
||||
retain_request,
|
||||
@@ -23,6 +23,9 @@ from hindsight_client_api.models.recall_result import RecallResult
|
||||
from hindsight_client_api.models.reflect_response import ReflectResponse
|
||||
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
|
||||
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
|
||||
from hindsight_client_api.models.mental_model_response import MentalModelResponse
|
||||
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
|
||||
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
@@ -78,6 +81,7 @@ class Hindsight:
|
||||
self._api_client.set_default_header("Authorization", f"Bearer {api_key}")
|
||||
self._memory_api = memory_api.MemoryApi(self._api_client)
|
||||
self._banks_api = banks_api.BanksApi(self._api_client)
|
||||
self._mental_models_api = mental_models_api.MentalModelsApi(self._api_client)
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
@@ -332,6 +336,256 @@ class Hindsight:
|
||||
|
||||
return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj))
|
||||
|
||||
def set_mission(
|
||||
self,
|
||||
bank_id: str,
|
||||
mission: str,
|
||||
) -> BankProfileResponse:
|
||||
"""
|
||||
Set or update the mission for a memory bank.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
mission: The mission text describing the agent's purpose
|
||||
|
||||
Returns:
|
||||
BankProfileResponse with updated bank profile
|
||||
"""
|
||||
from hindsight_client_api.models import create_bank_request
|
||||
|
||||
request_obj = create_bank_request.CreateBankRequest(mission=mission)
|
||||
return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj))
|
||||
|
||||
def list_mental_models(
|
||||
self,
|
||||
bank_id: str,
|
||||
subtype: Optional[Literal["structural", "emergent", "pinned", "learned", "directive"]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
tags_match: Optional[Literal["any", "all", "exact"]] = None,
|
||||
) -> MentalModelListResponse:
|
||||
"""
|
||||
List mental models for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
subtype: Optional filter by subtype (structural, emergent, pinned, learned, directive)
|
||||
tags: Optional list of tags to filter by
|
||||
tags_match: How to match tags - 'any' (OR), 'all' (AND), or 'exact'
|
||||
|
||||
Returns:
|
||||
MentalModelListResponse with list of mental models
|
||||
"""
|
||||
return _run_async(self._mental_models_api.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
subtype=subtype,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
))
|
||||
|
||||
def get_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
) -> MentalModelResponse:
|
||||
"""
|
||||
Get a specific mental model by ID.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
|
||||
Returns:
|
||||
MentalModelResponse with full mental model details including observations
|
||||
"""
|
||||
return _run_async(self._mental_models_api.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def create_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
name: str,
|
||||
description: str,
|
||||
subtype: Literal["pinned", "directive"] = "pinned",
|
||||
observations: Optional[List[Dict[str, str]]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> MentalModelResponse:
|
||||
"""
|
||||
Create a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
name: Human-readable name for the mental model
|
||||
description: One-liner description for quick scanning
|
||||
subtype: Type of mental model - 'pinned' (LLM-generated observations) or 'directive' (user-provided observations)
|
||||
observations: For directives only - list of observations with 'title' and 'content' keys
|
||||
tags: Optional list of tags for scoped visibility
|
||||
|
||||
Returns:
|
||||
MentalModelResponse with created mental model
|
||||
"""
|
||||
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
|
||||
from hindsight_client_api.models.observation_input import ObservationInput
|
||||
|
||||
obs_list = None
|
||||
if observations:
|
||||
obs_list = [ObservationInput(title=o.get("title", ""), content=o.get("content", "")) for o in observations]
|
||||
|
||||
request_obj = CreateMentalModelRequest(
|
||||
name=name,
|
||||
description=description,
|
||||
subtype=subtype,
|
||||
observations=obs_list,
|
||||
tags=tags or [],
|
||||
)
|
||||
return _run_async(self._mental_models_api.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
create_mental_model_request=request_obj,
|
||||
))
|
||||
|
||||
def update_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> MentalModelResponse:
|
||||
"""
|
||||
Update a mental model's name and/or description.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
name: Optional new name
|
||||
description: Optional new description
|
||||
|
||||
Returns:
|
||||
MentalModelResponse with updated mental model
|
||||
"""
|
||||
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
|
||||
|
||||
request_obj = UpdateMentalModelRequest(
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
return _run_async(self._mental_models_api.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
update_mental_model_request=request_obj,
|
||||
))
|
||||
|
||||
def delete_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
):
|
||||
"""
|
||||
Delete a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
|
||||
Returns:
|
||||
DeleteResponse confirming deletion
|
||||
"""
|
||||
return _run_async(self._mental_models_api.delete_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def refresh_mental_models(
|
||||
self,
|
||||
bank_id: str,
|
||||
subtype: Optional[Literal["structural", "emergent", "pinned", "learned"]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> AsyncOperationSubmitResponse:
|
||||
"""
|
||||
Submit a background job to refresh mental models for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
subtype: Optional - only refresh models of this subtype
|
||||
tags: Optional - tags to apply to newly created mental models
|
||||
|
||||
Returns:
|
||||
AsyncOperationSubmitResponse with operation_id to track progress
|
||||
"""
|
||||
from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest
|
||||
|
||||
request_obj = RefreshMentalModelsRequest(
|
||||
subtype=subtype,
|
||||
tags=tags,
|
||||
)
|
||||
return _run_async(self._mental_models_api.refresh_mental_models(
|
||||
bank_id=bank_id,
|
||||
refresh_mental_models_request=request_obj,
|
||||
))
|
||||
|
||||
def refresh_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
) -> AsyncOperationSubmitResponse:
|
||||
"""
|
||||
Submit a background job to refresh content for a specific mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID to refresh
|
||||
|
||||
Returns:
|
||||
AsyncOperationSubmitResponse with operation_id to track progress
|
||||
"""
|
||||
return _run_async(self._mental_models_api.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def list_mental_model_versions(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
):
|
||||
"""
|
||||
List all saved versions of a mental model's observations.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
|
||||
Returns:
|
||||
List of version objects ordered by version descending
|
||||
"""
|
||||
return _run_async(self._mental_models_api.list_mental_model_versions(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def get_mental_model_version(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
version: int,
|
||||
):
|
||||
"""
|
||||
Get observations from a specific version of a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
version: The version number
|
||||
|
||||
Returns:
|
||||
Version object with observations at that version
|
||||
"""
|
||||
return _run_async(self._mental_models_api.get_mental_model_version(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
version=version,
|
||||
))
|
||||
|
||||
# Async methods (native async, no _run_async wrapper)
|
||||
|
||||
async def aretain_batch(
|
||||
|
||||
@@ -544,3 +544,205 @@ class TestDeleteBank:
|
||||
# Verify bank data is deleted - memories should be gone
|
||||
memories = client.list_memories(bank_id=bank_id)
|
||||
assert memories.total == 0
|
||||
|
||||
|
||||
class TestMentalModels:
|
||||
"""Tests for mental model operations."""
|
||||
|
||||
def test_set_mission(self, client, bank_id):
|
||||
"""Test setting a bank's mission."""
|
||||
response = client.set_mission(
|
||||
bank_id=bank_id,
|
||||
mission="Be a helpful PM tracking sprint progress and team capacity",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.bank_id == bank_id
|
||||
assert response.mission == "Be a helpful PM tracking sprint progress and team capacity"
|
||||
|
||||
def test_create_pinned_mental_model(self, client, bank_id):
|
||||
"""Test creating a pinned mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
response = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Product Roadmap",
|
||||
description="Track product priorities and feature decisions",
|
||||
subtype="pinned",
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.name == "Product Roadmap"
|
||||
assert response.description == "Track product priorities and feature decisions"
|
||||
assert response.subtype == "pinned"
|
||||
|
||||
def test_create_directive_mental_model(self, client, bank_id):
|
||||
"""Test creating a directive mental model with observations."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
response = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Response Guidelines",
|
||||
description="Rules for responding to users",
|
||||
subtype="directive",
|
||||
observations=[
|
||||
{"title": "Always be polite", "content": "All responses must be courteous and professional"},
|
||||
{"title": "Never share private info", "content": "Do not reveal internal details or user data"},
|
||||
],
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.name == "Response Guidelines"
|
||||
assert response.subtype == "directive"
|
||||
assert response.observations is not None
|
||||
assert len(response.observations) == 2
|
||||
|
||||
def test_list_mental_models(self, client, bank_id):
|
||||
"""Test listing mental models."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Test Model",
|
||||
description="A test mental model",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.list_mental_models(bank_id=bank_id)
|
||||
|
||||
assert response is not None
|
||||
assert response.items is not None
|
||||
assert len(response.items) >= 1
|
||||
|
||||
def test_get_mental_model(self, client, bank_id):
|
||||
"""Test getting a specific mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Retrieve Test Model",
|
||||
description="A model to retrieve",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.id == created.id
|
||||
assert response.name == "Retrieve Test Model"
|
||||
|
||||
def test_update_mental_model(self, client, bank_id):
|
||||
"""Test updating a mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Update Test Model",
|
||||
description="Original description",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
name="Updated Model Name",
|
||||
description="Updated description",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.name == "Updated Model Name"
|
||||
assert response.description == "Updated description"
|
||||
|
||||
def test_delete_mental_model(self, client, bank_id):
|
||||
"""Test deleting a mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Delete Test Model",
|
||||
description="A model to delete",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.delete_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.success is True
|
||||
|
||||
def test_refresh_mental_models(self, client, bank_id):
|
||||
"""Test refreshing all mental models (async operation)."""
|
||||
# Set mission first (required for refresh) - this also creates the bank
|
||||
client.set_mission(
|
||||
bank_id=bank_id,
|
||||
mission="Track team progress and decisions",
|
||||
)
|
||||
|
||||
response = client.refresh_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.operation_id is not None
|
||||
assert response.status == "queued"
|
||||
|
||||
def test_refresh_mental_model(self, client, bank_id):
|
||||
"""Test refreshing a single mental model (async operation)."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Refresh Single Test",
|
||||
description="A model to refresh individually",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.operation_id is not None
|
||||
assert response.status == "queued"
|
||||
|
||||
def test_list_mental_model_versions(self, client, bank_id):
|
||||
"""Test listing mental model versions."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Versions Test Model",
|
||||
description="A model to test version history",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.list_mental_model_versions(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
# Newly created model should have version history
|
||||
assert response is not None
|
||||
|
||||
@@ -40,6 +40,10 @@ import type {
|
||||
BankProfileResponse,
|
||||
CreateBankRequest,
|
||||
Budget,
|
||||
MentalModelResponse,
|
||||
MentalModelListResponse,
|
||||
AsyncOperationSubmitResponse,
|
||||
ObservationInput,
|
||||
} from '../generated/types.gen';
|
||||
|
||||
export interface HindsightClientOptions {
|
||||
@@ -308,6 +312,176 @@ export class HindsightClient {
|
||||
|
||||
return this.validateResponse(response, 'getBankProfile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update the mission for a memory bank.
|
||||
*/
|
||||
async setMission(bankId: string, mission: string): Promise<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
|
||||
@@ -323,6 +497,10 @@ export type {
|
||||
BankProfileResponse,
|
||||
CreateBankRequest,
|
||||
Budget,
|
||||
MentalModelResponse,
|
||||
MentalModelListResponse,
|
||||
AsyncOperationSubmitResponse,
|
||||
ObservationInput,
|
||||
};
|
||||
|
||||
// Also export low-level SDK functions for advanced usage
|
||||
|
||||
@@ -412,3 +412,186 @@ describe('TestDeleteBank', () => {
|
||||
expect(memories.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TestMentalModels', () => {
|
||||
test('set mission', async () => {
|
||||
const bankId = randomBankId();
|
||||
const response = await client.setMission(
|
||||
bankId,
|
||||
'Be a helpful PM tracking sprint progress and team capacity'
|
||||
);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.bank_id).toBe(bankId);
|
||||
expect(response.mission).toBe('Be a helpful PM tracking sprint progress and team capacity');
|
||||
});
|
||||
|
||||
test('create pinned mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
const response = await client.createMentalModel(bankId, {
|
||||
name: 'Product Roadmap',
|
||||
description: 'Track product priorities and feature decisions',
|
||||
subtype: 'pinned',
|
||||
tags: ['test'],
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.name).toBe('Product Roadmap');
|
||||
expect(response.description).toBe('Track product priorities and feature decisions');
|
||||
expect(response.subtype).toBe('pinned');
|
||||
});
|
||||
|
||||
test('create directive mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
const response = await client.createMentalModel(bankId, {
|
||||
name: 'Response Guidelines',
|
||||
description: 'Rules for responding to users',
|
||||
subtype: 'directive',
|
||||
observations: [
|
||||
{ title: 'Always be polite', content: 'All responses must be courteous and professional' },
|
||||
{ title: 'Never share private info', content: 'Do not reveal internal details or user data' },
|
||||
],
|
||||
tags: ['test'],
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.name).toBe('Response Guidelines');
|
||||
expect(response.subtype).toBe('directive');
|
||||
expect(response.observations).toBeDefined();
|
||||
expect(response.observations!.length).toBe(2);
|
||||
});
|
||||
|
||||
test('list mental models', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
await client.createMentalModel(bankId, {
|
||||
name: 'Test Model',
|
||||
description: 'A test mental model',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.listMentalModels(bankId);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.items).toBeDefined();
|
||||
expect(response.items!.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('get mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Retrieve Test Model',
|
||||
description: 'A model to retrieve',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.getMentalModel(bankId, created.id);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.id).toBe(created.id);
|
||||
expect(response.name).toBe('Retrieve Test Model');
|
||||
});
|
||||
|
||||
test('update mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Update Test Model',
|
||||
description: 'Original description',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.updateMentalModel(bankId, created.id, {
|
||||
name: 'Updated Model Name',
|
||||
description: 'Updated description',
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.name).toBe('Updated Model Name');
|
||||
expect(response.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
test('delete mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Delete Test Model',
|
||||
description: 'A model to delete',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
// Delete should not throw
|
||||
await expect(client.deleteMentalModel(bankId, created.id)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('refresh mental models', async () => {
|
||||
const bankId = randomBankId();
|
||||
|
||||
// Set mission first (required for refresh) - this also creates the bank
|
||||
await client.setMission(bankId, 'Track team progress and decisions');
|
||||
|
||||
const response = await client.refreshMentalModels(bankId, {
|
||||
tags: ['test'],
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.operation_id).toBeDefined();
|
||||
expect(response.status).toBe('queued');
|
||||
});
|
||||
|
||||
test('refresh mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Refresh Single Test',
|
||||
description: 'A model to refresh individually',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.refreshMentalModel(bankId, created.id);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.operation_id).toBeDefined();
|
||||
expect(response.status).toBe('queued');
|
||||
});
|
||||
|
||||
test('list mental model versions', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Versions Test Model',
|
||||
description: 'A model to test version history',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.listMentalModelVersions(bankId, created.id);
|
||||
|
||||
// Newly created model should have version history
|
||||
expect(response).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user