Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5118134
fix(security): char-safe truncation in sanitize_system_prompt Fixes #…
May 19, 2026
f490a41
trigger: rebuild after repo cleanup
May 19, 2026
7218d53
fix(clippy): remaining field-reassign-with-default and unnecessary ca…
May 19, 2026
952b7e4
trigger: rebuild with clean repo
May 19, 2026
9dee7d0
trigger: rebuild webhook test
May 19, 2026
2d66e2c
trigger: full-clone pipeline test Refs #1721
May 19, 2026
5958b2a
trigger: pipeline test with full clone Refs #1721
May 19, 2026
b58044f
trigger: depth=1 fetch pipeline test Refs #1721
May 19, 2026
b0b43dd
ci: e2e pipeline test from local Refs #1721
May 19, 2026
83c114d
ci: selective refspec pipeline test Refs #1721
May 19, 2026
58c6a57
ci: final pipeline test with webhook ready Refs #1721
May 19, 2026
f65d8b9
style: cargo fmt for role_llm_api_key test Refs #1721
May 19, 2026
bdd1156
fix: remove accidentally committed project.rs Refs #1721
May 19, 2026
85b19a5
ci: fresh clone pipeline test Refs #1721
May 19, 2026
0b1ebf9
fix(build-runner): tee output to file and fix BUILD.md parsing
May 19, 2026
26f5b87
feat(config): implement project-level config discovery for .terraphim/
May 19, 2026
59cd233
feat(config): implement project-level config discovery for .terraphim/
May 19, 2026
82c7467
fix(config): remove needless borrows in project.rs clippy warnings
May 19, 2026
af9f0f5
Merge remote-tracking branch 'origin/push-1721' into push-1721
May 19, 2026
64136b2
docs: update project-level config discovery documentation
May 19, 2026
6fea5ad
fix(security): char-safe truncation in sanitize_system_prompt and bui…
May 19, 2026
9a4a83a
docs: add release announcement for v2026.05.19
May 19, 2026
2f3ada1
Merge remote-tracking branch 'origin/push-1721' into push-1721
May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .docs/releases/v2026.05.19.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .docs/summary-crates-terraphim_config-src-lib.rs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .docs/summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/terraphim_agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
};

Expand Down
2 changes: 1 addition & 1 deletion crates/terraphim_agent/src/repl/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
67 changes: 58 additions & 9 deletions crates/terraphim_agent/src/service.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<String>) -> Result<Self> {
///
/// 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<String>, no_project_config: bool) -> Result<Self> {
// Initialize logging
terraphim_service::logging::init_logging(
terraphim_service::logging::detect_logging_config(),
Expand All @@ -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) => {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand All @@ -104,6 +120,7 @@ impl TuiService {
async fn load_with_role_config(
role_config_path: &str,
device_settings: &DeviceSettings,
no_project_config: bool,
) -> Result<Self> {
// Try persistence first (preserves runtime changes like `config set`)
if let Ok(mut empty_config) = ConfigBuilder::new_with_id(ConfigId::Embedded).build() {
Expand All @@ -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;
}
}
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -161,18 +185,21 @@ impl TuiService {
role_config_path,
e
);
Self::new_with_embedded_defaults().await
Self::new_with_embedded_defaults(no_project_config).await
}
}
}

/// 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<Self> {
let config = ConfigBuilder::new_with_id(ConfigId::Embedded)
pub async fn new_with_embedded_defaults(no_project_config: bool) -> Result<Self> {
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
}

Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/terraphim_agent/tests/repl_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions crates/terraphim_agent/tests/tui_service_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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.";
Expand Down Expand Up @@ -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.";
Expand Down Expand Up @@ -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;

Expand All @@ -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.";
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion crates/terraphim_agent/tests/unit_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading