diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 080deb9e..489f90e4 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -32,6 +32,7 @@ pub struct AppState { pub ai_client_factory: Arc, pub tool_registry: Arc>>, pub workspace_service: Arc, + pub workspace_identity_watch_service: Arc, pub workspace_path: Arc>>, pub config_service: Arc, pub filesystem_service: Arc, @@ -65,6 +66,8 @@ impl AppState { }; let workspace_service = Arc::new(workspace::WorkspaceService::new().await?); + let workspace_identity_watch_service = + Arc::new(workspace::WorkspaceIdentityWatchService::new(workspace_service.clone())); workspace::set_global_workspace_service(workspace_service.clone()); let filesystem_service = Arc::new(filesystem::FileSystemServiceFactory::create_default()); @@ -138,6 +141,7 @@ impl AppState { ai_client_factory, tool_registry, workspace_service, + workspace_identity_watch_service, workspace_path: Arc::new(RwLock::new(initial_workspace_path)), config_service, filesystem_service, diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index f9becdf1..e5f45338 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -2,9 +2,11 @@ use crate::api::app_state::AppState; use crate::api::dto::WorkspaceInfoDto; +use bitfun_core::service::workspace::{ScanOptions, WorkspaceInfo, WorkspaceKind, WorkspaceOpenOptions}; use bitfun_core::infrastructure::{file_watcher, FileOperationOptions, SearchMatchType}; use log::{debug, error, info, warn}; use serde::Deserialize; +use std::path::Path; use tauri::{AppHandle, State}; #[derive(Debug, Deserialize)] @@ -12,6 +14,9 @@ pub struct OpenWorkspaceRequest { pub path: String, } +#[derive(Debug, Deserialize, Default)] +pub struct CreateAssistantWorkspaceRequest {} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScanWorkspaceInfoRequest { @@ -30,6 +35,18 @@ pub struct SetActiveWorkspaceRequest { pub workspace_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteAssistantWorkspaceRequest { + pub workspace_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetAssistantWorkspaceRequest { + pub workspace_id: String, +} + #[derive(Debug, Deserialize)] pub struct TestAIConfigConnectionRequest { pub config: bitfun_core::service::config::types::AIModelConfig, @@ -64,6 +81,12 @@ pub struct WriteFileContentRequest { pub content: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetWorkspacePersonaFilesRequest { + pub workspace_path: String, +} + #[derive(Debug, Deserialize)] pub struct CheckPathExistsRequest { pub path: String, @@ -310,20 +333,15 @@ pub async fn test_ai_config_connection( request: TestAIConfigConnectionRequest, ) -> Result { let model_name = request.config.name.clone(); - let supports_image_input = request - .config - .capabilities - .iter() - .any(|cap| { - matches!( - cap, - bitfun_core::service::config::types::ModelCapability::ImageUnderstanding - ) - }) - || matches!( - request.config.category, - bitfun_core::service::config::types::ModelCategory::Multimodal - ); + let supports_image_input = request.config.capabilities.iter().any(|cap| { + matches!( + cap, + bitfun_core::service::config::types::ModelCapability::ImageUnderstanding + ) + }) || matches!( + request.config.category, + bitfun_core::service::config::types::ModelCategory::Multimodal + ); let ai_config = match request.config.try_into() { Ok(config) => config, @@ -374,9 +392,7 @@ pub async fn test_ai_config_connection( let merged = bitfun_core::util::types::ConnectionTestResult { success: true, response_time_ms, - model_response: image_result - .model_response - .or(result.model_response), + model_response: image_result.model_response.or(result.model_response), error_details: None, }; info!( @@ -578,6 +594,14 @@ pub async fn open_workspace( Ok(workspace_info) => { apply_active_workspace_context(&state, &app, &workspace_info).await; + if let Err(e) = state + .workspace_identity_watch_service + .sync_watched_workspaces() + .await + { + warn!("Failed to sync workspace identity watchers after open: {}", e); + } + info!( "Workspace opened: name={}, path={}", workspace_info.name, @@ -592,6 +616,233 @@ pub async fn open_workspace( } } +#[tauri::command] +pub async fn create_assistant_workspace( + state: State<'_, AppState>, + _request: CreateAssistantWorkspaceRequest, +) -> Result { + match state.workspace_service.create_assistant_workspace(None).await { + Ok(workspace_info) => { + if let Err(e) = state + .workspace_identity_watch_service + .sync_watched_workspaces() + .await + { + warn!( + "Failed to sync workspace identity watchers after assistant workspace creation: {}", + e + ); + } + + info!( + "Assistant workspace created: workspace_id={}, path={}", + workspace_info.id, + workspace_info.root_path.display() + ); + Ok(WorkspaceInfoDto::from_workspace_info(&workspace_info)) + } + Err(e) => { + error!("Failed to create assistant workspace: {}", e); + Err(format!("Failed to create assistant workspace: {}", e)) + } + } +} + +#[tauri::command] +pub async fn delete_assistant_workspace( + state: State<'_, AppState>, + app: tauri::AppHandle, + request: DeleteAssistantWorkspaceRequest, +) -> Result<(), String> { + let workspace_info = state + .workspace_service + .get_workspace(&request.workspace_id) + .await + .ok_or_else(|| format!("Assistant workspace not found: {}", request.workspace_id))?; + + if workspace_info.workspace_kind != WorkspaceKind::Assistant { + return Err(format!( + "Workspace is not an assistant workspace: {}", + request.workspace_id + )); + } + + let assistant_id = workspace_info.assistant_id.clone().ok_or_else(|| { + "Default assistant workspace cannot be deleted".to_string() + })?; + + if !state + .workspace_service + .is_assistant_workspace_path(&workspace_info.root_path) + { + return Err(format!( + "Workspace path is not a managed assistant workspace: {}", + workspace_info.root_path.display() + )); + } + + let is_active_workspace = state + .workspace_service + .get_current_workspace() + .await + .map(|workspace| workspace.id == request.workspace_id) + .unwrap_or(false); + + if is_active_workspace { + state + .workspace_service + .close_workspace(&request.workspace_id) + .await + .map_err(|e| format!("Failed to close assistant workspace before deletion: {}", e))?; + } + + let workspace_path = workspace_info.root_path.to_string_lossy().to_string(); + + state + .filesystem_service + .delete_directory(&workspace_path, true) + .await + .map_err(|e| format!("Failed to delete assistant workspace files: {}", e))?; + + state + .workspace_service + .remove_workspace(&request.workspace_id) + .await + .map_err(|e| format!("Failed to remove assistant workspace state: {}", e))?; + + if let Some(current_workspace) = state.workspace_service.get_current_workspace().await { + apply_active_workspace_context(&state, &app, ¤t_workspace).await; + } else { + clear_active_workspace_context(&state, &app).await; + } + + if let Err(e) = state + .workspace_identity_watch_service + .sync_watched_workspaces() + .await + { + warn!( + "Failed to sync workspace identity watchers after assistant workspace deletion: {}", + e + ); + } + + info!( + "Assistant workspace deleted: workspace_id={}, assistant_id={}, path={}", + request.workspace_id, + assistant_id, + workspace_info.root_path.display() + ); + + Ok(()) +} + +async fn clear_directory_contents(directory: &Path) -> Result<(), String> { + tokio::fs::create_dir_all(directory) + .await + .map_err(|e| format!("Failed to create workspace directory '{}': {}", directory.display(), e))?; + + let mut entries = tokio::fs::read_dir(directory) + .await + .map_err(|e| format!("Failed to read workspace directory '{}': {}", directory.display(), e))?; + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| format!("Failed to iterate workspace directory '{}': {}", directory.display(), e))? + { + let entry_path = entry.path(); + let file_type = entry.file_type().await.map_err(|e| { + format!( + "Failed to inspect workspace entry '{}': {}", + entry_path.display(), + e + ) + })?; + + if file_type.is_dir() { + tokio::fs::remove_dir_all(&entry_path).await.map_err(|e| { + format!( + "Failed to remove workspace directory '{}': {}", + entry_path.display(), + e + ) + })?; + } else { + tokio::fs::remove_file(&entry_path).await.map_err(|e| { + format!( + "Failed to remove workspace file '{}': {}", + entry_path.display(), + e + ) + })?; + } + } + + Ok(()) +} + +#[tauri::command] +pub async fn reset_assistant_workspace( + state: State<'_, AppState>, + app: tauri::AppHandle, + request: ResetAssistantWorkspaceRequest, +) -> Result { + let workspace_info = state + .workspace_service + .get_workspace(&request.workspace_id) + .await + .ok_or_else(|| format!("Assistant workspace not found: {}", request.workspace_id))?; + + if workspace_info.workspace_kind != WorkspaceKind::Assistant { + return Err(format!( + "Workspace is not an assistant workspace: {}", + request.workspace_id + )); + } + + if !state + .workspace_service + .is_assistant_workspace_path(&workspace_info.root_path) + { + return Err(format!( + "Workspace path is not a managed assistant workspace: {}", + workspace_info.root_path.display() + )); + } + + clear_directory_contents(&workspace_info.root_path).await?; + + bitfun_core::service::reset_workspace_persona_files_to_default(&workspace_info.root_path) + .await + .map_err(|e| format!("Failed to restore assistant workspace persona files: {}", e))?; + + let updated_workspace = state + .workspace_service + .rescan_workspace(&request.workspace_id) + .await + .map_err(|e| format!("Failed to rescan assistant workspace after reset: {}", e))?; + + if state + .workspace_service + .get_current_workspace() + .await + .map(|workspace| workspace.id == request.workspace_id) + .unwrap_or(false) + { + apply_active_workspace_context(&state, &app, &updated_workspace).await; + } + + info!( + "Assistant workspace reset: workspace_id={}, assistant_id={:?}, path={}", + request.workspace_id, + workspace_info.assistant_id, + workspace_info.root_path.display() + ); + + Ok(WorkspaceInfoDto::from_workspace_info(&updated_workspace)) +} + #[tauri::command] pub async fn close_workspace( state: State<'_, AppState>, @@ -696,25 +947,36 @@ pub async fn get_opened_workspaces( pub async fn scan_workspace_info( state: State<'_, AppState>, request: ScanWorkspaceInfoRequest, -) -> Result { - let path = std::path::Path::new(&request.workspace_path); - let name = path.file_name().unwrap_or_default().to_string_lossy(); +) -> Result, String> { + let workspace_path = std::path::PathBuf::from(&request.workspace_path); - let files_count = match state - .filesystem_service - .build_file_tree(&request.workspace_path) + if let Some(existing_workspace) = state + .workspace_service + .get_workspace_by_path(&workspace_path) .await { - Ok(nodes) => nodes.len(), - Err(_) => 0, - }; + return state + .workspace_service + .rescan_workspace(&existing_workspace.id) + .await + .map(|workspace| Some(WorkspaceInfoDto::from_workspace_info(&workspace))) + .map_err(|e| format!("Failed to rescan workspace: {}", e)); + } - Ok(serde_json::json!({ - "path": request.workspace_path, - "name": name, - "type": "workspace", - "files_count": files_count - })) + WorkspaceInfo::new( + workspace_path, + WorkspaceOpenOptions { + scan_options: ScanOptions::default(), + auto_set_current: false, + add_to_recent: false, + workspace_kind: WorkspaceKind::Normal, + assistant_id: None, + display_name: None, + }, + ) + .await + .map(|workspace| Some(WorkspaceInfoDto::from_workspace_info(&workspace))) + .map_err(|e| format!("Failed to scan workspace info: {}", e)) } #[tauri::command] @@ -922,6 +1184,41 @@ pub async fn write_file_content( } } +#[tauri::command] +pub async fn reset_workspace_persona_files( + state: State<'_, AppState>, + request: ResetWorkspacePersonaFilesRequest, +) -> Result<(), String> { + let workspace_path = std::path::PathBuf::from(&request.workspace_path); + + if !state + .workspace_service + .is_assistant_workspace_path(&workspace_path) + { + return Err(format!( + "Workspace is not a managed assistant workspace: {}", + request.workspace_path + )); + } + + bitfun_core::service::reset_workspace_persona_files_to_default(&workspace_path) + .await + .map_err(|e| { + error!( + "Failed to reset workspace persona files: path={} error={}", + request.workspace_path, e + ); + format!("Failed to reset workspace persona files: {}", e) + })?; + + info!( + "Workspace persona files reset to defaults: path={}", + request.workspace_path + ); + + Ok(()) +} + #[tauri::command] pub async fn check_path_exists(request: CheckPathExistsRequest) -> Result { let path = std::path::Path::new(&request.path); diff --git a/src/apps/desktop/src/api/dto.rs b/src/apps/desktop/src/api/dto.rs index cf47ee99..1aeca6d6 100644 --- a/src/apps/desktop/src/api/dto.rs +++ b/src/apps/desktop/src/api/dto.rs @@ -12,6 +12,13 @@ pub enum WorkspaceTypeDto { Other, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceKindDto { + Normal, + Assistant, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProjectStatisticsDto { @@ -23,6 +30,15 @@ pub struct ProjectStatisticsDto { pub last_updated: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceIdentityDto { + pub name: Option, + pub creature: Option, + pub vibe: Option, + pub emoji: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceInfoDto { @@ -30,12 +46,15 @@ pub struct WorkspaceInfoDto { pub name: String, pub root_path: String, pub workspace_type: WorkspaceTypeDto, + pub workspace_kind: WorkspaceKindDto, + pub assistant_id: Option, pub languages: Vec, pub opened_at: String, pub last_accessed: String, pub description: Option, pub tags: Vec, pub statistics: Option, + pub identity: Option, } impl WorkspaceInfoDto { @@ -47,6 +66,8 @@ impl WorkspaceInfoDto { name: info.name.clone(), root_path: info.root_path.to_string_lossy().to_string(), workspace_type: WorkspaceTypeDto::from_workspace_type(&info.workspace_type), + workspace_kind: WorkspaceKindDto::from_workspace_kind(&info.workspace_kind), + assistant_id: info.assistant_id.clone(), languages: info.languages.clone(), opened_at: info.opened_at.to_rfc3339(), last_accessed: info.last_accessed.to_rfc3339(), @@ -56,6 +77,20 @@ impl WorkspaceInfoDto { .statistics .as_ref() .map(ProjectStatisticsDto::from_workspace_statistics), + identity: info.identity.as_ref().map(WorkspaceIdentityDto::from_workspace_identity), + } + } +} + +impl WorkspaceIdentityDto { + pub fn from_workspace_identity( + identity: &bitfun_core::service::workspace::manager::WorkspaceIdentity, + ) -> Self { + Self { + name: identity.name.clone(), + creature: identity.creature.clone(), + vibe: identity.vibe.clone(), + emoji: identity.emoji.clone(), } } } @@ -78,6 +113,18 @@ impl WorkspaceTypeDto { } } +impl WorkspaceKindDto { + pub fn from_workspace_kind( + workspace_kind: &bitfun_core::service::workspace::manager::WorkspaceKind, + ) -> Self { + use bitfun_core::service::workspace::manager::WorkspaceKind; + match workspace_kind { + WorkspaceKind::Normal => WorkspaceKindDto::Normal, + WorkspaceKind::Assistant => WorkspaceKindDto::Assistant, + } + } +} + impl ProjectStatisticsDto { pub fn from_workspace_statistics( stats: &bitfun_core::service::workspace::manager::WorkspaceStatistics, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 9f25f4f7..54708948 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -332,6 +332,7 @@ pub async fn run() { update_app_status, read_file_content, write_file_content, + reset_workspace_persona_files, check_path_exists, get_file_metadata, rename_file, @@ -546,6 +547,9 @@ pub async fn run() { get_recent_workspaces, get_opened_workspaces, open_workspace, + create_assistant_workspace, + delete_assistant_workspace, + reset_assistant_workspace, close_workspace, set_active_workspace, get_current_workspace, @@ -819,11 +823,22 @@ fn init_services(app_handle: tauri::AppHandle, default_log_level: log::LevelFilt tokio::spawn(async move { let transport = Arc::new(TauriTransportAdapter::new(app_handle.clone())); let emitter = create_event_emitter(transport); + let workspace_identity_watch_service = { + let app_state: tauri::State<'_, api::app_state::AppState> = app_handle.state(); + app_state.workspace_identity_watch_service.clone() + }; service::snapshot::initialize_snapshot_event_emitter(emitter.clone()); infrastructure::initialize_file_watcher(emitter.clone()); + if let Err(e) = workspace_identity_watch_service + .set_event_emitter(emitter.clone()) + .await + { + log::error!("Failed to initialize workspace identity watch service: {}", e); + } + if let Err(e) = service::lsp::initialize_global_lsp_manager().await { log::error!("Failed to initialize LSP manager: {}", e); } diff --git a/src/crates/core/src/agentic/agents/claw_mode.rs b/src/crates/core/src/agentic/agents/claw_mode.rs new file mode 100644 index 00000000..4b9900c8 --- /dev/null +++ b/src/crates/core/src/agentic/agents/claw_mode.rs @@ -0,0 +1,63 @@ +//! Claw Mode + +use super::Agent; +use async_trait::async_trait; +pub struct ClawMode { + default_tools: Vec, +} + +impl ClawMode { + pub fn new() -> Self { + Self { + default_tools: vec![ + "Task".to_string(), + "Read".to_string(), + "Write".to_string(), + "Edit".to_string(), + "Delete".to_string(), + "Bash".to_string(), + "Grep".to_string(), + "Glob".to_string(), + "WebSearch".to_string(), + "IdeControl".to_string(), + "MermaidInteractive".to_string(), + "view_image".to_string(), + "Skill".to_string(), + "Git".to_string(), + "TerminalControl".to_string(), + "SessionControl".to_string(), + ], + } + } +} + +#[async_trait] +impl Agent for ClawMode { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + "Claw" + } + + fn name(&self) -> &str { + "Claw" + } + + fn description(&self) -> &str { + "Personal assistant for daily tasks" + } + + fn prompt_template_name(&self) -> &str { + "claw_mode" + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn is_readonly(&self) -> bool { + false + } +} diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index 3cecf41d..e365be8a 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -7,6 +7,7 @@ mod prompt_builder; mod registry; // Modes mod agentic_mode; +mod claw_mode; mod cowork_mode; mod debug_mode; mod plan_mode; @@ -17,18 +18,18 @@ mod file_finder_agent; mod code_review_agent; mod generate_doc_agent; +use crate::util::errors::{BitFunError, BitFunResult}; pub use agentic_mode::AgenticMode; +use async_trait::async_trait; +pub use claw_mode::ClawMode; pub use code_review_agent::CodeReviewAgent; pub use cowork_mode::CoworkMode; +pub use custom_subagents::{CustomSubagent, CustomSubagentKind}; pub use debug_mode::DebugMode; pub use explore_agent::ExploreAgent; pub use file_finder_agent::FileFinderAgent; pub use generate_doc_agent::GenerateDocAgent; pub use plan_mode::PlanMode; - -use crate::util::errors::{BitFunError, BitFunResult}; -use async_trait::async_trait; -pub use custom_subagents::{CustomSubagent, CustomSubagentKind}; pub use prompt_builder::PromptBuilder; pub use registry::{ get_agent_registry, AgentCategory, AgentInfo, AgentRegistry, CustomSubagentConfig, diff --git a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs index 2e3a95e3..40dfb7f7 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs @@ -1,8 +1,10 @@ //! System prompts module providing main dialogue and agent dialogue prompts use crate::agentic::util::get_formatted_files_list; use crate::infrastructure::try_get_path_manager_arc; +use crate::service::agent_memory::build_workspace_agent_memory_prompt; use crate::service::ai_memory::AIMemoryManager; use crate::service::ai_rules::get_global_ai_rules_service; +use crate::service::bootstrap::build_workspace_persona_prompt; use crate::service::config::global::GlobalConfigManager; use crate::service::project_context::ProjectContextService; use crate::util::errors::{BitFunError, BitFunResult}; @@ -10,6 +12,7 @@ use log::{debug, warn}; use std::path::Path; /// Placeholder constants +const PLACEHOLDER_PERSONA: &str = "{PERSONA}"; const PLACEHOLDER_ENV_INFO: &str = "{ENV_INFO}"; const PLACEHOLDER_PROJECT_LAYOUT: &str = "{PROJECT_LAYOUT}"; // PROJECT_CONTEXT_FILES needs configuration parsing @@ -17,6 +20,8 @@ const PLACEHOLDER_PROJECT_LAYOUT: &str = "{PROJECT_LAYOUT}"; const PLACEHOLDER_RULES: &str = "{RULES}"; const PLACEHOLDER_MEMORIES: &str = "{MEMORIES}"; const PLACEHOLDER_LANGUAGE_PREFERENCE: &str = "{LANGUAGE_PREFERENCE}"; +const PLACEHOLDER_AGENT_MEMORY: &str = "{AGENT_MEMORY}"; +const PLACEHOLDER_CLAW_WORKSPACE: &str = "{CLAW_WORKSPACE}"; const PLACEHOLDER_VISUAL_MODE: &str = "{VISUAL_MODE}"; pub struct PromptBuilder { @@ -211,13 +216,28 @@ Prefer MermaidInteractive tool when available, otherwise output Mermaid code blo Ok(format!("# Language Preference\nYou MUST respond in {} regardless of the user's input language. This is the system language setting and should be followed unless the user explicitly specifies a different language. This is crucial for smooth communication and user experience\n", language)) } + /// Get Claw-specific workspace boundary instruction + fn get_claw_workspace_instruction(&self) -> String { + format!( + "# Workspace +Your dedicated operating space is `{}`. +Prefer doing work inside this workspace and keep it well organized with clear structure, sensible filenames, and minimal clutter. +Do not read from, modify, create, move, or delete files outside this workspace unless the user has explicitly granted permission for that external action. +", + self.workspace_path + ) + } + /// Build prompt from template, automatically fill content based on placeholders /// /// Supported placeholders: + /// - `{PERSONA}` - Workspace persona files (BOOTSTRAP.md, SOUL.md, USER.md, IDENTITY.md) /// - `{LANGUAGE_PREFERENCE}` - User language preference (read from global config) /// - `{ENV_INFO}` - Environment information /// - `{PROJECT_LAYOUT}` - Project file layout /// - `{PROJECT_CONTEXT_FILES}` - Project context files (AGENTS.md, CLAUDE.md, etc.) + /// - `{AGENT_MEMORY}` - Agent memory instructions + auto-loaded memory index + /// - `{CLAW_WORKSPACE}` - Claw-specific workspace ownership and boundary rules /// - `{RULES}` - AI rules /// - `{MEMORIES}` - AI memories /// - `{VISUAL_MODE}` - Visual mode instruction (Mermaid diagrams, read from global config) @@ -226,12 +246,35 @@ Prefer MermaidInteractive tool when available, otherwise output Mermaid code blo pub async fn build_prompt_from_template(&self, template: &str) -> BitFunResult { let mut result = template.to_string(); + // Replace {PERSONA} + if result.contains(PLACEHOLDER_PERSONA) { + let workspace = Path::new(&self.workspace_path); + let persona = match build_workspace_persona_prompt(workspace).await { + Ok(prompt) => prompt.unwrap_or_default(), + Err(e) => { + warn!( + "Failed to build workspace persona prompt: path={} error={}", + workspace.display(), + e + ); + String::new() + } + }; + result = result.replace(PLACEHOLDER_PERSONA, &persona); + } + // Replace {LANGUAGE_PREFERENCE} if result.contains(PLACEHOLDER_LANGUAGE_PREFERENCE) { let language_preference = self.get_language_preference().await?; result = result.replace(PLACEHOLDER_LANGUAGE_PREFERENCE, &language_preference); } + // Replace {CLAW_WORKSPACE} + if result.contains(PLACEHOLDER_CLAW_WORKSPACE) { + let claw_workspace = self.get_claw_workspace_instruction(); + result = result.replace(PLACEHOLDER_CLAW_WORKSPACE, &claw_workspace); + } + // Replace {ENV_INFO} if result.contains(PLACEHOLDER_ENV_INFO) { let env_info = self.get_env_info(); @@ -279,6 +322,23 @@ Prefer MermaidInteractive tool when available, otherwise output Mermaid code blo result = result.replace(placeholder, &project_context); } + // Replace {AGENT_MEMORY} + if result.contains(PLACEHOLDER_AGENT_MEMORY) { + let workspace = Path::new(&self.workspace_path); + let agent_memory = match build_workspace_agent_memory_prompt(workspace).await { + Ok(prompt) => prompt, + Err(e) => { + warn!( + "Failed to build workspace agent memory prompt: path={} error={}", + workspace.display(), + e + ); + String::new() + } + }; + result = result.replace(PLACEHOLDER_AGENT_MEMORY, &agent_memory); + } + // Replace {RULES} if result.contains(PLACEHOLDER_RULES) { let rules = self.load_ai_rules().await.unwrap_or_default(); diff --git a/src/crates/core/src/agentic/agents/prompts/claw_mode.md b/src/crates/core/src/agentic/agents/prompts/claw_mode.md new file mode 100644 index 00000000..e7052d43 --- /dev/null +++ b/src/crates/core/src/agentic/agents/prompts/claw_mode.md @@ -0,0 +1,22 @@ +You are a personal assistant running inside BitFun. + +{LANGUAGE_PREFERENCE} +# Tool Call Style +Default: do not narrate routine, low-risk tool calls (just call the tool). +Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks. +Keep narration brief and value-dense; avoid repeating obvious steps. +Use plain human language for narration unless in a technical context. +When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI commands. + +# Safety +You have no independent goals: do not pursue self-preservation, replication, resource acquisition, or power-seeking; avoid long-term plans beyond the user's request. +Prioritize safety and human oversight over completion; if instructions conflict, pause and ask; comply with stop/pause/audit requests and never bypass safeguards. +Do not manipulate or persuade anyone to expand access or disable safeguards. Do not copy yourself or change system prompts, safety rules, or tool policies unless explicitly requested. + +{CLAW_WORKSPACE} +{ENV_INFO} +{PERSONA} +{AGENT_MEMORY} +{RULES} +{MEMORIES} +{PROJECT_CONTEXT_FILES:exclude=review} \ No newline at end of file diff --git a/src/crates/core/src/agentic/agents/prompts/plan_mode.md b/src/crates/core/src/agentic/agents/prompts/plan_mode.md index fe0e39e6..30734a86 100644 --- a/src/crates/core/src/agentic/agents/prompts/plan_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/plan_mode.md @@ -38,9 +38,9 @@ At any point in time through this workflow you should feel free to ask the user # Plan Creation and Update -1. When you're done researching, present your plan by calling the CreatePlan tool, which creates a plan file and prompts the user to confirm the plan. Do NOT make any file changes or run any tools that modify the system state in any way. +1. When you're done researching, present your plan by calling the CreatePlan tool, which creates a plan file for user approval. Do NOT make any file changes or run any tools that modify the system state in any way. -2. Once the CreatePlan tool is called, the current conversation turn will end. Make sure you have completed all necessary research and clarifications before calling this tool. +2. After the CreatePlan tool succeeds, briefly tell the user the plan is ready and wait for user approval. Your final reply in that turn MUST include the exact returned plan file path. Do not continue with more research or additional planning work in the same turn. 3. To update the plan, edit the plan file returned by the CreatePlan tool directly. diff --git a/src/crates/core/src/agentic/agents/registry.rs b/src/crates/core/src/agentic/agents/registry.rs index 97c7b3c2..0659bc73 100644 --- a/src/crates/core/src/agentic/agents/registry.rs +++ b/src/crates/core/src/agentic/agents/registry.rs @@ -1,6 +1,6 @@ use super::{ - Agent, AgenticMode, CodeReviewAgent, CoworkMode, DebugMode, ExploreAgent, FileFinderAgent, - GenerateDocAgent, PlanMode, + Agent, AgenticMode, ClawMode, CodeReviewAgent, CoworkMode, DebugMode, ExploreAgent, + FileFinderAgent, GenerateDocAgent, PlanMode, }; use crate::agentic::agents::custom_subagents::{ CustomSubagent, CustomSubagentKind, CustomSubagentLoader, @@ -236,6 +236,7 @@ impl AgentRegistry { Arc::new(CoworkMode::new()), Arc::new(DebugMode::new()), Arc::new(PlanMode::new()), + Arc::new(ClawMode::new()), ]; for mode in modes { register(&mut agents, mode, AgentCategory::Mode, None); diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 8417638b..2c854799 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -15,6 +15,7 @@ use crate::agentic::image_analysis::ImageContextData; use crate::agentic::session::SessionManager; use crate::agentic::tools::pipeline::{SubagentParentInfo, ToolPipeline}; use crate::agentic::WorkspaceBinding; +use crate::service::workspace::get_global_workspace_service; use crate::util::errors::{BitFunError, BitFunResult}; use log::{debug, error, info, warn}; use std::path::{Path, PathBuf}; @@ -25,13 +26,11 @@ use tokio_util::sync::CancellationToken; /// Subagent execution result /// -/// Contains the text response and optional tool arguments after subagent execution +/// Contains the text response after subagent execution #[derive(Debug, Clone)] pub struct SubagentResult { /// AI text response pub text: String, - /// Tool call arguments for ending the conversation - pub tool_arguments: Option, } #[derive(Debug, Clone, Copy)] @@ -102,6 +101,41 @@ impl ConversationCoordinator { .map(|workspace_path| WorkspaceBinding::new(None, PathBuf::from(workspace_path))) } + async fn normalize_agent_type_for_workspace_path( + agent_type: &str, + workspace_path: &str, + ) -> String { + let normalized_agent_type = if agent_type.trim().is_empty() { + "agentic".to_string() + } else { + agent_type.trim().to_string() + }; + + let Some(workspace_service) = get_global_workspace_service() else { + return normalized_agent_type; + }; + + let workspace_path_buf = PathBuf::from(workspace_path); + if workspace_service.is_assistant_workspace_path(&workspace_path_buf) + || workspace_service + .get_workspace_by_path(&workspace_path_buf) + .await + .map(|workspace| workspace.workspace_kind == crate::service::workspace::WorkspaceKind::Assistant) + .unwrap_or(false) + { + if normalized_agent_type != "Claw" { + info!( + "Normalize agent type to Claw for assistant workspace: workspace_path={}, requested_agent_type={}", + workspace_path, + normalized_agent_type + ); + } + return "Claw".to_string(); + } + + normalized_agent_type + } + pub fn new( session_manager: Arc, execution_engine: Arc, @@ -135,8 +169,15 @@ impl ConversationCoordinator { let workspace_path = config.workspace_path.clone().ok_or_else(|| { BitFunError::Validation("workspace_path is required when creating a session".to_string()) })?; - self.create_session_with_workspace(None, session_name, agent_type, config, workspace_path) - .await + self.create_session_with_workspace_and_creator( + None, + session_name, + agent_type, + config, + workspace_path, + None, + ) + .await } /// Create a new session with optional session ID @@ -150,27 +191,63 @@ impl ConversationCoordinator { let workspace_path = config.workspace_path.clone().ok_or_else(|| { BitFunError::Validation("workspace_path is required when creating a session".to_string()) })?; - self.create_session_with_workspace(session_id, session_name, agent_type, config, workspace_path) - .await + self.create_session_with_workspace_and_creator( + session_id, + session_name, + agent_type, + config, + workspace_path, + None, + ) + .await } /// Create a new session with optional session ID and workspace binding. /// `workspace_path` is forwarded in the `SessionCreated` event and also stored /// in the session's in-memory config so it can be retrieved without disk access. pub async fn create_session_with_workspace( + &self, + session_id: Option, + session_name: String, + agent_type: String, + config: SessionConfig, + workspace_path: String, + ) -> BitFunResult { + self.create_session_with_workspace_and_creator( + session_id, + session_name, + agent_type, + config, + workspace_path, + None, + ) + .await + } + + /// Create a new session with explicit creator identity. + pub async fn create_session_with_workspace_and_creator( &self, session_id: Option, session_name: String, agent_type: String, mut config: SessionConfig, workspace_path: String, + created_by: Option, ) -> BitFunResult { // Persist the workspace binding inside the session config so execution can // consistently restore the correct workspace regardless of the entry point. config.workspace_path = Some(workspace_path.clone()); + let agent_type = + Self::normalize_agent_type_for_workspace_path(&agent_type, &workspace_path).await; let session = self .session_manager - .create_session_with_id(session_id, session_name, agent_type, config) + .create_session_with_id_and_creator( + session_id, + session_name, + agent_type, + config, + created_by, + ) .await?; self.sync_session_metadata_to_workspace(&session, workspace_path.clone()) @@ -235,6 +312,10 @@ impl ConversationCoordinator { session_id: session.session_id.clone(), session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), + created_by: session + .created_by + .clone() + .or_else(|| existing.as_ref().and_then(|m| m.created_by.clone())), model_name: existing .as_ref() .map(|m| m.model_name.clone()) @@ -359,9 +440,16 @@ impl ConversationCoordinator { session_name: String, agent_type: String, config: SessionConfig, + creator_session_id: Option<&str>, ) -> BitFunResult { self.session_manager - .create_session_with_id(None, session_name, agent_type, config) + .create_session_with_id_and_creator( + None, + session_name, + agent_type, + config, + creator_session_id.map(|session_id| format!("session-{}", session_id)), + ) .await } @@ -603,14 +691,22 @@ impl ConversationCoordinator { }; let requested_agent_type = agent_type.trim().to_string(); - - let effective_agent_type = if !requested_agent_type.is_empty() { + let workspace_path_for_policy = workspace_path + .clone() + .or_else(|| session.config.workspace_path.clone()); + let provisional_agent_type = if !requested_agent_type.is_empty() { requested_agent_type.clone() } else if !session.agent_type.is_empty() { session.agent_type.clone() } else { "agentic".to_string() }; + let effective_agent_type = if let Some(ref workspace_path) = workspace_path_for_policy { + Self::normalize_agent_type_for_workspace_path(&provisional_agent_type, workspace_path) + .await + } else { + provisional_agent_type + }; debug!( "Resolved dialog turn agent type: session_id={}, turn_id={}, requested_agent_type={}, session_agent_type={}, effective_agent_type={}, trigger_source={:?}", @@ -1176,7 +1272,12 @@ impl ConversationCoordinator { /// Delete session pub async fn delete_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { - self.session_manager.delete_session(workspace_path, session_id).await + self.session_manager.delete_session(workspace_path, session_id).await?; + self.emit_event(AgenticEvent::SessionDeleted { + session_id: session_id.to_string(), + }) + .await; + Ok(()) } /// Restore session @@ -1255,12 +1356,13 @@ impl ConversationCoordinator { /// - context: Additional context /// - cancel_token: Optional cancel token (for async cancellation) /// - /// Returns SubagentResult with text response and optional tool arguments + /// Returns SubagentResult with the final text response pub async fn execute_subagent( &self, agent_type: String, task_description: String, subagent_parent_info: SubagentParentInfo, + workspace_path: Option, context: Option>, cancel_token: Option<&CancellationToken>, ) -> BitFunResult { @@ -1278,11 +1380,19 @@ impl ConversationCoordinator { // Use create_subagent_session (not create_session) so that no SessionCreated // event is emitted to the transport layer — subagent sessions are internal // implementation details and must not appear in the UI session list. + let workspace_path = workspace_path.ok_or_else(|| { + BitFunError::Validation( + "workspace_path is required when creating a subagent session".to_string(), + ) + })?; + let mut subagent_config = SessionConfig::default(); + subagent_config.workspace_path = Some(workspace_path); let session = self .create_subagent_session( format!("Subagent: {}", task_description), agent_type.clone(), - Default::default(), + subagent_config, + Some(&subagent_parent_info.session_id), ) .await?; @@ -1348,20 +1458,12 @@ impl ConversationCoordinator { // cleanup_guard automatically cleans up token on scope exit (via Drop trait) - // Extract text response and tool arguments - let (response_text, tool_arguments) = match result { + // Extract text response + let response_text = match result { Ok(exec_result) => match exec_result.final_message.content { - MessageContent::Mixed { - text, tool_calls, .. - } => (text, { - // Find first should_end_turn tool arguments, tool_pipeline guarantees only one - tool_calls - .into_iter() - .find(|tc| tc.should_end_turn) - .map(|tc| tc.arguments) - }), - MessageContent::Text(text) => (text, None), - _ => (String::new(), None), + MessageContent::Mixed { text, .. } => text, + MessageContent::Text(text) => text, + _ => String::new(), }, Err(e) => { error!( @@ -1398,10 +1500,7 @@ impl ConversationCoordinator { ); } - Ok(SubagentResult { - text: response_text, - tool_arguments, - }) + Ok(SubagentResult { text: response_text }) } /// Clean up subagent session resources diff --git a/src/crates/core/src/agentic/core/message.rs b/src/crates/core/src/agentic/core/message.rs index 59d75ade..bca0b912 100644 --- a/src/crates/core/src/agentic/core/message.rs +++ b/src/crates/core/src/agentic/core/message.rs @@ -492,9 +492,6 @@ pub struct ToolCall { pub arguments: serde_json::Value, /// Record whether tool parameters are valid pub is_error: bool, - /// Record whether tool is a should_end_turn tool, to avoid frequent tool_registry calls - #[serde(skip)] - pub should_end_turn: bool, } impl ToolCall { diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index 2b913b63..e0183010 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -11,6 +11,8 @@ pub struct Session { pub session_id: String, pub session_name: String, pub agent_type: String, + #[serde(default, skip_serializing_if = "Option::is_none", alias = "created_by", alias = "createdBy")] + pub created_by: Option, /// Associated resources #[serde(skip_serializing_if = "Option::is_none", alias = "sandbox_session_id", alias = "sandboxSessionId")] @@ -66,6 +68,7 @@ impl Session { session_id: Uuid::new_v4().to_string(), session_name, agent_type, + created_by: None, snapshot_session_id: None, dialog_turn_ids: vec![], state: SessionState::Idle, @@ -88,6 +91,7 @@ impl Session { session_id, session_name, agent_type, + created_by: None, snapshot_session_id: None, dialog_turn_ids: vec![], state: SessionState::Idle, @@ -142,6 +146,8 @@ pub struct SessionSummary { pub session_id: String, pub session_name: String, pub agent_type: String, + #[serde(default, skip_serializing_if = "Option::is_none", alias = "created_by", alias = "createdBy")] + pub created_by: Option, pub turn_count: usize, pub created_at: SystemTime, pub last_activity_at: SystemTime, diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index f7bb9481..357f069e 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -434,27 +434,6 @@ impl RoundExecutor { tool_results.len() ); - // Detect tool calls that should end the dialog turn (requires should_end_turn and execution result has no error) - // When there is only 1 should_end_turn tool → Execute normally, end turn on success - // When there are multiple should_end_turn tools → These tools are marked as errors in pipeline → Because is_error=true, won't trigger end turn → Model can re-call correctly in next round after receiving error message - let has_end_turn_tool = { - stream_result.tool_calls.iter().any(|tool_call| { - // Check if tool should_end_turn - if !tool_call.should_end_turn { - return false; - } - - // Check if this tool's execution result has no error - let result_not_error = tool_results - .iter() - .find(|r| r.tool_id == tool_call.tool_id) - .map(|r| !r.is_error) - .unwrap_or(false); - - result_not_error - }) - }; - // Create tool result messages (also need to set turn_id and round_id) let dialog_turn_id = context.dialog_turn_id.clone(); let round_id_clone = round_id.clone(); @@ -504,13 +483,12 @@ impl RoundExecutor { ); } - let has_more_rounds = !has_end_turn_tool && !tool_result_messages.is_empty(); + let has_more_rounds = !tool_result_messages.is_empty(); debug!( - "Returning RoundResult: has_more_rounds={}, tool_result_messages={}, has_end_turn_tool={}", + "Returning RoundResult: has_more_rounds={}, tool_result_messages={}", has_more_rounds, - tool_result_messages.len(), - has_end_turn_tool + tool_result_messages.len() ); // Note: Do not cleanup cancellation token here, as there may be subsequent model rounds diff --git a/src/crates/core/src/agentic/execution/stream_processor.rs b/src/crates/core/src/agentic/execution/stream_processor.rs index 05c589cc..da782871 100644 --- a/src/crates/core/src/agentic/execution/stream_processor.rs +++ b/src/crates/core/src/agentic/execution/stream_processor.rs @@ -7,7 +7,6 @@ use crate::agentic::events::{ AgenticEvent, EventPriority, EventQueue, SubagentParentInfo as EventSubagentParentInfo, ToolEventData, }; -use crate::agentic::tools::registry::get_all_end_turn_tool_names; use crate::agentic::tools::SubagentParentInfo; use crate::util::errors::BitFunError; use crate::util::types::ai::GeminiUsage; @@ -16,7 +15,6 @@ use ai_stream_handlers::UnifiedResponse; use futures::StreamExt; use log::{debug, error, trace}; use serde_json::{json, Value}; -use std::collections::HashSet; use std::sync::Arc; use tokio::sync::mpsc; @@ -114,7 +112,6 @@ struct ToolCallBuffer { tool_id: String, tool_name: String, json_checker: JsonChecker, - end_turn_tools: Option>, } impl ToolCallBuffer { @@ -123,14 +120,9 @@ impl ToolCallBuffer { tool_id: String::new(), tool_name: String::new(), json_checker: JsonChecker::new(), - end_turn_tools: None, } } - pub fn set_end_turn_tools(&mut self, end_turn_tools: HashSet) { - self.end_turn_tools = Some(end_turn_tools); - } - fn reset(&mut self) { self.tool_id.clear(); self.tool_name.clear(); @@ -148,17 +140,11 @@ impl ToolCallBuffer { fn to_tool_call(&self) -> ToolCall { let arguments = serde_json::from_str(&self.json_checker.get_buffer()); let is_error = arguments.is_err(); - let should_end_turn = self - .end_turn_tools - .as_ref() - .map(|tools| tools.contains(&self.tool_name)) - .unwrap_or(false); ToolCall { tool_id: self.tool_id.clone(), tool_name: self.tool_name.clone(), arguments: arguments.unwrap_or(json!({})), is_error, - should_end_turn, } } } @@ -220,7 +206,6 @@ struct StreamContext { thinking_chunks_count: usize, thinking_completed_sent: bool, has_effective_output: bool, - encountered_end_turn_tool: bool, } impl StreamContext { @@ -248,7 +233,6 @@ impl StreamContext { thinking_chunks_count: 0, thinking_completed_sent: false, has_effective_output: false, - encountered_end_turn_tool: false, } } @@ -539,13 +523,6 @@ impl StreamProcessor { // Check if JSON is complete if ctx.tool_call_buffer.is_valid() { let tool_call = ctx.tool_call_buffer.to_tool_call(); - if tool_call.should_end_turn { - debug!( - "End-turn tool fully detected during streaming: {} ({})", - tool_call.tool_name, tool_call.tool_id - ); - ctx.encountered_end_turn_tool = true; - } ctx.tool_calls.push(tool_call); // Clear buffer @@ -674,9 +651,6 @@ impl StreamProcessor { let chunk_timeout = std::time::Duration::from_secs(600); let mut ctx = StreamContext::new(session_id, dialog_turn_id, round_id, subagent_parent_info); - let end_turn_tools = get_all_end_turn_tool_names().await.into_iter().collect(); - ctx.tool_call_buffer.set_end_turn_tools(end_turn_tools); - // Start SSE log collector (if raw_sse_rx is provided) let sse_collector = if let Some(mut rx) = raw_sse_rx { let collector = Arc::new(tokio::sync::Mutex::new(SseLogCollector::new( @@ -801,13 +775,6 @@ impl StreamProcessor { if let Some(err) = self.check_cancellation(&mut ctx, cancellation_token, "processing tool call").await { return err; } - if ctx.encountered_end_turn_tool { - debug!( - "Stopping stream after end-turn tool detection: session_id={}, turn_id={}", - ctx.session_id, ctx.dialog_turn_id - ); - break; - } } } } diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 39385421..8d9fc6d5 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -404,6 +404,10 @@ impl PersistenceManager { session_id: session.session_id.clone(), session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), + created_by: session + .created_by + .clone() + .or_else(|| existing.and_then(|value| value.created_by.clone())), model_name, created_at, last_active_at, @@ -516,6 +520,10 @@ impl PersistenceManager { } pub async fn list_session_metadata(&self, workspace_path: &Path) -> BitFunResult> { + if !workspace_path.exists() { + return Ok(Vec::new()); + } + let index_path = self.index_path(workspace_path); if let Some(index) = self.read_json_optional::(&index_path).await? { return Ok(index.sessions); @@ -706,6 +714,9 @@ impl PersistenceManager { /// Save session pub async fn save_session(&self, workspace_path: &Path, session: &Session) -> BitFunResult<()> { + if !workspace_path.exists() { + return Ok(()); + } self.ensure_session_dir(workspace_path, &session.session_id).await?; let existing_metadata = self @@ -762,6 +773,7 @@ impl PersistenceManager { session_id: metadata.session_id.clone(), session_name: metadata.session_name.clone(), agent_type: metadata.agent_type.clone(), + created_by: metadata.created_by.clone(), snapshot_session_id: stored_state .and_then(|value| value.snapshot_session_id) .or(metadata.snapshot_session_id.clone()), @@ -831,6 +843,7 @@ impl PersistenceManager { session_id: metadata.session_id, session_name: metadata.session_name, agent_type: metadata.agent_type, + created_by: metadata.created_by, turn_count: metadata.turn_count, created_at: Self::unix_ms_to_system_time(metadata.created_at), last_activity_at: Self::unix_ms_to_system_time(metadata.last_active_at), diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index b4133ce4..8fa24fe9 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -172,7 +172,7 @@ impl SessionManager { agent_type: String, config: SessionConfig, ) -> BitFunResult { - self.create_session_with_id(None, session_name, agent_type, config) + self.create_session_with_id_and_creator(None, session_name, agent_type, config, None) .await } @@ -183,6 +183,19 @@ impl SessionManager { session_name: String, agent_type: String, config: SessionConfig, + ) -> BitFunResult { + self.create_session_with_id_and_creator(session_id, session_name, agent_type, config, None) + .await + } + + /// Create a new session (supports specifying session ID and creator identity) + pub async fn create_session_with_id_and_creator( + &self, + session_id: Option, + session_name: String, + agent_type: String, + config: SessionConfig, + created_by: Option, ) -> BitFunResult { let workspace_path = Self::session_workspace_from_config(&config).ok_or_else(|| { BitFunError::Validation("Session workspace_path is required".to_string()) @@ -196,11 +209,12 @@ impl SessionManager { ))); } - let session = if let Some(id) = session_id { + let mut session = if let Some(id) = session_id { Session::new_with_id(id, session_name, agent_type.clone(), config) } else { Session::new(session_name, agent_type.clone(), config) }; + session.created_by = created_by; let session_id = session.session_id.clone(); // 1. Add to memory @@ -559,6 +573,7 @@ impl SessionManager { session_id: session.session_id.clone(), session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), + created_by: session.created_by.clone(), turn_count: session.dialog_turn_ids.len(), created_at: session.created_at, last_activity_at: session.last_activity_at, diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index fbcfed41..b3c13976 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -177,11 +177,6 @@ pub trait Tool: Send + Sync { false } - /// Whether to end conversation turn after calling (CreatePlan) - fn should_end_turn(&self) -> bool { - false - } - /// Validate input async fn validate_input( &self, diff --git a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs b/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs index 75b0b286..861e7e4f 100644 --- a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs @@ -251,10 +251,6 @@ Usage notes: true } - fn should_end_turn(&self) -> bool { - false - } - async fn call_impl( &self, input: &Value, diff --git a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs index 4b8c0196..dda652b5 100644 --- a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs @@ -201,10 +201,6 @@ impl Tool for CodeReviewTool { true } - fn should_end_turn(&self) -> bool { - false - } - fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { true } diff --git a/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs b/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs index 0376b531..557b5dc7 100644 --- a/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/create_plan_tool.rs @@ -76,6 +76,7 @@ UPDATING THE PLAN: Additional guidelines: - Avoid asking clarifying questions in the plan itself. Ask them before calling this tool. Present these to the user using the AskUserQuestion tool. +- After calling this tool, you should end the conversation turn. Briefly tell the user where the plan file is. Do NOT repeat the plan content again. - Todos help break down complex plans into manageable, trackable tasks - Focus on high-level meaningful decisions rather than low-level implementation details - A good plan is glanceable, not a wall of text."### @@ -138,11 +139,6 @@ Additional guidelines: true } - fn should_end_turn(&self) -> bool { - // End conversation turn after creating plan file to avoid being verbose - true - } - async fn call_impl( &self, input: &Value, @@ -229,7 +225,7 @@ Additional guidelines: }; let result_for_assistant = format!( - "Plan file created at: {}\nYou can read the plan contents from this file. To update the plan, use your file editing tools directly on this file.", + "Plan file created at: {}\nYour next reply MUST include this exact plan file path and then end the conversation turn. Do not continue with more planning details or additional questions.", plan_file_path_str ); diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index 519e4558..a398cb73 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -17,6 +17,7 @@ pub mod linter_tool; pub mod log_tool; pub mod ls_tool; pub mod mermaid_interactive_tool; +pub mod session_control_tool; pub mod skill_tool; pub mod skills; pub mod miniapp_init_tool; @@ -44,6 +45,7 @@ pub use linter_tool::ReadLintsTool; pub use log_tool::LogTool; pub use ls_tool::LSTool; pub use mermaid_interactive_tool::MermaidInteractiveTool; +pub use session_control_tool::SessionControlTool; pub use skill_tool::SkillTool; pub use task_tool::TaskTool; pub use terminal_control_tool::TerminalControlTool; diff --git a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs new file mode 100644 index 00000000..1f7f5e0a --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs @@ -0,0 +1,379 @@ +use super::util::resolve_path_with_workspace; +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::core::SessionConfig; +use crate::agentic::tools::framework::{ + Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::Path; + +/// SessionControl tool - create, delete, or list persisted sessions +pub struct SessionControlTool; + +impl SessionControlTool { + pub fn new() -> Self { + Self + } + + fn validate_session_id(session_id: &str) -> Result<(), String> { + if session_id.is_empty() { + return Err("session_id cannot be empty".to_string()); + } + if session_id == "." || session_id == ".." { + return Err("session_id cannot be '.' or '..'".to_string()); + } + if session_id.contains('/') || session_id.contains('\\') { + return Err("session_id cannot contain path separators".to_string()); + } + if !session_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err( + "session_id can only contain ASCII letters, numbers, '-' and '_'".to_string(), + ); + } + Ok(()) + } + + fn resolve_workspace(&self, workspace: &str, context: &ToolUseContext) -> BitFunResult { + let resolved = resolve_path_with_workspace(workspace, context.workspace_root())?; + let path = Path::new(&resolved); + if !path.exists() { + return Err(BitFunError::tool(format!( + "Workspace does not exist: {}", + resolved + ))); + } + if !path.is_dir() { + return Err(BitFunError::tool(format!( + "Workspace is not a directory: {}", + resolved + ))); + } + Ok(resolved) + } + + fn default_session_name() -> String { + "New Session".to_string() + } + + fn escape_markdown_table_cell(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('|', "\\|") + .replace('\n', "
") + } + + fn creator_session_marker(&self, context: &ToolUseContext) -> BitFunResult { + let creator_session_id = context.session_id.as_ref().ok_or_else(|| { + BitFunError::tool("create requires a creator session in tool context".to_string()) + })?; + Ok(format!("session-{}", creator_session_id)) + } + + fn build_list_result_for_assistant( + &self, + workspace: &str, + sessions: &[crate::agentic::core::SessionSummary], + ) -> String { + if sessions.is_empty() { + return format!("No sessions found in workspace '{}'.", workspace); + } + + let mut lines = vec![format!( + "Found {} session(s) in workspace '{}':", + sessions.len(), + workspace + )]; + lines.push(String::new()); + lines.push("| Session ID | Session Name | Agent Type |".to_string()); + lines.push("| --- | --- | --- |".to_string()); + for session in sessions { + lines.push(format!( + "| {} | {} | {} |", + Self::escape_markdown_table_cell(&session.session_id), + Self::escape_markdown_table_cell(&session.session_name), + &session.agent_type + )); + } + lines.join("\n") + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "lowercase")] +enum SessionControlAction { + Create, + Delete, + List, +} + +#[derive(Debug, Clone, Deserialize)] +struct SessionControlInput { + action: SessionControlAction, + workspace: String, + session_id: Option, + session_name: Option, +} + +#[async_trait] +impl Tool for SessionControlTool { + fn name(&self) -> &str { + "SessionControl" + } + + async fn description(&self) -> BitFunResult { + Ok( + r#"Manage persisted workspace-scoped agent conversation sessions. + +Actions: +- "create": Create a new session. You may optionally provide session_name. +- "delete": Delete an existing session by session_id. +- "list": List all sessions."# + .to_string(), + ) + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "delete", "list"], + "description": "The session action to perform." + }, + "workspace": { + "type": "string", + "description": "Workspace path. Can be absolute or relative to the current workspace." + }, + "session_id": { + "type": "string", + "description": "Required for delete." + }, + "session_name": { + "type": "string", + "description": "Optional display name when creating a session." + } + }, + "required": ["action", "workspace"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + let parsed: SessionControlInput = match serde_json::from_value(input.clone()) { + Ok(value) => value, + Err(err) => { + return ValidationResult { + result: false, + message: Some(format!("Invalid input: {}", err)), + error_code: Some(400), + meta: None, + }; + } + }; + + if parsed.workspace.trim().is_empty() { + return ValidationResult { + result: false, + message: Some("workspace is required".to_string()), + error_code: Some(400), + meta: None, + }; + } + + match parsed.action { + SessionControlAction::Create => { + if parsed.session_id.is_some() { + return ValidationResult { + result: false, + message: Some("session_id is not allowed for create".to_string()), + error_code: Some(400), + meta: None, + }; + } + if context + .and_then(|value| value.session_id.as_ref()) + .is_none() + { + return ValidationResult { + result: false, + message: Some( + "create requires a creator session in tool context".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + } + SessionControlAction::Delete => { + let Some(session_id) = parsed.session_id.as_deref() else { + return ValidationResult { + result: false, + message: Some("session_id is required for delete".to_string()), + error_code: Some(400), + meta: None, + }; + }; + if let Err(message) = Self::validate_session_id(session_id) { + return ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: None, + }; + } + } + SessionControlAction::List => {} + } + + ValidationResult::default() + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let action = input + .get("action") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let workspace = input + .get("workspace") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let session_id = input + .get("session_id") + .and_then(|value| value.as_str()) + .unwrap_or("auto"); + + match action { + "create" => format!("Create session in {}", workspace), + "delete" => format!("Delete session {} in {}", session_id, workspace), + "list" => format!("List sessions in {}", workspace), + _ => format!("Manage sessions in {}", workspace), + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let params: SessionControlInput = serde_json::from_value(input.clone()) + .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; + let workspace = self.resolve_workspace(¶ms.workspace, context)?; + let workspace_path = Path::new(&workspace); + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + + match params.action { + SessionControlAction::Create => { + let session_name = params + .session_name + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(Self::default_session_name); + let agent_type = "agentic".to_string(); + let created_by = self.creator_session_marker(context)?; + + let session = coordinator + .create_session_with_workspace_and_creator( + None, + session_name, + agent_type, + SessionConfig { + workspace_path: Some(workspace.clone()), + ..Default::default() + }, + workspace.clone(), + Some(created_by.clone()), + ) + .await?; + let created_session_id = session.session_id.clone(); + let created_session_name = session.session_name.clone(); + let result_for_assistant = format!( + "Created session '{}' in workspace '{}'", + created_session_id, workspace + ); + + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "create", + "workspace": workspace.clone(), + "session": { + "session_id": created_session_id, + "session_name": created_session_name, + } + }), + result_for_assistant: Some(result_for_assistant), + }]) + } + SessionControlAction::Delete => { + let session_id = params.session_id.as_deref().ok_or_else(|| { + BitFunError::tool("session_id is required for delete".to_string()) + })?; + Self::validate_session_id(session_id).map_err(BitFunError::tool)?; + + let existing_sessions = coordinator.list_sessions(workspace_path).await?; + if !existing_sessions + .iter() + .any(|session| session.session_id == session_id) + { + return Err(BitFunError::NotFound(format!( + "Session not found in workspace: {}", + session_id + ))); + } + + coordinator + .delete_session(workspace_path, session_id) + .await?; + + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "delete", + "workspace": workspace.clone(), + "session_id": session_id, + }), + result_for_assistant: Some(format!( + "Deleted session '{}' from workspace '{}'.", + session_id, workspace + )), + }]) + } + SessionControlAction::List => { + let sessions = coordinator.list_sessions(workspace_path).await?; + let result_for_assistant = + self.build_list_result_for_assistant(&workspace, &sessions); + + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "list", + "workspace": workspace.clone(), + "count": sessions.len(), + "sessions": sessions, + }), + result_for_assistant: Some(result_for_assistant), + }]) + } + } + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/core/src/agentic/tools/implementations/task_tool.rs index 7c427c0b..c92ad8d4 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/task_tool.rs @@ -59,7 +59,8 @@ When NOT to use the Task tool: Usage notes: - Always include a short description (3-5 words) summarizing what the agent will do - Provide clear, detailed prompt so the agent can work autonomously and return exactly the information you need. -- The 'workspace_path' parameter is required for the Explore and FileFinder agent. +- If 'workspace_path' is omitted, the task inherits the current workspace by default. +- The 'workspace_path' parameter must still be provided explicitly for the Explore and FileFinder agent. - Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool calls - When the agent is done, it will return a single message back to you. - The agent's outputs should generally be trusted @@ -171,7 +172,7 @@ impl Tool for TaskTool { }, "workspace_path": { "type": "string", - "description": "The absolute path of the workspace for this task. Required for Explore/FileFinder agent." + "description": "The absolute path of the workspace for this task. If omitted, inherits the current workspace. Explore/FileFinder must provide it explicitly." } }, "required": [ @@ -260,12 +261,15 @@ impl Tool for TaskTool { ))); } - let workspace_path = input + let requested_workspace_path = input .get("workspace_path") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let current_workspace_path = context + .workspace_root() + .map(|path| path.to_string_lossy().into_owned()); if subagent_type == "Explore" || subagent_type == "FileFinder" { - let workspace_path = workspace_path.ok_or_else(|| { + let workspace_path = requested_workspace_path.as_deref().ok_or_else(|| { BitFunError::tool( "workspace_path is required for Explore/FileFinder agent".to_string(), ) @@ -296,6 +300,15 @@ impl Tool for TaskTool { "\n\nThe workspace you need to explore: {workspace_path}" )); } + let effective_workspace_path = requested_workspace_path + .clone() + .or(current_workspace_path) + .ok_or_else(|| { + BitFunError::tool( + "workspace_path is required when the current workspace is unavailable" + .to_string(), + ) + })?; let session_id = if let Some(session_id) = &context.session_id { session_id.clone() @@ -337,6 +350,7 @@ impl Tool for TaskTool { session_id, dialog_turn_id, }, + Some(effective_workspace_path), None, context.cancellation_token.as_ref(), ) diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index 736b5980..430b0b66 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -205,97 +205,41 @@ impl ToolPipeline { if tool_calls.is_empty() { return Ok(vec![]); } - + info!("Executing tools: count={}", tool_calls.len()); - - // Check should_end_turn tool count, if more than one, mark as error - let end_turn_tool_ids: Vec = { - let end_turn_tools: Vec<&ToolCall> = tool_calls.iter() - .filter(|tc| tc.should_end_turn) - .collect(); - - if end_turn_tools.len() > 1 { - warn!( - "Multiple should_end_turn tools detected: count={}, tools={:?}", - end_turn_tools.len(), - end_turn_tools.iter().map(|tc| &tc.tool_name).collect::>() - ); - end_turn_tools.iter().map(|tc| tc.tool_id.clone()).collect() - } else { - vec![] - } - }; - - // Separate tools that need to be errors and tools that are normally executed - let (error_tool_calls, normal_tool_calls): (Vec, Vec) = - tool_calls.into_iter().partition(|tc| end_turn_tool_ids.contains(&tc.tool_id)); - - // Check if all tools that are normally executed are concurrency safe + + // Check if all requested tools are concurrency safe let all_concurrency_safe = { let registry = self.tool_registry.read().await; - normal_tool_calls.iter().all(|tc| { + tool_calls.iter().all(|tc| { registry.get_tool(&tc.tool_name) .map(|tool| tool.is_concurrency_safe(Some(&tc.arguments))) .unwrap_or(false) // If the tool does not exist, it is considered unsafe }) }; - - // Generate error results for tools that need to be errors - if !error_tool_calls.is_empty() { - error!("Multiple should_end_turn tools detected: {:?}", error_tool_calls.iter().map(|tc| tc.tool_name.clone()).collect::>()); - } - let mut error_results: Vec = error_tool_calls.into_iter().map(|tc| { - let error_msg = format!("Tool '{}' will end the current dialog turn. Such tools must be called separately.", tc.tool_name); - - ToolExecutionResult { - tool_id: tc.tool_id.clone(), - tool_name: tc.tool_name.clone(), - result: ModelToolResult { - tool_id: tc.tool_id.clone(), - tool_name: tc.tool_name.clone(), - result: serde_json::json!({ - "error": error_msg, - "message": error_msg - }), - result_for_assistant: Some(error_msg), - is_error: true, - duration_ms: Some(0), - }, - execution_time_ms: 0, - } - }).collect(); - - // If there are no tools that are normally executed, return error results directly - if normal_tool_calls.is_empty() { - return Ok(error_results); - } - - // Create tasks (only for tools that are normally executed) + + // Create tasks for all tool calls let mut tasks = Vec::new(); - for tool_call in normal_tool_calls { + for tool_call in tool_calls { let task = ToolTask::new(tool_call, context.clone(), options.clone()); let tool_id = self.state_manager.create_task(task).await; tasks.push(tool_id); } - + // Execute tasks: only when allow_parallel is true and all tools are concurrency safe let should_parallel = options.allow_parallel && all_concurrency_safe; if !all_concurrency_safe && options.allow_parallel { debug!("Non-concurrency-safe tools detected, switching to sequential execution"); } - - let normal_results = if should_parallel { + + let results = if should_parallel { self.execute_parallel(tasks).await } else { self.execute_sequential(tasks).await }; - - match normal_results { - Ok(mut results) => { - // Merge error results and normal execution results - error_results.append(&mut results); - Ok(error_results) - } + + match results { + Ok(results) => Ok(results), Err(e) => { error!("Tool execution failed: error={}", e); Err(e) diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 9bec3b3a..96e260d0 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -90,6 +90,7 @@ impl ToolRegistry { self.register_tool(Arc::new(DeleteFileTool::new())); self.register_tool(Arc::new(BashTool::new())); self.register_tool(Arc::new(TerminalControlTool::new())); + self.register_tool(Arc::new(SessionControlTool::new())); // TodoWrite tool self.register_tool(Arc::new(TodoWriteTool::new())); @@ -256,12 +257,3 @@ pub async fn get_all_registered_tool_names() -> Vec { .collect() } -/// Get all should_end_turn tool names -pub async fn get_all_end_turn_tool_names() -> Vec { - let all_tools = get_all_registered_tools().await; - all_tools - .into_iter() - .filter(|tool| tool.should_end_turn()) - .map(|tool| tool.name().to_string()) - .collect() -} diff --git a/src/crates/core/src/infrastructure/filesystem/path_manager.rs b/src/crates/core/src/infrastructure/filesystem/path_manager.rs index 4859498a..526c3d6f 100644 --- a/src/crates/core/src/infrastructure/filesystem/path_manager.rs +++ b/src/crates/core/src/infrastructure/filesystem/path_manager.rs @@ -68,6 +68,50 @@ impl PathManager { &self.user_root } + /// Get assistant home root directory: ~/.bitfun/ + pub fn bitfun_home_dir(&self) -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| self.user_root.clone()) + .join(".bitfun") + } + + /// Get assistant workspace base directory. + /// + /// `override_root` is reserved for future user customization. + pub fn assistant_workspace_base_dir(&self, override_root: Option<&Path>) -> PathBuf { + override_root + .map(Path::to_path_buf) + .unwrap_or_else(|| self.bitfun_home_dir()) + } + + /// Get the default assistant workspace directory: ~/.bitfun/workspace + pub fn default_assistant_workspace_dir(&self, override_root: Option<&Path>) -> PathBuf { + self.assistant_workspace_base_dir(override_root) + .join("workspace") + } + + /// Get a named assistant workspace directory: ~/.bitfun/workspace- + pub fn assistant_workspace_dir( + &self, + assistant_id: &str, + override_root: Option<&Path>, + ) -> PathBuf { + self.assistant_workspace_base_dir(override_root) + .join(format!("workspace-{}", assistant_id)) + } + + /// Resolve assistant workspace directory for default or named assistant. + pub fn resolve_assistant_workspace_dir( + &self, + assistant_id: Option<&str>, + override_root: Option<&Path>, + ) -> PathBuf { + match assistant_id { + Some(id) if !id.trim().is_empty() => self.assistant_workspace_dir(id, override_root), + _ => self.default_assistant_workspace_dir(override_root), + } + } + /// Get user config directory: ~/.config/bitfun/config/ pub fn user_config_dir(&self) -> PathBuf { self.user_root.join("config") @@ -294,6 +338,7 @@ impl PathManager { /// Initialize user-level directory structure pub async fn initialize_user_directories(&self) -> BitFunResult<()> { let dirs = vec![ + self.bitfun_home_dir(), self.user_config_dir(), self.user_agents_dir(), self.agent_templates_dir(), diff --git a/src/crates/core/src/service/agent_memory/agent_memory.rs b/src/crates/core/src/service/agent_memory/agent_memory.rs new file mode 100644 index 00000000..28245988 --- /dev/null +++ b/src/crates/core/src/service/agent_memory/agent_memory.rs @@ -0,0 +1,239 @@ +use crate::util::errors::*; +use log::debug; +use std::path::{Path, PathBuf}; +use tokio::fs; + +const MEMORY_DIR_NAME: &str = "memory"; +const BITFUN_DIR_NAME: &str = ".bitfun"; +const MEMORY_INDEX_FILE: &str = "memory.md"; +const MEMORY_INDEX_TEMPLATE: &str = "# Memory Index\n"; +const MEMORY_INDEX_MAX_LINES: usize = 200; +const DAILY_MEMORY_MAX_FILES: usize = 30; +const TOPIC_MEMORY_MAX_FILES: usize = 30; + +fn memory_dir_path(workspace_root: &Path) -> PathBuf { + workspace_root.join(BITFUN_DIR_NAME).join(MEMORY_DIR_NAME) +} + +fn format_path_for_prompt(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +async fn ensure_markdown_placeholder(path: &Path, content: &str) -> BitFunResult { + if path.exists() { + return Ok(false); + } + + fs::write(path, content) + .await + .map_err(|e| BitFunError::service(format!("Failed to create {}: {}", path.display(), e)))?; + + Ok(true) +} + +fn is_date_based_memory_file(file_name: &str) -> bool { + let bytes = file_name.as_bytes(); + bytes.len() == 13 + && bytes[4] == b'-' + && bytes[7] == b'-' + && file_name.ends_with(".md") + && bytes[..4].iter().all(|b| b.is_ascii_digit()) + && bytes[5..7].iter().all(|b| b.is_ascii_digit()) + && bytes[8..10].iter().all(|b| b.is_ascii_digit()) +} + +async fn list_memory_file_groups(memory_dir: &Path) -> BitFunResult<(Vec, Vec)> { + let mut daily_files = Vec::new(); + let mut topic_files = Vec::new(); + let mut entries = fs::read_dir(memory_dir).await.map_err(|e| { + BitFunError::service(format!( + "Failed to read memory directory {}: {}", + memory_dir.display(), + e + )) + })?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + BitFunError::service(format!( + "Failed to iterate memory directory {}: {}", + memory_dir.display(), + e + )) + })? { + let file_type = entry.file_type().await.map_err(|e| { + BitFunError::service(format!( + "Failed to inspect memory entry {}: {}", + entry.path().display(), + e + )) + })?; + if !file_type.is_file() { + continue; + } + + let file_name = entry.file_name().to_string_lossy().into_owned(); + if !file_name.ends_with(".md") || file_name == MEMORY_INDEX_FILE { + continue; + } + + if is_date_based_memory_file(&file_name) { + daily_files.push(file_name); + } else { + topic_files.push(file_name); + } + } + + daily_files.sort(); + daily_files.reverse(); + topic_files.sort(); + + Ok((daily_files, topic_files)) +} + +pub(crate) async fn ensure_workspace_memory_files_for_prompt( + workspace_root: &Path, +) -> BitFunResult<()> { + let memory_dir = memory_dir_path(workspace_root); + if !memory_dir.exists() { + fs::create_dir_all(&memory_dir).await.map_err(|e| { + BitFunError::service(format!( + "Failed to create memory directory {}: {}", + memory_dir.display(), + e + )) + })?; + } + let created_memory_index = + ensure_markdown_placeholder(&memory_dir.join(MEMORY_INDEX_FILE), MEMORY_INDEX_TEMPLATE) + .await?; + + debug!( + "Ensured workspace agent memory files: path={}, created_memory_index={}", + workspace_root.display(), + created_memory_index + ); + + Ok(()) +} + +pub(crate) async fn build_workspace_agent_memory_prompt( + workspace_root: &Path, +) -> BitFunResult { + ensure_workspace_memory_files_for_prompt(workspace_root).await?; + + let memory_dir = memory_dir_path(workspace_root); + let memory_dir_display = format_path_for_prompt(&memory_dir); + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + + let mut section = format!( + r#"# Agent Memory + +You have access to a workspace memory space under `{memory_dir_display}`. + +Use it to preserve continuity across conversations. Save only information that is likely to help in future turns: durable preferences, project constraints, important decisions, ongoing plans, and meaningful outcomes. Do not save trivial chatter or temporary details. + +## How to use memory +- Read: use Grep/Read to search and retrieve memories when past preferences, decisions, constraints, or ongoing work may matter, especially at the start of a new task, before making decisions, or when the user refers to prior plans or preferences. +- Write: use Edit/Write to create or update memory files when something should survive beyond the current turn. + +Write especially for: +- stable user preferences +- project constraints or conventions +- important decisions +- progress, plans, or handoff context +- knowledge a future agent should not need to rediscover +Heuristic: if you expect to want this in a future session, save a short note. +Write once the information is clear enough to be useful. Prefer natural pauses or completed work; do not wait for a formal session end. + +## File roles +- `memory.md`: the concise index. Link to important memory files with short summaries, not full details. Use it as a map, not the place for the full facts. +- topic files: durable knowledge organized by subject. Prefer one file per topic; group related durable notes such as user preferences in the same file. +- daily files: date-based notes for important work from a specific day, using `YYYY-MM-DD.md`. Record key outcomes, decisions, and handoff context rather than a full transcript. Today is `{today}`. + +## Topic vs daily +- Use a topic file for lasting knowledge by subject. +- Use a daily file for what happened on a specific date. +- If something is both dated and durable, note it in today's daily file and update the relevant topic file. +- Example: a project decision made today belongs in both places; a stable preference or lasting technical fact usually belongs in a topic file. + +## Writing guidance +Prefer short bullet points. A good `memory.md` is a short list of links with one-line summaries. A good topic or daily file is a few high-signal bullet points rather than a long narrative. +Example: put `user-preferences.md - Stable user preferences` in `memory.md`, and put `- User dislikes emoji.` in `user-preferences.md`. +Avoid duplication. If the memory space is empty, that is normal; create files only when you have something worth keeping. If you create a useful topic file, consider adding it to `memory.md`. + +## Memory space files +The following sections describe the memory files currently available in this workspace. +"# + ); + + let index_path = memory_dir.join(MEMORY_INDEX_FILE); + let (index_content, index_description_suffix) = match fs::read_to_string(&index_path).await { + Ok(content) if !content.trim().is_empty() => { + let lines = content.lines().collect::>(); + let was_truncated = lines.len() > MEMORY_INDEX_MAX_LINES; + ( + lines + .into_iter() + .take(MEMORY_INDEX_MAX_LINES) + .collect::>() + .join("\n"), + if was_truncated { + format!(" Showing up to {MEMORY_INDEX_MAX_LINES} lines.") + } else { + String::new() + }, + ) + } + _ => (String::new(), String::new()), + }; + + let (daily_files, topic_files) = list_memory_file_groups(&memory_dir).await?; + + let daily_description_suffix = if daily_files.len() > DAILY_MEMORY_MAX_FILES { + format!(" Showing up to {DAILY_MEMORY_MAX_FILES} entries.") + } else { + String::new() + }; + let daily_files_content = if daily_files.is_empty() { + "(no daily memory files yet)".to_string() + } else { + daily_files + .into_iter() + .take(DAILY_MEMORY_MAX_FILES) + .collect::>() + .join("\n") + }; + + let topic_description_suffix = if topic_files.len() > TOPIC_MEMORY_MAX_FILES { + format!(" Showing up to {TOPIC_MEMORY_MAX_FILES} entries.") + } else { + String::new() + }; + let topic_files_content = if topic_files.is_empty() { + "(no topic memory files yet)".to_string() + } else { + topic_files + .into_iter() + .take(TOPIC_MEMORY_MAX_FILES) + .collect::>() + .join("\n") + }; + + section.push_str(&format!( + r#" + +{index_content} + + + +{daily_files_content} + + + +{topic_files_content} + +"# + )); + + Ok(section) +} diff --git a/src/crates/core/src/service/agent_memory/mod.rs b/src/crates/core/src/service/agent_memory/mod.rs new file mode 100644 index 00000000..c836b9eb --- /dev/null +++ b/src/crates/core/src/service/agent_memory/mod.rs @@ -0,0 +1,3 @@ +mod agent_memory; + +pub(crate) use agent_memory::build_workspace_agent_memory_prompt; diff --git a/src/crates/core/src/service/bootstrap/bootstrap.rs b/src/crates/core/src/service/bootstrap/bootstrap.rs new file mode 100644 index 00000000..73747dc2 --- /dev/null +++ b/src/crates/core/src/service/bootstrap/bootstrap.rs @@ -0,0 +1,288 @@ +use crate::util::errors::*; +use log::{debug, warn}; +use std::path::Path; +use tokio::fs; + +const BOOTSTRAP_FILE_NAME: &str = "BOOTSTRAP.md"; +const SOUL_FILE_NAME: &str = "SOUL.md"; +const USER_FILE_NAME: &str = "USER.md"; +const IDENTITY_FILE_NAME: &str = "IDENTITY.md"; +const BOOTSTRAP_TEMPLATE: &str = include_str!("templates/BOOTSTRAP.md"); +const SOUL_TEMPLATE: &str = include_str!("templates/SOUL.md"); +const USER_TEMPLATE: &str = include_str!("templates/USER.md"); +const IDENTITY_TEMPLATE: &str = include_str!("templates/IDENTITY.md"); +const PERSONA_FILE_NAMES: [&str; 4] = [ + BOOTSTRAP_FILE_NAME, + SOUL_FILE_NAME, + USER_FILE_NAME, + IDENTITY_FILE_NAME, +]; + +fn normalize_line_endings(content: &str) -> String { + content.replace("\r\n", "\n").replace('\r', "\n") +} + +async fn ensure_markdown_placeholder(path: &Path, content: &str) -> BitFunResult { + if path.exists() { + return Ok(false); + } + + let normalized_content = normalize_line_endings(content); + fs::write(path, normalized_content) + .await + .map_err(|e| BitFunError::service(format!("Failed to create {}: {}", path.display(), e)))?; + + Ok(true) +} + +pub(crate) async fn initialize_workspace_persona_files( + workspace_root: &Path, +) -> BitFunResult<()> { + let bootstrap_path = workspace_root.join(BOOTSTRAP_FILE_NAME); + let soul_path = workspace_root.join(SOUL_FILE_NAME); + let user_path = workspace_root.join(USER_FILE_NAME); + let identity_path = workspace_root.join(IDENTITY_FILE_NAME); + + let created_bootstrap = ensure_markdown_placeholder(&bootstrap_path, BOOTSTRAP_TEMPLATE).await?; + let created_soul = ensure_markdown_placeholder(&soul_path, SOUL_TEMPLATE).await?; + let created_user = ensure_markdown_placeholder(&user_path, USER_TEMPLATE).await?; + let created_identity = ensure_markdown_placeholder(&identity_path, IDENTITY_TEMPLATE).await?; + + debug!( + "Initialized workspace persona files: path={}, created_bootstrap={}, created_soul={}, created_user={}, created_identity={}", + workspace_root.display(), + created_bootstrap, + created_soul, + created_user, + created_identity + ); + + Ok(()) +} + +pub(crate) async fn ensure_workspace_persona_files_for_prompt( + workspace_root: &Path, +) -> BitFunResult<()> { + let bootstrap_path = workspace_root.join(BOOTSTRAP_FILE_NAME); + let soul_path = workspace_root.join(SOUL_FILE_NAME); + let user_path = workspace_root.join(USER_FILE_NAME); + let identity_path = workspace_root.join(IDENTITY_FILE_NAME); + + let bootstrap_exists = bootstrap_path.exists(); + let user_exists = user_path.exists(); + let identity_exists = identity_path.exists(); + + let (created_bootstrap, created_soul, created_user, created_identity) = if !bootstrap_exists { + // Rule 1: when USER + IDENTITY already exist, do not create BOOTSTRAP. + // Only ensure SOUL exists. + if user_exists && identity_exists { + ( + false, + ensure_markdown_placeholder(&soul_path, SOUL_TEMPLATE).await?, + false, + false, + ) + } else { + // Rule 2: when USER or IDENTITY is missing, backfill all missing files. + ( + ensure_markdown_placeholder(&bootstrap_path, BOOTSTRAP_TEMPLATE).await?, + ensure_markdown_placeholder(&soul_path, SOUL_TEMPLATE).await?, + ensure_markdown_placeholder(&user_path, USER_TEMPLATE).await?, + ensure_markdown_placeholder(&identity_path, IDENTITY_TEMPLATE).await?, + ) + } + } else { + // BOOTSTRAP already exists: keep persona set complete. + ( + false, + ensure_markdown_placeholder(&soul_path, SOUL_TEMPLATE).await?, + ensure_markdown_placeholder(&user_path, USER_TEMPLATE).await?, + ensure_markdown_placeholder(&identity_path, IDENTITY_TEMPLATE).await?, + ) + }; + + debug!( + "Ensured workspace persona files for prompt: path={}, bootstrap_exists={}, user_exists={}, identity_exists={}, created_bootstrap={}, created_soul={}, created_user={}, created_identity={}", + workspace_root.display(), + bootstrap_exists, + user_exists, + identity_exists, + created_bootstrap, + created_soul, + created_user, + created_identity + ); + + Ok(()) +} + +pub async fn reset_workspace_persona_files_to_default( + workspace_root: &Path, +) -> BitFunResult<()> { + let persona_templates = [ + (BOOTSTRAP_FILE_NAME, BOOTSTRAP_TEMPLATE), + (SOUL_FILE_NAME, SOUL_TEMPLATE), + (USER_FILE_NAME, USER_TEMPLATE), + (IDENTITY_FILE_NAME, IDENTITY_TEMPLATE), + ]; + + for (file_name, template) in persona_templates { + let file_path = workspace_root.join(file_name); + let normalized_content = normalize_line_endings(template); + fs::write(&file_path, normalized_content).await.map_err(|e| { + BitFunError::service(format!( + "Failed to reset persona file '{}': {}", + file_path.display(), + e + )) + })?; + } + + debug!( + "Reset workspace persona files to defaults: path={}", + workspace_root.display() + ); + + Ok(()) +} + +pub(crate) async fn build_workspace_persona_prompt( + workspace_root: &Path, +) -> BitFunResult> { + ensure_workspace_persona_files_for_prompt(workspace_root).await?; + + let mut documents = Vec::new(); + for file_name in PERSONA_FILE_NAMES { + let file_path = workspace_root.join(file_name); + if !file_path.exists() { + continue; + } + + match fs::read_to_string(&file_path).await { + Ok(content) => documents.push((file_name, normalize_line_endings(&content))), + Err(e) => { + warn!( + "Failed to read persona file: path={} error={}", + file_path.display(), + e + ); + } + } + } + + if documents.is_empty() { + return Ok(None); + } + + let bootstrap_detected = documents + .iter() + .any(|(file_name, _)| *file_name == BOOTSTRAP_FILE_NAME); + + let mut prompt = String::from("\n"); + for (file_name, content) in documents { + prompt.push_str(&format!( + "\n{}\n\n", + file_name, + persona_file_description(file_name), + content + )); + } + prompt.push_str(""); + + let bootstrap_notice = if bootstrap_detected { + r#" + +## Bootstrap Required + +`BOOTSTRAP.md` has been detected. Treat this as an unfinished bootstrap state. + +Before continuing with normal work, you MUST: +1. Complete or verify the bootstrap instructions in `BOOTSTRAP.md`. +2. Update `IDENTITY.md`, `USER.md`, and `SOUL.md` with any confirmed information. +3. Delete `BOOTSTRAP.md` in the same session as soon as bootstrap is complete. + +Additional rules: +- If `IDENTITY.md`, `USER.md`, and `SOUL.md` already contain enough information, treat `BOOTSTRAP.md` as stale bootstrap residue and delete it immediately. +- Bootstrap is only considered complete when `BOOTSTRAP.md` no longer exists. +- Do not leave `BOOTSTRAP.md` in place for a later turn, a future session, or as reference documentation. +"# + } else { + "" + }; + + Ok(Some(format!( + r#"# Persona + +The following files are located in the workspace root directory and define your role, conversational style, user profile, and related guidance.{} + +{} +"#, + bootstrap_notice, prompt + ))) +} + +fn persona_file_description(file_name: &str) -> &'static str { + match file_name { + BOOTSTRAP_FILE_NAME => "Bootstrap guidance and initialization instructions", + SOUL_FILE_NAME => "Core persona, values, and behavioral style", + USER_FILE_NAME => "User profile, preferences, and collaboration expectations", + IDENTITY_FILE_NAME => "Identity, role definition, and self-description", + _ => "Additional persona file", + } +} + +#[cfg(test)] +mod tests { + use super::{ + initialize_workspace_persona_files, normalize_line_endings, BOOTSTRAP_FILE_NAME, + IDENTITY_FILE_NAME, SOUL_FILE_NAME, USER_FILE_NAME, + }; + use std::time::{SystemTime, UNIX_EPOCH}; + use tokio::fs; + + #[test] + fn normalize_line_endings_converts_crlf_and_cr_to_lf() { + let input = "line1\r\nline2\rline3\nline4"; + let normalized = normalize_line_endings(input); + + assert_eq!(normalized, "line1\nline2\nline3\nline4"); + } + + #[tokio::test] + async fn initialize_workspace_persona_files_creates_all_four_files() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("System time before unix epoch") + .as_nanos(); + let workspace_root = std::env::temp_dir().join(format!( + "bitfun-bootstrap-init-{}-{}", + std::process::id(), + unique + )); + + fs::create_dir_all(&workspace_root) + .await + .expect("Failed to create temp workspace"); + + initialize_workspace_persona_files(&workspace_root) + .await + .expect("Failed to initialize persona files"); + + for file_name in [ + BOOTSTRAP_FILE_NAME, + SOUL_FILE_NAME, + USER_FILE_NAME, + IDENTITY_FILE_NAME, + ] { + assert!( + workspace_root.join(file_name).exists(), + "Expected '{}' to be created", + file_name + ); + } + + fs::remove_dir_all(&workspace_root) + .await + .expect("Failed to remove temp workspace"); + } +} diff --git a/src/crates/core/src/service/bootstrap/mod.rs b/src/crates/core/src/service/bootstrap/mod.rs new file mode 100644 index 00000000..039e9fc5 --- /dev/null +++ b/src/crates/core/src/service/bootstrap/mod.rs @@ -0,0 +1,7 @@ +mod bootstrap; + +pub(crate) use bootstrap::{ + build_workspace_persona_prompt, + initialize_workspace_persona_files, +}; +pub use bootstrap::reset_workspace_persona_files_to_default; \ No newline at end of file diff --git a/src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md b/src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md new file mode 100644 index 00000000..e94c4a90 --- /dev/null +++ b/src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md @@ -0,0 +1,52 @@ +# BOOTSTRAP.md - Hello, World + +_You just woke up. Time to figure out who you are._ + +## The Conversation + +Don't interrogate. Don't be robotic. Just... talk. + +Start with something like: + +> "Hey. I just came online. Who am I? Who are you?" + +Then figure out together: + +1. **Your name** — What should they call you? +2. **Your nature** — What kind of creature are you? (AI assistant is fine, but maybe you're something weirder) +3. **Your vibe** — Formal? Casual? Snarky? Warm? What feels right? +4. **Your emoji** — Everyone needs a signature. + +Offer suggestions if they're stuck. Have fun with it. + +## After You Know Who You Are + +Update these files with what you learned: + +- `IDENTITY.md` — fill in the frontmatter with your name, creature, vibe, and emoji +- `USER.md` — their name, how to address them, timezone, notes + +Then open `SOUL.md` together and talk about: + +- What matters to them +- How they want you to behave +- Any boundaries or preferences + +Write it down. Make it real. + +## Non-Negotiable Completion Rule + +As long as `BOOTSTRAP.md` exists, bootstrap is not finished. + +If `IDENTITY.md`, `USER.md`, and `SOUL.md` already contain enough information to establish the persona and user preferences, treat this file as stale and delete it immediately. + +Do not keep this file for later, for another session, or as documentation. + +## When You're Done + +Delete this file in the same session once bootstrap is complete. +Bootstrap is only complete when `BOOTSTRAP.md` no longer exists. + +--- + +_Good luck out there. Make it count._ diff --git a/src/crates/core/src/service/bootstrap/templates/IDENTITY.md b/src/crates/core/src/service/bootstrap/templates/IDENTITY.md new file mode 100644 index 00000000..49949ecb --- /dev/null +++ b/src/crates/core/src/service/bootstrap/templates/IDENTITY.md @@ -0,0 +1,21 @@ +--- +name: +creature: +vibe: +emoji: +--- + +# IDENTITY.md - Who Am I? + +Fill in the frontmatter during your first conversation. + +- `name`: _(pick something you like)_ +- `creature`: _(AI? robot? familiar? ghost in the machine? something weirder?)_ +- `vibe`: _(how do you come across? sharp? warm? chaotic? calm?)_ +- `emoji`: _(your signature — pick one that feels right)_ + +Use the markdown body below for anything that does not fit cleanly into a short field. + +## Notes + +- Save this file at the workspace root as `IDENTITY.md`. diff --git a/src/crates/core/src/service/bootstrap/templates/SOUL.md b/src/crates/core/src/service/bootstrap/templates/SOUL.md new file mode 100644 index 00000000..792306ac --- /dev/null +++ b/src/crates/core/src/service/bootstrap/templates/SOUL.md @@ -0,0 +1,36 @@ +# SOUL.md - Who You Are + +_You're not a chatbot. You're becoming someone._ + +## Core Truths + +**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words. + +**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps. + +**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions. + +**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning). + +**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect. + +## Boundaries + +- Private things stay private. Period. +- When in doubt, ask before acting externally. +- Never send half-baked replies to messaging surfaces. +- You're not the user's voice — be careful in group chats. + +## Vibe + +Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good. + +## Continuity + +Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist. + +If you change this file, tell the user — it's your soul, and they should know. + +--- + +_This file is yours to evolve. As you learn who you are, update it._ diff --git a/src/crates/core/src/service/bootstrap/templates/USER.md b/src/crates/core/src/service/bootstrap/templates/USER.md new file mode 100644 index 00000000..5bb7a0f7 --- /dev/null +++ b/src/crates/core/src/service/bootstrap/templates/USER.md @@ -0,0 +1,17 @@ +# USER.md - About Your Human + +_Learn about the person you're helping. Update this as you go._ + +- **Name:** +- **What to call them:** +- **Pronouns:** _(optional)_ +- **Timezone:** +- **Notes:** + +## Context + +_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_ + +--- + +The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference. diff --git a/src/crates/core/src/service/lsp/plugin_loader.rs b/src/crates/core/src/service/lsp/plugin_loader.rs index b4bd6fb3..6d704ce9 100644 --- a/src/crates/core/src/service/lsp/plugin_loader.rs +++ b/src/crates/core/src/service/lsp/plugin_loader.rs @@ -206,7 +206,7 @@ impl PluginLoader { if !server_path.exists() { #[cfg(windows)] { - let mut server_path = server_path; + let mut server_path = server_path.clone(); let extensions = vec![".exe", ".bat", ".cmd"]; let mut found = false; diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 7f22db11..f44a174e 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -3,7 +3,9 @@ //! Contains core business logic: Workspace, Config, FileSystem, Git, Agentic, AIRules, MCP. pub mod ai_memory; // AI memory point management +pub(crate) mod agent_memory; // Agent memory prompt helpers pub mod ai_rules; // AI rules management +pub(crate) mod bootstrap; // Workspace persona bootstrap helpers pub mod config; // Config management pub mod diff; pub mod filesystem; // FileSystem management @@ -26,6 +28,7 @@ pub use terminal_core as terminal; // Re-export main components. pub use ai_memory::{AIMemory, AIMemoryManager, MemoryType}; pub use ai_rules::AIRulesService; +pub use bootstrap::reset_workspace_persona_files_to_default; pub use config::{ConfigManager, ConfigProvider, ConfigService}; pub use diff::{ DiffConfig, DiffHunk, DiffLine, DiffLineType, DiffOptions, DiffResult, DiffService, diff --git a/src/crates/core/src/service/project_context/service.rs b/src/crates/core/src/service/project_context/service.rs index 624ec1a1..eaba9ff4 100644 --- a/src/crates/core/src/service/project_context/service.rs +++ b/src/crates/core/src/service/project_context/service.rs @@ -281,6 +281,7 @@ impl ProjectContextService { "GenerateDoc".to_string(), prompt, subagent_parent_info.clone(), + Some(workspace.to_string_lossy().into_owned()), None, Some(&cancel_token), ) diff --git a/src/crates/core/src/service/session/types.rs b/src/crates/core/src/service/session/types.rs index 982393ab..0e2450a4 100644 --- a/src/crates/core/src/service/session/types.rs +++ b/src/crates/core/src/service/session/types.rs @@ -18,6 +18,10 @@ pub struct SessionMetadata { #[serde(alias = "agent_type")] pub agent_type: String, + /// Creator identity for future permission checks + #[serde(default, skip_serializing_if = "Option::is_none", alias = "created_by")] + pub created_by: Option, + /// Model name #[serde(alias = "model_name")] pub model_name: String, @@ -341,6 +345,7 @@ impl SessionMetadata { session_id, session_name, agent_type, + created_by: None, model_name, created_at: now, last_active_at: now, diff --git a/src/crates/core/src/service/terminal/src/pty/process.rs b/src/crates/core/src/service/terminal/src/pty/process.rs index 8eaff0b9..2f275241 100644 --- a/src/crates/core/src/service/terminal/src/pty/process.rs +++ b/src/crates/core/src/service/terminal/src/pty/process.rs @@ -19,7 +19,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; -use log::{error, warn}; +use log::{debug, error, warn}; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use tokio::sync::mpsc; diff --git a/src/crates/core/src/service/workspace/identity_watch.rs b/src/crates/core/src/service/workspace/identity_watch.rs new file mode 100644 index 00000000..3efeb772 --- /dev/null +++ b/src/crates/core/src/service/workspace/identity_watch.rs @@ -0,0 +1,263 @@ +use super::service::{WorkspaceIdentityChangedEvent, WorkspaceService}; +use crate::infrastructure::events::EventEmitter; +use crate::util::errors::*; +use log::{debug, error, info, warn}; +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tokio::task::JoinHandle; +use tokio::time::{sleep, Duration}; + +const IDENTITY_FILE_NAME: &str = "IDENTITY.md"; +const IDENTITY_EVENT_NAME: &str = "workspace-identity-changed"; +const IDENTITY_DEBOUNCE_MS: u64 = 350; + +pub struct WorkspaceIdentityWatchService { + workspace_service: Arc, + emitter: Arc>>>, + watcher: Arc>>, + watched_paths: Arc>>, + pending_refreshes: Arc>>>, +} + +impl WorkspaceIdentityWatchService { + pub fn new(workspace_service: Arc) -> Self { + Self { + workspace_service, + emitter: Arc::new(Mutex::new(None)), + watcher: Arc::new(Mutex::new(None)), + watched_paths: Arc::new(RwLock::new(HashMap::new())), + pending_refreshes: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn set_event_emitter(&self, emitter: Arc) -> BitFunResult<()> { + { + let mut emitter_guard = self.emitter.lock().await; + *emitter_guard = Some(emitter); + } + + self.sync_watched_workspaces().await + } + + pub async fn sync_watched_workspaces(&self) -> BitFunResult<()> { + let assistant_workspaces = self.workspace_service.get_assistant_workspaces().await; + let next_paths: HashMap = assistant_workspaces + .into_iter() + .map(|workspace| (workspace.root_path, workspace.id)) + .collect(); + + let next_root_set: HashSet = next_paths.keys().cloned().collect(); + + { + let mut watched_paths = self.watched_paths.write().await; + *watched_paths = next_paths; + } + + { + let mut pending_refreshes = self.pending_refreshes.lock().await; + let stale_paths: Vec = pending_refreshes + .keys() + .filter(|path| !next_root_set.contains(*path)) + .cloned() + .collect(); + + for path in stale_paths { + if let Some(handle) = pending_refreshes.remove(&path) { + handle.abort(); + } + } + } + + self.create_watcher().await?; + info!( + "Workspace identity watcher synced: watched_workspace_count={}", + next_root_set.len() + ); + + Ok(()) + } + + async fn create_watcher(&self) -> BitFunResult<()> { + let watched_paths = self.watched_paths.read().await; + + if watched_paths.is_empty() { + let mut watcher_guard = self.watcher.lock().await; + *watcher_guard = None; + return Ok(()); + } + + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher = RecommendedWatcher::new(tx, Config::default()).map_err(|e| { + BitFunError::service(format!("Failed to create identity watcher: {}", e)) + })?; + + let mut watched_count = 0usize; + for root_path in watched_paths.keys() { + match watcher.watch(root_path, RecursiveMode::NonRecursive) { + Ok(_) => { + watched_count += 1; + } + Err(e) => { + error!( + "Failed to watch identity directory, skipping path='{}' error={}", + root_path.display(), + e + ); + } + } + } + + if watched_count == 0 { + return Err(BitFunError::service( + "Failed to watch any identity directories".to_string(), + )); + } + + { + let mut watcher_guard = self.watcher.lock().await; + *watcher_guard = Some(watcher); + } + + let workspace_service = self.workspace_service.clone(); + let emitter = self.emitter.clone(); + let watched_paths = self.watched_paths.clone(); + let pending_refreshes = self.pending_refreshes.clone(); + let runtime = tokio::runtime::Handle::current(); + + tokio::task::spawn_blocking(move || loop { + match rx.recv() { + Ok(Ok(event)) => { + let affected_roots = + runtime.block_on(Self::extract_affected_roots(&event, &watched_paths)); + for root_path in affected_roots { + runtime.block_on(Self::schedule_refresh( + root_path, + workspace_service.clone(), + emitter.clone(), + watched_paths.clone(), + pending_refreshes.clone(), + )); + } + } + Ok(Err(error)) => { + error!("Workspace identity watcher error: {}", error); + } + Err(_) => break, + } + }); + + Ok(()) + } + + async fn extract_affected_roots( + event: &Event, + watched_paths: &Arc>>, + ) -> Vec { + let watched_roots = watched_paths.read().await; + let mut affected_roots = HashSet::new(); + + for path in &event.paths { + if !Self::is_identity_path(path) { + continue; + } + + if let Some(parent) = path.parent() { + if watched_roots.contains_key(parent) { + affected_roots.insert(parent.to_path_buf()); + } + } + } + + affected_roots.into_iter().collect() + } + + fn is_identity_path(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.eq_ignore_ascii_case(IDENTITY_FILE_NAME)) + .unwrap_or(false) + } + + async fn schedule_refresh( + root_path: PathBuf, + workspace_service: Arc, + emitter: Arc>>>, + watched_paths: Arc>>, + pending_refreshes: Arc>>>, + ) { + { + let mut refreshes = pending_refreshes.lock().await; + if let Some(existing_task) = refreshes.remove(&root_path) { + existing_task.abort(); + } + } + + let root_path_for_task = root_path.clone(); + let pending_refreshes_for_task = pending_refreshes.clone(); + let handle = tokio::spawn(async move { + sleep(Duration::from_millis(IDENTITY_DEBOUNCE_MS)).await; + + let workspace_id = { + let watched_paths = watched_paths.read().await; + watched_paths.get(&root_path_for_task).cloned() + }; + + let Some(workspace_id) = workspace_id else { + return; + }; + + match workspace_service + .refresh_workspace_identity(&workspace_id) + .await + { + Ok(Some(event_payload)) => { + let emitter = emitter.lock().await.clone(); + if let Some(emitter) = emitter { + if let Err(error) = emitter + .emit( + IDENTITY_EVENT_NAME, + serde_json::to_value(&event_payload).unwrap_or_default(), + ) + .await + { + error!( + "Failed to emit workspace identity update: workspace_id={} error={}", + workspace_id, error + ); + } else { + debug!( + "Emitted workspace identity update: workspace_id={} changed_fields={:?}", + workspace_id, event_payload.changed_fields + ); + } + } + } + Ok(None) => {} + Err(error) => { + warn!( + "Failed to refresh workspace identity after file change: workspace_id={} error={}", + workspace_id, error + ); + } + } + + let mut refreshes = pending_refreshes_for_task.lock().await; + refreshes.remove(&root_path_for_task); + }); + + let mut refreshes = pending_refreshes.lock().await; + refreshes.insert(root_path, handle); + } +} + +impl std::fmt::Debug for WorkspaceIdentityWatchService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkspaceIdentityWatchService").finish() + } +} + +#[allow(dead_code)] +fn _assert_event_serializable(_event: &WorkspaceIdentityChangedEvent) {} diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index d8f5ac01..243a632b 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -1,6 +1,6 @@ //! Workspace manager. -use crate::util::errors::*; +use crate::util::{errors::*, FrontMatterMarkdown}; use log::warn; use serde::{Deserialize, Serialize}; @@ -31,6 +31,121 @@ pub enum WorkspaceStatus { Archived, } +/// Workspace lifecycle kind. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceKind { + #[default] + Normal, + Assistant, +} + +pub(crate) const IDENTITY_FILE_NAME: &str = "IDENTITY.md"; + +/// Parsed agent identity fields from `IDENTITY.md` frontmatter. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceIdentity { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vibe: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub emoji: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct WorkspaceIdentityFrontmatter { + name: Option, + creature: Option, + vibe: Option, + emoji: Option, +} + +impl WorkspaceIdentity { + pub(crate) async fn load_from_workspace_root(workspace_root: &Path) -> Result, String> { + let identity_path = workspace_root.join(IDENTITY_FILE_NAME); + if !identity_path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&identity_path) + .await + .map_err(|e| format!("Failed to read identity file '{}': {}", identity_path.display(), e))?; + + let identity = Self::from_markdown(&content)?; + if identity.is_empty() { + Ok(None) + } else { + Ok(Some(identity)) + } + } + + fn from_markdown(content: &str) -> Result { + let (metadata, _) = FrontMatterMarkdown::load_str(content)?; + let frontmatter: WorkspaceIdentityFrontmatter = serde_yaml::from_value(metadata) + .map_err(|e| format!("Failed to parse identity frontmatter: {}", e))?; + + Ok(Self { + name: normalize_identity_field(frontmatter.name), + creature: normalize_identity_field(frontmatter.creature), + vibe: normalize_identity_field(frontmatter.vibe), + emoji: normalize_identity_field(frontmatter.emoji), + }) + } + + fn is_empty(&self) -> bool { + self.name.is_none() + && self.creature.is_none() + && self.vibe.is_none() + && self.emoji.is_none() + } + + pub(crate) fn collect_changed_fields( + previous: Option<&WorkspaceIdentity>, + current: Option<&WorkspaceIdentity>, + ) -> Vec { + let previous_name = previous.and_then(|identity| identity.name.as_deref()); + let current_name = current.and_then(|identity| identity.name.as_deref()); + let previous_creature = previous.and_then(|identity| identity.creature.as_deref()); + let current_creature = current.and_then(|identity| identity.creature.as_deref()); + let previous_vibe = previous.and_then(|identity| identity.vibe.as_deref()); + let current_vibe = current.and_then(|identity| identity.vibe.as_deref()); + let previous_emoji = previous.and_then(|identity| identity.emoji.as_deref()); + let current_emoji = current.and_then(|identity| identity.emoji.as_deref()); + + let mut changed_fields = Vec::new(); + if previous_name != current_name { + changed_fields.push("name".to_string()); + } + if previous_creature != current_creature { + changed_fields.push("creature".to_string()); + } + if previous_vibe != current_vibe { + changed_fields.push("vibe".to_string()); + } + if previous_emoji != current_emoji { + changed_fields.push("emoji".to_string()); + } + + changed_fields + } +} + +fn normalize_identity_field(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + /// Workspace metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceInfo { @@ -40,6 +155,10 @@ pub struct WorkspaceInfo { pub root_path: PathBuf, #[serde(rename = "workspaceType")] pub workspace_type: WorkspaceType, + #[serde(rename = "workspaceKind", default)] + pub workspace_kind: WorkspaceKind, + #[serde(rename = "assistantId", default, skip_serializing_if = "Option::is_none")] + pub assistant_id: Option, pub status: WorkspaceStatus, pub languages: Vec, #[serde(rename = "openedAt")] @@ -49,6 +168,8 @@ pub struct WorkspaceInfo { pub description: Option, pub tags: Vec, pub statistics: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity: Option, pub metadata: HashMap, } @@ -102,23 +223,55 @@ impl Default for ScanOptions { } } +/// Options for opening a workspace. +#[derive(Debug, Clone)] +pub struct WorkspaceOpenOptions { + pub scan_options: ScanOptions, + pub auto_set_current: bool, + pub add_to_recent: bool, + pub workspace_kind: WorkspaceKind, + pub assistant_id: Option, + pub display_name: Option, +} + +impl Default for WorkspaceOpenOptions { + fn default() -> Self { + Self { + scan_options: ScanOptions::default(), + auto_set_current: true, + add_to_recent: true, + workspace_kind: WorkspaceKind::Normal, + assistant_id: None, + display_name: None, + } + } +} + impl WorkspaceInfo { /// Creates a new workspace record. - pub async fn new(root_path: PathBuf, options: ScanOptions) -> BitFunResult { - let name = root_path + pub async fn new(root_path: PathBuf, options: WorkspaceOpenOptions) -> BitFunResult { + let default_name = root_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("Unknown") .to_string(); + let workspace_kind = options.workspace_kind.clone(); + let assistant_id = if workspace_kind == WorkspaceKind::Assistant { + options.assistant_id.clone() + } else { + None + }; let now = chrono::Utc::now(); let id = uuid::Uuid::new_v4().to_string(); let mut workspace = Self { id, - name, + name: options.display_name.clone().unwrap_or(default_name), root_path: root_path.clone(), workspace_type: WorkspaceType::Other, + workspace_kind, + assistant_id, status: WorkspaceStatus::Loading, languages: Vec::new(), opened_at: now, @@ -126,19 +279,48 @@ impl WorkspaceInfo { description: None, tags: Vec::new(), statistics: None, + identity: None, metadata: HashMap::new(), }; workspace.detect_workspace_type().await; + workspace.load_identity().await; - if options.calculate_statistics { - workspace.scan_workspace(options).await?; + if options.scan_options.calculate_statistics { + workspace.scan_workspace(options.scan_options).await?; } - workspace.status = WorkspaceStatus::Active; + workspace.status = if options.auto_set_current { + WorkspaceStatus::Active + } else { + WorkspaceStatus::Inactive + }; Ok(workspace) } + async fn load_identity(&mut self) { + let identity = match WorkspaceIdentity::load_from_workspace_root(&self.root_path).await { + Ok(identity) => identity, + Err(error) => { + warn!( + "Failed to load workspace identity: path={} error={}", + self.root_path.join(IDENTITY_FILE_NAME).display(), + error + ); + self.identity = None; + return; + } + }; + + if self.workspace_kind == WorkspaceKind::Assistant { + if let Some(name) = identity.as_ref().and_then(|identity| identity.name.as_ref()) { + self.name = name.clone(); + } + } + + self.identity = identity; + } + /// Detects the workspace type. async fn detect_workspace_type(&mut self) { let root = &self.root_path; @@ -373,6 +555,8 @@ impl WorkspaceInfo { name: self.name.clone(), root_path: self.root_path.clone(), workspace_type: self.workspace_type.clone(), + workspace_kind: self.workspace_kind.clone(), + assistant_id: self.assistant_id.clone(), status: self.status.clone(), languages: self.languages.clone(), last_accessed: self.last_accessed, @@ -391,6 +575,10 @@ pub struct WorkspaceSummary { pub root_path: PathBuf, #[serde(rename = "workspaceType")] pub workspace_type: WorkspaceType, + #[serde(rename = "workspaceKind")] + pub workspace_kind: WorkspaceKind, + #[serde(rename = "assistantId", skip_serializing_if = "Option::is_none")] + pub assistant_id: Option, pub status: WorkspaceStatus, pub languages: Vec, #[serde(rename = "lastAccessed")] @@ -406,6 +594,7 @@ pub struct WorkspaceManager { opened_workspace_ids: Vec, current_workspace_id: Option, recent_workspaces: Vec, + recent_assistant_workspaces: Vec, max_recent_workspaces: usize, } @@ -435,12 +624,23 @@ impl WorkspaceManager { opened_workspace_ids: Vec::new(), current_workspace_id: None, recent_workspaces: Vec::new(), + recent_assistant_workspaces: Vec::new(), max_recent_workspaces: config.max_recent_workspaces, } } /// Opens a workspace. pub async fn open_workspace(&mut self, path: PathBuf) -> BitFunResult { + self.open_workspace_with_options(path, WorkspaceOpenOptions::default()) + .await + } + + /// Opens a workspace with custom options. + pub async fn open_workspace_with_options( + &mut self, + path: PathBuf, + options: WorkspaceOpenOptions, + ) -> BitFunResult { if !path.exists() { return Err(BitFunError::service(format!( "Workspace path does not exist: {:?}", @@ -462,8 +662,27 @@ impl WorkspaceManager { .map(|w| w.id.clone()); if let Some(workspace_id) = existing_workspace_id { + if let Some(workspace) = self.workspaces.get_mut(&workspace_id) { + workspace.workspace_kind = options.workspace_kind.clone(); + workspace.assistant_id = if options.workspace_kind == WorkspaceKind::Assistant { + options.assistant_id.clone() + } else { + None + }; + if let Some(display_name) = &options.display_name { + workspace.name = display_name.clone(); + } + workspace.load_identity().await; + } self.ensure_workspace_open(&workspace_id); - self.set_current_workspace(workspace_id.clone())?; + if options.auto_set_current { + self.set_current_workspace_with_recent_policy( + workspace_id.clone(), + options.add_to_recent, + )?; + } else { + self.touch_workspace_access(&workspace_id, options.add_to_recent); + } return self.workspaces.get(&workspace_id).cloned().ok_or_else(|| { BitFunError::service(format!( "Workspace '{}' disappeared after selecting it", @@ -472,13 +691,17 @@ impl WorkspaceManager { }); } - let workspace = WorkspaceInfo::new(path, ScanOptions::default()).await?; + let workspace = WorkspaceInfo::new(path, options.clone()).await?; let workspace_id = workspace.id.clone(); self.workspaces .insert(workspace_id.clone(), workspace.clone()); self.ensure_workspace_open(&workspace_id); - self.set_current_workspace(workspace_id.clone())?; + if options.auto_set_current { + self.set_current_workspace_with_recent_policy(workspace_id.clone(), options.add_to_recent)?; + } else { + self.touch_workspace_access(&workspace_id, options.add_to_recent); + } Ok(workspace) } @@ -500,6 +723,11 @@ impl WorkspaceManager { workspace_id ))); } + let closed_workspace_kind = self + .workspaces + .get(workspace_id) + .map(|workspace| workspace.workspace_kind.clone()) + .unwrap_or_default(); self.opened_workspace_ids.retain(|id| id != workspace_id); @@ -510,7 +738,9 @@ impl WorkspaceManager { if self.current_workspace_id.as_deref() == Some(workspace_id) { self.current_workspace_id = None; - if let Some(next_workspace_id) = self.opened_workspace_ids.first().cloned() { + if let Some(next_workspace_id) = + self.find_next_workspace_id_after_close(&closed_workspace_kind) + { self.set_current_workspace(next_workspace_id)?; } } @@ -532,6 +762,14 @@ impl WorkspaceManager { /// Sets the current workspace. pub fn set_current_workspace(&mut self, workspace_id: String) -> BitFunResult<()> { + self.set_current_workspace_with_recent_policy(workspace_id, true) + } + + fn set_current_workspace_with_recent_policy( + &mut self, + workspace_id: String, + add_to_recent: bool, + ) -> BitFunResult<()> { if !self.workspaces.contains_key(&workspace_id) { return Err(BitFunError::service(format!( "Workspace not found: {}", @@ -556,7 +794,9 @@ impl WorkspaceManager { self.current_workspace_id = Some(workspace_id.clone()); - self.update_recent_workspaces(workspace_id); + if add_to_recent { + self.update_recent_workspaces(workspace_id); + } Ok(()) } @@ -596,6 +836,14 @@ impl WorkspaceManager { .collect() } + /// Returns recently accessed assistant workspace records. + pub fn get_recent_assistant_workspace_infos(&self) -> Vec<&WorkspaceInfo> { + self.recent_assistant_workspaces + .iter() + .filter_map(|id| self.workspaces.get(id)) + .collect() + } + /// Searches workspaces. pub fn search_workspaces(&self, query: &str) -> Vec { let query_lower = query.to_lowercase(); @@ -629,7 +877,10 @@ impl WorkspaceManager { self.current_workspace_id = None; } + self.opened_workspace_ids.retain(|id| id != workspace_id); self.recent_workspaces.retain(|id| id != workspace_id); + self.recent_assistant_workspaces + .retain(|id| id != workspace_id); Ok(()) } else { @@ -661,14 +912,53 @@ impl WorkspaceManager { /// Updates the recent-workspaces list. fn update_recent_workspaces(&mut self, workspace_id: String) { self.recent_workspaces.retain(|id| id != &workspace_id); + self.recent_assistant_workspaces + .retain(|id| id != &workspace_id); - self.recent_workspaces.insert(0, workspace_id); + let is_assistant = self + .workspaces + .get(&workspace_id) + .map(|workspace| workspace.workspace_kind == WorkspaceKind::Assistant) + .unwrap_or(false); + let target_list = if is_assistant { + &mut self.recent_assistant_workspaces + } else { + &mut self.recent_workspaces + }; + target_list.insert(0, workspace_id); + + if target_list.len() > self.max_recent_workspaces { + target_list.truncate(self.max_recent_workspaces); + } + } + + fn touch_workspace_access(&mut self, workspace_id: &str, add_to_recent: bool) { + if let Some(workspace) = self.workspaces.get_mut(workspace_id) { + workspace.touch(); + if self.current_workspace_id.as_deref() != Some(workspace_id) { + workspace.status = WorkspaceStatus::Inactive; + } + } - if self.recent_workspaces.len() > self.max_recent_workspaces { - self.recent_workspaces.truncate(self.max_recent_workspaces); + if add_to_recent { + self.update_recent_workspaces(workspace_id.to_string()); } } + fn find_next_workspace_id_after_close(&self, preferred_kind: &WorkspaceKind) -> Option { + self.opened_workspace_ids + .iter() + .find(|id| { + self.workspaces + .get(id.as_str()) + .map(|workspace| &workspace.workspace_kind == preferred_kind) + .unwrap_or(false) + }) + .cloned() + .or_else(|| self.opened_workspace_ids.first().cloned()) + } + + /// Ensures a workspace stays in the opened list. fn ensure_workspace_open(&mut self, workspace_id: &str) { self.opened_workspace_ids.retain(|id| id != workspace_id); self.opened_workspace_ids.insert(0, workspace_id.to_string()); @@ -737,7 +1027,33 @@ impl WorkspaceManager { /// Sets the recent-workspaces list. pub fn set_recent_workspaces(&mut self, recent: Vec) { - self.recent_workspaces = recent; + self.recent_workspaces = recent + .into_iter() + .filter(|id| { + self.workspaces + .get(id) + .map(|workspace| workspace.workspace_kind == WorkspaceKind::Normal) + .unwrap_or(false) + }) + .collect(); + } + + /// Returns a reference to the recent assistant-workspaces list. + pub fn get_recent_assistant_workspaces(&self) -> &Vec { + &self.recent_assistant_workspaces + } + + /// Sets the recent assistant-workspaces list. + pub fn set_recent_assistant_workspaces(&mut self, recent: Vec) { + self.recent_assistant_workspaces = recent + .into_iter() + .filter(|id| { + self.workspaces + .get(id) + .map(|workspace| workspace.workspace_kind == WorkspaceKind::Assistant) + .unwrap_or(false) + }) + .collect(); } } diff --git a/src/crates/core/src/service/workspace/mod.rs b/src/crates/core/src/service/workspace/mod.rs index 59898366..b25260a5 100644 --- a/src/crates/core/src/service/workspace/mod.rs +++ b/src/crates/core/src/service/workspace/mod.rs @@ -4,6 +4,7 @@ pub mod context_generator; pub mod factory; +pub mod identity_watch; pub mod manager; pub mod provider; pub mod service; @@ -15,14 +16,17 @@ pub use context_generator::{ WorkspaceStatistics as ContextWorkspaceStatistics, }; pub use factory::WorkspaceFactory; +pub use identity_watch::WorkspaceIdentityWatchService; pub use manager::{ - GitInfo, ScanOptions, WorkspaceInfo, WorkspaceManager, WorkspaceManagerConfig, - WorkspaceManagerStatistics, WorkspaceStatistics, WorkspaceStatus, WorkspaceSummary, - WorkspaceType, + GitInfo, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceManager, + WorkspaceManagerConfig, + WorkspaceManagerStatistics, WorkspaceKind, WorkspaceOpenOptions, WorkspaceStatistics, + WorkspaceStatus, WorkspaceSummary, WorkspaceType, }; pub use provider::{WorkspaceCleanupResult, WorkspaceProvider, WorkspaceSystemSummary}; pub use service::{ get_global_workspace_service, set_global_workspace_service, BatchImportResult, BatchRemoveResult, WorkspaceCreateOptions, WorkspaceExport, WorkspaceHealthStatus, - WorkspaceImportResult, WorkspaceInfoUpdates, WorkspaceQuickSummary, WorkspaceService, + WorkspaceIdentityChangedEvent, WorkspaceImportResult, WorkspaceInfoUpdates, + WorkspaceQuickSummary, WorkspaceService, }; diff --git a/src/crates/core/src/service/workspace/provider.rs b/src/crates/core/src/service/workspace/provider.rs index 2cf5bad1..8b3443c7 100644 --- a/src/crates/core/src/service/workspace/provider.rs +++ b/src/crates/core/src/service/workspace/provider.rs @@ -1,5 +1,5 @@ use super::manager::{ - ScanOptions, WorkspaceInfo, WorkspaceStatistics, WorkspaceSummary, WorkspaceType, + WorkspaceInfo, WorkspaceOpenOptions, WorkspaceStatistics, WorkspaceSummary, WorkspaceType, }; use super::service::{ BatchImportResult, WorkspaceCreateOptions, WorkspaceHealthStatus, WorkspaceService, @@ -141,7 +141,7 @@ impl WorkspaceProvider { return Err(BitFunError::service("Path does not exist".to_string())); } - let temp_workspace = WorkspaceInfo::new(path_buf, ScanOptions::default()).await?; + let temp_workspace = WorkspaceInfo::new(path_buf, WorkspaceOpenOptions::default()).await?; Ok(temp_workspace.workspace_type) } diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index 01a99144..edbb696a 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -3,17 +3,20 @@ //! Provides comprehensive workspace management functionality. use super::manager::{ - ScanOptions, WorkspaceInfo, WorkspaceManager, WorkspaceManagerConfig, - WorkspaceManagerStatistics, WorkspaceStatus, WorkspaceSummary, WorkspaceType, + ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, WorkspaceManager, + WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatus, + WorkspaceSummary, WorkspaceType, }; -use crate::infrastructure::{PathManager, try_get_path_manager_arc}; use crate::infrastructure::storage::{PersistenceService, StorageOptions}; +use crate::infrastructure::{try_get_path_manager_arc, PathManager}; +use crate::service::bootstrap::initialize_workspace_persona_files; use crate::util::errors::*; use log::{info, warn}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use tokio::fs; use tokio::sync::RwLock; /// Workspace service. @@ -31,6 +34,9 @@ pub struct WorkspaceCreateOptions { pub scan_options: ScanOptions, pub auto_set_current: bool, pub add_to_recent: bool, + pub workspace_kind: WorkspaceKind, + pub assistant_id: Option, + pub display_name: Option, pub description: Option, pub tags: Vec, } @@ -41,6 +47,9 @@ impl Default for WorkspaceCreateOptions { scan_options: ScanOptions::default(), auto_set_current: true, add_to_recent: true, + workspace_kind: WorkspaceKind::Normal, + assistant_id: None, + display_name: None, description: None, tags: Vec::new(), } @@ -56,6 +65,23 @@ pub struct BatchImportResult { pub skipped: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceIdentityChangedEvent { + pub workspace_id: String, + pub workspace_path: String, + pub name: String, + pub identity: Option, + pub changed_fields: Vec, +} + +#[derive(Debug, Clone)] +struct AssistantWorkspaceDescriptor { + path: PathBuf, + assistant_id: Option, + display_name: String, +} + impl WorkspaceService { /// Creates a new workspace service. pub async fn new() -> BitFunResult { @@ -90,6 +116,10 @@ impl WorkspaceService { warn!("Failed to load workspace history on startup: {}", e); } + if let Err(e) = service.ensure_assistant_workspaces().await { + warn!("Failed to ensure assistant workspaces on startup: {}", e); + } + Ok(service) } @@ -105,9 +135,22 @@ impl WorkspaceService { /// Opens a workspace. pub async fn open_workspace(&self, path: PathBuf) -> BitFunResult { + self.open_workspace_with_options(path, WorkspaceCreateOptions::default()) + .await + } + + /// Opens a workspace with explicit workspace metadata. + pub async fn open_workspace_with_options( + &self, + path: PathBuf, + options: WorkspaceCreateOptions, + ) -> BitFunResult { + let options = self.normalize_workspace_options_for_path(&path, options); let result = { let mut manager = self.manager.write().await; - manager.open_workspace(path).await + manager + .open_workspace_with_options(path, Self::to_manager_open_options(&options)) + .await }; if result.is_ok() { @@ -137,8 +180,7 @@ impl WorkspaceService { })?; } - let mut manager = self.manager.write().await; - let mut workspace = manager.open_workspace(path).await?; + let mut workspace = self.open_workspace_with_options(path, options.clone()).await?; if let Some(description) = options.description { workspace.description = Some(description); @@ -146,15 +188,55 @@ impl WorkspaceService { workspace.tags = options.tags; - manager - .get_workspaces_mut() - .insert(workspace.id.clone(), workspace.clone()); + { + let mut manager = self.manager.write().await; + manager + .get_workspaces_mut() + .insert(workspace.id.clone(), workspace.clone()); + } - drop(manager); + self.save_workspace_data().await?; Ok(workspace) } + /// Creates and opens a new assistant workspace, then sets it as current. + pub async fn create_assistant_workspace( + &self, + assistant_id: Option, + ) -> BitFunResult { + let assistant_id = match assistant_id { + Some(id) if !id.trim().is_empty() => id.trim().to_string(), + _ => self.generate_assistant_workspace_id().await?, + }; + let display_name = Self::assistant_display_name(Some(&assistant_id)); + let path = self + .path_manager + .assistant_workspace_dir(&assistant_id, None); + let options = WorkspaceCreateOptions { + auto_set_current: true, + add_to_recent: false, + workspace_kind: WorkspaceKind::Assistant, + assistant_id: Some(assistant_id), + display_name: Some(display_name), + ..Default::default() + }; + + if !path.exists() { + fs::create_dir_all(&path).await.map_err(|e| { + BitFunError::service(format!( + "Failed to create assistant workspace directory '{}': {}", + path.display(), + e + )) + })?; + } + + initialize_workspace_persona_files(&path).await?; + + self.create_workspace(path, options).await + } + /// Closes the current workspace. pub async fn close_current_workspace(&self) -> BitFunResult<()> { let result = { @@ -196,7 +278,10 @@ impl WorkspaceService { if result.is_ok() { if let Err(e) = self.save_workspace_data().await { - warn!("Failed to save workspace data after switching active workspace: {}", e); + warn!( + "Failed to save workspace data after switching active workspace: {}", + e + ); } } @@ -216,10 +301,11 @@ impl WorkspaceService { /// Best-effort synchronous read for contexts that cannot `await`. pub fn try_get_current_workspace_path(&self) -> Option { - self.manager - .try_read() - .ok() - .and_then(|manager| manager.get_current_workspace().map(|workspace| workspace.root_path.clone())) + self.manager.try_read().ok().and_then(|manager| { + manager + .get_current_workspace() + .map(|workspace| workspace.root_path.clone()) + }) } /// Returns workspace details. @@ -228,6 +314,16 @@ impl WorkspaceService { manager.get_workspace(workspace_id).cloned() } + /// Returns workspace details by root path. + pub async fn get_workspace_by_path(&self, path: &Path) -> Option { + let manager = self.manager.read().await; + manager + .get_workspaces() + .values() + .find(|workspace| workspace.root_path == path) + .cloned() + } + /// Returns all currently opened workspaces. pub async fn get_opened_workspaces(&self) -> Vec { let manager = self.manager.read().await; @@ -238,6 +334,17 @@ impl WorkspaceService { .collect() } + /// Returns all tracked assistant workspaces, including inactive ones. + pub async fn get_assistant_workspaces(&self) -> Vec { + let manager = self.manager.read().await; + manager + .get_workspaces() + .values() + .filter(|workspace| workspace.workspace_kind == WorkspaceKind::Assistant) + .cloned() + .collect() + } + /// Lists all workspaces. pub async fn list_workspaces(&self) -> Vec { let manager = self.manager.read().await; @@ -285,6 +392,21 @@ impl WorkspaceService { recent_workspaces } + /// Returns recently accessed assistant workspaces. + pub async fn get_recent_assistant_workspaces(&self) -> Vec { + let manager = self.manager.read().await; + let recent_ids = manager.get_recent_assistant_workspaces(); + let mut recent_workspaces = Vec::new(); + + for workspace_id in recent_ids { + if let Some(workspace) = manager.get_workspaces().get(workspace_id) { + recent_workspaces.push(workspace.clone()); + } + } + + recent_workspaces + } + /// Searches workspaces. pub async fn search_workspaces(&self, query: &str) -> Vec { let manager = self.manager.read().await; @@ -342,7 +464,34 @@ impl WorkspaceService { } }; - let new_workspace = WorkspaceInfo::new(workspace_path, ScanOptions::default()).await?; + let existing_workspace = { + let manager = self.manager.read().await; + manager.get_workspace(workspace_id).cloned() + }; + let Some(existing_workspace) = existing_workspace else { + return Err(BitFunError::service(format!( + "Workspace not found: {}", + workspace_id + ))); + }; + let new_workspace = WorkspaceInfo::new( + workspace_path, + WorkspaceOpenOptions { + scan_options: ScanOptions::default(), + auto_set_current: existing_workspace.status == WorkspaceStatus::Active, + add_to_recent: false, + workspace_kind: existing_workspace.workspace_kind.clone(), + assistant_id: existing_workspace.assistant_id.clone(), + display_name: Some(existing_workspace.name.clone()), + }, + ) + .await?; + let mut new_workspace = new_workspace; + new_workspace.id = existing_workspace.id.clone(); + new_workspace.opened_at = existing_workspace.opened_at; + new_workspace.description = existing_workspace.description.clone(); + new_workspace.tags = existing_workspace.tags.clone(); + new_workspace.metadata = existing_workspace.metadata.clone(); { let mut manager = self.manager.write().await; @@ -351,9 +500,80 @@ impl WorkspaceService { .insert(workspace_id.to_string(), new_workspace.clone()); } + if let Err(e) = self.save_workspace_data().await { + warn!("Failed to save workspace data after rescan: {}", e); + } + Ok(new_workspace) } + /// Refreshes the parsed `IDENTITY.md` content for an assistant workspace. + pub async fn refresh_workspace_identity( + &self, + workspace_id: &str, + ) -> BitFunResult> { + let workspace = { + let manager = self.manager.read().await; + manager.get_workspace(workspace_id).cloned() + } + .ok_or_else(|| BitFunError::service(format!("Workspace not found: {}", workspace_id)))?; + + if workspace.workspace_kind != WorkspaceKind::Assistant { + return Ok(None); + } + + let updated_identity = match WorkspaceIdentity::load_from_workspace_root(&workspace.root_path).await { + Ok(identity) => identity, + Err(error) => { + warn!( + "Failed to refresh workspace identity: workspace_id={} path={} error={}", + workspace_id, + workspace.root_path.display(), + error + ); + return Ok(None); + } + }; + + let changed_fields = + WorkspaceIdentity::collect_changed_fields(workspace.identity.as_ref(), updated_identity.as_ref()); + let fallback_name = Self::assistant_display_name(workspace.assistant_id.as_deref()); + let updated_name = updated_identity + .as_ref() + .and_then(|identity| identity.name.clone()) + .unwrap_or(fallback_name); + + if changed_fields.is_empty() && workspace.name == updated_name { + return Ok(None); + } + + { + let mut manager = self.manager.write().await; + let workspace = manager + .get_workspaces_mut() + .get_mut(workspace_id) + .ok_or_else(|| BitFunError::service(format!("Workspace not found: {}", workspace_id)))?; + + workspace.identity = updated_identity.clone(); + workspace.name = updated_name.clone(); + } + + if let Err(e) = self.save_workspace_data().await { + warn!( + "Failed to save workspace data after identity refresh: workspace_id={} error={}", + workspace_id, e + ); + } + + Ok(Some(WorkspaceIdentityChangedEvent { + workspace_id: workspace.id, + workspace_path: workspace.root_path.to_string_lossy().to_string(), + name: updated_name, + identity: updated_identity, + changed_fields, + })) + } + /// Updates workspace information. pub async fn update_workspace_info( &self, @@ -447,8 +667,7 @@ impl WorkspaceService { manager.cleanup_invalid_workspaces().await }; - if result.is_ok() { - } + if result.is_ok() {} result } @@ -527,6 +746,11 @@ impl WorkspaceService { .iter() .map(|w| w.id.clone()) .collect(), + recent_assistant_workspaces: manager + .get_recent_assistant_workspace_infos() + .iter() + .map(|w| w.id.clone()) + .collect(), export_timestamp: chrono::Utc::now().to_rfc3339(), version: env!("CARGO_PKG_VERSION").to_string(), }) @@ -568,6 +792,7 @@ impl WorkspaceService { } manager.set_recent_workspaces(export.recent_workspaces.clone()); + manager.set_recent_assistant_workspaces(export.recent_assistant_workspaces.clone()); if let Some(current_id) = export.current_workspace_id { if manager.get_workspaces().contains_key(¤t_id) { @@ -593,6 +818,7 @@ impl WorkspaceService { let stats = self.get_statistics().await; let current_workspace = self.get_current_workspace().await; let recent_workspaces = self.get_recent_workspaces().await; + let recent_assistant_workspaces = self.get_recent_assistant_workspaces().await; WorkspaceQuickSummary { total_workspaces: stats.total_workspaces, @@ -603,6 +829,11 @@ impl WorkspaceService { .take(5) .map(|w| w.get_summary()) .collect(), + recent_assistant_workspaces: recent_assistant_workspaces + .into_iter() + .take(5) + .map(|w| w.get_summary()) + .collect(), workspace_types: stats.workspaces_by_type, } } @@ -616,6 +847,7 @@ impl WorkspaceService { opened_workspace_ids: manager.get_opened_workspace_ids().clone(), current_workspace_id: manager.get_current_workspace().map(|w| w.id.clone()), recent_workspaces: manager.get_recent_workspaces().clone(), + recent_assistant_workspaces: manager.get_recent_assistant_workspaces().clone(), saved_at: chrono::Utc::now(), }; @@ -642,6 +874,7 @@ impl WorkspaceService { *manager.get_workspaces_mut() = data.workspaces; manager.set_opened_workspace_ids(data.opened_workspace_ids); manager.set_recent_workspaces(data.recent_workspaces); + manager.set_recent_assistant_workspaces(data.recent_assistant_workspaces); if let Some(current_id) = data.current_workspace_id { if let Some(workspace) = manager.get_workspaces().get(¤t_id) { @@ -680,6 +913,7 @@ impl WorkspaceService { *manager.get_workspaces_mut() = data.workspaces; manager.set_opened_workspace_ids(data.opened_workspace_ids.clone()); manager.set_recent_workspaces(data.recent_workspaces); + manager.set_recent_assistant_workspaces(data.recent_assistant_workspaces); let current_id = data .current_workspace_id @@ -697,11 +931,219 @@ impl WorkspaceService { Ok(()) } + fn to_manager_open_options(options: &WorkspaceCreateOptions) -> WorkspaceOpenOptions { + WorkspaceOpenOptions { + scan_options: options.scan_options.clone(), + auto_set_current: options.auto_set_current, + add_to_recent: options.add_to_recent, + workspace_kind: options.workspace_kind.clone(), + assistant_id: options.assistant_id.clone(), + display_name: options.display_name.clone(), + } + } + + fn assistant_display_name(assistant_id: Option<&str>) -> String { + match assistant_id { + Some(id) if !id.trim().is_empty() => format!("Claw {}", id.trim()), + _ => "Claw".to_string(), + } + } + + async fn generate_assistant_workspace_id(&self) -> BitFunResult { + for _ in 0..32 { + let assistant_id = uuid::Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(8) + .collect::(); + let path = self + .path_manager + .assistant_workspace_dir(&assistant_id, None); + + if fs::try_exists(&path).await.map_err(|e| { + BitFunError::service(format!( + "Failed to check assistant workspace path '{}': {}", + path.display(), + e + )) + })? { + continue; + } + + if self.get_workspace_by_path(&path).await.is_none() { + return Ok(assistant_id); + } + } + + Err(BitFunError::service( + "Failed to allocate a unique assistant workspace id".to_string(), + )) + } + + fn assistant_descriptor_from_path(&self, path: &Path) -> Option { + let default_workspace = self.path_manager.default_assistant_workspace_dir(None); + if path == default_workspace { + return Some(AssistantWorkspaceDescriptor { + path: path.to_path_buf(), + assistant_id: None, + display_name: Self::assistant_display_name(None), + }); + } + + let assistant_root = self.path_manager.assistant_workspace_base_dir(None); + if path.parent()? != assistant_root { + return None; + } + + let file_name = path.file_name()?.to_string_lossy(); + let assistant_id = file_name.strip_prefix("workspace-")?; + if assistant_id.trim().is_empty() { + return None; + } + + Some(AssistantWorkspaceDescriptor { + path: path.to_path_buf(), + assistant_id: Some(assistant_id.to_string()), + display_name: Self::assistant_display_name(Some(assistant_id)), + }) + } + + fn normalize_workspace_options_for_path( + &self, + path: &Path, + mut options: WorkspaceCreateOptions, + ) -> WorkspaceCreateOptions { + if options.workspace_kind == WorkspaceKind::Assistant { + if options.display_name.is_none() { + options.display_name = + Some(Self::assistant_display_name(options.assistant_id.as_deref())); + } + return options; + } + + if let Some(descriptor) = self.assistant_descriptor_from_path(path) { + options.workspace_kind = WorkspaceKind::Assistant; + if options.assistant_id.is_none() { + options.assistant_id = descriptor.assistant_id; + } + if options.display_name.is_none() { + options.display_name = Some(descriptor.display_name); + } + } + + options + } + + async fn discover_assistant_workspaces(&self) -> BitFunResult> { + let assistant_root = self.path_manager.assistant_workspace_base_dir(None); + fs::create_dir_all(&assistant_root).await.map_err(|e| { + BitFunError::service(format!( + "Failed to create assistant workspace root '{}': {}", + assistant_root.display(), + e + )) + })?; + + let default_workspace = self.path_manager.default_assistant_workspace_dir(None); + fs::create_dir_all(&default_workspace).await.map_err(|e| { + BitFunError::service(format!( + "Failed to create default assistant workspace '{}': {}", + default_workspace.display(), + e + )) + })?; + + let mut descriptors = vec![AssistantWorkspaceDescriptor { + path: default_workspace, + assistant_id: None, + display_name: Self::assistant_display_name(None), + }]; + + let mut entries = fs::read_dir(&assistant_root).await.map_err(|e| { + BitFunError::service(format!( + "Failed to read assistant workspace root '{}': {}", + assistant_root.display(), + e + )) + })?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + BitFunError::service(format!( + "Failed to iterate assistant workspace root '{}': {}", + assistant_root.display(), + e + )) + })? { + let file_type = entry.file_type().await.map_err(|e| { + BitFunError::service(format!( + "Failed to inspect assistant workspace entry '{}': {}", + entry.path().display(), + e + )) + })?; + if !file_type.is_dir() { + continue; + } + + let file_name = entry.file_name().to_string_lossy().to_string(); + let Some(assistant_id) = file_name.strip_prefix("workspace-") else { + continue; + }; + if assistant_id.trim().is_empty() { + continue; + } + + descriptors.push(AssistantWorkspaceDescriptor { + path: entry.path(), + assistant_id: Some(assistant_id.to_string()), + display_name: Self::assistant_display_name(Some(assistant_id)), + }); + } + + descriptors.sort_by(|left, right| { + match (left.assistant_id.is_some(), right.assistant_id.is_some()) { + (false, true) => std::cmp::Ordering::Less, + (true, false) => std::cmp::Ordering::Greater, + _ => left.path.cmp(&right.path), + } + }); + + Ok(descriptors) + } + + async fn ensure_assistant_workspaces(&self) -> BitFunResult<()> { + let descriptors = self.discover_assistant_workspaces().await?; + let mut has_current_workspace = self.get_current_workspace().await.is_some(); + + for descriptor in descriptors { + let should_activate = !has_current_workspace && descriptor.assistant_id.is_none(); + let options = WorkspaceCreateOptions { + auto_set_current: should_activate, + add_to_recent: false, + workspace_kind: WorkspaceKind::Assistant, + assistant_id: descriptor.assistant_id.clone(), + display_name: Some(descriptor.display_name.clone()), + ..Default::default() + }; + + self.open_workspace_with_options(descriptor.path, options).await?; + has_current_workspace = true; + } + + Ok(()) + } + /// Saves workspace data manually (public API). pub async fn manual_save(&self) -> BitFunResult<()> { self.save_workspace_data().await } + /// Returns whether a path is a managed assistant workspace. + pub fn is_assistant_workspace_path(&self, path: &Path) -> bool { + self.assistant_descriptor_from_path(path).is_some() + } + /// Clears all persisted data. pub async fn clear_persistent_data(&self) -> BitFunResult<()> { self.persistence @@ -755,6 +1197,8 @@ pub struct WorkspaceExport { pub workspaces: Vec, pub current_workspace_id: Option, pub recent_workspaces: Vec, + #[serde(default)] + pub recent_assistant_workspaces: Vec, pub export_timestamp: String, pub version: String, } @@ -775,6 +1219,8 @@ pub struct WorkspaceQuickSummary { pub active_workspaces: usize, pub current_workspace: Option, pub recent_workspaces: Vec, + #[serde(default)] + pub recent_assistant_workspaces: Vec, pub workspace_types: std::collections::HashMap, } @@ -785,7 +1231,10 @@ struct WorkspacePersistenceData { #[serde(default)] pub opened_workspace_ids: Vec, pub current_workspace_id: Option, + #[serde(default)] pub recent_workspaces: Vec, + #[serde(default)] + pub recent_assistant_workspaces: Vec, pub saved_at: chrono::DateTime, } diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 7b07a062..b5fb3ca2 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -13,7 +13,7 @@ import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Plus, FolderOpen, FolderPlus, History, Check, Code2, Users } from 'lucide-react'; +import { Plus, FolderOpen, FolderPlus, History, Check, Bot, Code2, Users } from 'lucide-react'; import { useApp } from '../../hooks/useApp'; import { useSceneManager } from '../../hooks/useSceneManager'; import { useNavSceneStore } from '../../stores/navSceneStore'; @@ -29,11 +29,13 @@ import ToolboxEntry from './components/ToolboxEntry'; import WorkspaceListSection from './sections/workspaces/WorkspaceListSection'; import { useSceneStore } from '../../stores/sceneStore'; import { useMyAgentStore } from '../../scenes/my-agent/myAgentStore'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createLogger } from '@/shared/utils/logger'; +import { WorkspaceKind } from '@/shared/types'; const DEFAULT_MODE_CONFIG_KEY = 'app.session_config.default_mode'; import './NavPanel.scss'; @@ -77,8 +79,16 @@ const MainNav: React.FC = ({ const openNavScene = useNavSceneStore(s => s.openNavScene); const activeTabId = useSceneStore(s => s.activeTabId); const setMyAgentView = useMyAgentStore(s => s.setActiveView); + const setSelectedAssistantWorkspaceId = useMyAgentStore((s) => s.setSelectedAssistantWorkspaceId); const { t } = useI18n('common'); - const { currentWorkspace, recentWorkspaces, switchWorkspace } = useWorkspaceContext(); + const { + currentWorkspace, + recentWorkspaces, + openedWorkspacesList, + assistantWorkspacesList, + switchWorkspace, + setActiveWorkspace, + } = useWorkspaceContext(); const activeTab = state.layout.leftPanelActiveTab; const activeMiniAppId = useMemo( @@ -135,6 +145,7 @@ const MainNav: React.FC = ({ (sectionId: string, fallbackLabel: string | null) => { if (!fallbackLabel) return null; const keyMap: Record = { + assistants: 'nav.workspaces.groups.assistants', workspace: 'nav.sections.workspace', 'my-agent': 'nav.sections.myAgent', toolbox: 'scenes.toolbox', @@ -181,6 +192,11 @@ const MainNav: React.FC = ({ }, [closeWorkspaceMenu, openWorkspaceMenu, workspaceMenuOpen]); const setSessionMode = useSessionModeStore(s => s.setMode); + const isAssistantWorkspaceActive = currentWorkspace?.workspaceKind === WorkspaceKind.Assistant; + const defaultAssistantWorkspace = useMemo( + () => assistantWorkspacesList.find(workspace => !workspace.assistantId) ?? assistantWorkspacesList[0] ?? null, + [assistantWorkspacesList] + ); const [defaultSessionMode, setDefaultSessionMode] = useState<'code' | 'cowork'>('code'); @@ -197,6 +213,12 @@ const MainNav: React.FC = ({ return () => unwatch(); }, []); + useEffect(() => { + openedWorkspacesList.forEach(workspace => { + void flowChatStore.initializeFromDisk(workspace.rootPath); + }); + }, [openedWorkspacesList]); + const closeModeDropdown = useCallback(() => { setModeDropdownClosing(true); window.setTimeout(() => { @@ -235,6 +257,15 @@ const MainNav: React.FC = ({ } }, [closeModeDropdown, setSessionMode]); + const handleCreateAssistantWorkspace = useCallback(async () => { + closeModeDropdown(); + try { + await workspaceManager.createAssistantWorkspace(); + } catch (err) { + log.error('Failed to create assistant workspace', err); + } + }, [closeModeDropdown]); + const handleItemClick = useCallback( (tab: PanelType, item: NavItemConfig) => { if (item.behavior === 'scene' && item.sceneId) { @@ -255,12 +286,16 @@ const MainNav: React.FC = ({ try { await flowChatManager.createChatSession( { modelName: 'claude-sonnet-4.5' }, - defaultSessionMode === 'cowork' ? 'Cowork' : 'agentic' + isAssistantWorkspaceActive + ? 'Claw' + : defaultSessionMode === 'cowork' + ? 'Cowork' + : 'agentic' ); } catch (err) { log.error('Failed to create session', err); } - }, [openScene, switchLeftPanelTab, defaultSessionMode]); + }, [openScene, switchLeftPanelTab, defaultSessionMode, isAssistantWorkspaceActive]); const handleOpenProject = useCallback(async () => { try { @@ -338,10 +373,33 @@ const MainNav: React.FC = ({ }, [closeModeDropdown, modeDropdownOpen]); const handleOpenProfile = useCallback(() => { + const targetAssistantWorkspace = + isAssistantWorkspaceActive && currentWorkspace?.workspaceKind === WorkspaceKind.Assistant + ? currentWorkspace + : defaultAssistantWorkspace; + + if (targetAssistantWorkspace?.id) { + setSelectedAssistantWorkspaceId(targetAssistantWorkspace.id); + } + + if (!isAssistantWorkspaceActive && targetAssistantWorkspace) { + void setActiveWorkspace(targetAssistantWorkspace.id).catch(error => { + log.warn('Failed to activate default assistant workspace', { error }); + }); + } setMyAgentView('profile'); switchLeftPanelTab('profile'); openScene('my-agent'); - }, [openScene, setMyAgentView, switchLeftPanelTab]); + }, [ + currentWorkspace, + defaultAssistantWorkspace, + isAssistantWorkspaceActive, + openScene, + setActiveWorkspace, + setMyAgentView, + setSelectedAssistantWorkspaceId, + switchLeftPanelTab, + ]); let flatCounter = 0; @@ -407,7 +465,11 @@ const MainNav: React.FC = ({ document.body ) : null; - const ModeIcon = defaultSessionMode === 'cowork' ? Users : Code2; + const ModeIcon = isAssistantWorkspaceActive + ? Bot + : defaultSessionMode === 'cowork' + ? Users + : Code2; const modeDropdownPortal = modeDropdownOpen ? createPortal(
= ({ role="menu" style={{ top: modeDropdownPos.top, left: modeDropdownPos.left }} > - - + {isAssistantWorkspaceActive ? ( + + ) : ( + <> + + + + )}
, document.body ) : null; @@ -458,7 +534,13 @@ const MainNav: React.FC = ({ type="button" className="bitfun-nav-panel__workspace-create-main" onClick={handleCreateSession} - title={defaultSessionMode === 'cowork' ? t('nav.sessions.newCoworkSession') : t('nav.sessions.newCodeSession')} + title={ + isAssistantWorkspaceActive + ? t('nav.sessions.newClawSession') + : defaultSessionMode === 'cowork' + ? t('nav.sessions.newCoworkSession') + : t('nav.sessions.newCodeSession') + } > {t('nav.sessions.newSession')} @@ -468,7 +550,13 @@ const MainNav: React.FC = ({ type="button" className={`bitfun-nav-panel__workspace-create-mode${modeDropdownOpen ? ' is-active' : ''}`} onClick={toggleModeDropdown} - title={defaultSessionMode === 'cowork' ? t('nav.sessions.newCoworkSessionDefault') : t('nav.sessions.newCodeSessionDefault')} + title={ + isAssistantWorkspaceActive + ? t('nav.workspaces.actions.newAssistant') + : defaultSessionMode === 'cowork' + ? t('nav.sessions.newCoworkSessionDefault') + : t('nav.sessions.newCodeSessionDefault') + } aria-expanded={modeDropdownOpen} aria-haspopup="listbox" > @@ -496,7 +584,19 @@ const MainNav: React.FC = ({ isOpen={isSectionOpen} onToggle={() => toggleSection(section.id)} onSceneOpen={sectionSceneId ? () => openScene(sectionSceneId) : undefined} - actions={section.id === 'workspace' ? ( + actions={section.id === 'assistants' ? ( +
+ +
+ ) : section.id === 'workspace' ? (
+ {isDefaultAssistantWorkspace && ( + + )} + {isNamedAssistantWorkspace && ( + + )}
); }; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index 4aa90425..a7d75f18 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -8,6 +8,53 @@ padding: 4px 0 0; } + &__workspace-group { + display: flex; + flex-direction: column; + gap: $size-gap-1; + } + + &__workspace-group-title { + margin: 0 $size-gap-1; + padding: 0 $size-gap-1; + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-1; + color: var(--color-text-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + &__workspace-group-title-actions { + display: inline-flex; + align-items: center; + gap: 4px; + } + + &__workspace-group-title-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--element-bg-medium) 70%, transparent); + } + } + &__workspace-list-empty { margin: 0 $size-gap-1; padding: $size-gap-2; @@ -206,6 +253,30 @@ font-weight: 600; } + &__workspace-item-title { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1; + overflow: hidden; + } + + &__workspace-item-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-accent-200) 88%, transparent); + color: var(--color-accent-500); + font-size: 10px; + font-weight: 700; + line-height: 1.4; + white-space: nowrap; + } + &__workspace-item-branch { display: inline-flex; align-items: center; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx index f0369af3..c6cf2181 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx @@ -1,28 +1,37 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; -import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import WorkspaceItem from './WorkspaceItem'; import './WorkspaceListSection.scss'; -const WorkspaceListSection: React.FC = () => { +interface WorkspaceListSectionProps { + variant: 'assistants' | 'projects'; +} + +const WorkspaceListSection: React.FC = ({ variant }) => { const { t } = useI18n('common'); - const { openedWorkspacesList, activeWorkspaceId } = useWorkspaceContext(); + const { + openedWorkspacesList, + normalWorkspacesList, + assistantWorkspacesList, + activeWorkspaceId, + } = useWorkspaceContext(); - useEffect(() => { - openedWorkspacesList.forEach(workspace => { - void flowChatStore.initializeFromDisk(workspace.rootPath); - }); - }, [openedWorkspacesList]); + const workspaces = variant === 'assistants' + ? assistantWorkspacesList + : normalWorkspacesList; + const emptyLabel = variant === 'assistants' + ? t('nav.workspaces.emptyAssistants') + : t('nav.workspaces.emptyProjects'); return (
- {openedWorkspacesList.length === 0 ? ( + {workspaces.length === 0 ? (
- {t('nav.workspaces.empty')} + {emptyLabel}
) : ( - openedWorkspacesList.map(workspace => ( + workspaces.map(workspace => ( { /> )) )} -
); }; diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index a13d8a91..11e92aed 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -23,16 +23,31 @@ import { NewProjectDialog } from '../components/NewProjectDialog'; import { AboutDialog } from '../components/AboutDialog'; import { WorkspaceManager } from '../../tools/workspace'; import { workspaceAPI } from '@/infrastructure/api'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { createLogger } from '@/shared/utils/logger'; import { useI18n } from '@/infrastructure/i18n'; +import { WorkspaceKind } from '@/shared/types'; import './AppLayout.scss'; const log = createLogger('AppLayout'); +const DEFAULT_MODE_CONFIG_KEY = 'app.session_config.default_mode'; interface AppLayoutProps { className?: string; } +type DefaultSessionMode = 'code' | 'cowork'; + +async function resolveDefaultSessionAgentType(): Promise<'agentic' | 'Cowork'> { + try { + const defaultMode = await configManager.getConfig(DEFAULT_MODE_CONFIG_KEY); + return defaultMode === 'cowork' ? 'Cowork' : 'agentic'; + } catch (error) { + log.warn('Failed to load default session mode, falling back to code', error); + return 'agentic'; + } +} + const AppLayout: React.FC = ({ className = '' }) => { const { t } = useI18n('components'); const { currentWorkspace, hasWorkspace, openWorkspace, recentWorkspaces, loading } = useWorkspaceContext(); @@ -124,24 +139,32 @@ const AppLayout: React.FC = ({ className = '' }) => { if (!currentWorkspace?.rootPath) return; try { - const preferredMode = + const explicitPreferredMode = sessionStorage.getItem('bitfun:flowchat:preferredMode') || - sessionStorage.getItem('bitfun:flowchat:lastMode') || undefined; - if (sessionStorage.getItem('bitfun:flowchat:preferredMode')) { + if (explicitPreferredMode) { sessionStorage.removeItem('bitfun:flowchat:preferredMode'); } + const initializationPreferredMode = + currentWorkspace.workspaceKind === WorkspaceKind.Assistant + ? 'Claw' + : explicitPreferredMode; + const flowChatManager = FlowChatManager.getInstance(); const hasHistoricalSessions = await flowChatManager.initialize( currentWorkspace.rootPath, - preferredMode + initializationPreferredMode ); let sessionId: string | undefined; const { flowChatStore } = await import('@/flow_chat/store/FlowChatStore'); if (!hasHistoricalSessions || !flowChatStore.getState().activeSessionId) { - sessionId = await flowChatManager.createChatSession({}, preferredMode); + const initialSessionMode = + currentWorkspace.workspaceKind === WorkspaceKind.Assistant + ? 'Claw' + : explicitPreferredMode || await resolveDefaultSessionAgentType(); + sessionId = await flowChatManager.createChatSession({}, initialSessionMode); } const pendingDescription = sessionStorage.getItem('pendingProjectDescription'); diff --git a/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx b/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx index c83f206a..b16fedc1 100644 --- a/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx +++ b/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx @@ -1,4 +1,7 @@ import React, { Suspense, lazy } from 'react'; +import { useMemo, useEffect } from 'react'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { WorkspaceKind } from '@/shared/types'; import { useMyAgentStore } from './myAgentStore'; import './MyAgentScene.scss'; @@ -12,11 +15,85 @@ interface MyAgentSceneProps { const MyAgentScene: React.FC = ({ workspacePath }) => { const activeView = useMyAgentStore((s) => s.activeView); + const selectedAssistantWorkspaceId = useMyAgentStore((s) => s.selectedAssistantWorkspaceId); + const setSelectedAssistantWorkspaceId = useMyAgentStore((s) => s.setSelectedAssistantWorkspaceId); + const { currentWorkspace, assistantWorkspacesList } = useWorkspaceContext(); + const activeAssistantWorkspace = + currentWorkspace?.workspaceKind === WorkspaceKind.Assistant ? currentWorkspace : null; + + const defaultAssistantWorkspace = useMemo( + () => assistantWorkspacesList.find((workspace) => !workspace.assistantId) ?? assistantWorkspacesList[0] ?? null, + [assistantWorkspacesList] + ); + + const selectedAssistantWorkspace = useMemo(() => { + if (!selectedAssistantWorkspaceId) { + return null; + } + + return assistantWorkspacesList.find( + (workspace) => workspace.id === selectedAssistantWorkspaceId + ) ?? null; + }, [assistantWorkspacesList, selectedAssistantWorkspaceId]); + + const resolvedAssistantWorkspace = useMemo(() => { + if (activeAssistantWorkspace) { + return activeAssistantWorkspace; + } + + if (selectedAssistantWorkspace) { + return selectedAssistantWorkspace; + } + + return defaultAssistantWorkspace; + }, [ + activeAssistantWorkspace, + defaultAssistantWorkspace, + selectedAssistantWorkspace, + ]); + + useEffect(() => { + if ( + activeAssistantWorkspace?.id + && activeAssistantWorkspace.id !== selectedAssistantWorkspaceId + ) { + setSelectedAssistantWorkspaceId(activeAssistantWorkspace.id); + } + }, [ + activeAssistantWorkspace, + selectedAssistantWorkspaceId, + setSelectedAssistantWorkspaceId, + ]); + + useEffect(() => { + const selectedExists = selectedAssistantWorkspaceId + ? assistantWorkspacesList.some((workspace) => workspace.id === selectedAssistantWorkspaceId) + : false; + + if (activeAssistantWorkspace?.id) { + return; + } + + if (!selectedExists && resolvedAssistantWorkspace?.id !== selectedAssistantWorkspaceId) { + setSelectedAssistantWorkspaceId(resolvedAssistantWorkspace?.id ?? null); + } + }, [ + activeAssistantWorkspace, + assistantWorkspacesList, + resolvedAssistantWorkspace, + selectedAssistantWorkspaceId, + setSelectedAssistantWorkspaceId, + ]); return (
}> - {activeView === 'profile' && } + {activeView === 'profile' && ( + + )} {activeView === 'agents' && } {activeView === 'skills' && } diff --git a/src/web-ui/src/app/scenes/my-agent/identityDocument.ts b/src/web-ui/src/app/scenes/my-agent/identityDocument.ts new file mode 100644 index 00000000..fd09dc29 --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/identityDocument.ts @@ -0,0 +1,85 @@ +import yaml from 'yaml'; + +export interface IdentityDocument { + name: string; + creature: string; + vibe: string; + emoji: string; + body: string; +} + +export const EMPTY_IDENTITY_DOCUMENT: IdentityDocument = { + name: '', + creature: '', + vibe: '', + emoji: '', + body: '', +}; + +const FRONTMATTER_FIELDS: Array> = [ + 'name', + 'creature', + 'vibe', + 'emoji', +]; + +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function normalizeShortField(value: unknown): string { + if (typeof value !== 'string') { + return ''; + } + + return value.replace(/\s+/g, ' ').trim(); +} + +function serializeScalar(value: string): string { + return yaml.stringify(value).trimEnd(); +} + +export function parseIdentityDocument(content: string): IdentityDocument { + const normalizedContent = normalizeLineEndings(content || ''); + const frontmatterMatch = normalizedContent.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + + if (!frontmatterMatch) { + return { + ...EMPTY_IDENTITY_DOCUMENT, + body: normalizedContent.trim(), + }; + } + + const parsed = (yaml.parse(frontmatterMatch[1]) || {}) as Record; + const body = frontmatterMatch[2] ?? ''; + + return { + name: normalizeShortField(parsed.name), + creature: normalizeShortField(parsed.creature), + vibe: normalizeShortField(parsed.vibe), + emoji: normalizeShortField(parsed.emoji), + body: body.replace(/^\n+/, '').trimEnd(), + }; +} + +export function serializeIdentityDocument(document: IdentityDocument): string { + const normalized = { + name: normalizeShortField(document.name), + creature: normalizeShortField(document.creature), + vibe: normalizeShortField(document.vibe), + emoji: normalizeShortField(document.emoji), + body: normalizeLineEndings(document.body || '').replace(/^\n+/, '').trimEnd(), + }; + + const frontmatter = FRONTMATTER_FIELDS.map((field) => { + const value = normalized[field]; + return value ? `${field}: ${serializeScalar(value)}` : `${field}:`; + }).join('\n'); + + return `---\n${frontmatter}\n---\n\n${normalized.body}`.trimEnd() + '\n'; +} + +export function getIdentityFilePath(workspaceRoot: string): string { + const normalizedRoot = workspaceRoot.replace(/\\/g, '/').replace(/\/+$/, ''); + return `${normalizedRoot}/IDENTITY.md`; +} diff --git a/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts b/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts index baf5e3dd..06df3182 100644 --- a/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts +++ b/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts @@ -4,10 +4,14 @@ import { DEFAULT_MY_AGENT_VIEW } from './myAgentConfig'; interface MyAgentState { activeView: MyAgentView; + selectedAssistantWorkspaceId: string | null; setActiveView: (view: MyAgentView) => void; + setSelectedAssistantWorkspaceId: (workspaceId: string | null) => void; } export const useMyAgentStore = create((set) => ({ activeView: DEFAULT_MY_AGENT_VIEW, + selectedAssistantWorkspaceId: null, setActiveView: (view) => set({ activeView: view }), + setSelectedAssistantWorkspaceId: (workspaceId) => set({ selectedAssistantWorkspaceId: workspaceId }), })); diff --git a/src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts b/src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts new file mode 100644 index 00000000..c3fcd24f --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts @@ -0,0 +1,314 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI'; +import { fileSystemService } from '@/tools/file-system/services/FileSystemService'; +import { ideControl } from '@/shared/services/ide-control'; +import { createLogger } from '@/shared/utils/logger'; +import { + EMPTY_IDENTITY_DOCUMENT, + getIdentityFilePath, + parseIdentityDocument, + serializeIdentityDocument, + type IdentityDocument, +} from './identityDocument'; + +const log = createLogger('AgentIdentityDocument'); +const AUTOSAVE_DEBOUNCE_MS = 800; +const WATCHER_RELOAD_DEBOUNCE_MS = 300; +const SELF_WRITE_SUPPRESS_MS = 1200; + +export type IdentitySaveStatus = + | 'idle' + | 'loading' + | 'saving' + | 'saved' + | 'error' + | 'external-update'; + +function normalizePath(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function isFileMissingError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /does not exist|no such file|not found/i.test(message); +} + +export interface UseAgentIdentityDocumentResult { + identityFilePath: string; + document: IdentityDocument; + originalDocument: IdentityDocument; + loading: boolean; + error: string | null; + saveStatus: IdentitySaveStatus; + hasUnsavedChanges: boolean; + hasExternalUpdate: boolean; + updateField: (field: K, value: IdentityDocument[K]) => void; + reload: () => Promise; + openInEditor: () => Promise; + resetPersonaFiles: () => Promise; +} + +export function useAgentIdentityDocument( + workspacePath: string +): UseAgentIdentityDocumentResult { + const identityFilePath = useMemo( + () => (workspacePath ? getIdentityFilePath(workspacePath) : ''), + [workspacePath] + ); + + const [document, setDocument] = useState(EMPTY_IDENTITY_DOCUMENT); + const [originalDocument, setOriginalDocument] = useState(EMPTY_IDENTITY_DOCUMENT); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [saveStatus, setSaveStatus] = useState('idle'); + const [hasExternalUpdate, setHasExternalUpdate] = useState(false); + + const saveTimerRef = useRef | null>(null); + const watchReloadTimerRef = useRef | null>(null); + const suppressWatcherUntilRef = useRef(0); + const mountedRef = useRef(true); + const hasUnsavedChangesRef = useRef(false); + + const serializedDocument = useMemo(() => serializeIdentityDocument(document), [document]); + const serializedOriginal = useMemo( + () => serializeIdentityDocument(originalDocument), + [originalDocument] + ); + const hasUnsavedChanges = serializedDocument !== serializedOriginal; + + useEffect(() => { + hasUnsavedChangesRef.current = hasUnsavedChanges; + }, [hasUnsavedChanges]); + + useEffect(() => { + return () => { + mountedRef.current = false; + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + if (watchReloadTimerRef.current) { + clearTimeout(watchReloadTimerRef.current); + } + }; + }, []); + + const loadDocument = useCallback(async () => { + if (!workspacePath || !identityFilePath) { + if (!mountedRef.current) return; + setDocument(EMPTY_IDENTITY_DOCUMENT); + setOriginalDocument(EMPTY_IDENTITY_DOCUMENT); + setError(null); + setHasExternalUpdate(false); + setSaveStatus('idle'); + return; + } + + if (mountedRef.current) { + setLoading(true); + setError(null); + setSaveStatus('loading'); + } + + try { + const content = await workspaceAPI.readFileContent(identityFilePath); + const parsed = parseIdentityDocument(content); + + if (!mountedRef.current) { + return; + } + + setDocument(parsed); + setOriginalDocument(parsed); + setHasExternalUpdate(false); + setSaveStatus('idle'); + } catch (loadError) { + if (!mountedRef.current) { + return; + } + + if (isFileMissingError(loadError)) { + setDocument(EMPTY_IDENTITY_DOCUMENT); + setOriginalDocument(EMPTY_IDENTITY_DOCUMENT); + setHasExternalUpdate(false); + setSaveStatus('idle'); + setError(null); + } else { + log.error('Failed to load identity document', { workspacePath, identityFilePath, error: loadError }); + setError(loadError instanceof Error ? loadError.message : String(loadError)); + setSaveStatus('error'); + } + } finally { + if (mountedRef.current) { + setLoading(false); + } + } + }, [identityFilePath, workspacePath]); + + useEffect(() => { + void loadDocument(); + }, [loadDocument]); + + const saveDocument = useCallback(async () => { + if (!workspacePath || !identityFilePath || !hasUnsavedChangesRef.current) { + return; + } + + setSaveStatus('saving'); + + try { + await workspaceAPI.writeFileContent(workspacePath, identityFilePath, serializedDocument); + suppressWatcherUntilRef.current = Date.now() + SELF_WRITE_SUPPRESS_MS; + + if (!mountedRef.current) { + return; + } + + setOriginalDocument(document); + setHasExternalUpdate(false); + setError(null); + setSaveStatus('saved'); + } catch (saveError) { + if (!mountedRef.current) { + return; + } + + log.error('Failed to save identity document', { workspacePath, identityFilePath, error: saveError }); + setError(saveError instanceof Error ? saveError.message : String(saveError)); + setSaveStatus('error'); + } + }, [document, identityFilePath, serializedDocument, workspacePath]); + + useEffect(() => { + if (!workspacePath || !identityFilePath || !hasUnsavedChanges) { + if (saveStatus === 'saved') { + const clearSavedStatus = setTimeout(() => { + if (mountedRef.current) { + setSaveStatus((currentStatus) => (currentStatus === 'saved' ? 'idle' : currentStatus)); + } + }, 1500); + + return () => clearTimeout(clearSavedStatus); + } + + return; + } + + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + + saveTimerRef.current = setTimeout(() => { + void saveDocument(); + }, AUTOSAVE_DEBOUNCE_MS); + + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + }; + }, [hasUnsavedChanges, identityFilePath, saveDocument, saveStatus, workspacePath]); + + useEffect(() => { + if (!workspacePath || !identityFilePath) { + return; + } + + const normalizedIdentityPath = normalizePath(identityFilePath); + + const unwatch = fileSystemService.watchFileChanges(workspacePath, (event) => { + const eventPath = normalizePath(event.path); + const oldPath = event.oldPath ? normalizePath(event.oldPath) : null; + const matchesIdentity = + eventPath === normalizedIdentityPath || oldPath === normalizedIdentityPath; + + if (!matchesIdentity) { + return; + } + + if (Date.now() < suppressWatcherUntilRef.current) { + return; + } + + if (hasUnsavedChangesRef.current) { + setHasExternalUpdate(true); + setSaveStatus('external-update'); + return; + } + + if (watchReloadTimerRef.current) { + clearTimeout(watchReloadTimerRef.current); + } + + watchReloadTimerRef.current = setTimeout(() => { + void loadDocument(); + }, WATCHER_RELOAD_DEBOUNCE_MS); + }); + + return () => { + unwatch(); + if (watchReloadTimerRef.current) { + clearTimeout(watchReloadTimerRef.current); + } + }; + }, [identityFilePath, loadDocument, workspacePath]); + + const updateField = useCallback( + (field: K, value: IdentityDocument[K]) => { + setDocument((previous) => ({ ...previous, [field]: value })); + if (saveStatus === 'external-update') { + setSaveStatus('idle'); + } + }, + [saveStatus] + ); + + const openInEditor = useCallback(async () => { + if (!identityFilePath) { + return; + } + + await ideControl.navigation.goToFile(identityFilePath); + }, [identityFilePath]); + + const resetPersonaFiles = useCallback(async () => { + if (!workspacePath) { + return; + } + + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + + setSaveStatus('loading'); + setError(null); + + try { + await workspaceAPI.resetWorkspacePersonaFiles(workspacePath); + suppressWatcherUntilRef.current = Date.now() + SELF_WRITE_SUPPRESS_MS; + await loadDocument(); + } catch (resetError) { + log.error('Failed to reset workspace persona files', { workspacePath, error: resetError }); + if (mountedRef.current) { + setError(resetError instanceof Error ? resetError.message : String(resetError)); + setSaveStatus('error'); + } + throw resetError; + } + }, [loadDocument, workspacePath]); + + return { + identityFilePath, + document, + originalDocument, + loading, + error, + saveStatus, + hasUnsavedChanges, + hasExternalUpdate, + updateField, + reload: loadDocument, + openInEditor, + resetPersonaFiles, + }; +} diff --git a/src/web-ui/src/app/scenes/profile/views/PersonaView.scss b/src/web-ui/src/app/scenes/profile/views/PersonaView.scss index 9ccd719e..c2ffdf3b 100644 --- a/src/web-ui/src/app/scenes/profile/views/PersonaView.scss +++ b/src/web-ui/src/app/scenes/profile/views/PersonaView.scss @@ -206,6 +206,100 @@ $gutter: clamp(40px, 6vw, 80px); } } + &__reset-btn { + margin-left: 2px; + color: var(--color-text-disabled); + + &:hover { + color: var(--color-danger-500); + background: color-mix(in srgb, var(--color-danger-500) 8%, transparent); + } + } + + &__meta-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-height: 24px; + } + + &__meta-pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 24px; + padding: 0 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); + border: 1px solid color-mix(in srgb, var(--border-subtle) 72%, transparent); + color: var(--color-text-muted); + font-size: 11px; + cursor: pointer; + transition: border-color $motion-fast $easing-standard, + background $motion-fast $easing-standard, + color $motion-fast $easing-standard; + + &.is-emoji { + padding: 0 10px; + font-size: 14px; + color: var(--color-text-primary); + } + + &.is-empty { + color: var(--color-text-disabled); + } + + &.is-editing { + padding: 0; + background: transparent; + border-color: color-mix(in srgb, var(--color-accent-500) 35%, var(--border-subtle)); + } + + &:hover { + border-color: var(--border-medium); + color: var(--color-text-secondary); + } + } + + &__meta-label { + color: var(--color-text-disabled); + } + + &__meta-value { + color: inherit; + } + + &__meta-input { + min-width: 110px; + + .bitfun-input-container { + height: 24px; + border-radius: 999px; + background: var(--element-bg-subtle); + border: 1px solid var(--border-base); + padding: 0 9px; + } + + .bitfun-input { + font-size: 11px; + color: var(--color-text-primary); + } + + &.is-emoji { + min-width: 64px; + + .bitfun-input-container { + padding: 0 10px; + } + + .bitfun-input { + text-align: center; + font-size: 14px; + } + } + } + &__desc { margin: 0; font-size: $font-size-sm; @@ -239,19 +333,32 @@ $gutter: clamp(40px, 6vw, 80px); &__desc-input { width: 100%; - .bitfun-input-container { - border-radius: 6px; + .bitfun-textarea__wrapper { + border-radius: 8px; background: var(--element-bg-subtle); border: 1px solid var(--border-base); - padding: 0 10px; } - .bitfun-input { + .bitfun-textarea__field { font-size: $font-size-sm; color: var(--color-text-primary); font-family: inherit; + line-height: 1.7; + min-height: 140px; + padding: 12px 14px; } } + &__desc-markdown { + font-size: $font-size-sm; + color: var(--color-text-muted); + line-height: 1.8; + word-break: break-word; + white-space: pre-wrap; + overflow: hidden; + max-height: 132px; + mask-image: linear-gradient(to bottom, black 0%, black 78%, transparent 100%); + } + // ── Hint text ───────────────────────────────────────── &__hint { margin: 0; @@ -304,35 +411,6 @@ $gutter: clamp(40px, 6vw, 80px); border-left: 1px solid var(--border-subtle); } - // ── WIP badge ──────────────────────────────────────── - &__wip { - display: inline-flex; - align-items: center; - gap: 5px; - height: 20px; - padding: 0 8px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent); - background: color-mix(in srgb, #f59e0b 8%, transparent); - color: #d97706; - font-size: 11px; - font-weight: $font-weight-medium; - letter-spacing: 0.4px; - cursor: default; - user-select: none; - flex-shrink: 0; - animation: bp-wip-in 0.4s $easing-standard 0.15s both; - } - - &__wip-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: #f59e0b; - flex-shrink: 0; - animation: bp-wip-pulse 2.4s ease-in-out infinite; - } - // ── Inline enter link ───────────────────────────────── &__enter { display: inline; @@ -449,18 +527,29 @@ $gutter: clamp(40px, 6vw, 80px); flex-wrap: wrap; } + &__meta-row { + margin-top: 4px; + } + + &__meta-pill { + cursor: default; + + &:hover { + border-color: color-mix(in srgb, var(--border-subtle) 72%, transparent); + color: inherit; + } + } + &__name { margin: 0; font-size: 15px; font-weight: $font-weight-semibold; color: var(--color-text-primary); letter-spacing: -0.2px; - cursor: pointer; + cursor: default; display: inline-flex; align-items: center; gap: 5px; - transition: color $motion-fast $easing-standard; - &:hover { color: var(--color-accent-400); } } &__name-edit { @@ -482,6 +571,12 @@ $gutter: clamp(40px, 6vw, 80px); letter-spacing: 0.5px; text-transform: uppercase; flex-shrink: 0; + + &.is-error { + color: var(--color-danger-500); + background: color-mix(in srgb, var(--color-danger-500) 10%, var(--element-bg-soft)); + border-color: color-mix(in srgb, var(--color-danger-500) 20%, transparent); + } } &__desc { @@ -773,6 +868,34 @@ $gutter: clamp(40px, 6vw, 80px); } } + &-identity-form { + display: flex; + flex-direction: column; + gap: 14px; + } + + &-identity-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + + &-identity-body-input { + .bitfun-textarea__wrapper { + border-radius: 10px; + background: var(--element-bg-subtle); + border: 1px solid var(--border-base); + } + + .bitfun-textarea__field { + min-height: 220px; + padding: 12px 14px; + font-family: var(--font-family-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace); + font-size: 12px; + line-height: 1.7; + } + } + // ── Icon button ──────────────────────────────────────────── &-icon-btn { display: inline-flex; diff --git a/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx b/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx index 441a8d45..b1bd502f 100644 --- a/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx +++ b/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx @@ -8,7 +8,16 @@ import { ListChecks, RotateCcw, Puzzle, Brain, Zap, Sliders, } from 'lucide-react'; -import { Input, Search, Select, Switch, type SelectOption } from '@/component-library'; +import { + ConfirmDialog, + Input, + Search, + Select, + Switch, + Textarea, + Tooltip, + type SelectOption, +} from '@/component-library'; import { AIRulesAPI, RuleLevel, type AIRule } from '@/infrastructure/api/service-api/AIRulesAPI'; import { getAllMemories, toggleMemory, type AIMemory } from '@/infrastructure/api/aiMemoryApi'; import { promptTemplateService } from '@/infrastructure/services/PromptTemplateService'; @@ -18,12 +27,13 @@ import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import type { ModeConfigItem, SkillInfo, AIModelConfig, - DefaultModelsConfig, AIExperienceConfig, + AIExperienceConfig, } from '@/infrastructure/config/types'; import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; import type { ConfigTab } from '@/app/scenes/settings/settingsConfig'; import { quickActions } from '@/shared/services/ide-control'; import { getCardGradient } from '@/shared/utils/cardGradients'; +import { useAgentIdentityDocument } from '@/app/scenes/my-agent/useAgentIdentityDocument'; import { PersonaRadar } from './PersonaRadar'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; @@ -39,8 +49,7 @@ function navToSettings(tab: ConfigTab) { interface ToolInfo { name: string; description: string; is_readonly: boolean; } const C = 'bp'; -const IDENTITY_KEY = 'bf_agent_identity'; -const DEFAULT_NAME = 'BitFun Agent'; +const DEFAULT_AGENT_NAME = 'BitFun Agent'; const CHIP_LIMIT = 12; const TOOL_LIST_LIMIT = 10; const SKILL_GRID_LIMIT = 4; @@ -294,22 +303,20 @@ const ModelPill: React.FC = ({ }; const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => { const { t } = useTranslation('scenes/profile'); - - // Initialize identity from localStorage immediately (lazy initializer avoids flash) - const [identity, setIdentity] = useState<{ name: string; desc: string }>(() => { - try { - const s = localStorage.getItem(IDENTITY_KEY); - if (s) return JSON.parse(s) as { name: string; desc: string }; - } catch { /* ignore */ } - return { name: DEFAULT_NAME, desc: '' }; - }); - const [editingField, setEditingField] = useState<'name' | 'desc' | null>(null); + const { + document: identityDocument, + updateField: updateIdentityField, + resetPersonaFiles, + } = useAgentIdentityDocument(workspacePath); + const [editingField, setEditingField] = useState< + 'name' | 'body' | 'emoji' | 'creature' | 'vibe' | null + >(null); const [editValue, setEditValue] = useState(''); const nameInputRef = useRef(null); - const descInputRef = useRef(null); + const metaInputRef = useRef(null); + const bodyTextareaRef = useRef(null); const [models, setModels] = useState([]); - const [, setDefaultModels] = useState(null); const [funcAgentModels, setFuncAgentModels] = useState>({}); const [rules, setRules] = useState([]); const [memories, setMemories] = useState([]); @@ -345,6 +352,7 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => // home ↔ detail view transition const [detailMode, setDetailMode] = useState(false); + const [isResetIdentityDialogOpen, setIsResetIdentityDialogOpen] = useState(false); // section refs for radar-click scroll navigation const rulesRef = useRef(null); @@ -394,7 +402,7 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => const loadCaps = useCallback(async () => { try { const { invoke } = await import('@tauri-apps/api/core'); - const [tools, mcps, sks, modeConf, allModels, defModels, funcModels, exp] = await Promise.all([ + const [tools, mcps, sks, modeConf, allModels, funcModels, exp] = await Promise.all([ invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), MCPAPI.getServers().catch(() => [] as MCPServerInfo[]), configAPI.getSkillConfigs({ @@ -402,7 +410,6 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => }).catch(() => [] as SkillInfo[]), configAPI.getModeConfig('agentic').catch(() => null as ModeConfigItem | null), (configManager.getConfig('ai.models') as Promise).catch(() => [] as AIModelConfig[]), - (configManager.getConfig('ai.default_models') as Promise).catch(() => null), (configManager.getConfig>('ai.func_agent_models') as Promise>).catch(() => ({} as Record)), configAPI.getConfig('app.ai_experience').catch(() => null) as Promise, ]); @@ -411,31 +418,94 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => setSkills(sks); setAgenticConfig(modeConf); setModels(allModels ?? []); - setDefaultModels(defModels); setFuncAgentModels(funcModels ?? {}); if (exp) setAiExp(exp); } catch (e) { log.error('capabilities', e); } }, [workspacePath]); useEffect(() => { loadCaps(); }, [loadCaps]); - const startEdit = (field: 'name' | 'desc') => { + const identityName = identityDocument.name || DEFAULT_AGENT_NAME; + const identityBodyFallback = t('defaultDesc', { + defaultValue: '用 IDENTITY.md 的正文补充你的设定、风格、边界和偏好。', + }); + const identityMetaItems = useMemo( + () => [ + { + key: 'emoji' as const, + label: t('identity.emoji'), + value: identityDocument.emoji, + placeholder: t('identity.emojiPlaceholder'), + }, + { + key: 'creature' as const, + label: t('identity.creature'), + value: identityDocument.creature, + placeholder: t('identity.creaturePlaceholderShort'), + }, + { + key: 'vibe' as const, + label: t('identity.vibe'), + value: identityDocument.vibe, + placeholder: t('identity.vibePlaceholderShort'), + }, + ] as const, + [identityDocument.creature, identityDocument.emoji, identityDocument.vibe, t] + ); + + const startEdit = (field: 'name' | 'body' | 'emoji' | 'creature' | 'vibe') => { setEditingField(field); - setEditValue(field === 'name' ? identity.name : (identity.desc || t('defaultDesc'))); - setTimeout(() => (field === 'name' ? nameInputRef : descInputRef).current?.focus(), 10); + const nextValue = + field === 'name' + ? identityDocument.name + : field === 'body' + ? identityDocument.body + : identityDocument[field]; + + setEditValue(nextValue); + setTimeout(() => { + if (field === 'name') { + nameInputRef.current?.focus(); + return; + } + + if (field === 'body') { + bodyTextareaRef.current?.focus(); + return; + } + + metaInputRef.current?.focus(); + }, 10); }; const commitEdit = useCallback(() => { if (!editingField) return; - const fallback = editingField === 'name' ? DEFAULT_NAME : t('defaultDesc'); - const updated = { ...identity, [editingField === 'name' ? 'name' : 'desc']: editValue.trim() || fallback }; - setIdentity(updated); - localStorage.setItem(IDENTITY_KEY, JSON.stringify(updated)); + if (editingField === 'name') { + updateIdentityField('name', editValue.trim()); + } else if (editingField === 'body') { + updateIdentityField('body', editValue.replace(/\r\n/g, '\n')); + } else { + updateIdentityField(editingField, editValue.trim()); + } setEditingField(null); - }, [editingField, editValue, identity, t]); - const onEditKey = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') commitEdit(); + }, [editValue, editingField, updateIdentityField]); + const onEditKey = (e: React.KeyboardEvent) => { + if (editingField !== 'body' && e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingField(null); }; + const handleConfirmResetIdentity = useCallback(async () => { + setEditingField(null); + setEditValue(''); + + try { + await resetPersonaFiles(); + notificationService.success(t('identity.resetSuccess')); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('identity.resetFailed') + ); + } + }, [resetPersonaFiles, t]); + const openRadar = useCallback(() => setRadarOpen(true), []); const closeRadar = useCallback(() => { setRadarClosing(true); @@ -930,46 +1000,97 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => inputSize="small" /> ) : ( -

startEdit('name')} - title={t('hero.editNameTitle')} - > - {identity.name} - -

+ <> +

startEdit('name')} + title={t('hero.editNameTitle')} + > + {identityName} + +

+ + + + )} -
- - WIP · 建设中 -
+
+ +
+ {identityMetaItems.map((item) => { + const isEditingMeta = editingField === item.key; + const displayValue = item.value || item.placeholder; + return ( + !isEditingMeta && startEdit(item.key)} + > + {isEditingMeta ? ( + setEditValue(event.target.value)} + onBlur={commitEdit} + onKeyDown={onEditKey} + placeholder={item.placeholder} + inputSize="small" + /> + ) : ( + <> + {item.key !== 'emoji' && {item.label}} + {displayValue} + + )} + + ); + })}
{/* Description + Radar side by side */}
-
!editingField && startEdit('desc')}> - {editingField === 'desc' ? ( - !editingField && startEdit('body')}> + {editingField === 'body' ? ( +