diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index 4e8d0b327e..83d6709061 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -43,9 +43,9 @@ jobs: echo "πŸ“ Synchronize event - checking files changed since previous commit" CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.before }}..${{ github.event.after }}) else - # For opened/reopened, check all files in the PR + # For opened/reopened, check all files in the PR (compare PR head against base) echo "πŸ“ PR opened/reopened - checking all files in PR" - CHANGED_FILES=$(git diff --name-only --diff-filter=AM origin/${{ github.base_ref }}..HEAD) + CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}) fi echo "Changed files in this push:" @@ -78,9 +78,9 @@ jobs: echo "πŸ“ Synchronize event - checking files changed/added since previous commit" CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.before }}..${{ github.event.after }}) else - # For opened/reopened, check all files in the PR (new and modified) + # For opened/reopened, check all files in the PR (compare PR head against base) echo "πŸ“ PR opened/reopened - checking all new/modified files in PR" - CHANGED_FILES=$(git diff --name-only --diff-filter=AM origin/${{ github.base_ref }}..HEAD) + CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}) fi # Filter for recipe files only that were changed or added diff --git a/.github/workflows/send-api-key.yml b/.github/workflows/send-api-key.yml index dfaf6ce7d5..f40e1e7202 100644 --- a/.github/workflows/send-api-key.yml +++ b/.github/workflows/send-api-key.yml @@ -24,15 +24,20 @@ jobs: set -e echo "πŸ” Checking if recipe files were added or modified in merged PR..." - # Get the current commit (merge commit) and the previous commit on the base branch - CURRENT_COMMIT=$(git rev-parse HEAD) - PREVIOUS_COMMIT=$(git rev-parse HEAD~1) + # Get the PR merge information + MERGE_COMMIT=$(git rev-parse HEAD) + echo "Merge commit: $MERGE_COMMIT" - echo "Current commit: $CURRENT_COMMIT" - echo "Previous commit: $PREVIOUS_COMMIT" + # For merged PRs, compare the PR's changes against the base branch + # Use the PR information from the event to get the actual changes + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" - # Get the list of files that were added or modified in this merge (not deleted) - CHANGED_FILES=$(git diff --name-only --diff-filter=AM $PREVIOUS_COMMIT..$CURRENT_COMMIT) + echo "PR base SHA: $BASE_SHA" + echo "PR head SHA: $HEAD_SHA" + + # Get the list of files that were added or modified in the PR (not deleted) + CHANGED_FILES=$(git diff --name-only --diff-filter=AM $BASE_SHA..$HEAD_SHA) echo "Files added/modified in merged PR:" echo "$CHANGED_FILES" diff --git a/.github/workflows/validate-recipe-pr.yml b/.github/workflows/validate-recipe-pr.yml index ad412659cf..04529c7f63 100644 --- a/.github/workflows/validate-recipe-pr.yml +++ b/.github/workflows/validate-recipe-pr.yml @@ -56,9 +56,9 @@ jobs: echo "πŸ“ Synchronize event - checking files changed since previous commit" CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.before }}..${{ github.event.after }}) else - # For opened/reopened, check all files in the PR + # For opened/reopened, check all files in the PR (compare PR head against base) echo "πŸ“ PR opened/reopened - checking all files in PR" - CHANGED_FILES=$(git diff --name-only --diff-filter=AM origin/${{ github.base_ref }}..HEAD) + CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}) fi echo "Changed files in this push:" @@ -87,13 +87,13 @@ jobs: echo "πŸ“ Synchronize event - checking files changed/added since previous commit" CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.before }}..${{ github.event.after }}) else - # For opened/reopened, check all files in the PR (new and modified) + # For opened/reopened, check all files in the PR (compare PR head against base) echo "πŸ“ PR opened/reopened - checking all new/modified files in PR" - CHANGED_FILES=$(git diff --name-only --diff-filter=AM origin/${{ github.base_ref }}..HEAD) + CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}) fi # Filter for recipe files only that were changed or added - RECIPE_FILES=$(git diff --diff-filter=AM --name-only origin/${{ github.base_ref }}..HEAD | grep "^documentation/src/pages/recipes/data/recipes/" | grep -E "\.(yaml|yml)$" || true) + RECIPE_FILES=$(echo "$CHANGED_FILES" | grep "^documentation/src/pages/recipes/data/recipes/" | grep -E "\.(yaml|yml)$" || true) if [ -z "$RECIPE_FILES" ]; then echo "No changed recipe files found in PR" @@ -159,7 +159,7 @@ jobs: # Check if this is a new file or an update to existing file # Get list of changed files in this PR compared to base branch - CHANGED_FILES=$(git diff --name-only --diff-filter=AM origin/${{ github.event.pull_request.base.ref }}...HEAD | grep "^$RECIPE_FILE$" || true) + CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} | grep "^$RECIPE_FILE$" || true) EXISTING_FILES=$(find documentation/src/pages/recipes/data/recipes/ -name "$FILENAME.yaml" -o -name "$FILENAME.yml" | grep -v "^$RECIPE_FILE$" || true) if [ -n "$EXISTING_FILES" ] && [ -z "$CHANGED_FILES" ]; then diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index d5dd848db5..db13b3d9f2 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -352,6 +352,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::get_session_insights, super::routes::session::update_session_description, super::routes::session::delete_session, + super::routes::session::export_session, + super::routes::session::import_session, super::routes::session::update_session_user_recipe_values, super::routes::schedule::create_schedule, super::routes::schedule::list_schedules, @@ -389,6 +391,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::reply::ChatRequest, super::routes::context::ContextManageRequest, super::routes::context::ContextManageResponse, + super::routes::session::ImportSessionRequest, super::routes::session::SessionListResponse, super::routes::session::UpdateSessionDescriptionRequest, super::routes::session::UpdateSessionUserRecipeValuesRequest, diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index bcd3e30e4d..3bea6a1251 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -1,4 +1,5 @@ use crate::state::AppState; +use axum::routing::post; use axum::{ extract::Path, http::StatusCode, @@ -33,6 +34,12 @@ pub struct UpdateSessionUserRecipeValuesRequest { user_recipe_values: HashMap, } +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportSessionRequest { + json: String, +} + const MAX_DESCRIPTION_LENGTH: usize = 200; #[utoipa::path( @@ -199,11 +206,63 @@ async fn delete_session(Path(session_id): Path) -> Result) -> Result, StatusCode> { + let exported = SessionManager::export_session(&session_id) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + Ok(Json(exported)) +} + +#[utoipa::path( + post, + path = "/sessions/import", + request_body = ImportSessionRequest, + responses( + (status = 200, description = "Session imported successfully", body = Session), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 400, description = "Bad request - Invalid JSON"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] +async fn import_session( + Json(request): Json, +) -> Result, StatusCode> { + let session = SessionManager::import_session(&request.json) + .await + .map_err(|_| StatusCode::BAD_REQUEST)?; + + Ok(Json(session)) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/sessions", get(list_sessions)) .route("/sessions/{session_id}", get(get_session)) .route("/sessions/{session_id}", delete(delete_session)) + .route("/sessions/{session_id}/export", get(export_session)) + .route("/sessions/import", post(import_session)) .route("/sessions/insights", get(get_session_insights)) .route( "/sessions/{session_id}/description", diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 1a846af547..283c52f434 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -211,6 +211,14 @@ impl SessionManager { Self::instance().await?.get_insights().await } + pub async fn export_session(id: &str) -> Result { + Self::instance().await?.export_session(id).await + } + + pub async fn import_session(json: &str) -> Result { + Self::instance().await?.import_session(json).await + } + pub async fn maybe_update_description(id: &str, provider: Arc) -> Result<()> { let session = Self::get_session(id, true).await?; let conversation = session @@ -895,6 +903,41 @@ impl SessionStorage { total_tokens: row.1.unwrap_or(0), }) } + + async fn export_session(&self, id: &str) -> Result { + let session = self.get_session(id, true).await?; + serde_json::to_string_pretty(&session).map_err(Into::into) + } + + async fn import_session(&self, json: &str) -> Result { + let import: Session = serde_json::from_str(json)?; + + let session = self + .create_session(import.working_dir.clone(), import.description.clone()) + .await?; + + self.apply_update( + SessionUpdateBuilder::new(session.id.clone()) + .extension_data(import.extension_data) + .total_tokens(import.total_tokens) + .input_tokens(import.input_tokens) + .output_tokens(import.output_tokens) + .accumulated_total_tokens(import.accumulated_total_tokens) + .accumulated_input_tokens(import.accumulated_input_tokens) + .accumulated_output_tokens(import.accumulated_output_tokens) + .schedule_id(import.schedule_id) + .recipe(import.recipe) + .user_recipe_values(import.user_recipe_values), + ) + .await?; + + if let Some(conversation) = import.conversation { + self.replace_conversation(&session.id, &conversation) + .await?; + } + + self.get_session(&session.id, true).await + } } #[cfg(test)] @@ -997,4 +1040,80 @@ mod tests { let expected_tokens = 100 * NUM_CONCURRENT_SESSIONS * (NUM_CONCURRENT_SESSIONS - 1) / 2; assert_eq!(insights.total_tokens, expected_tokens as i64); } + + #[tokio::test] + async fn test_export_import_roundtrip() { + const DESCRIPTION: &str = "Original session"; + const TOTAL_TOKENS: i32 = 500; + const INPUT_TOKENS: i32 = 300; + const OUTPUT_TOKENS: i32 = 200; + const ACCUMULATED_TOKENS: i32 = 1000; + const USER_MESSAGE: &str = "test message"; + const ASSISTANT_MESSAGE: &str = "test response"; + + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test_export.db"); + let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); + + let original = storage + .create_session(PathBuf::from("/tmp/test"), DESCRIPTION.to_string()) + .await + .unwrap(); + + storage + .apply_update( + SessionUpdateBuilder::new(original.id.clone()) + .total_tokens(Some(TOTAL_TOKENS)) + .input_tokens(Some(INPUT_TOKENS)) + .output_tokens(Some(OUTPUT_TOKENS)) + .accumulated_total_tokens(Some(ACCUMULATED_TOKENS)), + ) + .await + .unwrap(); + + storage + .add_message( + &original.id, + &Message { + id: None, + role: Role::User, + created: chrono::Utc::now().timestamp_millis(), + content: vec![MessageContent::text(USER_MESSAGE)], + metadata: Default::default(), + }, + ) + .await + .unwrap(); + + storage + .add_message( + &original.id, + &Message { + id: None, + role: Role::Assistant, + created: chrono::Utc::now().timestamp_millis(), + content: vec![MessageContent::text(ASSISTANT_MESSAGE)], + metadata: Default::default(), + }, + ) + .await + .unwrap(); + + let exported = storage.export_session(&original.id).await.unwrap(); + let imported = storage.import_session(&exported).await.unwrap(); + + assert_ne!(imported.id, original.id); + assert_eq!(imported.description, DESCRIPTION); + assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); + assert_eq!(imported.total_tokens, Some(TOTAL_TOKENS)); + assert_eq!(imported.input_tokens, Some(INPUT_TOKENS)); + assert_eq!(imported.output_tokens, Some(OUTPUT_TOKENS)); + assert_eq!(imported.accumulated_total_tokens, Some(ACCUMULATED_TOKENS)); + assert_eq!(imported.message_count, 2); + + let conversation = imported.conversation.unwrap(); + assert_eq!(conversation.messages().len(), 2); + assert_eq!(conversation.messages()[0].role, Role::User); + assert_eq!(conversation.messages()[1].role, Role::Assistant); + } } diff --git a/documentation/docs/mcp/elevenlabs-mcp.md b/documentation/docs/mcp/elevenlabs-mcp.md index 1b23890b93..59d6ad54d4 100644 --- a/documentation/docs/mcp/elevenlabs-mcp.md +++ b/documentation/docs/mcp/elevenlabs-mcp.md @@ -11,7 +11,7 @@ import GooseDesktopInstaller from '@site/src/components/GooseDesktopInstaller'; -This tutorial covers how to add the [ElevenLabs MCP Server](https://github.com/yamadashy/repomix) as a Goose extension to enable AI-powered voice generation, voice cloning, audio editing, and speech-to-text transcription. +This tutorial covers how to add the [ElevenLabs MCP Server](https://github.com/elevenlabs/elevenlabs-mcp) as a Goose extension to enable AI-powered voice generation, voice cloning, audio editing, and speech-to-text transcription. :::tip TLDR diff --git a/documentation/src/components/server-card.tsx b/documentation/src/components/server-card.tsx index f625e751bd..d7416e0902 100644 --- a/documentation/src/components/server-card.tsx +++ b/documentation/src/components/server-card.tsx @@ -85,7 +85,7 @@ export function ServerCard({ server }: { server: MCPServer }) { )} - {!server.is_builtin && ( + {!server.is_builtin && (server.show_install_command !== false) && ( <> +
@@ -598,6 +675,7 @@ const SessionListView: React.FC = React.memo( session={session} onEditClick={handleEditSession} onDeleteClick={handleDeleteSession} + onExportClick={handleExportSession} /> ))}
@@ -624,6 +702,15 @@ const SessionListView: React.FC = React.memo(

Chat history

+

View and search your past conversations with Goose. @@ -701,6 +788,14 @@ const SessionListView: React.FC = React.memo(

+ +