feat: screen intelligence pipeline + CLI + keep_screenshots config (#339)

* feat: add keep_screenshots functionality to Screen Intelligence settings

- Introduced a new `keep_screenshots` option in the Screen Intelligence settings, allowing users to save captured screenshots to the workspace instead of deleting them after processing.
- Updated relevant components and tests to reflect this new feature, ensuring consistent behavior across the application.
- Enhanced the user interface to include a checkbox for the `keep_screenshots` setting, improving user experience and configurability.

* feat: add `openhuman screen-intelligence` CLI for standalone testing

Adds a dedicated CLI (mirroring `openhuman skills`) to run the screen
intelligence capture + vision pipeline without the full desktop app.

Subcommands: run, status, capture, start, stop.
Makes save_screenshot_to_disk public so the CLI capture --keep flag works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add keep_screenshots to ScreenIntelligencePanel test assertion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-04-05 22:17:10 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8757bb9f15
commit bb1f2c3c8d
14 changed files with 637 additions and 0 deletions
@@ -74,6 +74,7 @@ const sampleStatus: AccessibilityStatus = {
session_ttl_secs: 300,
panic_stop_hotkey: 'Cmd+Shift+.',
autocomplete_enabled: true,
keep_screenshots: false,
allowlist: [],
denylist: [],
},
@@ -71,6 +71,7 @@ const ScreenIntelligencePanel = () => {
'all_except_blacklist'
);
const [baselineFps, setBaselineFps] = useState<string>('1');
const [keepScreenshots, setKeepScreenshots] = useState<boolean>(false);
const [allowlistText, setAllowlistText] = useState('');
const [denylistText, setDenylistText] = useState('');
const [isSavingConfig, setIsSavingConfig] = useState(false);
@@ -104,6 +105,7 @@ const ScreenIntelligencePanel = () => {
status.config.policy_mode === 'whitelist_only' ? 'whitelist_only' : 'all_except_blacklist'
);
setBaselineFps(String(status.config.baseline_fps ?? 1));
setKeepScreenshots(status.config.keep_screenshots ?? false);
setAllowlistText((status.config.allowlist ?? []).join('\n'));
setDenylistText((status.config.denylist ?? []).join('\n'));
}, [status?.config]);
@@ -143,6 +145,7 @@ const ScreenIntelligencePanel = () => {
enabled,
policy_mode: policyMode,
baseline_fps: Number.isFinite(fps) && fps > 0 ? fps : 1,
keep_screenshots: keepScreenshots,
allowlist: allowlistText
.split('\n')
.map(v => v.trim())
@@ -279,6 +282,18 @@ const ScreenIntelligencePanel = () => {
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<div>
<span className="text-sm text-stone-700">Keep Screenshots</span>
<p className="text-xs text-stone-400">Save captured screenshots to the workspace instead of deleting after processing</p>
</div>
<input
type="checkbox"
checked={keepScreenshots}
onChange={event => setKeepScreenshots(event.target.checked)}
/>
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600">Allowlist (one rule per line)</div>
<textarea
@@ -46,6 +46,7 @@ const status: AccessibilityStatus = {
session_ttl_secs: 300,
panic_stop_hotkey: 'Cmd+Shift+.',
autocomplete_enabled: true,
keep_screenshots: false,
allowlist: [],
denylist: ['wallet'],
},
@@ -78,6 +78,7 @@ const baseStatus: AccessibilityStatus = {
session_ttl_secs: 300,
panic_stop_hotkey: 'Cmd+Shift+.',
autocomplete_enabled: true,
keep_screenshots: false,
allowlist: ['Code'],
denylist: ['1Password'],
},
@@ -188,6 +189,7 @@ describe('ScreenIntelligencePanel', () => {
enabled: true,
policy_mode: 'all_except_blacklist',
baseline_fps: 1,
keep_screenshots: false,
allowlist: ['Code'],
denylist: ['1Password'],
});
@@ -41,6 +41,7 @@ function sampleAccessibilityStatus(
session_ttl_secs: 300,
panic_stop_hotkey: 'Cmd+Shift+.',
autocomplete_enabled: true,
keep_screenshots: false,
allowlist: [],
denylist: [],
},
@@ -45,6 +45,7 @@ const sampleStatus: AccessibilityStatus = {
session_ttl_secs: 300,
panic_stop_hotkey: 'Cmd+Shift+.',
autocomplete_enabled: true,
keep_screenshots: false,
allowlist: [],
denylist: ['wallet'],
},
+2
View File
@@ -793,6 +793,7 @@ export interface AccessibilityConfig {
session_ttl_secs: number;
panic_stop_hotkey: string;
autocomplete_enabled: boolean;
keep_screenshots: boolean;
allowlist: string[];
denylist: string[];
}
@@ -1039,6 +1040,7 @@ export interface ScreenIntelligenceSettingsUpdate {
baseline_fps?: number | null;
vision_enabled?: boolean | null;
autocomplete_enabled?: boolean | null;
keep_screenshots?: boolean | null;
allowlist?: string[] | null;
denylist?: string[] | null;
}
+3
View File
@@ -55,6 +55,9 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
"call" => run_call_command(&args[1..]),
"repl" | "shell" => crate::core::repl::run_repl(&args[1..]),
"skills" => crate::core::skills_cli::run_skills_command(&args[1..]),
"screen-intelligence" => {
crate::core::screen_intelligence_cli::run_screen_intelligence_command(&args[1..])
}
namespace => run_namespace_command(namespace, &args[1..], &grouped),
}
}
+1
View File
@@ -8,6 +8,7 @@ pub mod jsonrpc;
pub mod logging;
pub mod repl;
pub mod rpc_log;
pub mod screen_intelligence_cli;
pub mod skills_cli;
pub mod socketio;
pub mod types;
+511
View File
@@ -0,0 +1,511 @@
//! `openhuman screen-intelligence` — standalone CLI for the screen intelligence loop.
//!
//! Boots **only** the screen intelligence engine (accessibility capture + local-AI
//! vision) without the full desktop app, Socket.IO, or skills runtime. Useful for
//! testing the capture → save → vision-analysis pipeline from a terminal.
//!
//! Usage:
//! openhuman screen-intelligence run [--port <u16>] [-v]
//! openhuman screen-intelligence status
//! openhuman screen-intelligence capture [--keep]
//! openhuman screen-intelligence start [--ttl <secs>] [-v]
//! openhuman screen-intelligence stop
use anyhow::Result;
use std::sync::Arc;
/// Entry point for `openhuman screen-intelligence <subcommand>`.
pub fn run_screen_intelligence_command(args: &[String]) -> Result<()> {
if args.is_empty() || is_help(&args[0]) {
print_help();
return Ok(());
}
match args[0].as_str() {
"run" => run_server(&args[1..]),
"status" => run_status(&args[1..]),
"capture" => run_capture(&args[1..]),
"start" => run_start_session(&args[1..]),
"stop" => run_stop_session(&args[1..]),
other => Err(anyhow::anyhow!(
"unknown screen-intelligence subcommand '{other}'. Run `openhuman screen-intelligence --help`."
)),
}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
struct CliOpts {
port: u16,
verbose: bool,
ttl_secs: u64,
keep: bool,
}
fn parse_opts(args: &[String]) -> Result<(CliOpts, Vec<String>)> {
let mut port: u16 = 7797;
let mut verbose = false;
let mut ttl_secs: u64 = 300;
let mut keep = false;
let mut rest = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--port" => {
let val = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value for --port"))?;
port = val
.parse()
.map_err(|e| anyhow::anyhow!("invalid --port: {e}"))?;
i += 2;
}
"--ttl" => {
let val = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value for --ttl"))?;
ttl_secs = val
.parse()
.map_err(|e| anyhow::anyhow!("invalid --ttl: {e}"))?;
i += 2;
}
"--keep" => {
keep = true;
i += 1;
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
rest.push(args[i].clone());
i += 1;
}
_ => {
rest.push(args[i].clone());
i += 1;
}
}
}
Ok((
CliOpts {
port,
verbose,
ttl_secs,
keep,
},
rest,
))
}
/// Bootstrap the screen intelligence engine with config.
async fn bootstrap_engine(
verbose: bool,
) -> Result<Arc<crate::openhuman::screen_intelligence::AccessibilityEngine>> {
use crate::openhuman::config::Config;
use crate::openhuman::screen_intelligence::global_engine;
let config = Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("config load failed: {e}"))?;
let engine = global_engine();
let _ = engine
.apply_config(config.screen_intelligence.clone())
.await;
if verbose {
log::info!(
"[screen-intelligence-cli] engine initialized, enabled={}, vision={}, keep_screenshots={}, workspace={}",
config.screen_intelligence.enabled,
config.screen_intelligence.vision_enabled,
config.screen_intelligence.keep_screenshots,
config.workspace_dir.display(),
);
}
Ok(engine)
}
// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------
/// `openhuman screen-intelligence run` — start a minimal JSON-RPC server with the
/// screen intelligence engine, useful for integration testing.
fn run_server(args: &[String]) -> Result<()> {
let (opts, rest) = parse_opts(args)?;
if rest.iter().any(|a| is_help(a)) {
println!("Usage: openhuman screen-intelligence run [--port <u16>] [-v]");
println!();
println!("Start a lightweight JSON-RPC server exposing screen intelligence RPC methods.");
println!();
println!(" --port <u16> Listen port (default: 7797)");
println!(" -v, --verbose Enable debug logging");
return Ok(());
}
crate::core::logging::init_for_cli_run(opts.verbose);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let _engine = bootstrap_engine(opts.verbose).await?;
let app = build_router();
let bind_addr = format!("127.0.0.1:{}", opts.port);
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
log::info!(
"[screen-intelligence-cli] ready — http://{bind_addr}/rpc (JSON-RPC 2.0)"
);
eprintln!();
eprintln!(" Screen intelligence dev server listening on http://{bind_addr}");
eprintln!(" JSON-RPC endpoint: POST http://{bind_addr}/rpc");
eprintln!(" Health check: GET http://{bind_addr}/health");
eprintln!(" Press Ctrl+C to stop.");
eprintln!();
axum::serve(listener, app).await?;
Ok(())
})
}
/// `openhuman screen-intelligence status` — print current engine status as JSON.
fn run_status(args: &[String]) -> Result<()> {
if args.iter().any(|a| is_help(a)) {
println!("Usage: openhuman screen-intelligence status [-v]");
println!();
println!("Print current screen intelligence engine status (permissions, session, config).");
return Ok(());
}
let (opts, _) = parse_opts(args)?;
init_quiet_logging(opts.verbose);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let engine = bootstrap_engine(opts.verbose).await?;
let status = engine.status().await;
println!(
"{}",
serde_json::to_string_pretty(&status).unwrap_or_else(|_| format!("{:?}", status))
);
Ok(())
})
}
/// `openhuman screen-intelligence capture` — take a single screenshot and print info.
fn run_capture(args: &[String]) -> Result<()> {
if args.iter().any(|a| is_help(a)) {
println!("Usage: openhuman screen-intelligence capture [--keep] [-v]");
println!();
println!("Take a single screenshot, optionally save to workspace, and print diagnostics.");
println!();
println!(" --keep Save the screenshot to {{workspace}}/screenshots/");
println!(" -v, --verbose Enable debug logging");
return Ok(());
}
let (opts, _) = parse_opts(args)?;
init_quiet_logging(opts.verbose);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let engine = bootstrap_engine(opts.verbose).await?;
let result = engine.capture_test().await;
if result.ok {
eprintln!(" Capture: OK");
eprintln!(" Mode: {}", result.capture_mode);
eprintln!(" Timing: {}ms", result.timing_ms);
if let Some(bytes) = result.bytes_estimate {
eprintln!(" Size: {} bytes", bytes);
}
if let Some(ctx) = &result.context {
eprintln!(
" App: {}",
ctx.app_name.as_deref().unwrap_or("unknown")
);
eprintln!(
" Window: {}",
ctx.window_title.as_deref().unwrap_or("unknown")
);
}
// Save to disk if --keep
if opts.keep {
if let Some(image_ref) = &result.image_ref {
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("config load failed: {e}"))?;
let frame = crate::openhuman::screen_intelligence::CaptureFrame {
captured_at_ms: chrono::Utc::now().timestamp_millis(),
reason: "cli_capture".to_string(),
app_name: result
.context
.as_ref()
.and_then(|c| c.app_name.clone()),
window_title: result
.context
.as_ref()
.and_then(|c| c.window_title.clone()),
image_ref: Some(image_ref.clone()),
};
match crate::openhuman::screen_intelligence::AccessibilityEngine::save_screenshot_to_disk(
&config.workspace_dir,
&frame,
) {
Ok(path) => {
eprintln!(" Saved: {}", path.display());
}
Err(e) => {
eprintln!(" Save failed: {e}");
}
}
}
}
} else {
eprintln!(" Capture: FAILED");
if let Some(err) = &result.error {
eprintln!(" Error: {err}");
}
std::process::exit(1);
}
// Also print as JSON for machine-readable output.
let mut json_result = serde_json::to_value(&result).unwrap_or_default();
// Strip image_ref from JSON output (too large for terminal).
if let Some(obj) = json_result.as_object_mut() {
obj.remove("image_ref");
}
println!(
"{}",
serde_json::to_string_pretty(&json_result).unwrap_or_default()
);
Ok(())
})
}
/// `openhuman screen-intelligence start` — start a capture + vision session.
fn run_start_session(args: &[String]) -> Result<()> {
if args.iter().any(|a| is_help(a)) {
println!("Usage: openhuman screen-intelligence start [--ttl <secs>] [-v]");
println!();
println!("Start a screen intelligence capture session with vision analysis.");
println!("The session runs until TTL expires or Ctrl+C is pressed.");
println!();
println!(" --ttl <secs> Session duration (default: 300, max: 3600)");
println!(" -v, --verbose Enable debug logging");
return Ok(());
}
let (opts, _) = parse_opts(args)?;
crate::core::logging::init_for_cli_run(opts.verbose);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let engine = bootstrap_engine(opts.verbose).await?;
let params = crate::openhuman::screen_intelligence::StartSessionParams {
consent: true,
ttl_secs: Some(opts.ttl_secs),
screen_monitoring: Some(true),
device_control: Some(false),
predictive_input: Some(false),
};
match engine.start_session(params).await {
Ok(session) => {
eprintln!(" Session started!");
eprintln!(" TTL: {}s", session.ttl_secs);
eprintln!(" Vision: {}", session.vision_enabled);
eprintln!(" Panic hotkey: {}", session.panic_hotkey);
eprintln!();
eprintln!(" Capturing screenshots and running vision analysis...");
eprintln!(" Press Ctrl+C to stop.");
eprintln!();
// Print periodic status updates until the session ends.
let mut tick = tokio::time::interval(std::time::Duration::from_secs(5));
loop {
tick.tick().await;
let status = engine.status().await;
if !status.session.active {
eprintln!(
"\n Session ended: {}",
status.session.stop_reason.unwrap_or_else(|| "unknown".into())
);
break;
}
eprintln!(
" [{}] captures={} vision={} queue={} last_app={:?}",
chrono::Utc::now().format("%H:%M:%S"),
status.session.capture_count,
status.session.vision_state,
status.session.vision_queue_depth,
status.session.last_context.as_deref().unwrap_or("-"),
);
if let Some(summary) = &status.session.last_vision_summary {
let truncated = if summary.len() > 100 {
format!("{}", &summary[..100])
} else {
summary.clone()
};
eprintln!(" notes: {truncated}");
}
}
}
Err(e) => {
eprintln!(" Failed to start session: {e}");
std::process::exit(1);
}
}
Ok(())
})
}
/// `openhuman screen-intelligence stop` — stop an active session.
fn run_stop_session(args: &[String]) -> Result<()> {
if args.iter().any(|a| is_help(a)) {
println!("Usage: openhuman screen-intelligence stop [-v]");
println!();
println!("Stop the active screen intelligence session.");
return Ok(());
}
let (opts, _) = parse_opts(args)?;
init_quiet_logging(opts.verbose);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let engine = bootstrap_engine(opts.verbose).await?;
let session = engine.stop_session(Some("cli_stop".to_string())).await;
eprintln!(
" Session stopped: {}",
session
.stop_reason
.unwrap_or_else(|| "no active session".into())
);
Ok(())
})
}
// ---------------------------------------------------------------------------
// Minimal HTTP router
// ---------------------------------------------------------------------------
fn build_router() -> axum::Router {
use axum::routing::{get, post};
axum::Router::new()
.route("/health", get(health))
.route("/rpc", post(rpc))
.route("/status", get(status_endpoint))
}
async fn health() -> impl axum::response::IntoResponse {
axum::Json(serde_json::json!({ "ok": true, "mode": "screen-intelligence-dev" }))
}
async fn rpc(
axum::Json(req): axum::Json<crate::core::types::RpcRequest>,
) -> axum::response::Response {
use crate::core::types::{RpcError, RpcFailure, RpcSuccess};
use axum::response::IntoResponse;
let id = req.id.clone();
let state = crate::core::jsonrpc::default_state();
match crate::core::jsonrpc::invoke_method(state, req.method.as_str(), req.params).await {
Ok(value) => (
axum::http::StatusCode::OK,
axum::Json(RpcSuccess {
jsonrpc: "2.0",
id,
result: value,
}),
)
.into_response(),
Err(message) => (
axum::http::StatusCode::OK,
axum::Json(RpcFailure {
jsonrpc: "2.0",
id,
error: RpcError {
code: -32000,
message,
data: None,
},
}),
)
.into_response(),
}
}
async fn status_endpoint() -> impl axum::response::IntoResponse {
let engine = crate::openhuman::screen_intelligence::global_engine();
let status = engine.status().await;
axum::Json(serde_json::to_value(&status).unwrap_or_default())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn init_quiet_logging(verbose: bool) {
if !verbose && std::env::var_os("RUST_LOG").is_none() {
std::env::set_var("RUST_LOG", "warn");
}
crate::core::logging::init_for_cli_run(verbose);
}
fn is_help(value: &str) -> bool {
matches!(value, "-h" | "--help" | "help")
}
fn print_help() {
println!("openhuman screen-intelligence — screen intelligence runtime\n");
println!("Usage:");
println!(" openhuman screen-intelligence run [--port <u16>] [-v]");
println!(" openhuman screen-intelligence status [-v]");
println!(" openhuman screen-intelligence capture [--keep] [-v]");
println!(" openhuman screen-intelligence start [--ttl <secs>] [-v]");
println!(" openhuman screen-intelligence stop [-v]");
println!();
println!("Subcommands:");
println!(" run Start a lightweight JSON-RPC server with screen intelligence");
println!(" status Print current engine status (permissions, session, config)");
println!(" capture Take a single screenshot and print diagnostics");
println!(" start Start a capture + vision session (runs until TTL or Ctrl+C)");
println!(" stop Stop the active session");
println!();
println!("Common options:");
println!(" --port <u16> Server port for 'run' (default: 7797)");
println!(" --ttl <secs> Session TTL for 'start' (default: 300)");
println!(" --keep Save screenshot to disk (for 'capture')");
println!(" -v, --verbose Enable debug logging");
}
+4
View File
@@ -164,6 +164,7 @@ pub struct ScreenIntelligenceSettingsPatch {
pub baseline_fps: Option<f32>,
pub vision_enabled: Option<bool>,
pub autocomplete_enabled: Option<bool>,
pub keep_screenshots: Option<bool>,
pub allowlist: Option<Vec<String>>,
pub denylist: Option<Vec<String>>,
}
@@ -281,6 +282,9 @@ pub async fn apply_screen_intelligence_settings(
if let Some(autocomplete_enabled) = update.autocomplete_enabled {
config.screen_intelligence.autocomplete_enabled = autocomplete_enabled;
}
if let Some(keep_screenshots) = update.keep_screenshots {
config.screen_intelligence.keep_screenshots = keep_screenshots;
}
if let Some(allowlist) = update.allowlist {
config.screen_intelligence.allowlist = allowlist;
}
@@ -19,6 +19,10 @@ pub struct ScreenIntelligenceConfig {
pub panic_stop_hotkey: String,
#[serde(default = "default_autocomplete_enabled")]
pub autocomplete_enabled: bool,
/// When `true`, captured screenshots are saved to `{workspace_dir}/screenshots/`
/// instead of being discarded after vision processing. Default: `false`.
#[serde(default)]
pub keep_screenshots: bool,
#[serde(default)]
pub allowlist: Vec<String>,
#[serde(default)]
@@ -68,6 +72,7 @@ impl Default for ScreenIntelligenceConfig {
session_ttl_secs: default_session_ttl_secs(),
panic_stop_hotkey: default_panic_stop_hotkey(),
autocomplete_enabled: default_autocomplete_enabled(),
keep_screenshots: false,
allowlist: vec![],
denylist: vec![
"1password".to_string(),
+3
View File
@@ -45,6 +45,7 @@ struct ScreenIntelligenceSettingsUpdate {
baseline_fps: Option<f32>,
vision_enabled: Option<bool>,
autocomplete_enabled: Option<bool>,
keep_screenshots: Option<bool>,
allowlist: Option<Vec<String>>,
denylist: Option<Vec<String>>,
}
@@ -240,6 +241,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
},
optional_bool("vision_enabled", "Enable vision analysis."),
optional_bool("autocomplete_enabled", "Enable autocomplete integration."),
optional_bool("keep_screenshots", "Keep screenshots on disk after vision processing."),
FieldSchema {
name: "allowlist",
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(
@@ -481,6 +483,7 @@ fn handle_update_screen_intelligence_settings(params: Map<String, Value>) -> Con
baseline_fps: update.baseline_fps,
vision_enabled: update.vision_enabled,
autocomplete_enabled: update.autocomplete_enabled,
keep_screenshots: update.keep_screenshots,
allowlist: update.allowlist,
denylist: update.denylist,
};
@@ -2,6 +2,7 @@ use crate::openhuman::config::{Config, ScreenIntelligenceConfig};
use crate::openhuman::local_ai;
use once_cell::sync::Lazy;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
@@ -832,6 +833,54 @@ impl AccessibilityEngine {
}
}
/// Save a screenshot PNG to `{workspace_dir}/screenshots/{timestamp}_{app}.png`.
/// Returns the file path on success.
pub fn save_screenshot_to_disk(
workspace_dir: &std::path::Path,
frame: &CaptureFrame,
) -> Result<PathBuf, String> {
use base64::{engine::general_purpose::STANDARD as B64, Engine};
let image_ref = frame
.image_ref
.as_deref()
.ok_or_else(|| "frame has no image payload".to_string())?;
let b64_payload = if let Some(pos) = image_ref.find(";base64,") {
&image_ref[pos + 8..]
} else {
image_ref
};
let raw_bytes = B64
.decode(b64_payload)
.map_err(|e| format!("base64 decode for screenshot save failed: {e}"))?;
let screenshots_dir = workspace_dir.join("screenshots");
std::fs::create_dir_all(&screenshots_dir)
.map_err(|e| format!("failed to create screenshots dir: {e}"))?;
let app_slug = frame
.app_name
.as_deref()
.unwrap_or("unknown")
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' })
.collect::<String>();
let filename = format!("{}_{}.png", frame.captured_at_ms, app_slug);
let file_path = screenshots_dir.join(&filename);
std::fs::write(&file_path, &raw_bytes)
.map_err(|e| format!("failed to write screenshot {filename}: {e}"))?;
tracing::debug!(
"[screen_intelligence] screenshot saved: {} ({} bytes)",
file_path.display(),
raw_bytes.len()
);
Ok(file_path)
}
async fn run_vision_worker(
self: Arc<Self>,
mut rx: tokio::sync::mpsc::UnboundedReceiver<CaptureFrame>,
@@ -844,6 +893,32 @@ impl AccessibilityEngine {
frame.app_name,
frame.reason
);
// Read config for keep_screenshots and workspace_dir.
let (keep_screenshots, workspace_dir) = match Config::load_or_init().await {
Ok(cfg) => (
cfg.screen_intelligence.keep_screenshots,
cfg.workspace_dir.clone(),
),
Err(_) => (false, PathBuf::from(".")),
};
// Save screenshot to disk (always — we need it on disk for the workspace).
// If keep_screenshots is false, we delete after vision processing.
let saved_path = if frame.image_ref.is_some() {
match Self::save_screenshot_to_disk(&workspace_dir, &frame) {
Ok(path) => Some(path),
Err(err) => {
tracing::debug!(
"[screen_intelligence] screenshot save failed (non-fatal): {err}"
);
None
}
}
} else {
None
};
{
let mut state = self.inner.lock().await;
if let Some(session) = state.session.as_mut() {
@@ -856,6 +931,18 @@ impl AccessibilityEngine {
let result = self.analyze_frame_with_vision(frame).await;
// Clean up screenshot file if keep_screenshots is false.
if !keep_screenshots {
if let Some(path) = saved_path {
if let Err(err) = std::fs::remove_file(&path) {
tracing::trace!(
"[screen_intelligence] failed to remove temp screenshot {}: {err}",
path.display()
);
}
}
}
let mut state = self.inner.lock().await;
let Some(session) = state.session.as_mut() else {
break;