Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.
101 changes: 85 additions & 16 deletions otter-core/src/vibe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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 {
Expand All @@ -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())
Expand Down Expand Up @@ -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 {
Expand All @@ -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/<project-name>`), 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::<serde_json::Value>(line)
.unwrap_or_else(|_| serde_json::json!({ "type": "raw_line", "content": line }))
})
.collect::<Vec<_>>();
serde_json::Value::Array(entries)
}

fn extract_latest_assistant_message(value: &serde_json::Value) -> Option<String> {
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<String> {
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<String> {
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::<Vec<_>>()
.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() {
Expand All @@ -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"));
}
}
85 changes: 80 additions & 5 deletions otter-server/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::convert::Infallible;
use std::fs;
use std::path::{Path as FsPath, PathBuf};
Expand Down Expand Up @@ -32,6 +33,7 @@ use uuid::Uuid;
struct AppState {
service: Arc<OtterService<RedisQueue>>,
lavoix_url: String,
shell_sessions: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
}

#[derive(serde::Deserialize)]
Expand Down Expand Up @@ -60,6 +62,7 @@ struct WorkspaceFileQuery {
struct WorkspaceCommandRequest {
command: String,
working_directory: Option<String>,
shell_session_id: Option<String>,
timeout_seconds: Option<u64>,
}

Expand All @@ -68,6 +71,7 @@ struct WorkspaceCommandDispatchRequest {
workspace_id: Option<Uuid>,
command: String,
working_directory: Option<String>,
shell_session_id: Option<String>,
timeout_seconds: Option<u64>,
}

Expand Down Expand Up @@ -100,6 +104,7 @@ struct WorkspaceCommandResponse {
workspace_id: Uuid,
command: String,
working_directory: String,
shell_session_id: Option<String>,
exit_code: Option<i32>,
stdout: String,
stderr: String,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
)
Expand Down Expand Up @@ -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::<String>());
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,
Expand All @@ -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())
Expand All @@ -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<AppState>,
Json(body): Json<EnqueuePromptRequest>,
Expand Down Expand Up @@ -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");
}
}