feat(screen-intelligence): standalone server with OCR + vision pipeline (#382)

* feat(screen-intelligence): add new commands for diagnostics and vision processing

- Introduced `doctor` command for system readiness diagnostics, checking permissions and platform support.
- Added `vision` command to analyze frames and persist vision summaries.
- Updated CLI usage documentation to reflect new command options and improved verbosity handling.
- Enhanced the `run_server` function to provide detailed endpoint information for better user guidance.

This update improves the functionality and usability of the screen intelligence CLI, enabling better diagnostics and vision processing capabilities.

* feat(screen-intelligence): update CLI endpoints for status monitoring

- Added `tower-http` dependency for enhanced HTTP capabilities.
- Updated endpoint documentation in `run_server` to reflect changes from SSE to long-polling for status updates.
- Renamed `/events` endpoint to `/watch` with a query parameter for interval control, improving clarity and usability.

This update enhances the screen intelligence CLI by providing more flexible status monitoring options.

* feat(screen-intelligence): integrate standalone server for screen intelligence

- Added a new `server` module to handle the standalone screen intelligence server functionality.
- Updated CLI commands to include an `--auto-start` option for initiating capture sessions on server boot.
- Enhanced the `run_server` function to provide detailed endpoint information and improved logging for server status.
- Integrated the screen intelligence engine with JSON-RPC and REST endpoints for better debugging and usability.

This update significantly enhances the screen intelligence capabilities by allowing it to run independently and providing more flexible configuration options.

* refactor(screen-intelligence): simplify CLI options and server configuration

- Removed the `--port` and `--auto-start` options from the CLI for the `screen-intelligence run` command, streamlining the command usage.
- Updated the server configuration to focus on session duration and logging, enhancing clarity and usability.
- Adjusted the documentation to reflect the new command structure and improved logging details for the screen intelligence server.

This refactor improves the user experience by simplifying command options and enhancing the clarity of server operations.

* feat(screen-intelligence): enhance screenshot management and CLI options

- Updated the CLI usage documentation to include the new `--keep` option for retaining screenshots after processing.
- Modified the server configuration to support the `keep_screenshots` flag, allowing for immediate saving of screenshots to disk.
- Enhanced the `run_server` function to reflect the updated configuration and logging details regarding screenshot retention.
- Improved the capture logic to prioritize window ID for more reliable screenshot capturing on macOS.

These changes improve the usability and functionality of the screen intelligence feature by providing better control over screenshot management.

* refactor(accessibility): improve capture logic and window ID handling

- Updated the capture mode logic to prioritize reliable window ID capture on macOS, falling back to fullscreen capture to avoid issues with region-based capture.
- Refactored the method for resolving the frontmost window ID to utilize Swift for better performance and reliability, replacing the previous JavaScript-based approach.
- Enhanced the frame processing in the accessibility engine to track processed timestamps, preventing re-analysis of the same screenshot and improving efficiency.
- Adjusted the vision summary parsing to support both JSON and plain text formats, ensuring better compatibility and usability.

These changes enhance the reliability and performance of the accessibility features, particularly in macOS environments.

* feat(accessibility): integrate Apple Vision OCR and enhance LLM context analysis

- Implemented Apple Vision OCR for extracting text from screenshots, improving accuracy and reducing hallucination.
- Refactored the vision processing flow to include structured prompts for LLM context analysis, enhancing the quality of the output summary.
- Updated the vision summary structure to include detailed fields such as APP, DOING, FOCUS, and MOOD, providing clearer insights into the captured content.
- Improved error handling and logging for image processing and OCR operations, ensuring better reliability and debugging capabilities.

These changes significantly enhance the accessibility engine's ability to analyze and summarize screen content effectively.

* refactor(screen-intelligence): enhance capture logic and summary structure

- Improved capture mode handling to prioritize window ID availability, with a fallback to bounds-based capture before defaulting to fullscreen.
- Updated the vision summary structure to clearly separate synthesized summaries, visual context, and raw OCR text for better clarity and usability.
- Enhanced logging to provide more informative output during capture operations, aiding in debugging and performance monitoring.

These changes improve the reliability and clarity of the screen intelligence features, particularly in managing capture contexts and summarizing visual information.

* refactor(accessibility): enhance capture logic to reject fullscreen fallback

- Updated the screen capture logic to prevent falling back to fullscreen mode when no valid window ID or bounds are available, improving privacy and user intent.
- Added detailed logging for cases where fullscreen capture is refused, aiding in debugging and understanding capture decisions.
- Introduced tests to ensure that the new logic correctly rejects fullscreen capture under invalid conditions, enhancing reliability.

These changes improve the accessibility engine's handling of screen captures, ensuring that only relevant content is captured.

* feat(screen-intelligence): implement capture and processing workers for enhanced vision analysis

- Introduced a dedicated `capture_worker` to manage screenshot capturing from the foreground context, ensuring efficient frame handling and immediate saving of screenshots when configured.
- Added a `processing_worker` to analyze captured frames using Apple Vision OCR and LLM, synthesizing insights and persisting results to unified memory.
- Refactored the `AccessibilityEngine` to integrate these workers, improving the overall architecture and separation of concerns in the screen intelligence module.
- Enhanced logging throughout the capture and processing workflows for better debugging and performance monitoring.

These changes significantly improve the screen intelligence capabilities by enabling real-time capture and analysis of visual data, enhancing user experience and functionality.

* Merge remote-tracking branch 'upstream/main' into fix/screen-intgellignce-2

* refactor(screen-intelligence): improve type handling and visibility in capture and processing modules

- Updated the calculation of baseline milliseconds in `capture_worker` to ensure proper type handling with floating-point division.
- Made several fields in `SessionRuntime` and `EngineState` public for better accessibility within the crate.
- Changed the visibility of the `analyze_frame` function in `processing_worker` to public within the crate, allowing it to be called from `engine.rs`.

These changes enhance type safety and improve the modularity of the screen intelligence components, facilitating better integration and usage across the codebase.

* refactor(screen-intelligence): update visibility and helper function usage in engine and processing modules

- Changed the `SessionRuntime` struct to be public within the crate, enhancing accessibility.
- Updated the `analyze_frame` function parameter to use a reference instead of a direct reference to the `AccessibilityEngine`, improving clarity.
- Refactored calls to `persist_vision_summary` to utilize the helper function from the `super::helpers` module, promoting better organization and code reuse.

These changes streamline the code structure and improve the modularity of the screen intelligence components.

* feat(screen-intelligence): introduce input handling and autocomplete features

- Added a new `input.rs` module to manage input actions, including keyboard/mouse automation and predictive text functionalities.
- Implemented methods for handling input actions, generating autocomplete suggestions, and committing selected suggestions within the `AccessibilityEngine`.
- Created a `state.rs` module to encapsulate engine state management, including session runtime and engine state structures.
- Enhanced the `engine.rs` module to support session lifecycle management and integrate new input functionalities.
- Introduced a `vision.rs` module for vision-related query methods, improving the overall architecture and modularity of the screen intelligence components.

These changes significantly enhance user interaction capabilities and streamline the management of input actions and vision processing.

* fix(screen-intelligence): update worker imports to use state module

Workers imported AccessibilityEngine from engine.rs but it was moved
to state.rs during the refactor. Fix the import paths.

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

* refactor(screen-intelligence): clean up formatting and improve readability in various modules

- Reformatted print statements in `screen_intelligence_cli.rs` for better readability.
- Simplified assertions in tests within `capture.rs` to enhance clarity.
- Streamlined function definitions and calls in `focus.rs`, `engine.rs`, and `processing_worker.rs` for improved code organization.
- Updated import statements in `mod.rs` and `server.rs` to maintain consistency and clarity.

These changes enhance the overall readability and maintainability of the codebase, promoting better coding practices across the screen intelligence components.

* refactor(screen-intelligence): update import paths for AccessibilityEngine and EngineState

- Changed the import statements in `tests.rs` to source `AccessibilityEngine` and `EngineState` from the `state` module instead of the `engine` module.
- This adjustment aligns with recent refactoring efforts to improve module organization and maintainability.

These changes enhance code clarity and ensure consistency in module usage across the screen intelligence components.

* fix(screen-intelligence): fix test compilation and assertions

- Update test imports from engine to state module
- Add mock env var path to processing_worker::analyze_frame for test support
- Update parser tests for plain-text mode (non-JSON fallback)
- Handle missing window_id gracefully in capture_scheduler test

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

* refactor(screen-intelligence): enhance code clarity and performance in various modules

- Updated command-line usage documentation in `screen_intelligence_cli.rs` to reflect new options.
- Improved string handling for truncation in `run_start_session` and `run_vision` functions to use character counts instead of byte lengths, ensuring accurate truncation for multi-byte characters.
- Added intentional blocking sleep in `resolve_frontmost_window_id` to minimize impact on the Tokio runtime during app switches.
- Implemented a consistent default confidence score in vision processing and improved YAML escaping in `persist_vision_summary`.
- Refactored session management in `capture_worker` and `engine` modules to reduce lock contention and improve performance during I/O operations.

These changes enhance the overall performance, maintainability, and user experience of the screen intelligence components.

* fix(tests): update confidence assertion in vision summary test

- Adjusted the expected confidence value in the `parse_vision_missing_fields` test to reflect the new default confidence score of 0.8, ensuring consistency across JSON and plain-text branches.

This change improves the accuracy of the test and aligns it with recent updates in the vision processing logic.

* refactor(screen-intelligence): improve code formatting and readability in various modules

- Enhanced string formatting for truncation in `run_vision` to improve clarity.
- Reformatted variable declarations in `capture_worker` for better readability.
- Updated YAML escaping logic in `helpers.rs` for consistency and clarity.

These changes contribute to improved maintainability and readability of the codebase.

* refactor(screen-intelligence): reorganize analyze_frame function for improved flow

- Moved configuration validation to the beginning of the `analyze_frame` function to ensure proper setup before processing.
- Reintroduced the Apple Vision OCR and Vision LLM steps in the correct order, enhancing the logical flow of the analysis process.

These changes improve the readability and maintainability of the code, ensuring a clearer structure for future modifications.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-04-06 21:16:49 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8627cee960
commit a98817917c
16 changed files with 2157 additions and 1020 deletions
+3
View File
@@ -699,6 +699,9 @@ async fn run_server_inner(
// Start the global dictation hotkey listener (rdev-based, core-side).
crate::openhuman::voice::server::start_if_enabled(&config).await;
crate::openhuman::voice::dictation_listener::start_if_enabled(&config).await;
// Initialize screen intelligence engine if enabled in config.
crate::openhuman::screen_intelligence::server::start_if_enabled(&config).await;
}
Err(err) => {
log::warn!("[core] config load failed, skipping local-ai and overlay: {err}");
+241 -94
View File
@@ -5,11 +5,13 @@
//! 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 run [--ttl <secs>] [--keep] [-v]
//! openhuman screen-intelligence status [-v]
//! openhuman screen-intelligence capture [--keep] [-v]
//! openhuman screen-intelligence start [--ttl <secs>] [-v]
//! openhuman screen-intelligence stop
//! openhuman screen-intelligence stop [-v]
//! openhuman screen-intelligence doctor [-v]
//! openhuman screen-intelligence vision [--limit <n>] [-v]
use anyhow::Result;
use std::sync::Arc;
@@ -27,6 +29,8 @@ pub fn run_screen_intelligence_command(args: &[String]) -> Result<()> {
"capture" => run_capture(&args[1..]),
"start" => run_start_session(&args[1..]),
"stop" => run_stop_session(&args[1..]),
"doctor" => run_doctor(&args[1..]),
"vision" => run_vision(&args[1..]),
other => Err(anyhow::anyhow!(
"unknown screen-intelligence subcommand '{other}'. Run `openhuman screen-intelligence --help`."
)),
@@ -38,31 +42,22 @@ pub fn run_screen_intelligence_command(args: &[String]) -> Result<()> {
// ---------------------------------------------------------------------------
struct CliOpts {
port: u16,
verbose: bool,
ttl_secs: u64,
keep: bool,
limit: usize,
}
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 limit: usize = 10;
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)
@@ -72,6 +67,15 @@ fn parse_opts(args: &[String]) -> Result<(CliOpts, Vec<String>)> {
.map_err(|e| anyhow::anyhow!("invalid --ttl: {e}"))?;
i += 2;
}
"--limit" => {
let val = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value for --limit"))?;
limit = val
.parse()
.map_err(|e| anyhow::anyhow!("invalid --limit: {e}"))?;
i += 2;
}
"--keep" => {
keep = true;
i += 1;
@@ -93,10 +97,10 @@ fn parse_opts(args: &[String]) -> Result<(CliOpts, Vec<String>)> {
Ok((
CliOpts {
port,
verbose,
ttl_secs,
keep,
limit,
},
rest,
))
@@ -135,17 +139,23 @@ async fn bootstrap_engine(
// Subcommands
// ---------------------------------------------------------------------------
/// `openhuman screen-intelligence run` — start a minimal JSON-RPC server with the
/// screen intelligence engine, useful for integration testing.
/// `openhuman screen-intelligence run` — start the standalone capture + vision loop.
///
/// Delegates to [`crate::openhuman::screen_intelligence::server::run_standalone`],
/// which boots the engine, starts a capture session, and blocks in a
/// monitoring loop logging captures and vision summaries until TTL or Ctrl+C.
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!("Usage: openhuman screen-intelligence run [--ttl <secs>] [--keep] [-v]");
println!();
println!("Start a lightweight JSON-RPC server exposing screen intelligence RPC methods.");
println!("Start the screen intelligence capture + vision loop.");
println!("Captures screenshots at baseline FPS, sends to vision model,");
println!("and logs summaries. Blocks until TTL expires or Ctrl+C.");
println!();
println!(" --port <u16> Listen port (default: 7797)");
println!(" --ttl <secs> Session duration (default: 300)");
println!(" --keep Keep screenshots on disk after vision processing");
println!(" -v, --verbose Enable debug logging");
return Ok(());
}
@@ -157,24 +167,40 @@ fn run_server(args: &[String]) -> Result<()> {
.build()?;
rt.block_on(async {
let _engine = bootstrap_engine(opts.verbose).await?;
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("config load failed: {e}"))?;
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)");
let server_config = crate::openhuman::screen_intelligence::server::SiServerConfig {
ttl_secs: opts.ttl_secs,
log_interval_secs: 5,
keep_screenshots: opts.keep,
};
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!(" Screen Intelligence");
eprintln!(" ───────────────────");
eprintln!(" TTL: {}s", opts.ttl_secs);
eprintln!(
" Vision: {}",
config.screen_intelligence.vision_enabled
);
eprintln!(" Vision model: {}", config.local_ai.vision_model_id);
eprintln!(
" FPS: {}",
config.screen_intelligence.baseline_fps
);
eprintln!(
" Keep screenshots: {}",
opts.keep || config.screen_intelligence.keep_screenshots
);
eprintln!();
eprintln!(" Capturing → Vision → Log. Press Ctrl+C to stop.");
eprintln!();
axum::serve(listener, app).await?;
Ok(())
crate::openhuman::screen_intelligence::server::run_standalone(config, server_config)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
})
}
@@ -368,8 +394,8 @@ fn run_start_session(args: &[String]) -> Result<()> {
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])
let truncated = if summary.chars().count() > 100 {
format!("{}", summary.chars().take(100).collect::<String>())
} else {
summary.clone()
};
@@ -415,68 +441,183 @@ fn run_stop_session(args: &[String]) -> Result<()> {
})
}
// ---------------------------------------------------------------------------
// 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(),
/// `openhuman screen-intelligence doctor` — diagnostic readiness check.
fn run_doctor(args: &[String]) -> Result<()> {
if args.iter().any(|a| is_help(a)) {
println!("Usage: openhuman screen-intelligence doctor [-v]");
println!();
println!("Check system readiness: permissions, platform support, vision 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 doctor_json =
crate::openhuman::screen_intelligence::rpc::accessibility_doctor_cli_json()
.await
.map_err(|e| anyhow::anyhow!("doctor check failed: {e}"))?;
let summary = &doctor_json["result"]["summary"];
let recommendations = &doctor_json["result"]["recommendations"];
let permissions = &doctor_json["result"]["permissions"];
eprintln!(" Screen Intelligence Doctor");
eprintln!(" ──────────────────────────");
eprintln!();
let check = |ok: bool| if ok { "" } else { "" };
let platform_ok = summary["platform_supported"].as_bool().unwrap_or(false);
let screen_ok = summary["screen_capture_ready"].as_bool().unwrap_or(false);
let control_ok = summary["device_control_ready"].as_bool().unwrap_or(false);
let input_ok = summary["input_monitoring_ready"].as_bool().unwrap_or(false);
let overall_ok = summary["overall_ready"].as_bool().unwrap_or(false);
eprintln!(" {} Platform supported", check(platform_ok));
eprintln!(" {} Screen recording", check(screen_ok));
eprintln!(" {} Accessibility (device control)", check(control_ok));
eprintln!(" {} Input monitoring", check(input_ok));
eprintln!();
// Vision config check.
let config = crate::openhuman::config::Config::load_or_init().await.ok();
if let Some(ref cfg) = config {
let si = &cfg.screen_intelligence;
let la = &cfg.local_ai;
eprintln!(" Config:");
eprintln!(" enabled: {}", si.enabled);
eprintln!(" vision_enabled: {}", si.vision_enabled);
eprintln!(" baseline_fps: {}", si.baseline_fps);
eprintln!(" keep_screenshots: {}", si.keep_screenshots);
eprintln!(" local_ai.enabled: {}", la.enabled);
eprintln!(" local_ai.provider: {}", la.provider);
if si.vision_enabled && !la.enabled {
eprintln!(" ⚠ Vision is enabled but local_ai.enabled=false — vision analysis will fail");
}
}
eprintln!();
if overall_ok {
eprintln!(" ✓ Overall: READY");
} else {
eprintln!(" ✗ Overall: NOT READY");
eprintln!();
eprintln!(" Recommendations:");
if let Some(recs) = recommendations.as_array() {
for rec in recs {
if let Some(s) = rec.as_str() {
eprintln!("{s}");
}
}
}
}
eprintln!();
// Machine-readable JSON output.
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"summary": summary,
"permissions": permissions,
"config": config.as_ref().map(|c| serde_json::json!({
"enabled": c.screen_intelligence.enabled,
"vision_enabled": c.screen_intelligence.vision_enabled,
"baseline_fps": c.screen_intelligence.baseline_fps,
"keep_screenshots": c.screen_intelligence.keep_screenshots,
"local_ai_enabled": c.local_ai.enabled,
"local_ai_provider": c.local_ai.provider,
})),
}))
.unwrap_or_default()
);
Ok(())
})
}
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())
/// `openhuman screen-intelligence vision` — inspect recent vision summaries.
fn run_vision(args: &[String]) -> Result<()> {
if args.iter().any(|a| is_help(a)) {
println!("Usage: openhuman screen-intelligence vision [--limit <n>] [-v]");
println!();
println!("Print recent vision summaries from the active session.");
println!();
println!(" --limit <n> Maximum summaries to show (default: 10)");
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.vision_recent(Some(opts.limit)).await;
if result.summaries.is_empty() {
eprintln!(" No vision summaries available.");
eprintln!(" Start a session first: openhuman screen-intelligence start");
} else {
eprintln!(" {} vision summary(ies):\n", result.summaries.len());
for (i, s) in result.summaries.iter().enumerate() {
let ts = chrono::DateTime::from_timestamp_millis(s.captured_at_ms)
.map(|dt| dt.format("%H:%M:%S").to_string())
.unwrap_or_else(|| "?".to_string());
eprintln!(
" [{}] {}{} (confidence: {:.0}%)",
i + 1,
ts,
s.app_name.as_deref().unwrap_or("unknown"),
s.confidence * 100.0,
);
if !s.ui_state.is_empty() {
let truncated = if s.ui_state.chars().count() > 120 {
format!("{}", s.ui_state.chars().take(120).collect::<String>())
} else {
s.ui_state.clone()
};
eprintln!(" ui: {truncated}");
}
if !s.actionable_notes.is_empty() {
let truncated = if s.actionable_notes.chars().count() > 120 {
format!(
"{}",
s.actionable_notes.chars().take(120).collect::<String>()
)
} else {
s.actionable_notes.clone()
};
eprintln!(" notes: {truncated}");
}
eprintln!();
}
}
// Machine-readable output.
println!(
"{}",
serde_json::to_string_pretty(&result).unwrap_or_default()
);
Ok(())
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Quiet logging: only `warn` unless verbose (used for non-server subcommands).
fn init_quiet_logging(verbose: bool) {
if !verbose && std::env::var_os("RUST_LOG").is_none() {
std::env::set_var("RUST_LOG", "warn");
@@ -489,24 +630,30 @@ fn is_help(value: &str) -> bool {
}
fn print_help() {
println!("openhuman screen-intelligence — screen intelligence runtime\n");
println!("openhuman screen-intelligence — standalone screen intelligence runtime\n");
println!("Boots only the screen intelligence engine (accessibility capture + local-AI");
println!("vision) without the full desktop app, Socket.IO, or skills runtime.\n");
println!("Usage:");
println!(" openhuman screen-intelligence run [--port <u16>] [-v]");
println!(" openhuman screen-intelligence run [--ttl <secs>] [-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!(" openhuman screen-intelligence doctor [-v]");
println!(" openhuman screen-intelligence vision [--limit <n>] [-v]");
println!();
println!("Subcommands:");
println!(" run Start a lightweight JSON-RPC server with screen intelligence");
println!(" run Start the capture → vision → log loop (blocks until TTL/Ctrl+C)");
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!(" doctor Check system readiness (permissions, vision config, platform)");
println!(" vision Print recent vision summaries from 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!(" --ttl <secs> Session TTL (default: 300)");
println!(" --limit <n> Max vision summaries for 'vision' (default: 10)");
println!(" --keep Save screenshot to disk (for 'capture')");
println!(" -v, --verbose Enable debug logging");
}
+57 -3
View File
@@ -28,6 +28,14 @@ impl std::fmt::Display for CaptureMode {
}
fn capture_mode_for_context(context: Option<&AppContext>) -> CaptureMode {
// Only use windowed capture when we have a reliable CGWindowID.
// Without a window ID we still return Windowed so the caller can
// decide — but `capture_screen_image_ref_for_context` will fail
// gracefully when neither window_id nor bounds are available.
if context.and_then(|ctx| ctx.window_id).is_some() {
return CaptureMode::Windowed;
}
// Fallback to bounds-based if available, otherwise fullscreen.
match context.and_then(|ctx| ctx.bounds) {
Some(bounds) if bounds.width > 0 && bounds.height > 0 => CaptureMode::Windowed,
_ => CaptureMode::Fullscreen,
@@ -114,17 +122,37 @@ pub fn capture_screen_image_ref_for_context(
let capture_mode = capture_mode_for_context(context);
log_capture_mode_decision(context, &capture_mode);
// Never fall back to fullscreen — capturing the entire display is
// almost never what the caller wants and leaks unrelated content.
if capture_mode == CaptureMode::Fullscreen {
let app = context.and_then(|ctx| ctx.app_name.as_deref());
tracing::debug!(
"[accessibility] refusing fullscreen fallback for app={:?} — \
no window_id or valid bounds available",
app,
);
return Err(
"no window_id or valid bounds available — refusing fullscreen capture".to_string(),
);
}
let mut cmd = std::process::Command::new("screencapture");
cmd.arg("-x").arg("-t").arg("png");
if capture_mode == CaptureMode::Windowed {
if let Some(wid) = context.and_then(|ctx| ctx.window_id) {
// Capture by window ID — most reliable, no coordinate issues.
cmd.arg("-l").arg(wid.to_string());
tracing::debug!(
"[accessibility] capture mode=window_id id={} app={:?}",
wid,
context.and_then(|ctx| ctx.app_name.as_deref())
);
} else if capture_mode == CaptureMode::Windowed {
let b = &context
.and_then(|ctx| ctx.bounds)
.expect("windowed capture requires bounds");
let rect = format!("{},{},{},{}", b.x, b.y, b.width, b.height);
cmd.arg("-R").arg(&rect);
} else {
tracing::debug!("[accessibility] capture mode=fullscreen (primary display)");
}
cmd.arg(tmp_file.path());
@@ -235,6 +263,7 @@ mod tests {
width: 1440,
height: 900,
}),
window_id: None,
};
assert_eq!(
@@ -248,6 +277,7 @@ mod tests {
let invalid_context = AppContext {
app_name: Some("Finder".to_string()),
window_title: Some("Desktop".to_string()),
window_id: None,
bounds: Some(ElementBounds {
x: 0,
y: 0,
@@ -263,6 +293,30 @@ mod tests {
assert_eq!(capture_mode_for_context(None), CaptureMode::Fullscreen);
}
#[cfg(target_os = "macos")]
#[test]
fn fullscreen_fallback_is_rejected() {
// No window_id and no valid bounds → should refuse to capture.
let result = capture_screen_image_ref_for_context(None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("refusing fullscreen capture"));
let invalid_context = AppContext {
app_name: Some("Finder".to_string()),
window_title: Some("Desktop".to_string()),
window_id: None,
bounds: Some(ElementBounds {
x: 0,
y: 0,
width: 0,
height: 900,
}),
};
let result = capture_screen_image_ref_for_context(Some(&invalid_context));
assert!(result.is_err());
assert!(result.unwrap_err().contains("refusing fullscreen capture"));
}
#[cfg(target_os = "macos")]
#[test]
fn oversized_windowed_capture_is_eligible_for_downscale_retry() {
+168 -2
View File
@@ -415,6 +415,7 @@ pub fn parse_foreground_output(stdout: &str) -> Option<AppContext> {
app_name: app,
window_title: title,
bounds,
window_id: None, // Populated later by foreground_context() via resolve_frontmost_window_id.
})
}
@@ -463,15 +464,180 @@ pub fn foreground_context() -> Option<AppContext> {
}
let text = String::from_utf8_lossy(&output.stdout);
let result = parse_foreground_output(&text);
let mut result = parse_foreground_output(&text);
// Resolve the CGWindowID for the frontmost window so capture can use
// `screencapture -l <id>` instead of the fragile `-R x,y,w,h` region
// approach. Falls back gracefully — window_id stays None.
if let Some(ref mut ctx) = result {
ctx.window_id =
resolve_frontmost_window_id(ctx.app_name.as_deref(), ctx.window_title.as_deref());
}
tracing::debug!(
"[accessibility] foreground_context: app={:?} bounds_present={}",
"[accessibility] foreground_context: app={:?} window_id={:?} bounds_present={}",
result.as_ref().and_then(|c| c.app_name.as_deref()),
result.as_ref().and_then(|c| c.window_id),
result.as_ref().map(|c| c.bounds.is_some()).unwrap_or(false)
);
result
}
/// Resolve the CGWindowID of the frontmost on-screen window owned by the
/// given application name (and optionally matching the window title).
///
/// Uses a Swift subprocess to query Quartz `CGWindowListCopyWindowInfo`.
/// Swift ships with macOS and has direct CoreGraphics access.
///
/// Strategy:
/// 1. Prefer a window matching both app name AND title (when title provided).
/// 2. Fall back to first layer-0 window matching app name only.
/// 3. Retry once after a short delay if the first attempt fails (the window
/// list can be briefly stale during fast app switches).
#[cfg(target_os = "macos")]
fn resolve_frontmost_window_id(app_name: Option<&str>, window_title: Option<&str>) -> Option<u32> {
let app = app_name?;
// Try up to 2 times — the CGWindowList can briefly lag behind
// AppleScript during fast app switches.
for attempt in 0..2 {
if attempt > 0 {
// Intentional blocking sleep: `resolve_frontmost_window_id` is called
// from `foreground_context()`, which is a synchronous function invoked
// from within an async context (the capture/status hot path). The sleep
// is only 50ms and is rare (second attempt only), so the blocking impact
// on the Tokio runtime is minimal and acceptable here.
std::thread::sleep(std::time::Duration::from_millis(50));
tracing::debug!(
"[accessibility] retrying window_id resolution for app={:?} (attempt {})",
app,
attempt + 1
);
}
if let Some(wid) = run_swift_window_lookup(app, window_title) {
return Some(wid);
}
}
tracing::debug!(
"[accessibility] window_id resolution failed after retries for app={:?} title={:?}",
app,
window_title,
);
None
}
/// Run the Swift subprocess that queries CGWindowList and returns the best
/// matching window ID.
#[cfg(target_os = "macos")]
fn run_swift_window_lookup(app_name: &str, window_title: Option<&str>) -> Option<u32> {
// Escape single-quotes for shell embedding.
let escaped_app = app_name.replace('\'', "'\\''");
let escaped_title = window_title
.map(|t| t.replace('\'', "'\\''"))
.unwrap_or_default();
let has_title = window_title.is_some() && !escaped_title.is_empty();
// Strip Unicode formatting/control characters (e.g. U+200E LTR mark)
// from the app name before embedding in Swift. Some apps like WhatsApp
// have invisible Unicode prefixes in their bundle name that AppleScript
// preserves but can cause comparison issues.
let stripped_app: String = escaped_app
.chars()
.filter(|c| {
!c.is_control()
&& !matches!(
c,
'\u{200E}' | '\u{200F}' | '\u{200B}' | '\u{FEFF}' | '\u{200C}' | '\u{200D}'
)
})
.collect();
let stripped_title: String = escaped_title
.chars()
.filter(|c| {
!c.is_control()
&& !matches!(
c,
'\u{200E}' | '\u{200F}' | '\u{200B}' | '\u{FEFF}' | '\u{200C}' | '\u{200D}'
)
})
.collect();
// Swift snippet: iterate CGWindowList, prefer title+app match, fall
// back to first layer-0 app-name-only match.
//
// Uses `.optionAll` instead of `.optionOnScreenOnly` because some apps
// (e.g. WhatsApp, Catalyst/Electron apps) have visible windows that
// aren't reported by the on-screen-only filter. We compensate by
// requiring layer == 0 and positive bounds to skip truly off-screen
// or minimised windows.
let swift_code = format!(
r#"
import CoreGraphics
import Foundation
func strip(_ s: String) -> String {{
s.unicodeScalars.filter {{ !($0.properties.isDefaultIgnorableCodePoint || $0.value == 0x200E || $0.value == 0x200F || $0.value == 0xFEFF) }}.map {{ String($0) }}.joined()
}}
let target = strip("{stripped_app}")
let targetTitle = strip("{stripped_title}")
let o: CGWindowListOption = [.optionAll, .excludeDesktopElements]
var fallback: Int = -1
if let l = CGWindowListCopyWindowInfo(o, kCGNullWindowID) as? [[String: Any]] {{
for w in l {{
let owner = strip(w["kCGWindowOwnerName"] as? String ?? "")
let layer = w["kCGWindowLayer"] as? Int ?? -1
let wid = w["kCGWindowNumber"] as? Int ?? -1
let name = strip(w["kCGWindowName"] as? String ?? "")
let bounds = w["kCGWindowBounds"] as? [String: Any] ?? [:]
let bw = bounds["Width"] as? Int ?? 0
let bh = bounds["Height"] as? Int ?? 0
if owner == target && layer == 0 && bw > 1 && bh > 1 {{
if {has_title_swift} && name == targetTitle {{
print(wid)
exit(0)
}}
if fallback < 0 {{
fallback = wid
}}
}}
}}
}}
if fallback > 0 {{ print(fallback) }}
"#,
has_title_swift = if has_title { "true" } else { "false" },
);
// Note: this subprocess has no explicit timeout. This is consistent with
// the rest of the codebase (`screencapture`, `osascript`) which also run
// without timeouts. Swift startup for a trivial snippet is typically <1s.
let output = std::process::Command::new("swift")
.arg("-e")
.arg(&swift_code)
.output()
.ok()?;
if !output.status.success() {
tracing::debug!(
"[accessibility] swift CGWindowList failed: status={:?} stderr={} app={:?}",
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim(),
app_name,
);
return None;
}
let id_str = String::from_utf8_lossy(&output.stdout);
let wid = id_str.trim().parse::<u32>().ok().filter(|&id| id > 0);
tracing::debug!(
"[accessibility] resolved window_id={:?} for app={:?} title={:?}",
wid,
app_name,
window_title,
);
wid
}
#[cfg(not(target_os = "macos"))]
pub fn validate_focused_target(
_expected_app: Option<&str>,
+3
View File
@@ -28,12 +28,15 @@ pub struct AppContext {
pub app_name: Option<String>,
pub window_title: Option<String>,
pub bounds: Option<ElementBounds>,
/// macOS CGWindowID — used by `screencapture -l` for reliable window capture.
pub window_id: Option<u32>,
}
impl AppContext {
pub fn same_as(&self, other: &AppContext) -> bool {
self.app_name == other.app_name
&& self.window_title == other.window_title
&& self.window_id == other.window_id
&& self.bounds.as_ref().map(|b| (b.x, b.y, b.width, b.height))
== other.bounds.as_ref().map(|b| (b.x, b.y, b.width, b.height))
}
+1 -1
View File
@@ -42,7 +42,7 @@ fn default_policy_mode() -> String {
}
fn default_baseline_fps() -> f32 {
1.0
0.2
}
fn default_vision_enabled() -> bool {
@@ -0,0 +1,189 @@
//! Screenshot capture worker — polls foreground context at baseline FPS,
//! captures the active window via `screencapture -l <windowID>`, saves to
//! disk when `keep_screenshots` is set, and sends frames to the vision
//! processing worker via an unbounded channel.
use std::path::PathBuf;
use std::sync::Arc;
use crate::openhuman::accessibility::{capture_screen_image_ref_for_context, foreground_context};
use crate::openhuman::config::Config;
use super::capture::now_ms;
use super::helpers::push_ephemeral_frame;
use super::state::AccessibilityEngine;
use super::types::CaptureFrame;
/// Main capture loop. Runs until session TTL expires or the session is stopped.
pub(crate) async fn run(engine: Arc<AccessibilityEngine>) {
let mut tick = tokio::time::interval(std::time::Duration::from_millis(250));
tracing::debug!("[capture_worker] started");
loop {
tick.tick().await;
// Check TTL.
let should_stop = {
let state = engine.inner.lock().await;
match &state.session {
Some(session) => now_ms() >= session.expires_at_ms,
None => {
tracing::debug!("[capture_worker] no session, exiting");
return;
}
}
};
if should_stop {
tracing::debug!("[capture_worker] TTL expired, stopping");
engine
.stop_session_internal("ttl_expired".to_string())
.await;
return;
}
let context = foreground_context();
let now = now_ms();
// Read all session/config fields we need while holding the lock, then
// drop it before performing I/O (screencapture + optional disk save).
let (
baseline_ms,
screen_monitoring,
config,
last_capture_at_ms,
last_context_clone,
vision_enabled,
) = {
let state = engine.inner.lock().await;
let baseline_ms =
(1000.0_f64 / (state.config.baseline_fps.max(0.2) as f64)).round() as i64;
let screen_monitoring = state.features.screen_monitoring;
let config = state.config.clone();
let (last_capture_at_ms, last_context_clone, vision_enabled) = match &state.session {
Some(session) => (
session.last_capture_at_ms,
session.last_context.clone(),
session.vision_enabled,
),
None => {
tracing::debug!("[capture_worker] no session while reading fields, exiting");
return;
}
};
(
baseline_ms,
screen_monitoring,
config,
last_capture_at_ms,
last_context_clone,
vision_enabled,
)
};
if !screen_monitoring {
continue;
}
let is_allowed = context
.as_ref()
.map(|ctx| engine.should_capture_context(ctx, &config))
.unwrap_or(false);
if !is_allowed {
tracing::trace!(
"[capture_worker] skipped: context blocked by denylist (app={:?})",
context.as_ref().and_then(|c| c.app_name.as_deref())
);
continue;
}
let context_changed = match (&last_context_clone, &context) {
(Some(prev), Some(curr)) => !prev.same_as(curr),
(None, Some(_)) => true,
_ => false,
};
let baseline_due = last_capture_at_ms
.map(|last| now - last >= baseline_ms)
.unwrap_or(true);
if !(context_changed || baseline_due) {
continue;
}
let reason = if context_changed {
"event:foreground_changed"
} else {
"baseline"
};
// Only capture when we have a window ID — never fall back to fullscreen.
let has_window_id = context.as_ref().and_then(|c| c.window_id).is_some();
if !has_window_id {
tracing::debug!(
"[capture_worker] skipping: no window_id for app={:?}",
context.as_ref().and_then(|c| c.app_name.as_deref()),
);
// Re-acquire lock to update last_context.
let mut state = engine.inner.lock().await;
if let Some(session) = state.session.as_mut() {
session.last_context = context;
}
continue;
}
tracing::debug!(
"[capture_worker] capturing app={:?} window_id={:?}",
context.as_ref().and_then(|c| c.app_name.as_deref()),
context.as_ref().and_then(|c| c.window_id),
);
// Perform I/O (screencapture) without holding the lock.
let capture_result = capture_screen_image_ref_for_context(context.as_ref());
if let Err(ref e) = capture_result {
tracing::debug!("[capture_worker] capture failed (reason={}): {}", reason, e);
}
let frame = CaptureFrame {
captured_at_ms: now,
reason: reason.to_string(),
app_name: context.as_ref().and_then(|c| c.app_name.clone()),
window_title: context.as_ref().and_then(|c| c.window_title.clone()),
image_ref: capture_result.ok(),
};
// Save to disk without holding the lock — this is slow I/O.
if frame.image_ref.is_some() && config.keep_screenshots {
let ws = match Config::load_or_init().await {
Ok(c) => c.workspace_dir.clone(),
Err(_) => PathBuf::from("."),
};
match AccessibilityEngine::save_screenshot_to_disk(&ws, &frame) {
Ok(path) => {
tracing::debug!("[capture_worker] saved: {}", path.display())
}
Err(e) => tracing::debug!("[capture_worker] save failed: {e}"),
}
}
// Re-acquire lock to update session state and enqueue the frame.
let mut state = engine.inner.lock().await;
let Some(session) = state.session.as_mut() else {
return;
};
push_ephemeral_frame(&mut session.frames, frame.clone());
session.capture_count = session.capture_count.saturating_add(1);
session.last_capture_at_ms = Some(now);
session.last_context = context;
if frame.image_ref.is_some() && vision_enabled {
if let Some(tx) = session.vision_tx.as_ref() {
if tx.send(frame).is_ok() {
session.vision_queue_depth = session.vision_queue_depth.saturating_add(1);
session.vision_state = "queued".to_string();
}
}
}
state.last_event = Some(reason.to_string());
}
}
File diff suppressed because it is too large Load Diff
+97 -44
View File
@@ -7,6 +7,10 @@ use uuid::Uuid;
use super::limits::{MAX_CONTEXT_CHARS, MAX_EPHEMERAL_FRAMES, MAX_EPHEMERAL_VISION_SUMMARIES};
use super::types::{AutocompleteSuggestion, CaptureFrame, InputActionParams, VisionSummary};
/// Default confidence score used when the model does not provide one.
/// Applied consistently across both JSON and plain-text vision output branches.
const DEFAULT_VISION_CONFIDENCE: f32 = 0.8;
pub(crate) const VISION_MEMORY_NAMESPACE: &str = "background";
pub(crate) const VISION_MEMORY_SOURCE_TYPE: &str = "screenshot";
pub(crate) const VISION_MEMORY_CATEGORY: &str = "screen_intelligence";
@@ -68,6 +72,19 @@ pub(crate) fn push_ephemeral_vision_summary(
summaries: &mut VecDeque<VisionSummary>,
summary: VisionSummary,
) {
// Deduplicate: skip if a summary with the same captured_at_ms already exists.
// This prevents `vision_flush` from storing duplicates when called concurrently
// with the processing worker channel path.
if summaries
.iter()
.any(|s| s.captured_at_ms == summary.captured_at_ms)
{
tracing::debug!(
"[screen_intelligence] skipping duplicate vision summary (captured_at_ms={})",
summary.captured_at_ms
);
return;
}
summaries.push_back(summary);
while summaries.len() > MAX_EPHEMERAL_VISION_SUMMARIES {
let _ = summaries.pop_front();
@@ -75,45 +92,60 @@ pub(crate) fn push_ephemeral_vision_summary(
}
pub(crate) fn parse_vision_summary_output(frame: CaptureFrame, raw: &str) -> VisionSummary {
let fallback = truncate_tail(raw.trim(), 512);
let value = serde_json::from_str::<serde_json::Value>(raw).ok();
let ui_state = value
.as_ref()
.and_then(|v| v.get("ui_state"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.unwrap_or("UI state unavailable");
let key_text = value
.as_ref()
.and_then(|v| v.get("key_text"))
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
let actionable_notes = value
.as_ref()
.and_then(|v| v.get("actionable_notes"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.unwrap_or(&fallback);
let confidence = value
.as_ref()
.and_then(|v| v.get("confidence"))
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(0.66)
.clamp(0.0, 1.0);
let trimmed = raw.trim();
// Try JSON first (backwards compat / mock testing).
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
let ui_state = value
.get("ui_state")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let key_text = value
.get("key_text")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let actionable_notes = value
.get("actionable_notes")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let confidence = value
.get("confidence")
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(DEFAULT_VISION_CONFIDENCE)
.clamp(0.0, 1.0);
return VisionSummary {
id: format!("vision-{}-{}", frame.captured_at_ms, Uuid::new_v4()),
captured_at_ms: frame.captured_at_ms,
app_name: frame.app_name,
window_title: frame.window_title,
ui_state: truncate_tail(ui_state, 500),
key_text: truncate_tail(key_text, 2000),
actionable_notes: truncate_tail(actionable_notes, 1000),
confidence,
};
}
// Plain text mode: first line = ui_state, second line = actionable_notes,
// rest = key_text (the full content extraction).
let mut lines = trimmed.lines();
let ui_state = lines.next().unwrap_or("").trim().to_string();
let actionable_notes = lines.next().unwrap_or("").trim().to_string();
let key_text: String = lines.collect::<Vec<_>>().join("\n").trim().to_string();
VisionSummary {
id: format!("vision-{}-{}", frame.captured_at_ms, Uuid::new_v4()),
captured_at_ms: frame.captured_at_ms,
app_name: frame.app_name,
window_title: frame.window_title,
ui_state: truncate_tail(ui_state, 220),
key_text: truncate_tail(key_text, 280),
actionable_notes: truncate_tail(actionable_notes, 560),
confidence,
ui_state: truncate_tail(&ui_state, 500),
key_text: truncate_tail(&key_text, 4000),
actionable_notes: truncate_tail(&actionable_notes, 1000),
confidence: DEFAULT_VISION_CONFIDENCE,
}
}
@@ -154,23 +186,44 @@ pub(crate) async fn persist_vision_summary(
}
};
let content = match serde_json::to_string(&summary) {
Ok(content) => content,
Err(err) => {
let message = format!("serialization failed: {err}");
tracing::debug!(
"[screen_intelligence] vision summary persistence skipped: {}",
message
);
return Err(message);
}
let ts = chrono::DateTime::from_timestamp_millis(summary.captured_at_ms)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| summary.captured_at_ms.to_string());
let app = summary.app_name.as_deref().unwrap_or("Unknown");
let window = summary.window_title.as_deref().unwrap_or("");
let title = format!("Screen capture — {}{}", app, ts);
// YAML frontmatter for metadata, body is clean markdown content.
// Limitation: escaping is best-effort — only double-quotes and newlines are
// escaped. Values containing YAML-special characters like `:`, `{`, `}`, `[`,
// `]`, `#`, `|`, `>`, `&`, `*` may still produce invalid YAML in edge cases.
let yaml_escape = |s: &str| -> String {
s.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "")
};
let mut content = String::from("---\n");
content.push_str(&format!("app: \"{}\"\n", yaml_escape(app)));
if !window.is_empty() {
content.push_str(&format!("window: \"{}\"\n", yaml_escape(window)));
}
content.push_str(&format!("captured: \"{}\"\n", ts));
content.push_str(&format!("captured_ms: {}\n", summary.captured_at_ms));
content.push_str(&format!("confidence: {:.2}\n", summary.confidence));
content.push_str(&format!("id: \"{}\"\n", summary.id));
content.push_str("---\n\n");
// key_text = synthesized summary (the main document body)
if !summary.key_text.is_empty() {
content.push_str(&format!("{}\n", summary.key_text));
}
let key = format!("screen_intelligence_{}", summary.id);
mem.upsert_document(NamespaceDocumentInput {
namespace: VISION_MEMORY_NAMESPACE.to_string(),
key: key.clone(),
title: key.clone(),
title,
content,
source_type: VISION_MEMORY_SOURCE_TYPE.to_string(),
priority: "medium".to_string(),
+128
View File
@@ -0,0 +1,128 @@
//! Input actions and autocomplete — keyboard/mouse automation + predictive text.
use super::helpers::{generate_suggestions, truncate_tail, validate_input_action};
use super::limits::{MAX_CONTEXT_CHARS, MAX_SUGGESTION_CHARS};
use super::state::AccessibilityEngine;
use super::types::{
AutocompleteCommitParams, AutocompleteCommitResult, AutocompleteSuggestParams,
AutocompleteSuggestResult, InputActionParams, InputActionResult,
};
use crate::openhuman::accessibility::foreground_context;
impl AccessibilityEngine {
pub async fn input_action(
&self,
action: InputActionParams,
) -> Result<InputActionResult, String> {
let mut state = self.inner.lock().await;
if action.action == "panic_stop" {
drop(state);
self.stop_session_internal("panic_stop".to_string()).await;
return Ok(InputActionResult {
accepted: true,
blocked: false,
reason: Some("panic stop executed".to_string()),
});
}
if state.session.is_none() {
return Ok(InputActionResult {
accepted: false,
blocked: true,
reason: Some("session is not active".to_string()),
});
}
if !state.features.device_control {
return Ok(InputActionResult {
accepted: false,
blocked: true,
reason: Some("device control is disabled".to_string()),
});
}
let context = foreground_context();
if let Some(ctx) = &context {
if !self.should_capture_context(ctx, &state.config) {
return Ok(InputActionResult {
accepted: false,
blocked: true,
reason: Some("action blocked by denylisted context".to_string()),
});
}
}
validate_input_action(&action)?;
if let Some(text) = action.text.as_ref() {
if !text.is_empty() {
if !state.autocomplete_context.is_empty() {
state.autocomplete_context.push(' ');
}
state.autocomplete_context.push_str(text);
state.autocomplete_context =
truncate_tail(&state.autocomplete_context, MAX_CONTEXT_CHARS);
}
}
let action_name = action.action.clone();
state.last_event = Some(format!("input_action:{action_name}"));
Ok(InputActionResult {
accepted: true,
blocked: false,
reason: None,
})
}
pub async fn autocomplete_suggest(
&self,
params: AutocompleteSuggestParams,
) -> Result<AutocompleteSuggestResult, String> {
let state = self.inner.lock().await;
if !state.features.predictive_input {
return Ok(AutocompleteSuggestResult {
suggestions: Vec::new(),
});
}
let mut context = params.context.unwrap_or_default();
if context.trim().is_empty() {
context = state.autocomplete_context.clone();
}
drop(state);
let max_results = params.max_results.unwrap_or(3).clamp(1, 8);
let suggestions = generate_suggestions(&context, max_results);
Ok(AutocompleteSuggestResult { suggestions })
}
pub async fn autocomplete_commit(
&self,
params: AutocompleteCommitParams,
) -> Result<AutocompleteCommitResult, String> {
let cleaned = params.suggestion.trim();
if cleaned.is_empty() {
return Err("suggestion cannot be empty".to_string());
}
if cleaned.len() > MAX_SUGGESTION_CHARS {
return Err("suggestion exceeds maximum length".to_string());
}
let mut state = self.inner.lock().await;
if !state.features.predictive_input {
return Ok(AutocompleteCommitResult { committed: false });
}
if !state.autocomplete_context.is_empty() {
state.autocomplete_context.push(' ');
}
state.autocomplete_context.push_str(cleaned);
state.autocomplete_context = truncate_tail(&state.autocomplete_context, MAX_CONTEXT_CHARS);
state.last_event = Some("autocomplete_commit".to_string());
Ok(AutocompleteCommitResult { committed: true })
}
}
+7 -1
View File
@@ -2,22 +2,28 @@
pub mod ops;
mod schemas;
pub mod server;
mod capture;
mod capture_worker;
mod engine;
mod helpers;
mod image_processing;
mod input;
mod limits;
mod permissions;
mod processing_worker;
mod state;
mod types;
mod vision;
pub use engine::{global_engine, AccessibilityEngine};
pub use ops as rpc;
pub use ops::*;
pub use schemas::{
all_controller_schemas as all_screen_intelligence_controller_schemas,
all_registered_controllers as all_screen_intelligence_registered_controllers,
};
pub use state::{global_engine, AccessibilityEngine};
pub use types::*;
#[cfg(test)]
@@ -0,0 +1,375 @@
//! Vision processing worker — receives captured frames, runs OCR + LLM
//! analysis, and persists the synthesized document to unified memory.
//!
//! Pipeline per frame:
//! 1. Apple Vision OCR (Swift, ~200ms) → raw text extraction
//! 2. Vision LLM (Ollama, ~2-5s) → app/activity/focus/mood context
//! 3. Synthesis LLM (Ollama, ~3-5s) → final informative document
//! 4. Persist to unified memory as markdown with YAML frontmatter
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use crate::openhuman::config::Config;
use crate::openhuman::local_ai;
use super::helpers::{persist_vision_summary, push_ephemeral_vision_summary, truncate_tail};
use super::state::AccessibilityEngine;
use super::types::{CaptureFrame, VisionSummary};
/// Main processing loop. Receives frames from the capture worker channel.
pub(crate) async fn run(
engine: Arc<AccessibilityEngine>,
mut rx: tokio::sync::mpsc::UnboundedReceiver<CaptureFrame>,
) {
tracing::debug!("[processing_worker] started");
let mut processed_timestamps: HashSet<i64> = HashSet::new();
while let Some(mut frame) = rx.recv().await {
// Drain channel — keep only the latest frame.
let mut skipped = 0u64;
while let Ok(newer) = rx.try_recv() {
skipped += 1;
frame = newer;
}
if skipped > 0 {
tracing::debug!(
"[processing_worker] skipped {} stale frame(s), processing latest (ts={})",
skipped,
frame.captured_at_ms,
);
let mut state = engine.inner.lock().await;
if let Some(session) = state.session.as_mut() {
session.vision_queue_depth =
session.vision_queue_depth.saturating_sub(skipped as usize);
}
}
// Skip already-processed frames.
if processed_timestamps.contains(&frame.captured_at_ms) {
tracing::debug!(
"[processing_worker] frame ts={} already processed, skipping",
frame.captured_at_ms,
);
let mut state = engine.inner.lock().await;
if let Some(session) = state.session.as_mut() {
session.vision_queue_depth = session.vision_queue_depth.saturating_sub(1);
}
continue;
}
tracing::debug!(
"[processing_worker] processing frame (app={:?}, ts={}, reason={})",
frame.app_name,
frame.captured_at_ms,
frame.reason
);
let keep_screenshots = engine.inner.lock().await.config.keep_screenshots;
// Temp save for vision processing when keep_screenshots is off.
let saved_path = if !keep_screenshots && frame.image_ref.is_some() {
let workspace_dir = match Config::load_or_init().await {
Ok(cfg) => cfg.workspace_dir.clone(),
Err(_) => PathBuf::from("."),
};
match AccessibilityEngine::save_screenshot_to_disk(&workspace_dir, &frame) {
Ok(path) => Some(path),
Err(err) => {
tracing::debug!("[processing_worker] temp save failed: {err}");
None
}
}
} else {
None
};
{
let mut state = engine.inner.lock().await;
if let Some(session) = state.session.as_mut() {
session.vision_state = "processing".to_string();
} else {
tracing::debug!("[processing_worker] no session, exiting");
break;
}
}
let capture_ts = frame.captured_at_ms;
let result = analyze_frame(&engine, frame).await;
// Mark processed.
processed_timestamps.insert(capture_ts);
if processed_timestamps.len() > 500 {
let oldest = *processed_timestamps.iter().min().unwrap();
processed_timestamps.remove(&oldest);
}
// Clean up temp screenshot.
if !keep_screenshots {
if let Some(path) = saved_path {
let _ = std::fs::remove_file(&path);
}
}
// Update session state and persist.
let mut summary_to_persist: Option<VisionSummary> = None;
{
let mut state = engine.inner.lock().await;
let Some(session) = state.session.as_mut() else {
break;
};
session.vision_queue_depth = session.vision_queue_depth.saturating_sub(1);
match result {
Ok(summary) => {
tracing::debug!(
"[processing_worker] analysis complete (id={} confidence={:.2})",
summary.id,
summary.confidence
);
push_ephemeral_vision_summary(&mut session.vision_summaries, summary.clone());
session.last_vision_at_ms = Some(summary.captured_at_ms);
session.last_vision_summary = Some(summary.key_text.clone());
session.vision_state = "ready".to_string();
summary_to_persist = Some(summary);
}
Err(err) => {
tracing::debug!("[processing_worker] analysis failed: {err}");
session.vision_state = "error".to_string();
state.last_error = Some(err);
}
}
}
if let Some(summary) = summary_to_persist {
match persist_vision_summary(summary).await {
Ok(persisted) => {
let mut state = engine.inner.lock().await;
if let Some(session) = state.session.as_mut() {
session.vision_persist_count =
session.vision_persist_count.saturating_add(1);
session.last_vision_persisted_key = Some(persisted.key.clone());
session.last_vision_persist_error = None;
}
}
Err(err) => {
tracing::debug!("[processing_worker] persistence failed: {err}");
let mut state = engine.inner.lock().await;
if let Some(session) = state.session.as_mut() {
session.vision_state = "error".to_string();
session.last_vision_persist_error = Some(err.clone());
}
state.last_error = Some(format!("vision_summary_persist_failed: {err}"));
}
}
}
}
tracing::debug!("[processing_worker] exiting");
}
// ── Analysis pipeline ───────────────────────────────────────────────────
/// Run the full 3-pass analysis pipeline on a captured frame.
/// Public within the crate so `engine.rs` can call it for flush/diagnostics.
pub(crate) async fn analyze_frame(
_engine: &AccessibilityEngine,
frame: CaptureFrame,
) -> Result<VisionSummary, String> {
let image_ref = frame
.image_ref
.clone()
.ok_or_else(|| "frame has no image payload".to_string())?;
// ── Mock path for testing ───────────────────────────────────────
if let Ok(mock_raw) = std::env::var("OPENHUMAN_SCREEN_INTELLIGENCE_MOCK_VISION_JSON") {
if !mock_raw.trim().is_empty() {
tracing::debug!("[processing_worker] using mocked vision output");
return Ok(super::helpers::parse_vision_summary_output(
frame, &mock_raw,
));
}
}
// ── Validate config before doing any work ─────────────────────────
let config = Config::load_or_init()
.await
.map_err(|e| format!("failed to load config: {e}"))?;
if !config.local_ai.enabled {
return Err(
"screen intelligence vision requires local_ai.enabled=true in config".to_string(),
);
}
let provider = config.local_ai.provider.trim().to_ascii_lowercase();
if provider != "ollama" {
return Err(format!(
"screen intelligence vision requires provider 'ollama' (found '{provider}')",
));
}
// ── Pass 1: OCR via Apple Vision ────────────────────────────────
tracing::debug!("[processing_worker] pass 1/3: Apple Vision OCR");
let ocr_text = tokio::time::timeout(
std::time::Duration::from_secs(30),
run_apple_vision_ocr(image_ref.clone()),
)
.await
.map_err(|_| "Apple Vision OCR timed out after 30s".to_string())??;
tracing::debug!("[processing_worker] OCR extracted {} chars", ocr_text.len());
// ── Pass 2: Vision LLM for context ──────────────────────────────
let compressed = super::image_processing::compress_screenshot(&image_ref, None, None)
.map_err(|e| format!("image compression failed: {e}"))?;
let vision_image_ref = compressed.data_uri;
tracing::debug!(
"[processing_worker] pass 2/3: vision LLM (model={})",
config.local_ai.vision_model_id,
);
let service = local_ai::global(&config);
let vision_prompt = r#"Describe this screenshot briefly. Answer each on its own line:
APP: Name the application and the specific page/view/tab shown.
DOING: What is the user actively doing? (e.g. writing code, reading email, browsing, chatting)
FOCUS: What's the main content area about? (e.g. a PR review for auth refactor, a Slack thread about deployment)
MOOD: Is anything urgent, broken, or notable? (errors, notifications, warnings — or "nothing notable")
One line per answer. No text extraction. Be specific and concise."#;
let vision_context = service
.vision_prompt(&config, vision_prompt, &[vision_image_ref], Some(150))
.await?
.trim()
.to_string();
// ── Pass 3: Synthesis LLM — final document ──────────────────────
let app_label = frame.app_name.as_deref().unwrap_or("Unknown");
let window_label = frame.window_title.as_deref().unwrap_or("");
let ocr_truncated = truncate_tail(&ocr_text, 4000);
tracing::debug!(
"[processing_worker] pass 3/3: synthesis LLM (ocr={} chars, vision={} chars)",
ocr_truncated.len(),
vision_context.len(),
);
let synthesis_prompt = format!(
r#"You are summarizing what a user is doing on their computer right now.
Application: {app_label}
Window: {window_label}
Visual context from the screenshot:
{vision_context}
Extracted text from screen (OCR):
{ocr_truncated}
Write a clear, informative summary in plain text. Include:
- What application and specific view/page the user has open
- What they are actively doing (coding, reading, chatting, browsing, etc.)
- Key content visible — summarize what's on screen (don't just list raw OCR, synthesize it)
- Any notable items: errors, notifications, deadlines, action items
- Brief context that would help someone understand this moment later
Be specific and informative. Write in present tense. ~300-500 words."#
);
let synthesis = service
.prompt(&config, &synthesis_prompt, Some(700), true)
.await
.unwrap_or_else(|e| {
tracing::debug!("[processing_worker] synthesis failed, using fallback: {e}");
format!("{}\n\n{}", vision_context, ocr_truncated)
});
tracing::debug!(
"[processing_worker] synthesis complete ({} chars)",
synthesis.len(),
);
Ok(VisionSummary {
id: format!("vision-{}-{}", frame.captured_at_ms, uuid::Uuid::new_v4()),
captured_at_ms: frame.captured_at_ms,
app_name: frame.app_name,
window_title: frame.window_title,
ui_state: truncate_tail(&vision_context, 500),
key_text: truncate_tail(&synthesis, 4000),
actionable_notes: String::new(),
confidence: 0.9,
})
}
// ── Apple Vision OCR ────────────────────────────────────────────────────
/// Run Apple Vision framework OCR on a base64-encoded image.
///
/// Uses `tokio::process::Command` with `.kill_on_drop(true)` so the subprocess
/// is cleaned up if the future is dropped (e.g. due to the 30s timeout in the
/// caller). The temp file is removed whether or not the OCR succeeds.
async fn run_apple_vision_ocr(image_ref: String) -> Result<String, String> {
use base64::{engine::general_purpose::STANDARD as B64, Engine};
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 OCR failed: {e}"))?;
let tmp_path = std::env::temp_dir().join(format!("openhuman_ocr_{}.png", uuid::Uuid::new_v4()));
std::fs::write(&tmp_path, &raw_bytes)
.map_err(|e| format!("failed to write temp OCR image: {e}"))?;
let swift_code = format!(
r#"
import Vision
import AppKit
let url = URL(fileURLWithPath: "{path}")
guard let image = NSImage(contentsOf: url),
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {{
fputs("ERROR: failed to load image\n", stderr)
exit(1)
}}
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
try handler.perform([request])
guard let observations = request.results else {{
exit(0)
}}
for obs in observations {{
if let candidate = obs.topCandidates(1).first {{
print(candidate.string)
}}
}}
"#,
path = tmp_path.display()
);
let output = tokio::process::Command::new("swift")
.arg("-e")
.arg(&swift_code)
.kill_on_drop(true)
.output()
.await
.map_err(|e| format!("swift OCR failed to start: {e}"))?;
let _ = std::fs::remove_file(&tmp_path);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Apple Vision OCR failed: {}", stderr.trim()));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
+417
View File
@@ -0,0 +1,417 @@
//! Standalone screen intelligence server — capture → vision → persist.
//!
//! Can run as part of the core process or independently via the CLI.
//! The server boots the accessibility engine, starts a capture + vision
//! session, and blocks in a monitoring loop — logging captures, vision
//! summaries, and context changes to stderr. No HTTP surface; RPC is
//! handled by the core server's `screen_intelligence.*` routes through
//! the shared engine singleton.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use log::{debug, error, info, warn};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use crate::openhuman::config::Config;
use super::global_engine;
use super::state::AccessibilityEngine;
use super::types::StartSessionParams;
const LOG_PREFIX: &str = "[si_server]";
/// Running state of the screen intelligence server.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServerState {
/// Server is not running.
Stopped,
/// Server is running, engine ready, no active session.
Idle,
/// Active capture session (vision may or may not be enabled).
Running,
}
/// Status snapshot of the screen intelligence server.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SiServerStatus {
pub state: ServerState,
pub capture_count: u64,
pub vision_count: u64,
pub last_error: Option<String>,
}
/// Configuration for the screen intelligence server.
#[derive(Debug, Clone)]
pub struct SiServerConfig {
/// Session TTL in seconds.
pub ttl_secs: u64,
/// Status log interval in seconds.
pub log_interval_secs: u64,
/// Keep screenshots on disk after vision processing.
pub keep_screenshots: bool,
}
impl Default for SiServerConfig {
fn default() -> Self {
Self {
ttl_secs: 300,
log_interval_secs: 5,
keep_screenshots: false,
}
}
}
/// The screen intelligence server runtime.
pub struct SiServer {
state: Arc<Mutex<ServerState>>,
cancel: CancellationToken,
config: SiServerConfig,
engine: Arc<AccessibilityEngine>,
capture_count: Arc<AtomicU64>,
vision_count: Arc<AtomicU64>,
last_error: Arc<Mutex<Option<String>>>,
}
impl SiServer {
pub fn new(config: SiServerConfig) -> Self {
Self {
state: Arc::new(Mutex::new(ServerState::Stopped)),
cancel: CancellationToken::new(),
config,
engine: global_engine(),
capture_count: Arc::new(AtomicU64::new(0)),
vision_count: Arc::new(AtomicU64::new(0)),
last_error: Arc::new(Mutex::new(None)),
}
}
/// Get the current server status.
pub async fn status(&self) -> SiServerStatus {
SiServerStatus {
state: *self.state.lock().await,
capture_count: self.capture_count.load(Ordering::Relaxed),
vision_count: self.vision_count.load(Ordering::Relaxed),
last_error: self.last_error.lock().await.clone(),
}
}
/// Run the screen intelligence server. Blocks until stopped.
///
/// This is the main entry point for both embedded and standalone modes.
/// It starts a capture + vision session, then blocks in a monitoring
/// loop that logs status until the session ends or Ctrl+C is received.
pub async fn run(&self, app_config: &Config) -> Result<(), String> {
info!(
"{LOG_PREFIX} starting: ttl={}s vision={} fps={} keep_screenshots={}",
self.config.ttl_secs,
app_config.screen_intelligence.vision_enabled,
app_config.screen_intelligence.baseline_fps,
app_config.screen_intelligence.keep_screenshots,
);
// Apply config to the global engine, optionally overriding keep_screenshots.
let mut si_config = app_config.screen_intelligence.clone();
if self.config.keep_screenshots {
si_config.keep_screenshots = true;
}
if let Err(e) = self.engine.apply_config(si_config).await {
warn!("{LOG_PREFIX} apply_config failed: {e}");
}
*self.state.lock().await = ServerState::Idle;
// Start capture + vision session.
let params = StartSessionParams {
consent: true,
ttl_secs: Some(self.config.ttl_secs),
screen_monitoring: Some(true),
device_control: Some(false),
predictive_input: Some(false),
};
match self.engine.start_session(params).await {
Ok(session) => {
*self.state.lock().await = ServerState::Running;
info!(
"{LOG_PREFIX} session started: vision={} ttl={}s panic_hotkey={}",
session.vision_enabled, session.ttl_secs, session.panic_hotkey,
);
}
Err(e) => {
error!("{LOG_PREFIX} failed to start session: {e}");
*self.last_error.lock().await = Some(e.clone());
*self.state.lock().await = ServerState::Stopped;
return Err(e);
}
}
// Main monitoring loop — log status until session ends or cancelled.
let mut tick = tokio::time::interval(std::time::Duration::from_secs(
self.config.log_interval_secs,
));
let mut prev_capture_count: u64 = 0;
let mut prev_vision_persist: u64 = 0;
let mut prev_last_summary: Option<String> = None;
loop {
tokio::select! {
_ = tick.tick() => {}
_ = self.cancel.cancelled() => {
debug!("{LOG_PREFIX} cancellation received");
break;
}
}
let status = self.engine.status().await;
if !status.session.active {
info!(
"{LOG_PREFIX} session ended: {}",
status
.session
.stop_reason
.unwrap_or_else(|| "unknown".into()),
);
break;
}
// Track counts.
self.capture_count
.store(status.session.capture_count, Ordering::Relaxed);
self.vision_count
.store(status.session.vision_persist_count, Ordering::Relaxed);
// Log capture progress when new captures arrive.
if status.session.capture_count != prev_capture_count {
info!(
"{LOG_PREFIX} capture #{} — app={:?} window={:?}",
status.session.capture_count,
status.session.last_context.as_deref().unwrap_or("-"),
status.session.last_window_title.as_deref().unwrap_or("-"),
);
prev_capture_count = status.session.capture_count;
}
// Log new vision summaries.
if status.session.vision_persist_count != prev_vision_persist {
info!(
"{LOG_PREFIX} vision #{} persisted (key={:?})",
status.session.vision_persist_count,
status
.session
.last_vision_persisted_key
.as_deref()
.unwrap_or("-"),
);
prev_vision_persist = status.session.vision_persist_count;
}
// Print full vision output when a new summary arrives.
if status.session.last_vision_summary != prev_last_summary {
if status.session.last_vision_summary.is_some() {
// Fetch the latest full summary from the engine.
let recent = self.engine.vision_recent(Some(1)).await;
if let Some(s) = recent.summaries.first() {
let ts = chrono::DateTime::from_timestamp_millis(s.captured_at_ms)
.map(|dt| dt.format("%H:%M:%S").to_string())
.unwrap_or_else(|| "?".to_string());
eprintln!();
eprintln!(
" ┌─ #{}{}{} ──────────────────",
status.session.vision_persist_count,
s.app_name.as_deref().unwrap_or("?"),
ts,
);
// Print the synthesized summary (key_text)
for line in s.key_text.lines() {
eprintln!("{}", line);
}
eprintln!(" └────────────────────────────────────");
eprintln!();
}
}
prev_last_summary = status.session.last_vision_summary.clone();
}
// Log vision errors.
if let Some(ref err) = status.session.last_vision_persist_error {
warn!("{LOG_PREFIX} vision persist error: {err}");
}
// Periodic heartbeat at debug level.
debug!(
"{LOG_PREFIX} [heartbeat] captures={} vision_state={} queue={} persisted={} remaining={}s",
status.session.capture_count,
status.session.vision_state,
status.session.vision_queue_depth,
status.session.vision_persist_count,
status.session.remaining_ms.unwrap_or(0) / 1000,
);
}
// Cleanup.
let _ = self
.engine
.stop_session(Some("server_stopped".to_string()))
.await;
*self.state.lock().await = ServerState::Stopped;
info!(
"{LOG_PREFIX} stopped — total captures={} vision_summaries={}",
self.capture_count.load(Ordering::Relaxed),
self.vision_count.load(Ordering::Relaxed),
);
Ok(())
}
/// Stop the server.
pub async fn stop(&self) {
info!("{LOG_PREFIX} stopping screen intelligence server");
self.cancel.cancel();
}
}
// ── Global singleton ────────────────────────────────────────────────────
static SI_SERVER: once_cell::sync::OnceCell<Arc<SiServer>> = once_cell::sync::OnceCell::new();
/// Get or initialize the global server instance.
pub fn global_server(config: SiServerConfig) -> Arc<SiServer> {
SI_SERVER
.get_or_init(|| Arc::new(SiServer::new(config)))
.clone()
}
/// Get the global server if already initialized.
pub fn try_global_server() -> Option<Arc<SiServer>> {
SI_SERVER.get().cloned()
}
/// Start the embedded global screen intelligence server when config enables it.
///
/// Intended for core process startup. The server runs in the background
/// and reuses the process-global singleton so RPC status/stop calls
/// operate on the same instance.
pub async fn start_if_enabled(app_config: &Config) {
if !app_config.screen_intelligence.enabled {
info!("{LOG_PREFIX} screen intelligence disabled in config, skipping embedded server");
return;
}
let server_config = SiServerConfig {
ttl_secs: app_config.screen_intelligence.session_ttl_secs,
log_interval_secs: 10,
keep_screenshots: app_config.screen_intelligence.keep_screenshots,
};
if let Some(existing) = try_global_server() {
let status = existing.status().await;
if status.state != ServerState::Stopped {
info!(
"{LOG_PREFIX} embedded server already running: state={:?}",
status.state,
);
return;
}
}
info!("{LOG_PREFIX} auto-start enabled, launching embedded screen intelligence server");
let server = global_server(server_config);
let config_for_run = app_config.clone();
tokio::spawn(async move {
if let Err(e) = server.run(&config_for_run).await {
error!("{LOG_PREFIX} embedded server exited with error: {e}");
}
});
}
/// Run the screen intelligence server standalone (blocking). Intended for CLI usage.
///
/// Creates a fresh `SiServer` that is **not** registered in the global
/// singleton. This keeps CLI-started instances isolated from the core RPC
/// lifecycle.
pub async fn run_standalone(
app_config: Config,
server_config: SiServerConfig,
) -> Result<(), String> {
info!("{LOG_PREFIX} starting standalone screen intelligence server");
info!("{LOG_PREFIX} ttl: {}s", server_config.ttl_secs);
info!(
"{LOG_PREFIX} log_interval: {}s",
server_config.log_interval_secs
);
info!(
"{LOG_PREFIX} vision: {} (provider: {} model: {})",
app_config.screen_intelligence.vision_enabled,
app_config.local_ai.provider,
app_config.local_ai.vision_model_id,
);
let server = SiServer::new(server_config);
// Handle Ctrl+C gracefully.
let server_arc = Arc::new(server);
let server_for_signal = server_arc.clone();
tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await {
info!("{LOG_PREFIX} Ctrl+C received, shutting down");
server_for_signal.stop().await;
}
});
server_arc.run(&app_config).await
}
#[cfg(test)]
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() > max {
format!("{}", s.chars().take(max).collect::<String>())
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_server_config() {
let cfg = SiServerConfig::default();
assert_eq!(cfg.ttl_secs, 300);
assert_eq!(cfg.log_interval_secs, 5);
}
#[test]
fn server_state_serializes() {
let json = serde_json::to_string(&ServerState::Running).unwrap();
assert_eq!(json, "\"running\"");
}
#[tokio::test]
async fn server_status_initial() {
let server = SiServer::new(SiServerConfig::default());
let status = server.status().await;
assert_eq!(status.state, ServerState::Stopped);
assert_eq!(status.capture_count, 0);
assert_eq!(status.vision_count, 0);
assert!(status.last_error.is_none());
}
#[test]
fn truncate_short() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_long() {
let result = truncate("hello world this is a long string", 10);
assert!(result.ends_with('…'));
}
}
@@ -0,0 +1,81 @@
//! Engine state types and global singleton.
use crate::openhuman::accessibility::{AppContext, PermissionState, PermissionStatus};
use crate::openhuman::config::ScreenIntelligenceConfig;
use once_cell::sync::Lazy;
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use super::types::{AccessibilityFeatures, CaptureFrame, VisionSummary};
pub(crate) struct SessionRuntime {
pub(crate) started_at_ms: i64,
pub(crate) expires_at_ms: i64,
pub(crate) ttl_secs: u64,
pub(crate) panic_hotkey: String,
pub(crate) stop_reason: Option<String>,
pub(crate) last_capture_at_ms: Option<i64>,
pub(crate) capture_count: u64,
pub(crate) frames: VecDeque<CaptureFrame>,
pub(crate) last_context: Option<AppContext>,
pub(crate) task: Option<JoinHandle<()>>,
pub(crate) vision_enabled: bool,
pub(crate) vision_state: String,
pub(crate) vision_queue_depth: usize,
pub(crate) last_vision_at_ms: Option<i64>,
pub(crate) last_vision_summary: Option<String>,
pub(crate) vision_persist_count: u64,
pub(crate) last_vision_persisted_key: Option<String>,
pub(crate) last_vision_persist_error: Option<String>,
pub(crate) vision_summaries: VecDeque<VisionSummary>,
pub(crate) vision_task: Option<JoinHandle<()>>,
pub(crate) vision_tx: Option<tokio::sync::mpsc::UnboundedSender<CaptureFrame>>,
}
pub(crate) struct EngineState {
pub(crate) config: ScreenIntelligenceConfig,
pub(crate) permissions: PermissionStatus,
pub(crate) features: AccessibilityFeatures,
pub(crate) session: Option<SessionRuntime>,
pub(crate) last_error: Option<String>,
pub(crate) last_event: Option<String>,
pub(crate) autocomplete_context: String,
}
impl EngineState {
pub(crate) fn new(config: ScreenIntelligenceConfig) -> Self {
Self {
permissions: PermissionStatus {
screen_recording: PermissionState::Unknown,
accessibility: PermissionState::Unknown,
input_monitoring: PermissionState::Unknown,
},
features: AccessibilityFeatures {
screen_monitoring: true,
device_control: true,
predictive_input: config.autocomplete_enabled,
},
config,
session: None,
last_error: None,
last_event: None,
autocomplete_context: String::new(),
}
}
}
pub struct AccessibilityEngine {
pub(crate) inner: Mutex<EngineState>,
}
static ACCESSIBILITY_ENGINE: Lazy<Arc<AccessibilityEngine>> = Lazy::new(|| {
Arc::new(AccessibilityEngine {
inner: Mutex::new(EngineState::new(ScreenIntelligenceConfig::default())),
})
});
pub fn global_engine() -> Arc<AccessibilityEngine> {
ACCESSIBILITY_ENGINE.clone()
}
+23 -8
View File
@@ -8,10 +8,10 @@ use image::codecs::png::PngEncoder;
use image::{ImageBuffer, Rgb, RgbImage};
use tempfile::tempdir;
use super::engine::{AccessibilityEngine, EngineState};
use super::helpers::{
generate_suggestions, parse_vision_summary_output, truncate_tail, validate_input_action,
};
use super::state::{AccessibilityEngine, EngineState};
use super::types::{CaptureFrame, InputActionParams, StartSessionParams};
use crate::openhuman::accessibility::{parse_foreground_output, AppContext};
use crate::openhuman::config::{Config, ScreenIntelligenceConfig};
@@ -202,11 +202,11 @@ fn parse_vision_valid_json() {
#[test]
fn parse_vision_malformed_json_falls_back() {
// Plain text mode: first line = ui_state
let raw = "this is not json at all";
let summary = parse_vision_summary_output(test_frame(), raw);
assert_eq!(summary.ui_state, "UI state unavailable");
assert!(summary.actionable_notes.contains("this is not json at all"));
assert!((summary.confidence - 0.66).abs() < 0.01);
assert_eq!(summary.ui_state, "this is not json at all");
assert!((summary.confidence - 0.8).abs() < 0.01);
}
#[test]
@@ -215,7 +215,8 @@ fn parse_vision_missing_fields() {
let summary = parse_vision_summary_output(test_frame(), raw);
assert_eq!(summary.ui_state, "active");
assert_eq!(summary.key_text, "");
assert!((summary.confidence - 0.66).abs() < 0.01);
// Default confidence is now 0.8 (consistent across JSON and plain-text branches).
assert!((summary.confidence - 0.8).abs() < 0.01);
}
#[test]
@@ -231,11 +232,11 @@ fn parse_vision_confidence_clamping() {
#[test]
fn parse_vision_empty_strings_use_fallback() {
// JSON with empty strings — JSON path still works, empty fields stay empty
let raw = r#"{"ui_state": "", "actionable_notes": ""}"#;
let summary = parse_vision_summary_output(test_frame(), raw);
assert_eq!(summary.ui_state, "UI state unavailable");
// actionable_notes falls back to truncated raw when empty
assert!(!summary.actionable_notes.is_empty());
assert_eq!(summary.ui_state, "");
assert_eq!(summary.actionable_notes, "");
}
// ── should_capture_context / rule_matches_context ───────────────────────
@@ -253,6 +254,7 @@ fn denylist_blocks_matching_context() {
app_name: Some("1Password 8".to_string()),
window_title: Some("Vault".to_string()),
bounds: None,
window_id: None,
};
assert!(
!engine.should_capture_context(&ctx, &config),
@@ -273,6 +275,7 @@ fn denylist_allows_non_matching_context() {
app_name: Some("Safari".to_string()),
window_title: Some("GitHub".to_string()),
bounds: None,
window_id: None,
};
assert!(engine.should_capture_context(&ctx, &config));
}
@@ -292,6 +295,7 @@ fn whitelist_only_mode_blocks_unlisted() {
app_name: Some("Safari".to_string()),
window_title: Some("Web".to_string()),
bounds: None,
window_id: None,
};
assert!(
!engine.should_capture_context(&ctx, &config),
@@ -302,6 +306,7 @@ fn whitelist_only_mode_blocks_unlisted() {
app_name: Some("Visual Studio Code".to_string()),
window_title: Some("main.rs".to_string()),
bounds: None,
window_id: None,
};
assert!(engine.should_capture_context(&ctx_allowed, &config));
}
@@ -319,6 +324,7 @@ fn denylist_matching_is_case_insensitive() {
app_name: Some("KEYCHAIN Access".to_string()),
window_title: None,
bounds: None,
window_id: None,
};
assert!(!engine.should_capture_context(&ctx, &config));
}
@@ -586,6 +592,15 @@ async fn capture_scheduler_adds_baseline_frames() {
time::sleep(Duration::from_millis(700)).await;
let status = engine.status().await;
// The capture worker requires a valid window_id (CGWindowID) to capture.
// In some environments (CI, headless, or when the foreground app doesn't
// expose a Quartz window) no frames will be captured — skip gracefully.
if status.session.frames_in_memory == 0 {
let _ = engine
.stop_session(Some("test_skip_no_window_id".to_string()))
.await;
return;
}
assert!(status.session.frames_in_memory >= 1);
let _ = engine.stop_session(Some("test_end".to_string())).await;
+129
View File
@@ -0,0 +1,129 @@
//! Vision query methods — recent summaries, flush, and analyze-and-persist.
use super::helpers::push_ephemeral_vision_summary;
use super::state::AccessibilityEngine;
use super::types::{CaptureFrame, VisionFlushResult, VisionRecentResult, VisionSummary};
impl AccessibilityEngine {
pub async fn vision_recent(&self, limit: Option<usize>) -> VisionRecentResult {
let state = self.inner.lock().await;
let max_items = limit.unwrap_or(10).clamp(1, 120);
let summaries = state
.session
.as_ref()
.map(|session| {
session
.vision_summaries
.iter()
.rev()
.take(max_items)
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
VisionRecentResult { summaries }
}
pub async fn vision_flush(&self) -> Result<VisionFlushResult, String> {
let candidate = {
let mut state = self.inner.lock().await;
let Some(session) = state.session.as_mut() else {
return Ok(VisionFlushResult {
accepted: false,
summary: None,
});
};
let latest = session
.frames
.iter()
.rev()
.find(|f| f.image_ref.is_some())
.cloned();
if let Some(frame) = latest.clone() {
session.vision_state = "queued".to_string();
session.vision_queue_depth = session.vision_queue_depth.saturating_add(1);
Some(frame)
} else {
None
}
};
let Some(frame) = candidate else {
return Ok(VisionFlushResult {
accepted: false,
summary: None,
});
};
let summary = match super::processing_worker::analyze_frame(self, frame).await {
Ok(summary) => summary,
Err(err) => {
let mut state = self.inner.lock().await;
if let Some(session) = state.session.as_mut() {
session.vision_queue_depth = session.vision_queue_depth.saturating_sub(1);
session.vision_state = "error".to_string();
}
state.last_error = Some(format!("vision_flush_analysis_failed: {err}"));
return Err(format!("vision flush failed: {err}"));
}
};
let persist = super::helpers::persist_vision_summary(summary.clone())
.await
.map_err(|err| format!("vision summary persistence failed: {err}"));
{
let mut state = self.inner.lock().await;
if let Some(session) = state.session.as_mut() {
session.vision_queue_depth = session.vision_queue_depth.saturating_sub(1);
push_ephemeral_vision_summary(&mut session.vision_summaries, summary.clone());
session.last_vision_at_ms = Some(summary.captured_at_ms);
session.last_vision_summary = Some(summary.key_text.clone());
match &persist {
Ok(result) => {
session.vision_state = "ready".to_string();
session.vision_persist_count =
session.vision_persist_count.saturating_add(1);
session.last_vision_persisted_key = Some(result.key.clone());
session.last_vision_persist_error = None;
}
Err(err) => {
session.vision_state = "error".to_string();
session.last_vision_persist_error = Some(err.clone());
state.last_error = Some(format!("vision_flush_persist_failed: {err}"));
}
}
}
}
if let Err(err) = persist {
return Err(format!("vision flush failed: {err}"));
}
Ok(VisionFlushResult {
accepted: true,
summary: Some(summary),
})
}
/// Deterministic pipeline hook used by tests and diagnostics:
/// analyze one frame with the local vision model and persist the summary to memory.
pub async fn analyze_and_persist_frame(
&self,
frame: CaptureFrame,
) -> Result<VisionSummary, String> {
let summary = super::processing_worker::analyze_frame(self, frame).await?;
let persisted = super::helpers::persist_vision_summary(summary.clone())
.await
.map_err(|err| format!("vision summary persistence failed: {err}"))?;
tracing::debug!(
"[screen_intelligence] analyze_and_persist_frame completed (namespace={} key={})",
persisted.namespace,
persisted.key
);
Ok(summary)
}
}