diff --git a/README.md b/README.md index a116ccf..0027961 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ It accepts prompts through HTTP endpoints, queues and schedules work, executes ` - Isolated per-workspace `VIBE_HOME` trust model. - Streaming execution chunks (`output_chunk`) via SSE. - Post-run `setup.sh` hook execution with streamed logs. +- Vibe programmatic mode uses `--output streaming` for pseudo-live frontend feedback. - Workspace filesystem APIs (`tree` / `file`) for frontend explorer UX. - Request tracing + lifecycle logs for operations visibility. - Dockerized runtime with Compose stack for local and NUC deployment. @@ -88,6 +89,7 @@ These are forwarded as `VIBE_MODEL` / `MISTRAL_MODEL` and `VIBE_PROVIDER` for co - `POST /v1/projects`, `GET /v1/projects` - `POST /v1/workspaces`, `GET /v1/workspaces` - `GET /v1/workspaces/{id}/tree`, `GET /v1/workspaces/{id}/file` +- `POST /v1/workspaces/{id}/command` and `POST /v1/workspaces/command` support `shell_session_id` for persistent shell cwd across commands (`cd` state is retained per session). - `POST /v1/prompts` (`workspace_id` optional when `OTTER_DEFAULT_WORKSPACE_PATH` is configured) - `GET /v1/jobs/{id}` - `GET /v1/jobs/{id}/events` @@ -154,8 +156,11 @@ Otter composes a system prompt that enforces safe and repeatable project executi - Always work in a dedicated project subfolder under workspace. - Install dependencies before run/build. +- Generate a production-ready Dockerfile at project root. +- Build and run the generated app inside Docker as the primary execution path. - Generate an executable `setup.sh` at project root. - Start the generated app/service in background. - Print clear run/stop instructions and project location. +- Include app access information (URL and host port) in the final output. After a successful vibe execution, Otter attempts to run `setup.sh` and streams its output back as `output_chunk` events. diff --git a/otter-core/src/vibe.rs b/otter-core/src/vibe.rs index 808048e..4deacc6 100644 --- a/otter-core/src/vibe.rs +++ b/otter-core/src/vibe.rs @@ -77,7 +77,7 @@ impl VibeExecutor { cmd.arg("--prompt") .arg(&effective_prompt) .arg("--output") - .arg("json") + .arg("streaming") .arg("--workdir") .arg(workspace_path.as_os_str()) .env("VIBE_HOME", isolated_vibe_home.as_os_str()) @@ -97,9 +97,7 @@ impl VibeExecutor { bail!("vibe exited with code {exit_code}: {stderr}"); } - let raw_json: serde_json::Value = - serde_json::from_str(&stdout).context("vibe output was not valid JSON")?; - + let raw_json = parse_streaming_json_lines(&stdout); let assistant_output = extract_latest_assistant_message(&raw_json).unwrap_or_default(); Ok(VibeExecutionResult { @@ -126,7 +124,7 @@ impl VibeExecutor { cmd.arg("--prompt") .arg(&effective_prompt) .arg("--output") - .arg("json") + .arg("streaming") .arg("--workdir") .arg(workspace_path.as_os_str()) .env("VIBE_HOME", isolated_vibe_home.as_os_str()) @@ -204,8 +202,7 @@ impl VibeExecutor { bail!("vibe exited with code {exit_code}: {stderr_buffer}"); } - let raw_json: serde_json::Value = - serde_json::from_str(&stdout_buffer).context("vibe output was not valid JSON")?; + let raw_json = parse_streaming_json_lines(&stdout_buffer); let assistant_output = extract_latest_assistant_message(&raw_json).unwrap_or_default(); Ok(VibeExecutionResult { @@ -223,31 +220,85 @@ fn compose_vibe_prompt(user_prompt: &str) -> String { - Work in a project-specific subfolder under the current workspace. Never develop directly in workspace root. - If needed, create a clear project folder first (for example `projects/`), then work only inside it. - Ensure dependencies are installed before running/building (detect toolchain and install accordingly: npm/pnpm/yarn, pip/uv/poetry, cargo, etc.). +- Always create a production-ready Dockerfile at the project root and use it as the primary run path. +- Build and run the generated app/service inside Docker (do not run directly on host process as the main path). +- Verify the container is running and expose the app on a reachable port from the current environment. - Always create a setup script named `setup.sh` at the project root that installs and configures everything needed to run the project. - Ensure `setup.sh` is executable (`chmod +x setup.sh`) and deterministic/idempotent (safe to run multiple times). - When implementation is complete, start the app/service in background and verify it runs. -- At the end, print clear run instructions: start command, stop command, and where the project lives. -- Always run the app/service in the background. Give the link to the project in the output. +- At the end, print clear run instructions: docker build command, docker run command, docker stop/remove command, and where the project lives. +- Always include where to access the running app (URL/host port) in the final output. USER TASK: {user_prompt}"# ) } +fn parse_streaming_json_lines(stdout: &str) -> serde_json::Value { + let entries = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| { + serde_json::from_str::(line) + .unwrap_or_else(|_| serde_json::json!({ "type": "raw_line", "content": line })) + }) + .collect::>(); + serde_json::Value::Array(entries) +} + fn extract_latest_assistant_message(value: &serde_json::Value) -> Option { let messages = value.as_array()?; - messages.iter().rev().find_map(|msg| { - let role = msg.get("role")?.as_str()?; - if role != "assistant" { - return None; + messages + .iter() + .rev() + .find_map(extract_assistant_content_from_entry) +} + +fn extract_assistant_content_from_entry(entry: &serde_json::Value) -> Option { + let role = entry + .get("role") + .or_else(|| entry.pointer("/message/role")) + .or_else(|| entry.pointer("/delta/role")) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if role != "assistant" { + return None; + } + value_to_text( + entry + .get("content") + .or_else(|| entry.pointer("/message/content")) + .or_else(|| entry.pointer("/delta/content"))?, + ) +} + +fn value_to_text(value: &serde_json::Value) -> Option { + if let Some(content) = value.as_str() { + return Some(content.to_string()); + } + if let Some(array) = value.as_array() { + let joined = array + .iter() + .filter_map(|item| { + item.as_str().map(ToString::to_string).or_else(|| { + item.get("text") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + }) + }) + .collect::>() + .join(""); + if !joined.is_empty() { + return Some(joined); } - msg.get("content")?.as_str().map(ToString::to_string) - }) + } + None } #[cfg(test)] mod tests { - use super::extract_latest_assistant_message; + use super::{extract_latest_assistant_message, parse_streaming_json_lines}; #[test] fn finds_last_assistant_message() { @@ -259,4 +310,22 @@ mod tests { let content = extract_latest_assistant_message(&payload); assert_eq!(content.as_deref(), Some("two")); } + + #[test] + fn parses_streaming_json_lines_as_array() { + let raw = r#"{"role":"assistant","content":"hello"} +{"role":"assistant","content":"world"}"#; + let parsed = parse_streaming_json_lines(raw); + assert_eq!(parsed.as_array().map(|v| v.len()), Some(2)); + } + + #[test] + fn extracts_assistant_from_nested_message_shape() { + let payload = serde_json::json!([ + {"type":"message","message":{"role":"assistant","content":"from-message"}}, + {"type":"delta","delta":{"role":"assistant","content":"from-delta"}} + ]); + let content = extract_latest_assistant_message(&payload); + assert_eq!(content.as_deref(), Some("from-delta")); + } } diff --git a/otter-server/src/main.rs b/otter-server/src/main.rs index 4a4faaa..db64217 100644 --- a/otter-server/src/main.rs +++ b/otter-server/src/main.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::convert::Infallible; use std::fs; use std::path::{Path as FsPath, PathBuf}; @@ -32,6 +33,7 @@ use uuid::Uuid; struct AppState { service: Arc>, lavoix_url: String, + shell_sessions: Arc>>, } #[derive(serde::Deserialize)] @@ -60,6 +62,7 @@ struct WorkspaceFileQuery { struct WorkspaceCommandRequest { command: String, working_directory: Option, + shell_session_id: Option, timeout_seconds: Option, } @@ -68,6 +71,7 @@ struct WorkspaceCommandDispatchRequest { workspace_id: Option, command: String, working_directory: Option, + shell_session_id: Option, timeout_seconds: Option, } @@ -100,6 +104,7 @@ struct WorkspaceCommandResponse { workspace_id: Uuid, command: String, working_directory: String, + shell_session_id: Option, exit_code: Option, stdout: String, stderr: String, @@ -138,6 +143,7 @@ async fn main() -> Result<()> { let state = AppState { service, lavoix_url: config.lavoix_url.clone(), + shell_sessions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), }; let allow_any_origin = config @@ -376,6 +382,7 @@ async fn run_workspace_command_any( WorkspaceCommandRequest { command: body.command, working_directory: body.working_directory, + shell_session_id: body.shell_session_id, timeout_seconds: body.timeout_seconds, }, ) @@ -409,9 +416,31 @@ async fn execute_workspace_command( .map_err(internal_error)? .ok_or((StatusCode::NOT_FOUND, "workspace not found".to_string()))?; - let cwd = - resolve_workspace_subpath(&workspace, body.working_directory.as_deref().unwrap_or(""))?; + let shell_session_id = body + .shell_session_id + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| value.chars().take(120).collect::()); + let session_key = shell_session_id + .as_ref() + .map(|session_id| format!("{resolved_workspace_id}:{session_id}")); + let persisted_cwd = if let Some(key) = &session_key { + state.shell_sessions.lock().await.get(key).cloned() + } else { + None + }; + let requested_working_directory = body + .working_directory + .clone() + .or(persisted_cwd) + .unwrap_or_default(); + let cwd = resolve_workspace_subpath(&workspace, &requested_working_directory)?; let timeout_seconds = body.timeout_seconds.unwrap_or(30).clamp(1, 300); + let wrapped_command = format!( + "({command}); __otter_exit=$?; printf '\\n__OTTER_CWD__:%s\\n' \"$PWD\"; exit $__otter_exit", + command = body.command + ); info!( workspace_id = %resolved_workspace_id, @@ -425,7 +454,7 @@ async fn execute_workspace_command( .arg(format!("{timeout_seconds}s")) .arg("bash") .arg("-lc") - .arg(&body.command) + .arg(&wrapped_command) .current_dir(&cwd) .env("VIBE_HOME", &workspace.isolated_vibe_home) .stdout(Stdio::piped()) @@ -445,17 +474,49 @@ async fn execute_workspace_command( ); } + let stdout_with_marker = String::from_utf8_lossy(&output.stdout).to_string(); + let (stdout, resolved_cwd) = split_stdout_and_cwd(&stdout_with_marker, &cwd); + if let Some(key) = session_key.as_ref() { + state + .shell_sessions + .lock() + .await + .insert(key.clone(), resolved_cwd.clone()); + } + Ok(WorkspaceCommandResponse { workspace_id: resolved_workspace_id, command: body.command, - working_directory: cwd.display().to_string(), + working_directory: resolved_cwd, + shell_session_id, exit_code: output.status.code(), - stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stdout, stderr: String::from_utf8_lossy(&output.stderr).to_string(), timed_out, }) } +fn split_stdout_and_cwd(stdout: &str, fallback_cwd: &FsPath) -> (String, String) { + const CWD_MARKER: &str = "__OTTER_CWD__:"; + let mut cleaned_lines = Vec::new(); + let mut resolved_cwd = fallback_cwd.display().to_string(); + for raw_line in stdout.lines() { + if let Some((_, cwd)) = raw_line.split_once(CWD_MARKER) { + let candidate = cwd.trim(); + if !candidate.is_empty() { + resolved_cwd = candidate.to_string(); + } + continue; + } + cleaned_lines.push(raw_line); + } + let mut cleaned_stdout = cleaned_lines.join("\n"); + if stdout.ends_with('\n') && !cleaned_stdout.is_empty() { + cleaned_stdout.push('\n'); + } + (cleaned_stdout, resolved_cwd) +} + async fn enqueue_prompt( State(state): State, Json(body): Json, @@ -808,3 +869,17 @@ fn list_workspace_entries( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::split_stdout_and_cwd; + use std::path::Path; + + #[test] + fn strips_cwd_marker_from_stdout() { + let stdout = "hello\n__OTTER_CWD__:/tmp/demo\n"; + let (cleaned, cwd) = split_stdout_and_cwd(stdout, Path::new("/fallback")); + assert_eq!(cleaned, "hello\n"); + assert_eq!(cwd, "/tmp/demo"); + } +}