refactor(core): move CLI adapters out of src/core/ into per-domain cli modules (#758)

* refactor(voice): move standalone CLI adapter into voice domain

Move the blocking `openhuman voice` / `openhuman dictate` dictation-server
subcommand out of `src/core/cli.rs` and into a new `src/openhuman/voice/cli.rs`
owned by the voice domain. The core CLI dispatcher now routes to
`voice::cli::run_standalone_subcommand` and no longer imports voice internals.

Why this shape: the standalone server blocks forever on the hotkey listener,
which doesn't fit the controller registry's request/response contract. A
domain-owned CLI adapter is the right home for long-lived operational
commands; the controller registry stays for RPC-style capabilities.

Establishes the exposure rule documented in PLAN.md for the rest of the
backend overhaul.

- src/openhuman/voice/cli.rs: new 118-line adapter (flag parsing, tokio
  runtime, run_standalone call)
- src/openhuman/voice/mod.rs: declare `pub mod cli`
- src/core/cli.rs: delete 92-line run_voice_server_command, dispatch only
- PLAN.md: new scope doc + exposure rule

* refactor(text_input): move CLI adapter into domain

Move src/core/text_input_cli.rs to src/openhuman/text_input/cli.rs and
drop the stale `pub mod text_input_cli` from src/core/mod.rs. Core CLI
dispatcher now routes `text-input` to the domain-owned adapter.

The moved file has a mixed shape that validates the exposure rule from
the previous voice commit:
 - `run` starts a long-lived HTTP JSON-RPC server (domain cli.rs shape)
 - `read / insert / ghost / dismiss` are short-lived UX wrappers around
   `text_input::rpc::*` (pretty-print, flag parsing — CLI affordance, not
   transport duplication)

Both shapes legitimately belong in the domain. The controller registry
stays for transport-agnostic capabilities; CLI UX wrappers live next to
their domain RPC.

- git mv src/core/text_input_cli.rs src/openhuman/text_input/cli.rs
- src/openhuman/text_input/mod.rs: declare `pub mod cli`
- src/core/mod.rs: drop `pub mod text_input_cli`
- src/core/cli.rs: update dispatch path

* refactor(tree_summarizer): move CLI adapter into domain

git mv src/core/tree_summarizer_cli.rs src/openhuman/tree_summarizer/cli.rs
and drop the stale `pub mod tree_summarizer_cli` from src/core/mod.rs.
Core CLI dispatcher now routes `tree-summarizer` to the domain-owned
adapter.

All five subcommands (ingest / run / query / status / rebuild) are short
-lived UX wrappers over registry handlers that already exist in
tree_summarizer/schemas.rs — no long-running server, no business logic
duplicated. Straight relocation keeps transport generic.

- git mv src/core/tree_summarizer_cli.rs src/openhuman/tree_summarizer/cli.rs
- src/openhuman/tree_summarizer/mod.rs: declare `pub mod cli`
- src/core/mod.rs: drop `pub mod tree_summarizer_cli`
- src/core/cli.rs: update dispatch path

* refactor(screen_intelligence): move + split CLI adapter into domain

Move src/core/screen_intelligence_cli.rs (699 lines, 7 subcommands) into
a domain-owned cli/ submodule, split by responsibility so no single file
exceeds the project's 500-line soft limit.

Layout:
  screen_intelligence/cli/mod.rs        (204) dispatch + shared helpers
                                              (CliOpts, parse_opts,
                                              bootstrap_engine, logging,
                                              print_help)
  screen_intelligence/cli/server.rs      (79) run_server (long-running
                                              capture+vision loop)
  screen_intelligence/cli/session.rs    (150) status / start / stop
  screen_intelligence/cli/capture.rs    (173) capture / vision (inspect pair)
  screen_intelligence/cli/doctor.rs     (107) readiness diagnostics

Classification before moving:
 - no business-logic duplication vs schemas.rs handlers
 - heavy fns (doctor 103, capture 97, start 89) were 80%+ pretty-printing
   and CLI-side orchestration (polling loop, flag-driven save) — legit
   CLI UX, not extracted domain logic
 - shared helpers stayed pub(super) inside mod.rs; no new domain APIs
   introduced

Core transport changes:
 - delete src/core/screen_intelligence_cli.rs
 - drop `pub mod screen_intelligence_cli` from src/core/mod.rs
 - src/core/cli.rs dispatch routes to domain cli::

After this commit, src/core/cli.rs contains only dispatch lines for all
four migrated domains (voice, text_input, tree_summarizer,
screen_intelligence) — no embedded domain logic.

* refactor(screen_intelligence): address external review — extract capture save + tighten cli visibility

Follow-ups from codex + gemini review of the branch:

 1. Extract CaptureFrame construction + disk save from the CLI.
    `capture.rs --keep` was building a CaptureFrame inline (stamping
    timestamps, copying context fields) and calling
    `AccessibilityEngine::save_screenshot_to_disk` directly. Added
    `AccessibilityEngine::save_capture_test_result(workspace_dir,
    &CaptureTestResult, reason) -> Option<Result<PathBuf, String>>`
    so the CLI only passes the result through and picks a reason
    label. Domain owns the frame shape, not the CLI.

 2. Tighten CLI module visibility across all four migrated domains.
    `pub mod cli` -> `pub(crate) mod cli` and
    `pub fn run_*_command` -> `pub(crate) fn` for voice, text_input,
    tree_summarizer, screen_intelligence. Only src/core/cli.rs needs
    to see these; they're transport plumbing, not domain public API.

No behavior change.

* style: cargo fmt

* address review comments from coderabbitai

- screen_intelligence/cli/server: use effective keep_screenshots (opts.keep
  || config flag) for both SiServerConfig and status output, so the server
  behaves consistently with what the CLI reports.
- voice/cli: surface config load errors with a warning instead of silently
  falling back to Config::default(); reject unknown --mode values instead
  of silently coercing them to Push.
- PLAN.md: narrow the Phase 4 enforcement grep so it forbids domain
  imports/non-dispatcher references in the transport layer while still
  allowing crate::openhuman::<domain>::cli::run_*(...) dispatch calls,
  which this PR legitimately introduces.

* style: cargo fmt voice cli

* style: cargo fmt + bump tauri-cef vendor

* fix(screen_intelligence): narrow save_capture_test_result to pub(crate) (addresses @coderabbitai nitpick on engine.rs:486)

AccessibilityEngine::save_capture_test_result is only called from the
domain's own cli/capture.rs (crate-internal). Making it pub leaks it
into the public API surface unnecessarily — tighten to pub(crate) to
match the visibility-tightening done across the rest of this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* update

* chore: update .gitignore to include scheduled_tasks.lock in app/.claude

* ran formatter

---------

Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jwalin Shah
2026-04-22 14:30:31 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Jwalin Shah Steven Enamakel
parent 8212438112
commit 325f019853
26 changed files with 1008 additions and 851 deletions
+4 -97
View File
@@ -61,12 +61,12 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
"call" => run_call_command(&args[1..]),
// Domain-specific CLI adapters that don't follow the generic namespace pattern.
"screen-intelligence" => {
crate::core::screen_intelligence_cli::run_screen_intelligence_command(&args[1..])
crate::openhuman::screen_intelligence::cli::run_screen_intelligence_command(&args[1..])
}
"voice" | "dictate" => run_voice_server_command(&args[1..]),
"text-input" => crate::core::text_input_cli::run_text_input_command(&args[1..]),
"voice" | "dictate" => crate::openhuman::voice::cli::run_standalone_subcommand(&args[1..]),
"text-input" => crate::openhuman::text_input::cli::run_text_input_command(&args[1..]),
"tree-summarizer" => {
crate::core::tree_summarizer_cli::run_tree_summarizer_command(&args[1..])
crate::openhuman::tree_summarizer::cli::run_tree_summarizer_command(&args[1..])
}
"memory" => crate::core::memory_cli::run_memory_command(&args[1..]),
"agent" => {
@@ -334,99 +334,6 @@ fn run_call_command(args: &[String]) -> Result<()> {
Ok(())
}
/// Handles the `voice` subcommand to run the standalone voice dictation server.
///
/// Listens for a hotkey, records audio, transcribes via whisper, and inserts
/// the result into the active text field.
fn run_voice_server_command(args: &[String]) -> Result<()> {
use crate::openhuman::voice::hotkey::ActivationMode;
use crate::openhuman::voice::server::{run_standalone, VoiceServerConfig};
let mut hotkey: Option<String> = None;
let mut mode: Option<String> = None;
let mut skip_cleanup = false;
let mut verbose = false;
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--hotkey" => {
hotkey = Some(
args.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value for --hotkey"))?
.clone(),
);
i += 2;
}
"--mode" => {
mode = Some(
args.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value for --mode"))?
.clone(),
);
i += 2;
}
"--skip-cleanup" => {
skip_cleanup = true;
i += 1;
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
println!("Usage: openhuman voice [--hotkey <combo>] [--mode <tap|push>] [--skip-cleanup] [-v]");
println!();
println!(" --hotkey <combo> Key combination (default: fn)");
println!(
" --mode <tap|push> Activation: tap to toggle, push to hold (default: push)"
);
println!(" --skip-cleanup Skip LLM post-processing on transcriptions");
println!(" -v, --verbose Enable debug logging");
println!();
println!("Standalone voice dictation server. Press the hotkey to dictate,");
println!("transcribed text is inserted into the active text field.");
return Ok(());
}
other => return Err(anyhow::anyhow!("unknown voice arg: {other}")),
}
}
crate::core::logging::init_for_cli_run(verbose, CliLogDefault::Global);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let mut config = crate::openhuman::config::Config::load_or_init()
.await
.unwrap_or_default();
config.apply_env_overrides();
let activation_mode = match mode.as_deref() {
Some("tap") => ActivationMode::Tap,
_ => ActivationMode::Push,
};
let server_config = VoiceServerConfig {
hotkey: hotkey.unwrap_or_else(|| config.voice_server.hotkey.clone()),
activation_mode,
skip_cleanup,
context: None,
min_duration_secs: config.voice_server.min_duration_secs,
silence_threshold: config.voice_server.silence_threshold,
custom_dictionary: config.voice_server.custom_dictionary.clone(),
};
run_standalone(config, server_config)
.await
.map_err(anyhow::Error::msg)
})?;
Ok(())
}
/// Dispatches commands that fall under a specific namespace (e.g., `openhuman <namespace> <function>`).
///
/// It looks up the function schema for validation and executes the request.
-3
View File
@@ -16,11 +16,8 @@ pub mod jsonrpc;
pub mod logging;
pub mod memory_cli;
pub mod rpc_log;
pub mod screen_intelligence_cli;
pub mod shutdown;
pub mod socketio;
pub mod text_input_cli;
pub mod tree_summarizer_cli;
pub mod types;
/// Canonical function contract for domain controllers.
-699
View File
@@ -1,699 +0,0 @@
//! `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 [--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 [-v]
//! openhuman screen-intelligence doctor [-v]
//! openhuman screen-intelligence vision [--limit <n>] [-v]
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..]),
"doctor" => run_doctor(&args[1..]),
"vision" => run_vision(&args[1..]),
other => Err(anyhow::anyhow!(
"unknown screen-intelligence subcommand '{other}'. Run `openhuman screen-intelligence --help`."
)),
}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
struct CliOpts {
verbose: bool,
ttl_secs: u64,
keep: bool,
limit: usize,
no_vision_model: bool,
}
fn parse_opts(args: &[String]) -> Result<(CliOpts, Vec<String>)> {
let mut verbose = false;
let mut ttl_secs: u64 = 300;
let mut keep = false;
let mut limit: usize = 10;
let mut no_vision_model = false;
let mut rest = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--no-vision-model" | "--ocr-only" => {
no_vision_model = true;
i += 1;
}
"--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;
}
"--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;
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
rest.push(args[i].clone());
i += 1;
}
_ => {
rest.push(args[i].clone());
i += 1;
}
}
}
Ok((
CliOpts {
verbose,
ttl_secs,
keep,
limit,
no_vision_model,
},
rest,
))
}
/// Bootstrap the screen intelligence engine with config.
async fn bootstrap_engine(
verbose: bool,
) -> Result<Arc<crate::openhuman::screen_intelligence::AccessibilityEngine>> {
bootstrap_engine_with_opts(verbose, false).await
}
/// Bootstrap with CLI overrides.
async fn bootstrap_engine_with_opts(
verbose: bool,
no_vision_model: bool,
) -> Result<Arc<crate::openhuman::screen_intelligence::AccessibilityEngine>> {
use crate::openhuman::config::Config;
use crate::openhuman::screen_intelligence::global_engine;
let mut config = Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("config load failed: {e}"))?;
if no_vision_model {
config.screen_intelligence.use_vision_model = false;
}
let engine = global_engine();
let _ = engine
.apply_config(config.screen_intelligence.clone())
.await;
if verbose {
log::info!(
"[screen-intelligence-cli] engine initialized, enabled={}, vision={}, use_vision_model={}, keep_screenshots={}, workspace={}",
config.screen_intelligence.enabled,
config.screen_intelligence.vision_enabled,
config.screen_intelligence.use_vision_model,
config.screen_intelligence.keep_screenshots,
config.workspace_dir.display(),
);
}
Ok(engine)
}
// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------
/// `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 [--ttl <secs>] [--keep] [--no-vision-model] [-v]");
println!();
println!("Start the screen intelligence capture + vision loop.");
println!("Captures screenshots at baseline FPS, runs OCR and vision analysis,");
println!("and logs summaries. Blocks until TTL expires or Ctrl+C.");
println!();
println!(" --ttl <secs> Session duration (default: 300)");
println!(" --keep Keep screenshots on disk after vision processing");
println!(" --no-vision-model Skip the vision LLM — use OCR + text LLM only");
println!(" --ocr-only Alias for --no-vision-model");
println!(" -v, --verbose Enable debug logging");
return Ok(());
}
crate::core::logging::init_for_cli_run(
opts.verbose,
crate::core::logging::CliLogDefault::Global,
);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let mut config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("config load failed: {e}"))?;
if opts.no_vision_model {
config.screen_intelligence.use_vision_model = false;
}
let server_config = crate::openhuman::screen_intelligence::server::SiServerConfig {
ttl_secs: opts.ttl_secs,
log_interval_secs: 5,
keep_screenshots: opts.keep,
};
let mode_label = if config.screen_intelligence.use_vision_model {
format!("vision LLM ({})", config.local_ai.vision_model_id)
} else {
"OCR + text LLM (no vision model)".to_string()
};
eprintln!();
eprintln!(" Screen Intelligence");
eprintln!(" ───────────────────");
eprintln!(" TTL: {}s", opts.ttl_secs);
eprintln!(" Mode: {}", mode_label);
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!();
crate::openhuman::screen_intelligence::server::run_standalone(config, server_config)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
})
}
/// `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>] [--no-vision-model] [-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!(" --no-vision-model Skip the vision LLM — use OCR + text LLM only");
println!(" --ocr-only Alias for --no-vision-model");
println!(" -v, --verbose Enable debug logging");
return Ok(());
}
let (opts, _) = parse_opts(args)?;
crate::core::logging::init_for_cli_run(
opts.verbose,
crate::core::logging::CliLogDefault::Global,
);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let engine = bootstrap_engine_with_opts(opts.verbose, opts.no_vision_model).await?;
let params = crate::openhuman::screen_intelligence::StartSessionParams {
consent: true,
ttl_secs: Some(opts.ttl_secs),
screen_monitoring: Some(true),
};
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.chars().count() > 100 {
format!("{}", summary.chars().take(100).collect::<String>())
} 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(())
})
}
/// `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["accessibility_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 automation", 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!(" use_vision_model: {}", si.use_vision_model);
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,
"use_vision_model": c.screen_intelligence.use_vision_model,
"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(())
})
}
/// `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");
}
crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global);
}
fn is_help(value: &str) -> bool {
matches!(value, "-h" | "--help" | "help")
}
fn print_help() {
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 [--ttl <secs>] [--no-vision-model] [-v]");
println!(" openhuman screen-intelligence status [-v]");
println!(" openhuman screen-intelligence capture [--keep] [-v]");
println!(" openhuman screen-intelligence start [--ttl <secs>] [--no-vision-model] [-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 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!(" --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!(" --no-vision-model Skip vision LLM — use OCR + text LLM only");
println!(" --ocr-only Alias for --no-vision-model");
println!(" -v, --verbose Enable debug logging");
}
@@ -0,0 +1,155 @@
//! Capture + vision inspection subcommands: `capture`, `vision`.
use anyhow::Result;
use super::{bootstrap_engine, init_quiet_logging, is_help, parse_opts};
/// `openhuman screen-intelligence capture` — take a single screenshot and print info.
pub(super) 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 {
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("config load failed: {e}"))?;
match crate::openhuman::screen_intelligence::AccessibilityEngine::save_capture_test_result(
&config.workspace_dir,
&result,
"cli_capture",
) {
Some(Ok(path)) => eprintln!(" Saved: {}", path.display()),
Some(Err(e)) => eprintln!(" Save failed: {e}"),
None => {}
}
}
} 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 vision` — inspect recent vision summaries.
pub(super) 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(())
})
}
@@ -0,0 +1,107 @@
//! `openhuman screen-intelligence doctor` — diagnostic readiness check.
use anyhow::Result;
use super::{bootstrap_engine, init_quiet_logging, is_help, parse_opts};
pub(super) 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["accessibility_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 automation", 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!(" use_vision_model: {}", si.use_vision_model);
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,
"use_vision_model": c.screen_intelligence.use_vision_model,
"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(())
})
}
@@ -0,0 +1,204 @@
//! `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 [--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 [-v]
//! openhuman screen-intelligence doctor [-v]
//! openhuman screen-intelligence vision [--limit <n>] [-v]
use anyhow::Result;
use std::sync::Arc;
mod capture;
mod doctor;
mod server;
mod session;
/// Entry point for `openhuman screen-intelligence <subcommand>`.
pub(crate) 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" => server::run_server(&args[1..]),
"status" => session::run_status(&args[1..]),
"capture" => capture::run_capture(&args[1..]),
"start" => session::run_start_session(&args[1..]),
"stop" => session::run_stop_session(&args[1..]),
"doctor" => doctor::run_doctor(&args[1..]),
"vision" => capture::run_vision(&args[1..]),
other => Err(anyhow::anyhow!(
"unknown screen-intelligence subcommand '{other}'. Run `openhuman screen-intelligence --help`."
)),
}
}
// ---------------------------------------------------------------------------
// Shared helpers (visible to sibling subcommand modules)
// ---------------------------------------------------------------------------
pub(super) struct CliOpts {
pub verbose: bool,
pub ttl_secs: u64,
pub keep: bool,
pub limit: usize,
pub no_vision_model: bool,
}
pub(super) fn parse_opts(args: &[String]) -> Result<(CliOpts, Vec<String>)> {
let mut verbose = false;
let mut ttl_secs: u64 = 300;
let mut keep = false;
let mut limit: usize = 10;
let mut no_vision_model = false;
let mut rest = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--no-vision-model" | "--ocr-only" => {
no_vision_model = true;
i += 1;
}
"--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;
}
"--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;
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
rest.push(args[i].clone());
i += 1;
}
_ => {
rest.push(args[i].clone());
i += 1;
}
}
}
Ok((
CliOpts {
verbose,
ttl_secs,
keep,
limit,
no_vision_model,
},
rest,
))
}
/// Bootstrap the screen intelligence engine with config.
pub(super) async fn bootstrap_engine(
verbose: bool,
) -> Result<Arc<crate::openhuman::screen_intelligence::AccessibilityEngine>> {
bootstrap_engine_with_opts(verbose, false).await
}
/// Bootstrap with CLI overrides.
pub(super) async fn bootstrap_engine_with_opts(
verbose: bool,
no_vision_model: bool,
) -> Result<Arc<crate::openhuman::screen_intelligence::AccessibilityEngine>> {
use crate::openhuman::config::Config;
use crate::openhuman::screen_intelligence::global_engine;
let mut config = Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("config load failed: {e}"))?;
if no_vision_model {
config.screen_intelligence.use_vision_model = false;
}
let engine = global_engine();
let _ = engine
.apply_config(config.screen_intelligence.clone())
.await;
if verbose {
log::info!(
"[screen-intelligence-cli] engine initialized, enabled={}, vision={}, use_vision_model={}, keep_screenshots={}, workspace={}",
config.screen_intelligence.enabled,
config.screen_intelligence.vision_enabled,
config.screen_intelligence.use_vision_model,
config.screen_intelligence.keep_screenshots,
config.workspace_dir.display(),
);
}
Ok(engine)
}
/// Quiet logging: only `warn` unless verbose (used for non-server subcommands).
pub(super) 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, crate::core::logging::CliLogDefault::Global);
}
pub(super) fn is_help(value: &str) -> bool {
matches!(value, "-h" | "--help" | "help")
}
fn print_help() {
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 [--ttl <secs>] [--no-vision-model] [-v]");
println!(" openhuman screen-intelligence status [-v]");
println!(" openhuman screen-intelligence capture [--keep] [-v]");
println!(" openhuman screen-intelligence start [--ttl <secs>] [--no-vision-model] [-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 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!(" --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!(" --no-vision-model Skip vision LLM — use OCR + text LLM only");
println!(" --ocr-only Alias for --no-vision-model");
println!(" -v, --verbose Enable debug logging");
}
@@ -0,0 +1,78 @@
//! `openhuman screen-intelligence run` — start the standalone capture + vision loop.
use anyhow::Result;
use super::{is_help, parse_opts};
/// 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.
pub(super) 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 [--ttl <secs>] [--keep] [--no-vision-model] [-v]");
println!();
println!("Start the screen intelligence capture + vision loop.");
println!("Captures screenshots at baseline FPS, runs OCR and vision analysis,");
println!("and logs summaries. Blocks until TTL expires or Ctrl+C.");
println!();
println!(" --ttl <secs> Session duration (default: 300)");
println!(" --keep Keep screenshots on disk after vision processing");
println!(" --no-vision-model Skip the vision LLM — use OCR + text LLM only");
println!(" --ocr-only Alias for --no-vision-model");
println!(" -v, --verbose Enable debug logging");
return Ok(());
}
crate::core::logging::init_for_cli_run(
opts.verbose,
crate::core::logging::CliLogDefault::Global,
);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let mut config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("config load failed: {e}"))?;
if opts.no_vision_model {
config.screen_intelligence.use_vision_model = false;
}
let keep_screenshots = opts.keep || config.screen_intelligence.keep_screenshots;
let server_config = crate::openhuman::screen_intelligence::server::SiServerConfig {
ttl_secs: opts.ttl_secs,
log_interval_secs: 5,
keep_screenshots,
};
let mode_label = if config.screen_intelligence.use_vision_model {
format!("vision LLM ({})", config.local_ai.vision_model_id)
} else {
"OCR + text LLM (no vision model)".to_string()
};
eprintln!();
eprintln!(" Screen Intelligence");
eprintln!(" ───────────────────");
eprintln!(" TTL: {}s", opts.ttl_secs);
eprintln!(" Mode: {}", mode_label);
eprintln!(
" FPS: {}",
config.screen_intelligence.baseline_fps
);
eprintln!(" Keep screenshots: {}", keep_screenshots);
eprintln!();
eprintln!(" Capturing → Vision → Log. Press Ctrl+C to stop.");
eprintln!();
crate::openhuman::screen_intelligence::server::run_standalone(config, server_config)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
})
}
@@ -0,0 +1,152 @@
//! Session lifecycle subcommands: `start`, `stop`, `status`.
use anyhow::Result;
use super::{
bootstrap_engine, bootstrap_engine_with_opts, init_quiet_logging, is_help, parse_opts,
};
/// `openhuman screen-intelligence status` — print current engine status as JSON.
pub(super) 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 start` — start a capture + vision session.
pub(super) fn run_start_session(args: &[String]) -> Result<()> {
if args.iter().any(|a| is_help(a)) {
println!(
"Usage: openhuman screen-intelligence start [--ttl <secs>] [--no-vision-model] [-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!(" --no-vision-model Skip the vision LLM — use OCR + text LLM only");
println!(" --ocr-only Alias for --no-vision-model");
println!(" -v, --verbose Enable debug logging");
return Ok(());
}
let (opts, _) = parse_opts(args)?;
crate::core::logging::init_for_cli_run(
opts.verbose,
crate::core::logging::CliLogDefault::Global,
);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let engine = bootstrap_engine_with_opts(opts.verbose, opts.no_vision_model).await?;
let params = crate::openhuman::screen_intelligence::StartSessionParams {
consent: true,
ttl_secs: Some(opts.ttl_secs),
screen_monitoring: Some(true),
};
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.chars().count() > 100 {
format!("{}", summary.chars().take(100).collect::<String>())
} 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.
pub(super) 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(())
})
}
@@ -478,6 +478,27 @@ impl AccessibilityEngine {
}
}
/// Save the image payload from a [`CaptureTestResult`] to disk, reconstructing
/// the minimum [`CaptureFrame`] needed by [`save_screenshot_to_disk`].
///
/// Returns `None` when the result carries no `image_ref` (nothing to save).
/// Callers supply a `reason` string to label the frame (e.g. `"cli_capture"`).
pub(crate) fn save_capture_test_result(
workspace_dir: &std::path::Path,
result: &CaptureTestResult,
reason: &str,
) -> Option<Result<PathBuf, String>> {
let image_ref = result.image_ref.as_ref()?.clone();
let frame = CaptureFrame {
captured_at_ms: chrono::Utc::now().timestamp_millis(),
reason: reason.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),
};
Some(Self::save_screenshot_to_disk(workspace_dir, &frame))
}
/// Save a screenshot PNG to `{workspace_dir}/screenshots/{timestamp}_{app}.png`.
pub fn save_screenshot_to_disk(
workspace_dir: &std::path::Path,
+1
View File
@@ -1,5 +1,6 @@
//! Screen capture, accessibility automation, and vision summaries (macOS-focused).
pub(crate) mod cli;
pub mod ops;
mod schemas;
pub mod server;
@@ -14,7 +14,7 @@
use anyhow::Result;
/// Entry point for `openhuman text-input <subcommand>`.
pub fn run_text_input_command(args: &[String]) -> Result<()> {
pub(crate) fn run_text_input_command(args: &[String]) -> Result<()> {
if args.is_empty() || is_help(&args[0]) {
print_help();
return Ok(());
+1
View File
@@ -4,6 +4,7 @@
//! Thin orchestration layer consumed by autocomplete, voice control, and other
//! text-aware features. All platform work delegates to `accessibility::*`.
pub(crate) mod cli;
pub mod ops;
mod schemas;
mod types;
@@ -13,7 +13,7 @@
use anyhow::Result;
/// Entry point for `openhuman tree-summarizer <subcommand>`.
pub fn run_tree_summarizer_command(args: &[String]) -> Result<()> {
pub(crate) fn run_tree_summarizer_command(args: &[String]) -> Result<()> {
if args.is_empty() || is_help(&args[0]) {
print_help();
return Ok(());
+1
View File
@@ -6,6 +6,7 @@
//! Stored as markdown files in `memory/namespaces/{ns}/tree/`.
pub mod bus;
pub(crate) mod cli;
pub mod engine;
pub mod ops;
pub mod store;
+122
View File
@@ -0,0 +1,122 @@
//! Voice CLI adapter — domain-owned.
//!
//! Handles the `openhuman voice` / `openhuman dictate` subcommand which runs a
//! long-lived, blocking standalone dictation server (hotkey → record →
//! transcribe → insert). This flow doesn't fit the request/response controller
//! registry pattern because it blocks forever on the hotkey listener, so the
//! adapter lives here inside the voice domain rather than in `src/core/cli.rs`.
use anyhow::{anyhow, Result};
use crate::core::logging::{init_for_cli_run, CliLogDefault};
use crate::openhuman::voice::hotkey::ActivationMode;
use crate::openhuman::voice::server::{run_standalone, VoiceServerConfig};
/// Parse and execute the `openhuman voice` / `openhuman dictate` subcommand.
///
/// Supported flags:
/// --hotkey <combo> Key combination (default from config, usually `fn`)
/// --mode <tap|push> Activation mode (default push)
/// --skip-cleanup Skip LLM post-processing on transcriptions
/// -v / --verbose Enable debug logging
/// -h / --help Print usage
pub(crate) fn run_standalone_subcommand(args: &[String]) -> Result<()> {
let mut hotkey: Option<String> = None;
let mut mode: Option<String> = None;
let mut skip_cleanup = false;
let mut verbose = false;
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--hotkey" => {
hotkey = Some(
args.get(i + 1)
.ok_or_else(|| anyhow!("missing value for --hotkey"))?
.clone(),
);
i += 2;
}
"--mode" => {
mode = Some(
args.get(i + 1)
.ok_or_else(|| anyhow!("missing value for --mode"))?
.clone(),
);
i += 2;
}
"--skip-cleanup" => {
skip_cleanup = true;
i += 1;
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
print_help();
return Ok(());
}
other => return Err(anyhow!("unknown voice arg: {other}")),
}
}
log::debug!(
"[voice-cli] starting standalone server hotkey={:?} mode={:?} skip_cleanup={} verbose={}",
hotkey,
mode,
skip_cleanup,
verbose
);
init_for_cli_run(verbose, CliLogDefault::Global);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let mut config = match crate::openhuman::config::Config::load_or_init().await {
Ok(cfg) => cfg,
Err(e) => {
log::warn!("[voice-cli] config load failed, using defaults: {e}");
crate::openhuman::config::Config::default()
}
};
config.apply_env_overrides();
let activation_mode = match mode.as_deref() {
Some("tap") => ActivationMode::Tap,
Some("push") | None => ActivationMode::Push,
Some(other) => return Err(anyhow!("invalid --mode '{other}', expected tap|push")),
};
let server_config = VoiceServerConfig {
hotkey: hotkey.unwrap_or_else(|| config.voice_server.hotkey.clone()),
activation_mode,
skip_cleanup,
context: None,
min_duration_secs: config.voice_server.min_duration_secs,
silence_threshold: config.voice_server.silence_threshold,
custom_dictionary: config.voice_server.custom_dictionary.clone(),
};
run_standalone(config, server_config)
.await
.map_err(anyhow::Error::msg)
})?;
Ok(())
}
fn print_help() {
println!("Usage: openhuman voice [--hotkey <combo>] [--mode <tap|push>] [--skip-cleanup] [-v]");
println!();
println!(" --hotkey <combo> Key combination (default: fn)");
println!(" --mode <tap|push> Activation: tap to toggle, push to hold (default: push)");
println!(" --skip-cleanup Skip LLM post-processing on transcriptions");
println!(" -v, --verbose Enable debug logging");
println!();
println!("Standalone voice dictation server. Press the hotkey to dictate,");
println!("transcribed text is inserted into the active text field.");
}
+1
View File
@@ -5,6 +5,7 @@
//! standalone voice dictation server (hotkey → record → transcribe → insert).
pub mod audio_capture;
pub(crate) mod cli;
pub mod dictation_listener;
pub mod hallucination;
pub mod hotkey;