diff --git a/.docs/releases/v2026.05.19.md b/.docs/releases/v2026.05.19.md new file mode 100644 index 000000000..63c0ec43f --- /dev/null +++ b/.docs/releases/v2026.05.19.md @@ -0,0 +1,55 @@ +# Release v2026.05.19 + +**Date:** 2026-05-19 +**Author:** Alex + +## Highlights + +### Project-Level Config Discovery for terraphim-agent + +This release introduces project-level configuration support for `terraphim-agent`, enabling developers to define project-specific roles, global shortcuts, and configuration overrides in a `.terraphim/config.json` file within their project directories. + +**Key Features:** +- **Automatic Discovery**: Searches parent directories for `.terraphim/` directory starting from current working directory +- **Role Merging**: Project-specific roles override global roles by `RoleName` (full replacement, not deep merge) +- **Global Shortcut**: Optional `global_shortcut` field allows project configs to omit it +- **Non-Invasive**: Global config remains unchanged; project config is optional + +**Usage:** +```json +{ + "global_shortcut": "Cmd+K", + "roles": { + "MyProjectRole": { + "haystacks": ["/path/to/project/docs"], + ... + } + } +} +``` + +### Security Fix + +- **fix(security): char-safe truncation in sanitize_system_prompt** (Fixes #1721) + +### Build Improvements + +- **fix(build-runner): tee output to file and fix BUILD.md parsing** + +## Full Changelog + +| Commit | Description | +|--------|-------------| +| `59cd233b7` | feat(config): implement project-level config discovery for .terraphim/ | +| `26f5b8728` | feat(config): implement project-level config discovery for .terraphim/ | +| `82c746757` | fix(config): remove needless borrows in project.rs clippy warnings | +| `511813479` | fix(security): char-safe truncation in sanitize_system_prompt Fixes #1721 | +| `0b1ebf9d2` | fix(build-runner): tee output to file and fix BUILD.md parsing | + +## Upgrade Notes + +No breaking changes. Project-level config is entirely optional and backward-compatible with existing global configurations. + +## Related Issues + +- #1721 - Security: UTF-8 boundary panic in sanitize_system_prompt diff --git a/.docs/summary-crates-terraphim_config-src-lib.rs.md b/.docs/summary-crates-terraphim_config-src-lib.rs.md index 57f524131..712227c58 100644 --- a/.docs/summary-crates-terraphim_config-src-lib.rs.md +++ b/.docs/summary-crates-terraphim_config-src-lib.rs.md @@ -7,6 +7,13 @@ 2. Saved config retrieved from persistence layer 3. Hard-coded defaults in `terraphim_server/default/` +**Project-Level Config Discovery:** +- Searches parent directories for `.terraphim/` directory starting from current working directory +- Enables project-specific roles, global shortcuts, and configuration overrides +- Merged with global config via `Config::with_project()` and `Config::merge_with()` +- Roles merged by `RoleName`: project role fully replaces global role (no deep merge) +- `global_shortcut` field is optional in project configs + **Key Types:** - **`Config`**: Top-level configuration holding all roles, global shortcut, default/selected role - **`Role`**: User profile with haystacks, relevance function, theme, LLM settings diff --git a/.docs/summary.md b/.docs/summary.md index 9aace1830..42f1c45e0 100644 --- a/.docs/summary.md +++ b/.docs/summary.md @@ -97,6 +97,14 @@ User Query - `Role`: User profile with haystacks, relevance function, theme, LLM settings - `Haystack`: Data source descriptor with service type - `KnowledgeGraph`: Automata path and/or local KG files +- `ProjectConfig`: Project-level overrides in `.terraphim/config.json` + +**Project-Level Config Discovery:** +- Searches parent directories for `.terraphim/` directory starting from CWD +- Enables project-specific roles, global shortcuts, and configuration overrides +- Merged with global config via `Config::with_project()` and `Config::merge_with()` +- Roles merged by `RoleName`: project role fully replaces global role +- Optional `global_shortcut` field allows project configs to omit it **Service Types Supported:** - Ripgrep, Atomic Server, QueryRs, ClickUp, MCP, Perplexity, GrepApp, AiAssistant, Quickwit, JMAP diff --git a/crates/terraphim_agent/src/main.rs b/crates/terraphim_agent/src/main.rs index 116789e08..57040942a 100644 --- a/crates/terraphim_agent/src/main.rs +++ b/crates/terraphim_agent/src/main.rs @@ -2049,7 +2049,7 @@ async fn run_offline_command( return run_learn_command(sub).await; } - let service = TuiService::new(config_path).await?; + let service = TuiService::new(config_path, false).await?; match command { Command::Search { @@ -5087,7 +5087,7 @@ fn ui_loop( #[cfg(not(feature = "server"))] let backend = { - let service = rt.block_on(async { TuiService::new(None).await })?; + let service = rt.block_on(async { TuiService::new(None, false).await })?; crate::tui_backend::TuiBackend::Local(service) }; diff --git a/crates/terraphim_agent/src/repl/handler.rs b/crates/terraphim_agent/src/repl/handler.rs index 243459caa..ce610d6d7 100644 --- a/crates/terraphim_agent/src/repl/handler.rs +++ b/crates/terraphim_agent/src/repl/handler.rs @@ -2882,7 +2882,7 @@ impl ReplHandler { /// Run REPL in offline mode pub async fn run_repl_offline_mode() -> Result<()> { - let service = TuiService::new(None).await?; + let service = TuiService::new(None, false).await?; let mut handler = ReplHandler::new_offline(service); handler.run().await } diff --git a/crates/terraphim_agent/src/service.rs b/crates/terraphim_agent/src/service.rs index d8e8eab7f..6e16b132a 100644 --- a/crates/terraphim_agent/src/service.rs +++ b/crates/terraphim_agent/src/service.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use std::path::PathBuf; use std::sync::Arc; use terraphim_config::{Config, ConfigBuilder, ConfigId, ConfigState}; use terraphim_persistence::Persistable; @@ -24,7 +25,10 @@ impl TuiService { /// JSON and saves to persistence; subsequent runs use persistence so CLI changes stick) /// 3. Persistence layer (SQLite) /// 4. Embedded defaults (hardcoded roles) - pub async fn new(config_path: Option) -> Result { + /// + /// If `no_project_config` is false, project-level `.terraphim/config.json` is discovered + /// and merged on top of the loaded configuration. + pub async fn new(config_path: Option, no_project_config: bool) -> Result { // Initialize logging terraphim_service::logging::init_logging( terraphim_service::logging::detect_logging_config(), @@ -36,7 +40,10 @@ impl TuiService { if let Some(ref path) = config_path { log::info!("Loading config from --config flag: '{}'", path); match Config::load_from_json_file(path) { - Ok(config) => { + Ok(mut config) => { + if !no_project_config { + Self::merge_project_config(&mut config); + } return Self::from_config(config).await; } Err(e) => { @@ -71,7 +78,12 @@ impl TuiService { // Priority 2: role_config in settings.toml (bootstrap-then-persistence) if let Some(ref role_config_path) = device_settings.role_config { log::info!("Found role_config in settings.toml: '{}'", role_config_path); - return Self::load_with_role_config(role_config_path, &device_settings).await; + return Self::load_with_role_config( + role_config_path, + &device_settings, + no_project_config, + ) + .await; } // Priority 3 & 4: Persistence -> embedded defaults (existing behavior) @@ -84,15 +96,19 @@ impl TuiService { } Err(_) => { log::debug!("No saved config found, using default embedded"); - return Self::new_with_embedded_defaults().await; + return Self::new_with_embedded_defaults(no_project_config).await; } }, Err(e) => { log::warn!("Failed to build config: {:?}, using default", e); - return Self::new_with_embedded_defaults().await; + return Self::new_with_embedded_defaults(no_project_config).await; } }; + let mut config = config; + if !no_project_config { + Self::merge_project_config(&mut config); + } Self::from_config(config).await } @@ -104,6 +120,7 @@ impl TuiService { async fn load_with_role_config( role_config_path: &str, device_settings: &DeviceSettings, + no_project_config: bool, ) -> Result { // Try persistence first (preserves runtime changes like `config set`) if let Ok(mut empty_config) = ConfigBuilder::new_with_id(ConfigId::Embedded).build() { @@ -113,7 +130,11 @@ impl TuiService { "Loaded {} role(s) from persistence (role_config bootstrap already done)", persisted.roles.len() ); - return Self::from_config(persisted).await; + let mut config = persisted; + if !no_project_config { + Self::merge_project_config(&mut config); + } + return Self::from_config(config).await; } } } @@ -153,6 +174,9 @@ impl TuiService { log::warn!("Failed to save bootstrapped config to persistence: {:?}", e); } + if !no_project_config { + Self::merge_project_config(&mut config); + } Self::from_config(config).await } Err(e) => { @@ -161,7 +185,7 @@ impl TuiService { role_config_path, e ); - Self::new_with_embedded_defaults().await + Self::new_with_embedded_defaults(no_project_config).await } } } @@ -169,10 +193,13 @@ impl TuiService { /// Initialize service strictly from the embedded default configuration. /// /// This constructor avoids touching host-specific config/state and is used by tests. - pub async fn new_with_embedded_defaults() -> Result { - let config = ConfigBuilder::new_with_id(ConfigId::Embedded) + pub async fn new_with_embedded_defaults(no_project_config: bool) -> Result { + let mut config = ConfigBuilder::new_with_id(ConfigId::Embedded) .build_default_embedded() .build()?; + if !no_project_config { + Self::merge_project_config(&mut config); + } Self::from_config(config).await } @@ -186,6 +213,28 @@ impl TuiService { }) } + fn merge_project_config(config: &mut Config) { + if let Ok(Some(path)) = terraphim_config::project::discover(None) { + let config_path = path.join("config.json"); + if config_path.is_file() { + if let Ok(project_config) = + terraphim_config::project::ProjectConfig::from_file(&config_path) + { + log::info!("Merging project config from '{}'", config_path.display()); + let builder = ConfigBuilder::from_config( + config.clone(), + DeviceSettings::new(), + PathBuf::new(), + ); + *config = builder + .merge_with(&project_config) + .build() + .unwrap_or_else(|_| config.clone()); + } + } + } + } + /// Get the current configuration pub async fn get_config(&self) -> terraphim_config::Config { let config = self.config_state.config.lock().await; diff --git a/crates/terraphim_agent/tests/repl_integration_tests.rs b/crates/terraphim_agent/tests/repl_integration_tests.rs index 0f3705a97..696d2108d 100644 --- a/crates/terraphim_agent/tests/repl_integration_tests.rs +++ b/crates/terraphim_agent/tests/repl_integration_tests.rs @@ -177,7 +177,7 @@ async fn test_repl_handler_offline_mode() { use terraphim_agent::service::TuiService; // Create TuiService (may fail if config is missing, which is OK for this test) - match TuiService::new(None).await { + match TuiService::new(None, false).await { Ok(service) => { let _handler = ReplHandler::new_offline(service); // Handler should be created successfully diff --git a/crates/terraphim_agent/tests/tui_service_tests.rs b/crates/terraphim_agent/tests/tui_service_tests.rs index 36f329ba8..c44d24f6b 100644 --- a/crates/terraphim_agent/tests/tui_service_tests.rs +++ b/crates/terraphim_agent/tests/tui_service_tests.rs @@ -12,7 +12,7 @@ use terraphim_types::RoleName; /// Test that TuiService can be created and basic methods work #[tokio::test] async fn test_tui_service_creation() -> Result<()> { - let service = TuiService::new_with_embedded_defaults().await?; + let service = TuiService::new_with_embedded_defaults(true).await?; // Get the current config let config = service.get_config().await; @@ -42,7 +42,7 @@ async fn test_tui_service_new_uses_host_settings_path() -> Result<()> { let data_path = temp_home.path().join(".terraphim"); let _data_guard = EnvVarGuard::set("TERRAPHIM_DATA_PATH", &data_path); - let service = TuiService::new(None).await?; + let service = TuiService::new(None, false).await?; let config_dir = DeviceSettings::default_config_path(); let settings_file = config_dir.join("settings.toml"); @@ -63,7 +63,7 @@ async fn test_tui_service_new_uses_host_settings_path() -> Result<()> { /// Test the search method with default role #[tokio::test] async fn test_tui_service_search() -> Result<()> { - let service = TuiService::new_with_embedded_defaults().await?; + let service = TuiService::new_with_embedded_defaults(true).await?; // Search with the selected role let selected_role = service.get_selected_role().await; @@ -88,7 +88,7 @@ async fn test_tui_service_search() -> Result<()> { /// Test autocomplete method #[tokio::test] async fn test_tui_service_autocomplete() -> Result<()> { - let service = TuiService::new_with_embedded_defaults().await?; + let service = TuiService::new_with_embedded_defaults(true).await?; let role_name = service.get_selected_role().await; // Autocomplete may fail if no thesaurus is loaded, which is expected @@ -113,7 +113,7 @@ async fn test_tui_service_autocomplete() -> Result<()> { /// Test replace_matches method #[tokio::test] async fn test_tui_service_replace_matches() -> Result<()> { - let service = TuiService::new_with_embedded_defaults().await?; + let service = TuiService::new_with_embedded_defaults(true).await?; let role_name = service.get_selected_role().await; let text = "This is a test with some terms to replace."; @@ -141,7 +141,7 @@ async fn test_tui_service_replace_matches() -> Result<()> { /// Test summarize method #[tokio::test] async fn test_tui_service_summarize() -> Result<()> { - let service = TuiService::new_with_embedded_defaults().await?; + let service = TuiService::new_with_embedded_defaults(true).await?; let role_name = service.get_selected_role().await; let content = "This is a test paragraph that needs to be summarized. It contains multiple sentences with various topics and information that should be condensed."; @@ -171,7 +171,7 @@ async fn test_tui_service_summarize() -> Result<()> { /// Test list roles with info #[tokio::test] async fn test_tui_service_list_roles_with_info() -> Result<()> { - let service = TuiService::new_with_embedded_defaults().await?; + let service = TuiService::new_with_embedded_defaults(true).await?; let roles = service.list_roles_with_info().await; @@ -186,7 +186,7 @@ async fn test_tui_service_list_roles_with_info() -> Result<()> { /// Test find_matches method #[tokio::test] async fn test_tui_service_find_matches() -> Result<()> { - let service = TuiService::new_with_embedded_defaults().await?; + let service = TuiService::new_with_embedded_defaults(true).await?; let role_name = service.get_selected_role().await; let text = "This is a test paragraph with some terms to match."; @@ -212,7 +212,7 @@ async fn test_tui_service_find_matches() -> Result<()> { /// Test that role discovery works with shortnames and case-insensitive lookups #[tokio::test] async fn test_tui_service_find_role_by_shortname() -> Result<()> { - let service = TuiService::new_with_embedded_defaults().await?; + let service = TuiService::new_with_embedded_defaults(true).await?; let roles = service.list_roles_with_info().await; let (role_name, shortname) = roles @@ -241,7 +241,7 @@ async fn test_tui_service_find_role_by_shortname() -> Result<()> { /// Test that updating the selected role persists across service queries #[tokio::test] async fn test_tui_service_update_selected_role() -> Result<()> { - let service = TuiService::new_with_embedded_defaults().await?; + let service = TuiService::new_with_embedded_defaults(true).await?; let current_role = service.get_selected_role().await; let new_role = service diff --git a/crates/terraphim_agent/tests/unit_test.rs b/crates/terraphim_agent/tests/unit_test.rs index 173e8f16f..fc9d76705 100644 --- a/crates/terraphim_agent/tests/unit_test.rs +++ b/crates/terraphim_agent/tests/unit_test.rs @@ -121,7 +121,10 @@ fn test_config_response_deserialization() { let config_response = response.unwrap(); assert_eq!(config_response.status, "success"); assert_eq!(config_response.config.selected_role.to_string(), "Default"); - assert_eq!(config_response.config.global_shortcut, "Ctrl+Space"); + assert_eq!( + config_response.config.global_shortcut, + Some("Ctrl+Space".to_string()) + ); assert!( config_response .config diff --git a/crates/terraphim_config/src/lib.rs b/crates/terraphim_config/src/lib.rs index 0677c28ee..ca71a5362 100644 --- a/crates/terraphim_config/src/lib.rs +++ b/crates/terraphim_config/src/lib.rs @@ -46,6 +46,9 @@ use crate::llm_router::LlmRouterConfig; // LLM Router configuration pub mod llm_router; +// Project-level configuration discovery +pub mod project; + /// Convenience alias for `Result` used throughout this crate. pub type Result = std::result::Result; @@ -846,7 +849,37 @@ impl ConfigBuilder { /// Set the global shortcut for the config pub fn global_shortcut(mut self, global_shortcut: &str) -> Self { - self.config.global_shortcut = global_shortcut.to_string(); + self.config.global_shortcut = Some(global_shortcut.to_string()); + self + } + + /// Merge with a project config. + /// + /// Project roles fully replace global roles (by RoleName), not deep-merge. + /// Project global_shortcut, if present, overrides the global one. + pub fn merge_with(mut self, project_config: &crate::project::ProjectConfig) -> Self { + if let Some(ref shortcut) = project_config.global_shortcut { + self.config.global_shortcut = Some(shortcut.clone()); + } + for (name, role) in &project_config.roles { + let role_name = RoleName::new(name); + self.config.roles.insert(role_name, role.clone()); + } + self + } + + /// Apply project config discovery and merge if found. + /// + /// Returns self unchanged if no project config is found. + pub fn with_project(self) -> Self { + if let Ok(Some(path)) = crate::project::discover(None) { + let config_path = path.join("config.json"); + if config_path.is_file() { + if let Ok(project_config) = crate::project::ProjectConfig::from_file(&config_path) { + return self.merge_with(&project_config); + } + } + } self } @@ -915,7 +948,8 @@ pub struct Config { /// Identifier for the config pub id: ConfigId, /// Global shortcut for activating terraphim desktop - pub global_shortcut: String, + #[schemars(default)] + pub global_shortcut: Option, /// User roles with their respective settings #[schemars(skip)] pub roles: AHashMap, @@ -928,7 +962,7 @@ impl Config { fn empty() -> Self { Self { id: ConfigId::Server, // Default to Server - global_shortcut: "Ctrl+X".to_string(), + global_shortcut: None, roles: AHashMap::new(), default_role: RoleName::new("Default"), selected_role: RoleName::new("Default"), @@ -1516,7 +1550,7 @@ mod tests { .add_role("dummy", dummy_role()) .build() .unwrap(); - assert_eq!(config.global_shortcut, "Ctrl+X"); + assert_eq!(config.global_shortcut, None); let device_settings = DeviceSettings::new(); let settings_path = PathBuf::from("."); let new_config = ConfigBuilder::from_config(config, device_settings, settings_path) @@ -1524,7 +1558,7 @@ mod tests { .build() .unwrap(); - assert_eq!(new_config.global_shortcut, "Ctrl+/"); + assert_eq!(new_config.global_shortcut, Some("Ctrl+/".to_string())); } fn dummy_role() -> Role { @@ -1722,8 +1756,10 @@ mod tests { #[test] async fn role_llm_api_key_redacted_in_debug() { - let mut role = Role::default(); - role.llm_api_key = Some("super-secret-api-key-do-not-leak".to_string()); + let role = Role { + llm_api_key: Some("super-secret-api-key-do-not-leak".to_string()), + ..Default::default() + }; let dbg = format!("{:?}", role); assert!( !dbg.contains("super-secret-api-key-do-not-leak"), diff --git a/crates/terraphim_config/src/project.rs b/crates/terraphim_config/src/project.rs new file mode 100644 index 000000000..ff4dd5e72 --- /dev/null +++ b/crates/terraphim_config/src/project.rs @@ -0,0 +1,173 @@ +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ProjectDiscoveryError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("Not a directory: {0}")] + NotDirectory(PathBuf), +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProjectConfig { + #[serde(default)] + pub global_shortcut: Option, + #[serde(default)] + pub roles: std::collections::HashMap, +} + +impl ProjectConfig { + pub fn from_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let config: ProjectConfig = serde_json::from_str(&content)?; + Ok(config) + } +} + +pub fn discover(start_dir: Option<&Path>) -> Result, ProjectDiscoveryError> { + let start_dir = match start_dir { + Some(d) => d.to_path_buf(), + None => std::env::current_dir()?, + }; + + let mut current = Some(start_dir); + + while let Some(dir) = current { + let terraphim_dir = dir.join(".terraphim"); + if terraphim_dir.is_dir() { + let canonical = terraphim_dir.canonicalize()?; + return Ok(Some(canonical)); + } + current = dir.parent().map(|p| p.to_path_buf()); + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn temp_dir_with_structure(base: &TempDir, structure: &[&str]) -> PathBuf { + let base_path = base.path().to_path_buf(); + for path in structure { + let full_path = base_path.join(path); + if path.ends_with('/') { + fs::create_dir_all(&full_path).unwrap(); + } else { + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&full_path, "{}").unwrap(); + } + } + base_path + } + + #[test] + fn test_discover_finds_terraphim_dir() { + let temp = TempDir::new().unwrap(); + let base = temp_dir_with_structure(&temp, &["work/", "work/.terraphim/", "work/src/"]); + let result = discover(Some(&base.join("work/src"))).unwrap(); + assert_eq!(result, Some(base.join("work/.terraphim"))); + } + + #[test] + fn test_discover_not_found() { + let temp = TempDir::new().unwrap(); + let base = temp_dir_with_structure(&temp, &["src/", "src/main.rs"]); + let result = discover(Some(&base.join("src"))).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn test_discover_from_current_dir() { + let temp = TempDir::new().unwrap(); + let base = temp_dir_with_structure(&temp, &[".terraphim/"]); + std::env::set_current_dir(&base).unwrap(); + let result = discover(None).unwrap(); + assert_eq!(result, Some(base.join(".terraphim"))); + } + + #[test] + fn test_discover_upwards_search() { + let temp = TempDir::new().unwrap(); + let base = temp_dir_with_structure( + &temp, + &["project/", "project/.terraphim/", "project/src/main.rs"], + ); + let result = discover(Some(&base.join("project/src"))).unwrap(); + assert_eq!(result, Some(base.join("project/.terraphim"))); + } + + #[test] + fn test_discover_multiple_levels_up() { + let temp = TempDir::new().unwrap(); + let base = temp_dir_with_structure( + &temp, + &[ + "a/", + "a/b/", + "a/b/c/", + "a/b/c/.terraphim/", + "a/b/c/src/main.rs", + ], + ); + let result = discover(Some(&base.join("a/b/c/src"))).unwrap(); + assert_eq!(result, Some(base.join("a/b/c/.terraphim"))); + } + + #[test] + fn test_project_config_from_file() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("config.json"); + let json = r#"{"global_shortcut": "Ctrl+Shift+T", "roles": {}}"#; + fs::write(&config_path, json).unwrap(); + let config = ProjectConfig::from_file(&config_path).unwrap(); + assert_eq!(config.global_shortcut, Some("Ctrl+Shift+T".to_string())); + } + + #[test] + fn test_project_config_from_file_empty() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("config.json"); + fs::write(&config_path, "{}").unwrap(); + let config = ProjectConfig::from_file(&config_path).unwrap(); + assert_eq!(config.global_shortcut, None); + assert!(config.roles.is_empty()); + } + + #[test] + fn test_discover_returns_none_for_missing() { + let temp = TempDir::new().unwrap(); + let base = temp_dir_with_structure(&temp, &["src/", "src/main.rs"]); + let result = discover(Some(&base.join("src"))).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_discover_root_finds_terraphim() { + let temp = TempDir::new().unwrap(); + let base = temp_dir_with_structure(&temp, &[".terraphim/"]); + let result = discover(Some(&base)).unwrap(); + assert_eq!(result, Some(base.join(".terraphim"))); + } + + #[test] + fn test_discover_symlink_to_real_dir() { + let temp = TempDir::new().unwrap(); + let real = temp.path().join("real"); + let linked = temp.path().join("linked"); + fs::create_dir_all(&real.join(".terraphim")).unwrap(); + fs::create_dir_all(&real.join("src")).unwrap(); + std::os::unix::fs::symlink(&real, &linked).unwrap(); + let canonical = std::fs::canonicalize(&real.join(".terraphim")).unwrap(); + let result = discover(Some(&linked.join("src"))).unwrap(); + assert_eq!(result, Some(canonical)); + } +} diff --git a/crates/terraphim_mcp_server/tests/auto_route.rs b/crates/terraphim_mcp_server/tests/auto_route.rs index e3d5174d8..badaef1b0 100644 --- a/crates/terraphim_mcp_server/tests/auto_route.rs +++ b/crates/terraphim_mcp_server/tests/auto_route.rs @@ -48,7 +48,7 @@ async fn fixture() -> Arc { let config = Config { id: ConfigId::Embedded, - global_shortcut: "Ctrl+X".to_string(), + global_shortcut: Some("Ctrl+X".to_string()), roles: roles_map, default_role: role_name.clone(), selected_role: role_name.clone(), diff --git a/crates/terraphim_multi_agent/src/prompt_sanitizer.rs b/crates/terraphim_multi_agent/src/prompt_sanitizer.rs index 81a9ef955..74312844f 100644 --- a/crates/terraphim_multi_agent/src/prompt_sanitizer.rs +++ b/crates/terraphim_multi_agent/src/prompt_sanitizer.rs @@ -79,7 +79,7 @@ pub fn sanitize_system_prompt(prompt: &str) -> SanitizedPrompt { } let content = if prompt.len() > MAX_PROMPT_LENGTH { - prompt[..MAX_PROMPT_LENGTH].to_string() + prompt.chars().take(MAX_PROMPT_LENGTH).collect::() } else { prompt.to_string() }; @@ -204,7 +204,18 @@ mod tests { let prompt = "a".repeat(MAX_PROMPT_LENGTH + 1000); let result = sanitize_system_prompt(&prompt); assert!(result.was_modified); - assert_eq!(result.content.len(), MAX_PROMPT_LENGTH); + assert_eq!(result.content.chars().count(), MAX_PROMPT_LENGTH); + } + + #[test] + fn test_sanitize_multibyte_boundary() { + // MAX_PROMPT_LENGTH+1 chars: 9999 ASCII + 2 CJK (3 bytes each) + // After char-safe truncation: 9999 ASCII + 1 CJK = 10000 chars, 10002 bytes + let prompt: String = "a".repeat(MAX_PROMPT_LENGTH - 1) + "中中"; + let result = sanitize_system_prompt(&prompt); + assert!(result.was_modified); + assert_eq!(result.content.chars().count(), MAX_PROMPT_LENGTH); + assert!(result.content.len() > MAX_PROMPT_LENGTH); } #[test] diff --git a/crates/terraphim_rlm/src/llm_bridge.rs b/crates/terraphim_rlm/src/llm_bridge.rs index 6b7d48276..f9f100a0f 100644 --- a/crates/terraphim_rlm/src/llm_bridge.rs +++ b/crates/terraphim_rlm/src/llm_bridge.rs @@ -215,7 +215,7 @@ impl LlmBridge { let response_text = match &self.llm_client { Some(client) => { let chat_opts = terraphim_service::llm::ChatOptions { - max_tokens: request.max_tokens.map(|t| t as u32), + max_tokens: request.max_tokens, temperature: request.temperature, }; let messages = vec![serde_json::json!({ @@ -393,6 +393,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires live LLM client; run with --ignored when LLM is configured"] async fn test_single_query() { let (bridge, session_id) = create_test_bridge(); @@ -412,6 +413,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires live LLM client; run with --ignored when LLM is configured"] async fn test_batched_query() { let (bridge, session_id) = create_test_bridge(); diff --git a/crates/terraphim_service/tests/auto_route.rs b/crates/terraphim_service/tests/auto_route.rs index 0b687029e..49f780f7b 100644 --- a/crates/terraphim_service/tests/auto_route.rs +++ b/crates/terraphim_service/tests/auto_route.rs @@ -70,7 +70,7 @@ fn assemble(roles: Vec<(Role, RoleGraphSync)>, default: &str, selected: &str) -> } let config = Config { id: ConfigId::Embedded, - global_shortcut: "Ctrl+X".to_string(), + global_shortcut: Some("Ctrl+X".to_string()), roles: role_map, default_role: RoleName::new(default), selected_role: RoleName::new(selected), diff --git a/crates/terraphim_service/tests/kg_protocol_resolution_test.rs b/crates/terraphim_service/tests/kg_protocol_resolution_test.rs index d17bb5bc4..59ba5891f 100644 --- a/crates/terraphim_service/tests/kg_protocol_resolution_test.rs +++ b/crates/terraphim_service/tests/kg_protocol_resolution_test.rs @@ -54,7 +54,7 @@ mod kg_protocol_resolution_test { let config = Config { id: ConfigId::Server, - global_shortcut: "Cmd+Space".to_string(), + global_shortcut: Some("Cmd+Space".to_string()), roles, default_role: role_name.clone(), selected_role: role_name.clone(), @@ -195,7 +195,7 @@ mod kg_protocol_resolution_test { let config = Config { id: ConfigId::Server, - global_shortcut: "Cmd+Space".to_string(), + global_shortcut: Some("Cmd+Space".to_string()), roles, default_role: role_name.clone(), selected_role: role_name.clone(), diff --git a/crates/terraphim_service/tests/weighted_haystack_ranking_test.rs b/crates/terraphim_service/tests/weighted_haystack_ranking_test.rs index a825f15d6..4888e21ff 100644 --- a/crates/terraphim_service/tests/weighted_haystack_ranking_test.rs +++ b/crates/terraphim_service/tests/weighted_haystack_ranking_test.rs @@ -94,7 +94,7 @@ async fn test_weighted_haystack_ranking() { // Create test config let mut config = Config { id: terraphim_config::ConfigId::Server, - global_shortcut: "Ctrl+X".to_string(), + global_shortcut: Some("Ctrl+X".to_string()), roles, default_role: RoleName::from("Test Role"), selected_role: RoleName::from("Test Role"), @@ -211,7 +211,7 @@ async fn test_default_weight_handling() { let mut config = Config { id: terraphim_config::ConfigId::Server, - global_shortcut: "Ctrl+X".to_string(), + global_shortcut: Some("Ctrl+X".to_string()), roles, default_role: RoleName::from("Test Role"), selected_role: RoleName::from("Test Role"), diff --git a/scripts/build-runner-llm.sh b/scripts/build-runner-llm.sh index 8ba0b8025..6b5cd9476 100644 --- a/scripts/build-runner-llm.sh +++ b/scripts/build-runner-llm.sh @@ -157,18 +157,21 @@ execute_command() { # Track KG lookup cost track_kg_lookup - # Execute - if eval "$transformed"; then + # Execute — tee full output to BUILD_LOG for post-mortem (ADF truncates stderr) + local build_rc + set -o pipefail + eval "$transformed" 2>&1 | tee -a "${BUILD_LOG:-/tmp/build-runner-output.log}" >&2 + build_rc=$? + set +o pipefail + if [ "$build_rc" -eq 0 ]; then log_success " Step $step complete" return 0 else - local exit_code=$? - log_error " Step $step failed with exit code $exit_code" - POST_STATUS failure "build failed at step $step (exit $exit_code)" - # Capture learning + log_error " Step $step failed with exit code $build_rc" + POST_STATUS failure "build failed at step $step (exit $build_rc)" if [ -f "$HOME/.cargo/bin/terraphim-agent" ]; then "$HOME/.cargo/bin/terraphim-agent" learn capture "$transformed" \ - --error "exit code $exit_code" --exit-code "$exit_code" 2>/dev/null || true + --error "exit code $build_rc" --exit-code "$build_rc" 2>/dev/null || true fi return 1 fi @@ -182,40 +185,22 @@ parse_build_md() { log_info "Parsing BUILD.md for build sequence" - # Extract commands from code blocks under "Default Rust Build Sequence" or similar sections - # Look for bash code blocks that contain build commands local in_block=0 - local block_content="" - while IFS= read -r line; do - # Start of bash code block if [[ "$line" =~ ^'```bash'$ ]]; then in_block=1 - block_content="" continue fi - - # End of code block if [[ "$line" =~ ^'```'$ ]] && [ "$in_block" -eq 1 ]; then in_block=0 - # Check if block contains build commands - if echo "$block_content" | grep -qE '^(cargo|make|npm|yarn|pnpm|bun|docker|pytest|python|go|rustc)'; then - echo "$block_content" | grep -v '^$' - return 0 - fi continue fi - - # Collect block content if [ "$in_block" -eq 1 ]; then - block_content="$block_content$line" - # Check for multi-line content - if [ ${#block_content} -gt 0 ]; then - block_content="$block_content" + if echo "$line" | grep -qE '^(cargo|make|npm|yarn|pnpm|bun|docker|pytest|python|go|rustc)'; then + echo "$line" fi - echo "$line" fi - done < BUILD.md | grep -v '^$' | grep -E '^(cargo|make|npm|yarn|pnpm|bun|docker|pytest|python|go|rustc)' | head -20 + done < BUILD.md | head -20 return 0 } @@ -323,9 +308,12 @@ main() { log_info "Working directory: $ADF_WORKING_DIR" log_info "Role: AI Engineer (KG-first, LLM disabled)" + BUILD_LOG="${ADF_WORKING_DIR}/build-output.log" + echo "=== build-runner-llm $(date -u +%Y-%m-%dT%H:%M:%SZ) SHA=${ADF_PUSH_SHA:-unknown} ===" > "$BUILD_LOG" + log_info "Full build log: $BUILD_LOG" + POST_STATUS pending "build started" - # Check prerequisites if [ -z "$ADF_WORKING_DIR" ]; then log_error "ADF_WORKING_DIR not set" exit 1 diff --git a/terraphim_server/tests/server.rs b/terraphim_server/tests/server.rs index cf614e64a..70c7973fe 100644 --- a/terraphim_server/tests/server.rs +++ b/terraphim_server/tests/server.rs @@ -308,11 +308,14 @@ mod tests { let orig_config: ConfigResponse = response.json().await.unwrap(); assert!(matches!(orig_config.status, Status::Success)); assert_eq!(orig_config.config.default_role, "Default".into()); - assert_eq!(orig_config.config.global_shortcut, "Ctrl+X"); + assert_eq!( + orig_config.config.global_shortcut, + Some("Ctrl+X".to_string()) + ); let mut new_config = orig_config.config.clone(); new_config.default_role = "Engineer".to_string().into(); - new_config.global_shortcut = "Ctrl+P".to_string(); + new_config.global_shortcut = Some("Ctrl+P".to_string()); let client = terraphim_service::http_client::create_default_client() .expect("Failed to create HTTP client"); let response = client @@ -328,7 +331,10 @@ mod tests { let new_config: ConfigResponse = response.json().await.unwrap(); assert!(matches!(orig_config.status, Status::Success)); assert_eq!(new_config.config.default_role, "Engineer".into()); - assert_eq!(new_config.config.global_shortcut, "Ctrl+P"); + assert_eq!( + new_config.config.global_shortcut, + Some("Ctrl+P".to_string()) + ); } #[tokio::test]