Compare commits

...
5 changed files with 608 additions and 53 deletions
+14 -26
View File
@@ -153,8 +153,15 @@ jobs:
- name: Build docs
run: npm run build --workspace=hindsight-docs
build-rust-cli:
test-rust-cli:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
@@ -171,6 +178,10 @@ jobs:
hindsight-cli/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Run unit tests
working-directory: hindsight-cli
run: cargo test
- name: Build CLI
working-directory: hindsight-cli
run: cargo build --release
@@ -182,29 +193,6 @@ jobs:
path: hindsight-cli/target/release/hindsight
retention-days: 1
test-rust-cli:
runs-on: ubuntu-latest
needs: build-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Download CLI artifact
uses: actions/download-artifact@v4
with:
name: hindsight-cli
path: /tmp/cli
- name: Make CLI executable
run: chmod +x /tmp/cli/hindsight
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
@@ -251,7 +239,7 @@ jobs:
- name: Run CLI smoke test
run: |
HINDSIGHT_CLI=/tmp/cli/hindsight ./hindsight-cli/smoke-test.sh
HINDSIGHT_CLI=hindsight-cli/target/release/hindsight ./hindsight-cli/smoke-test.sh
- name: Show API server logs
if: always()
@@ -777,7 +765,7 @@ jobs:
test-doc-examples:
runs-on: ubuntu-latest
needs: build-rust-cli
needs: test-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
+147
View File
@@ -55,6 +55,7 @@ pub struct MemoryPutResult {
pub items_count: i64,
pub message: String,
pub is_async: bool,
pub operation_id: Option<String>,
}
#[derive(Clone)]
@@ -161,10 +162,54 @@ impl ApiClient {
items_count: result.items_count,
message: format!("Stored {} memory units", result.items_count),
is_async: result.async_,
operation_id: result.operation_id,
})
})
}
/// Poll an operation until it completes or fails.
/// Returns Ok(true) if completed successfully, Ok(false) if failed, Err if polling error.
pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option<String>)> {
self.runtime.block_on(async {
loop {
let response = self.client.list_operations(agent_id, None).await?;
let ops = response.into_inner();
// Find our operation
let op = ops.operations.iter().find(|o| o.id == operation_id);
match op {
Some(operation) => {
if verbose {
eprintln!("Operation {} status: {}", operation_id, operation.status);
}
match operation.status.as_str() {
"pending" => {
// Still running, wait and poll again
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
"completed" => {
// Operation completed successfully
return Ok((true, None));
}
"failed" => {
return Ok((false, operation.error_message.clone()));
}
_ => {
// Unknown status, treat as failed
return Ok((false, Some(format!("Unknown status: {}", operation.status))));
}
}
}
None => {
// Operation not in list means it completed successfully (removed from pending/failed)
return Ok((true, None));
}
}
}
})
}
pub fn delete_memory(&self, _agent_id: &str, _unit_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
// Note: Individual memory deletion is no longer supported in the API
anyhow::bail!("Individual memory deletion is no longer supported. Use 'memory clear' to clear all memories.")
@@ -281,3 +326,105 @@ pub use types::{
ReflectResponse,
RetainRequest,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_operation_deserialize() {
let json = r#"{
"id": "test-op-123",
"task_type": "retain",
"items_count": 5,
"document_id": "doc-456",
"created_at": "2024-01-15T10:00:00Z",
"status": "pending",
"error_message": null
}"#;
let op: Operation = serde_json::from_str(json).unwrap();
assert_eq!(op.id, "test-op-123");
assert_eq!(op.task_type, "retain");
assert_eq!(op.items_count, 5);
assert_eq!(op.document_id, Some("doc-456".to_string()));
assert_eq!(op.status, "pending");
assert!(op.error_message.is_none());
}
#[test]
fn test_operation_deserialize_with_error() {
let json = r#"{
"id": "test-op-456",
"task_type": "retain",
"items_count": 3,
"document_id": null,
"created_at": "2024-01-15T10:00:00Z",
"status": "failed",
"error_message": "Something went wrong"
}"#;
let op: Operation = serde_json::from_str(json).unwrap();
assert_eq!(op.status, "failed");
assert_eq!(op.error_message, Some("Something went wrong".to_string()));
}
#[test]
fn test_memory_put_result_serialize() {
let result = MemoryPutResult {
success: true,
items_count: 10,
message: "Stored 10 memory units".to_string(),
is_async: true,
operation_id: Some("op-789".to_string()),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"success\":true"));
assert!(json.contains("\"items_count\":10"));
assert!(json.contains("\"is_async\":true"));
assert!(json.contains("\"operation_id\":\"op-789\""));
}
#[test]
fn test_memory_put_result_without_operation_id() {
let result = MemoryPutResult {
success: true,
items_count: 5,
message: "Stored 5 memory units".to_string(),
is_async: false,
operation_id: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"operation_id\":null"));
}
#[test]
fn test_operations_response_deserialize() {
let json = r#"{
"bank_id": "test-bank",
"operations": [
{
"id": "op-1",
"task_type": "retain",
"items_count": 2,
"document_id": null,
"created_at": "2024-01-15T10:00:00Z",
"status": "pending",
"error_message": null
},
{
"id": "op-2",
"task_type": "retain",
"items_count": 3,
"document_id": "doc-123",
"created_at": "2024-01-15T11:00:00Z",
"status": "completed",
"error_message": null
}
]
}"#;
let ops: OperationsResponse = serde_json::from_str(json).unwrap();
assert_eq!(ops.bank_id, "test-bank");
assert_eq!(ops.operations.len(), 2);
assert_eq!(ops.operations[0].status, "pending");
assert_eq!(ops.operations[1].status, "completed");
}
}
+151 -25
View File
@@ -21,6 +21,17 @@ fn parse_budget(budget: &str) -> Budget {
}
}
// 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] = &[
"txt", "md", "json", "yaml", "yml", "toml", "xml", "csv", "log", "rst", "adoc",
];
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| TEXT_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
.unwrap_or(false)
}
pub fn recall(
client: &ApiClient,
agent_id: &str,
@@ -229,29 +240,23 @@ pub fn retain_files(
.filter(|e| e.file_type().is_file())
{
let path = entry.path();
if let Some(ext) = path.extension() {
if ext == "txt" || ext == "md" {
files.push(path.to_path_buf());
}
if is_text_file(&path) {
files.push(path.to_path_buf());
}
}
} else {
for entry in fs::read_dir(&path)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "txt" || ext == "md" {
files.push(path);
}
}
if path.is_file() && is_text_file(&path) {
files.push(path);
}
}
}
}
if files.is_empty() {
ui::print_warning("No .txt or .md files found");
ui::print_warning("No text files found (supported: txt, md, json, yaml, yml, toml, xml, csv, log, rst, adoc)");
return Ok(());
}
@@ -286,19 +291,20 @@ pub fn retain_files(
pb.finish_with_message("Files processed");
// Always use async mode for the API call
let request = RetainRequest {
items,
async_: true,
document_tags: None,
};
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Retaining memories..."))
Some(ui::create_spinner("Submitting retain request..."))
} else {
None
};
let request = RetainRequest {
items,
async_: r#async,
document_tags: None,
};
let response = client.retain(agent_id, &request, r#async, verbose);
let response = client.retain(agent_id, &request, true, verbose);
if let Some(mut sp) = spinner {
sp.finish();
@@ -306,16 +312,55 @@ pub fn retain_files(
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_success("Files retained successfully");
if result.is_async {
println!(" Status: queued for background processing");
if r#async {
// User requested async mode - return immediately
if output_format == OutputFormat::Pretty {
ui::print_success("Files queued for processing");
println!(" Items: {}", result.items_count);
if let Some(op_id) = &result.operation_id {
println!(" Operation ID: {}", op_id);
}
} else {
println!(" Total units created: {}", result.items_count);
output::print_output(&result, output_format)?;
}
} else {
output::print_output(&result, output_format)?;
// Poll until completion
if let Some(operation_id) = &result.operation_id {
let poll_spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Processing memories..."))
} else {
None
};
let (success, error_msg) = client.poll_operation(agent_id, operation_id, verbose)?;
if let Some(mut sp) = poll_spinner {
sp.finish();
}
if success {
if output_format == OutputFormat::Pretty {
ui::print_success("Files retained successfully");
println!(" Items processed: {}", result.items_count);
} else {
output::print_output(&result, output_format)?;
}
} else {
let msg = error_msg.unwrap_or_else(|| "Unknown error".to_string());
if output_format == OutputFormat::Pretty {
ui::print_error(&format!("Retain operation failed: {}", msg));
}
anyhow::bail!("Retain operation failed: {}", msg);
}
} else {
// No operation ID returned, shouldn't happen with async=true
if output_format == OutputFormat::Pretty {
ui::print_success("Files retained successfully");
println!(" Items processed: {}", result.items_count);
} else {
output::print_output(&result, output_format)?;
}
}
}
Ok(())
}
@@ -428,3 +473,84 @@ pub fn clear(
Err(e) => Err(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_is_text_file_supported_extensions() {
let supported = [
"file.txt", "file.md", "file.json", "file.yaml", "file.yml",
"file.toml", "file.xml", "file.csv", "file.log", "file.rst", "file.adoc",
];
for filename in supported {
assert!(
is_text_file(Path::new(filename)),
"{} should be recognized as a text file",
filename
);
}
}
#[test]
fn test_is_text_file_case_insensitive() {
assert!(is_text_file(Path::new("file.JSON")));
assert!(is_text_file(Path::new("file.TXT")));
assert!(is_text_file(Path::new("file.Md")));
assert!(is_text_file(Path::new("file.YAML")));
}
#[test]
fn test_is_text_file_unsupported_extensions() {
let unsupported = [
"file.pdf", "file.doc", "file.docx", "file.png", "file.jpg",
"file.exe", "file.bin", "file.zip", "file.tar", "file.gz",
];
for filename in unsupported {
assert!(
!is_text_file(Path::new(filename)),
"{} should NOT be recognized as a text file",
filename
);
}
}
#[test]
fn test_is_text_file_no_extension() {
assert!(!is_text_file(Path::new("README")));
assert!(!is_text_file(Path::new("Makefile")));
assert!(!is_text_file(Path::new(".gitignore")));
}
#[test]
fn test_is_text_file_with_path() {
assert!(is_text_file(Path::new("/some/path/to/file.json")));
assert!(is_text_file(Path::new("../relative/path/file.md")));
assert!(!is_text_file(Path::new("/path/to/image.png")));
}
#[test]
fn test_parse_budget_valid_values() {
assert!(matches!(parse_budget("low"), Budget::Low));
assert!(matches!(parse_budget("mid"), Budget::Mid));
assert!(matches!(parse_budget("high"), Budget::High));
}
#[test]
fn test_parse_budget_case_insensitive() {
assert!(matches!(parse_budget("LOW"), Budget::Low));
assert!(matches!(parse_budget("MID"), Budget::Mid));
assert!(matches!(parse_budget("HIGH"), Budget::High));
assert!(matches!(parse_budget("Low"), Budget::Low));
assert!(matches!(parse_budget("High"), Budget::High));
}
#[test]
fn test_parse_budget_defaults_to_mid() {
assert!(matches!(parse_budget("invalid"), Budget::Mid));
assert!(matches!(parse_budget(""), Budget::Mid));
assert!(matches!(parse_budget("unknown"), Budget::Mid));
}
}
+154
View File
@@ -8,6 +8,7 @@ const DEFAULT_API_URL: &str = "http://localhost:8888";
const CONFIG_FILE_NAME: &str = "config";
const CONFIG_DIR_NAME: &str = ".hindsight";
#[derive(Debug)]
pub struct Config {
pub api_url: String,
pub api_key: Option<String>,
@@ -174,3 +175,156 @@ pub fn generate_doc_id() -> String {
let now = chrono::Local::now();
format!("cli_put_{}", now.format("%Y%m%d_%H%M%S"))
}
/// Parse a simple TOML-like config line and extract value.
/// Handles both quoted and unquoted values.
pub fn parse_config_value(line: &str, key: &str) -> Option<String> {
let line = line.trim();
if !line.starts_with(key) {
return None;
}
line.split('=').nth(1).map(|value| {
value.trim().trim_matches('"').trim_matches('\'').to_string()
}).filter(|v| !v.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_source_display() {
assert_eq!(format!("{}", ConfigSource::LocalFile), "config file");
assert_eq!(format!("{}", ConfigSource::Environment), "environment variable");
assert_eq!(format!("{}", ConfigSource::Default), "default");
}
#[test]
fn test_validate_and_create_valid_http() {
let config = Config::validate_and_create(
"http://localhost:8888".to_string(),
None,
ConfigSource::Default,
);
assert!(config.is_ok());
let config = config.unwrap();
assert_eq!(config.api_url, "http://localhost:8888");
assert_eq!(config.source, ConfigSource::Default);
}
#[test]
fn test_validate_and_create_valid_https() {
let config = Config::validate_and_create(
"https://api.example.com".to_string(),
Some("secret-key".to_string()),
ConfigSource::Environment,
);
assert!(config.is_ok());
let config = config.unwrap();
assert_eq!(config.api_url, "https://api.example.com");
assert_eq!(config.api_key, Some("secret-key".to_string()));
assert_eq!(config.source, ConfigSource::Environment);
}
#[test]
fn test_validate_and_create_invalid_url() {
let config = Config::validate_and_create(
"localhost:8888".to_string(),
None,
ConfigSource::Default,
);
assert!(config.is_err());
let err = config.unwrap_err().to_string();
assert!(err.contains("Invalid API URL"));
assert!(err.contains("Must start with http:// or https://"));
}
#[test]
fn test_validate_and_create_ftp_url() {
let config = Config::validate_and_create(
"ftp://example.com".to_string(),
None,
ConfigSource::Default,
);
assert!(config.is_err());
}
#[test]
fn test_generate_doc_id_format() {
let doc_id = generate_doc_id();
assert!(doc_id.starts_with("cli_put_"));
// Should be cli_put_YYYYMMDD_HHMMSS format
assert!(doc_id.len() > 20); // cli_put_ (8) + date (8) + _ (1) + time (6) = 23
}
#[test]
fn test_generate_doc_id_uniqueness() {
let id1 = generate_doc_id();
std::thread::sleep(std::time::Duration::from_secs(1));
let id2 = generate_doc_id();
// IDs generated at different times should be different
assert_ne!(id1, id2);
}
#[test]
fn test_parse_config_value_quoted() {
assert_eq!(
parse_config_value(r#"api_url = "http://localhost:8888""#, "api_url"),
Some("http://localhost:8888".to_string())
);
}
#[test]
fn test_parse_config_value_single_quoted() {
assert_eq!(
parse_config_value("api_url = 'http://localhost:8888'", "api_url"),
Some("http://localhost:8888".to_string())
);
}
#[test]
fn test_parse_config_value_unquoted() {
assert_eq!(
parse_config_value("api_url = http://localhost:8888", "api_url"),
Some("http://localhost:8888".to_string())
);
}
#[test]
fn test_parse_config_value_with_spaces() {
assert_eq!(
parse_config_value(" api_url = \"http://localhost:8888\" ", "api_url"),
Some("http://localhost:8888".to_string())
);
}
#[test]
fn test_parse_config_value_wrong_key() {
assert_eq!(
parse_config_value("api_key = secret", "api_url"),
None
);
}
#[test]
fn test_parse_config_value_empty() {
assert_eq!(
parse_config_value("api_url = ", "api_url"),
None
);
assert_eq!(
parse_config_value("api_url = \"\"", "api_url"),
None
);
}
#[test]
fn test_config_api_url_accessor() {
let config = Config {
api_url: "http://test:8080".to_string(),
api_key: None,
source: ConfigSource::Default,
};
assert_eq!(config.api_url(), "http://test:8080");
}
}
+142 -2
View File
@@ -8,13 +8,35 @@ pub enum OutputFormat {
Yaml,
}
impl OutputFormat {
/// Parse output format from string
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"json" => Some(OutputFormat::Json),
"yaml" | "yml" => Some(OutputFormat::Yaml),
"pretty" | "text" => Some(OutputFormat::Pretty),
_ => None,
}
}
}
/// Format data as JSON string
pub fn to_json<T: Serialize>(data: &T) -> Result<String> {
Ok(serde_json::to_string_pretty(data)?)
}
/// Format data as YAML string
pub fn to_yaml<T: Serialize>(data: &T) -> Result<String> {
Ok(serde_yaml::to_string(data)?)
}
pub fn print_output<T: Serialize>(data: &T, format: OutputFormat) -> Result<()> {
match format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(data)?);
println!("{}", to_json(data)?);
}
OutputFormat::Yaml => {
println!("{}", serde_yaml::to_string(data)?);
println!("{}", to_yaml(data)?);
}
OutputFormat::Pretty => {
// This should not be called - pretty printing is handled in ui.rs
@@ -23,3 +45,121 @@ pub fn print_output<T: Serialize>(data: &T, format: OutputFormat) -> Result<()>
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct TestData {
name: String,
count: i32,
active: bool,
}
#[test]
fn test_output_format_from_str_json() {
assert_eq!(OutputFormat::from_str("json"), Some(OutputFormat::Json));
assert_eq!(OutputFormat::from_str("JSON"), Some(OutputFormat::Json));
assert_eq!(OutputFormat::from_str("Json"), Some(OutputFormat::Json));
}
#[test]
fn test_output_format_from_str_yaml() {
assert_eq!(OutputFormat::from_str("yaml"), Some(OutputFormat::Yaml));
assert_eq!(OutputFormat::from_str("YAML"), Some(OutputFormat::Yaml));
assert_eq!(OutputFormat::from_str("yml"), Some(OutputFormat::Yaml));
assert_eq!(OutputFormat::from_str("YML"), Some(OutputFormat::Yaml));
}
#[test]
fn test_output_format_from_str_pretty() {
assert_eq!(OutputFormat::from_str("pretty"), Some(OutputFormat::Pretty));
assert_eq!(OutputFormat::from_str("PRETTY"), Some(OutputFormat::Pretty));
assert_eq!(OutputFormat::from_str("text"), Some(OutputFormat::Pretty));
}
#[test]
fn test_output_format_from_str_invalid() {
assert_eq!(OutputFormat::from_str("xml"), None);
assert_eq!(OutputFormat::from_str("csv"), None);
assert_eq!(OutputFormat::from_str(""), None);
}
#[test]
fn test_to_json() {
let data = TestData {
name: "test".to_string(),
count: 42,
active: true,
};
let json = to_json(&data).unwrap();
assert!(json.contains("\"name\": \"test\""));
assert!(json.contains("\"count\": 42"));
assert!(json.contains("\"active\": true"));
}
#[test]
fn test_to_yaml() {
let data = TestData {
name: "test".to_string(),
count: 42,
active: true,
};
let yaml = to_yaml(&data).unwrap();
assert!(yaml.contains("name: test"));
assert!(yaml.contains("count: 42"));
assert!(yaml.contains("active: true"));
}
#[test]
fn test_to_json_array() {
let data = vec![
TestData { name: "a".to_string(), count: 1, active: true },
TestData { name: "b".to_string(), count: 2, active: false },
];
let json = to_json(&data).unwrap();
assert!(json.contains("\"name\": \"a\""));
assert!(json.contains("\"name\": \"b\""));
}
#[test]
fn test_to_yaml_array() {
let data = vec![
TestData { name: "a".to_string(), count: 1, active: true },
TestData { name: "b".to_string(), count: 2, active: false },
];
let yaml = to_yaml(&data).unwrap();
assert!(yaml.contains("name: a"));
assert!(yaml.contains("name: b"));
}
#[test]
fn test_output_format_equality() {
assert_eq!(OutputFormat::Json, OutputFormat::Json);
assert_ne!(OutputFormat::Json, OutputFormat::Yaml);
assert_ne!(OutputFormat::Yaml, OutputFormat::Pretty);
}
#[test]
fn test_output_format_clone() {
let format = OutputFormat::Json;
let cloned = format.clone();
assert_eq!(format, cloned);
}
#[test]
fn test_to_json_special_chars() {
let data = TestData {
name: "test\"with\\special\nchars".to_string(),
count: 0,
active: false,
};
let json = to_json(&data).unwrap();
// JSON should properly escape special characters
assert!(json.contains("\\\""));
assert!(json.contains("\\\\"));
assert!(json.contains("\\n"));
}
}