diff --git a/.gitignore b/.gitignore
index 07ffa5dd2..69683d52d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,3 +65,4 @@ workflow
.fastembed_cache
overlay/src-tauri/target/
.claude/*.lock
+app/.claude/scheduled_tasks.lock
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 000000000..f3a652695
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,139 @@
+# Backend Overhaul — Controller Registry Consolidation
+
+Branch: `feat/backend-overhaul`
+Base: `main` @ 515c4f4b
+
+## Why
+
+Project rule (CLAUDE.md): domain functionality exposed to CLI + JSON-RPC **only** via the controller registry (`schemas.rs` per domain + `src/core/all.rs`). Current state is 90% there — 34 domains fully migrated — but six CLI adapters and one JSON-RPC fallback still live in the transport layer. Finishing this lets us delete ~95KB of adapter code and enforce the rule with a lint/grep check.
+
+## Goals
+
+1. Zero domain-specific code in `src/core/cli.rs`, `src/core/jsonrpc.rs`, `src/rpc/dispatch.rs`.
+2. Every CLI command and JSON-RPC method dispatched through the shared registry.
+3. No behavior change — all existing CLI flags and RPC methods preserve names, params, outputs.
+4. Unregistered infrastructure modules documented as intentionally transport-agnostic.
+
+## Non-goals
+
+- No new domain functionality.
+- No changes to skills QuickJS runtime exposure (stays runtime-only).
+- No renames of existing CLI flags or RPC method names.
+- No touching memory-tree domain (sanil-23 has in-flight PRs #732 #733).
+
+## Current leakage
+
+| File | LOC | What's there | Target |
+|---|---|---|---|
+| `src/core/agent_cli.rs` | 21.4K | agent CLI adapter | `openhuman/agent/schemas.rs` |
+| `src/core/memory_cli.rs` | 15.3K | memory CLI adapter | `openhuman/memory/schemas.rs` |
+| `src/core/text_input_cli.rs` | 12.4K | text_input CLI adapter | `openhuman/text_input/schemas.rs` |
+| `src/core/screen_intelligence_cli.rs` | 26.5K | screen_intelligence CLI adapter | `openhuman/screen_intelligence/schemas.rs` |
+| `src/core/tree_summarizer_cli.rs` | 13.2K | tree_summarizer CLI adapter | `openhuman/tree_summarizer/schemas.rs` |
+| `src/core/cli.rs` (voice block) | ~2K | `run_voice_server_command()` | `openhuman/voice/schemas.rs` |
+| `src/rpc/dispatch.rs` | 76 | `openhuman.security_policy_info` fallback | `openhuman/security/schemas.rs` (or about_app) |
+
+Total: ~95KB of adapter code to fold into registry.
+
+## Exposure rule (decision tree)
+
+Two legitimate shapes for exposing a domain capability. Pick by command shape, not by convenience:
+
+1. **Controller registry** (`src/openhuman//schemas.rs` + `src/core/all.rs`).
+ Use for **request/response RPC-style capabilities** — take params, do work, return JSON, done. The vast majority of domain methods. CLI access is free via the generic namespace dispatcher (`openhuman --args`).
+
+2. **Domain-owned CLI adapter** (`src/openhuman//cli.rs`).
+ Use only for **standalone long-running / blocking operational commands** — e.g. `openhuman voice` runs a hotkey listener forever, holds the process, logs to stderr. These don't fit the registry's fire-and-return JSON contract. The adapter lives *inside* the domain (not in `src/core/cli.rs`) so transport layer stays generic.
+
+Hard rule: no domain-specific imports or logic in `src/core/cli.rs`, `src/core/jsonrpc.rs`, or `src/rpc/dispatch.rs`. The only allowed reference is the dispatch line that routes `args[0]` to the domain's CLI function. Everything else lives in the domain.
+
+Don't invent a third shape. If a capability feels like it needs one, it probably belongs in one of the two above with a thin adjustment.
+
+## Approach
+
+Each migration is a self-contained diff:
+
+1. Confirm domain already has `schemas.rs` with `all__registered_controllers()`.
+2. For each CLI subcommand in `_cli.rs`:
+ - Identify the underlying domain function it calls.
+ - Add a `handle_` fn in `schemas.rs` that takes `Map` and returns `ControllerFuture`.
+ - Register it in `all__registered_controllers()`.
+3. Delete the `_cli.rs` file.
+4. Remove its import from `src/core/cli.rs` / `mod.rs`.
+5. Verify CLI flags still work via generic dispatcher — the registry already exposes them by method name.
+6. `cargo check` + `cargo test -p openhuman` per step.
+
+The generic CLI adapter in `cli.rs` already knows how to route ` --key value` to registry method `openhuman._`. Migration is mostly deletion once handlers exist.
+
+## Phases
+
+### Phase 1 — Smallest adapter first (shakedown)
+
+**voice** (~2K inline in `cli.rs`). Lowest risk: one server command, confirms the approach end-to-end before touching bigger files.
+
+- Commit: `refactor(voice): move CLI adapter to controller registry`
+
+### Phase 2 — Mechanical migrations (one PR each)
+
+Order = smallest file first, so each PR is reviewable and CI catches issues early.
+
+1. `text_input` (12.4K) — likely thin wrappers.
+2. `tree_summarizer` (13.2K).
+3. `memory_cli` (15.3K) — **coordinate with sanil-23 memory-tree PRs**; rebase after #732/#733 merge or pair-review.
+4. `agent_cli` (21.4K).
+5. `screen_intelligence` (26.5K) — largest, leave for last.
+
+Each: one commit, one PR, self-contained. No bundling.
+
+### Phase 3 — JSON-RPC dispatch fallback
+
+Move `openhuman.security_policy_info` from `src/rpc/dispatch.rs` into whichever domain owns security policy (likely `about_app` or a new `security` module). Delete `rpc/dispatch.rs` if it becomes empty, else leave as a thin generic dispatch shim.
+
+### Phase 4 — Enforce the rule
+
+Add a build-time check or CI grep. The rule must forbid domain **imports/types** in the transport layer while still allowing the dispatcher to call the generic `::cli::run_*` entrypoints that live inside each domain:
+
+```bash
+# Forbid `use crate::openhuman::::…` imports in transport files.
+! grep -rE '^\s*use\s+crate::openhuman::[a-z_]+::' \
+ src/core/cli.rs src/core/jsonrpc.rs src/rpc/dispatch.rs
+
+# Forbid any other domain reference that is NOT a call into ::cli::run_*(…).
+# (The dispatcher is allowed to invoke `crate::openhuman::::cli::run_(...)`.)
+! grep -rE 'crate::openhuman::[a-z_]+::' \
+ src/core/cli.rs src/core/jsonrpc.rs src/rpc/dispatch.rs \
+ | grep -vE 'crate::openhuman::[a-z_]+::cli::run_[a-z_]+\('
+```
+
+Fails if any domain-specific import or non-dispatcher reference creeps back in. Commit as `chore(core): fence domain imports out of transport layer`.
+
+### Phase 5 — Document unregistered modules
+
+Add a short table to `CLAUDE.md` listing intentionally-unregistered infra modules (accessibility, approval, context, embeddings, integrations, node_runtime, overlay, providers, routing, tokenjuice, tool_timeout) with one-liner "why not in registry" for each. Also document skills as runtime-only.
+
+## Risks
+
+- **agent_cli / screen_intelligence size** — large files may hide subtle flag-parsing logic not obvious from a first read. Mitigation: test-drive each CLI subcommand manually after migration; add CLI smoke tests to the registry dispatcher if missing.
+- **memory_cli collision with sanil-23** — memory-tree PRs touch adjacent paths. Mitigation: do phase 2.3 last, rebase on top of merged PRs.
+- **CLI flag drift** — generic adapter parses flags by schema. If current `_cli.rs` does custom parsing (e.g. positional args, subcommand nesting), need to extend schema before deleting. Mitigation: read each CLI file top-to-bottom before writing handler.
+- **Behavior diff at registry boundary** — registry handlers return `ControllerFuture` with JSON; `_cli.rs` may print formatted text. Mitigation: keep a thin formatter in the generic CLI path, not per-domain.
+
+## Verification per step
+
+```bash
+cargo check --manifest-path Cargo.toml
+cargo test -p openhuman --test json_rpc_e2e
+# Manual CLI smoke for migrated domain:
+./target/debug/openhuman --args...
+```
+
+Plus `yarn test:rust` for the full mock-backed integration suite before each PR.
+
+## Milestones
+
+- M1: Phase 1 done, shakedown merged.
+- M2: Phases 2.1–2.5 done, all `*_cli.rs` deleted.
+- M3: Phase 3 done, `rpc/dispatch.rs` cleaned.
+- M4: Phase 4+5 done, rule enforced, docs updated.
+
+Estimate: M1 = 0.5d, M2 = 3–4d (one domain/day), M3 = 0.5d, M4 = 0.5d. Total ~1 week of focused work.
diff --git a/app/src/components/settings/panels/PrivacyPanel.tsx b/app/src/components/settings/panels/PrivacyPanel.tsx
index bf8353867..7a86f40c2 100644
--- a/app/src/components/settings/panels/PrivacyPanel.tsx
+++ b/app/src/components/settings/panels/PrivacyPanel.tsx
@@ -5,8 +5,8 @@ import { useCoreState } from '../../../providers/CoreStateProvider';
import {
type Capability,
type CapabilityPrivacy,
- type PrivacyDataKind,
listCapabilities,
+ type PrivacyDataKind,
} from '../../../utils/tauriCommands/aboutApp';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -94,9 +94,7 @@ const PrivacyPanel = () => {
Loading privacy details…
)}
{loadState === 'error' && (
-
+
Couldn’t load the live privacy list. Analytics controls below still work.
)}
@@ -106,14 +104,9 @@ const PrivacyPanel = () => {
)}
{loadState === 'ready' && capabilities.length > 0 && (
-
+
{capabilities.map(cap => (
-
+
{cap.name}
diff --git a/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx b/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx
index f42be3690..cb7e78bc6 100644
--- a/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx
+++ b/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx
@@ -5,15 +5,10 @@ import { renderWithProviders } from '../../../../test/test-utils';
import { type Capability, listCapabilities } from '../../../../utils/tauriCommands/aboutApp';
import PrivacyPanel from '../PrivacyPanel';
-vi.mock('../../../../utils/tauriCommands/aboutApp', () => ({
- listCapabilities: vi.fn(),
-}));
+vi.mock('../../../../utils/tauriCommands/aboutApp', () => ({ listCapabilities: vi.fn() }));
vi.mock('../../../../providers/CoreStateProvider', () => ({
- useCoreState: () => ({
- snapshot: { analyticsEnabled: false },
- setAnalyticsEnabled: vi.fn(),
- }),
+ useCoreState: () => ({ snapshot: { analyticsEnabled: false }, setAnalyticsEnabled: vi.fn() }),
}));
vi.mock('../../hooks/useSettingsNavigation', () => ({
diff --git a/app/src/pages/onboarding/Onboarding.tsx b/app/src/pages/onboarding/Onboarding.tsx
index 4c83205e6..c4a7959c4 100644
--- a/app/src/pages/onboarding/Onboarding.tsx
+++ b/app/src/pages/onboarding/Onboarding.tsx
@@ -103,7 +103,9 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
};
return (
-
+
{onDefer && (
{
}, []);
return (
-
+
{slide === 0 &&
}
{slide === 1 &&
}
diff --git a/app/src/utils/tauriCommands/aboutApp.ts b/app/src/utils/tauriCommands/aboutApp.ts
index f0607259d..bd8e5bc0b 100644
--- a/app/src/utils/tauriCommands/aboutApp.ts
+++ b/app/src/utils/tauriCommands/aboutApp.ts
@@ -6,7 +6,6 @@
* the first consumer; future panels can reuse the same types.
*/
import { callCoreRpc } from '../../services/coreRpcClient';
-
import { CommandResponse } from './common';
export type CapabilityCategory =
@@ -23,12 +22,7 @@ export type CapabilityCategory =
export type CapabilityStatus = 'stable' | 'beta' | 'coming_soon' | 'deprecated';
-export type PrivacyDataKind =
- | 'raw'
- | 'derived'
- | 'credentials'
- | 'diagnostics'
- | 'metadata';
+export type PrivacyDataKind = 'raw' | 'derived' | 'credentials' | 'diagnostics' | 'metadata';
export interface CapabilityPrivacy {
leaves_device: boolean;
diff --git a/app/test/e2e/helpers/artifacts.ts b/app/test/e2e/helpers/artifacts.ts
index 25ea8dbff..7236c5c8f 100644
--- a/app/test/e2e/helpers/artifacts.ts
+++ b/app/test/e2e/helpers/artifacts.ts
@@ -52,9 +52,7 @@ function nowStamp(): string {
}
function getRoot(): string {
- return process.env.E2E_ARTIFACT_ROOT
- ? path.resolve(process.env.E2E_ARTIFACT_ROOT)
- : defaultRoot;
+ return process.env.E2E_ARTIFACT_ROOT ? path.resolve(process.env.E2E_ARTIFACT_ROOT) : defaultRoot;
}
/**
@@ -134,12 +132,7 @@ export async function captureCheckpoint(name: string): Promise
{
if (await writeSource(xmlFile)) files.push(path.basename(xmlFile));
if (meta) {
- meta.checkpoints.push({
- index: checkpointIndex,
- name,
- at: new Date().toISOString(),
- files,
- });
+ meta.checkpoints.push({ index: checkpointIndex, name, at: new Date().toISOString(), files });
writeMeta();
}
// eslint-disable-next-line no-console
@@ -162,11 +155,7 @@ export async function captureFailureArtifacts(testName: string): Promise {
if (await writeSource(xmlFile)) files.push(path.basename(xmlFile));
if (meta) {
- meta.failures.push({
- testName,
- at: new Date().toISOString(),
- files,
- });
+ meta.failures.push({ testName, at: new Date().toISOString(), files });
writeMeta();
}
// eslint-disable-next-line no-console
diff --git a/app/test/e2e/specs/agent-review.spec.ts b/app/test/e2e/specs/agent-review.spec.ts
index 207d94bec..071a97f17 100644
--- a/app/test/e2e/specs/agent-review.spec.ts
+++ b/app/test/e2e/specs/agent-review.spec.ts
@@ -17,11 +17,7 @@
* UI assertion — we already have login-flow.spec.ts for that.
*/
import { waitForApp, waitForAppReady, waitForAuthBootstrap } from '../helpers/app-helpers';
-import {
- captureCheckpoint,
- getArtifactDir,
- saveMockRequestLog,
-} from '../helpers/artifacts';
+import { captureCheckpoint, getArtifactDir, saveMockRequestLog } from '../helpers/artifacts';
import { triggerAuthDeepLink } from '../helpers/deep-link-helpers';
import {
clickText,
@@ -90,9 +86,7 @@ describe('Agent review — canonical onboarding + privacy flow', () => {
it('02 advances past welcome step', async () => {
const clicked =
- (await tryClick("Let's Start")) ||
- (await tryClick('Continue')) ||
- (await tryClick('Skip'));
+ (await tryClick("Let's Start")) || (await tryClick('Continue')) || (await tryClick('Skip'));
// eslint-disable-next-line no-console
console.log(`[agent-review] welcome advance clicked=${clicked}`);
await browser.pause(2_000);
diff --git a/app/test/wdio.conf.ts b/app/test/wdio.conf.ts
index b7e7ba81b..0dfd2d252 100644
--- a/app/test/wdio.conf.ts
+++ b/app/test/wdio.conf.ts
@@ -108,7 +108,7 @@ export const config: Options.Testrunner & Record = {
afterTest: async function (
test: { title: string; parent?: string },
_context: unknown,
- result: { passed: boolean; error?: Error },
+ result: { passed: boolean; error?: Error }
) {
if (result.passed) return;
const name = [test.parent, test.title].filter(Boolean).join(' ').trim() || test.title;
diff --git a/src/core/cli.rs b/src/core/cli.rs
index 5314d86b2..51074b895 100644
--- a/src/core/cli.rs
+++ b/src/core/cli.rs
@@ -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 = None;
- let mut mode: Option = 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 ] [--mode ] [--skip-cleanup] [-v]");
- println!();
- println!(" --hotkey Key combination (default: fn)");
- println!(
- " --mode 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 `).
///
/// It looks up the function schema for validation and executes the request.
diff --git a/src/core/mod.rs b/src/core/mod.rs
index 257fed087..a9d9b15a8 100644
--- a/src/core/mod.rs
+++ b/src/core/mod.rs
@@ -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.
diff --git a/src/core/screen_intelligence_cli.rs b/src/core/screen_intelligence_cli.rs
deleted file mode 100644
index 09b2f7a33..000000000
--- a/src/core/screen_intelligence_cli.rs
+++ /dev/null
@@ -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 ] [--keep] [-v]
-//! openhuman screen-intelligence status [-v]
-//! openhuman screen-intelligence capture [--keep] [-v]
-//! openhuman screen-intelligence start [--ttl ] [-v]
-//! openhuman screen-intelligence stop [-v]
-//! openhuman screen-intelligence doctor [-v]
-//! openhuman screen-intelligence vision [--limit ] [-v]
-
-use anyhow::Result;
-use std::sync::Arc;
-
-/// Entry point for `openhuman screen-intelligence `.
-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)> {
- 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> {
- bootstrap_engine_with_opts(verbose, false).await
-}
-
-/// Bootstrap with CLI overrides.
-async fn bootstrap_engine_with_opts(
- verbose: bool,
- no_vision_model: bool,
-) -> Result> {
- 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 ] [--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 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 ] [--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 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::())
- } 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 ] [-v]");
- println!();
- println!("Print recent vision summaries from the active session.");
- println!();
- println!(" --limit 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::())
- } 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::()
- )
- } 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 ] [--no-vision-model] [-v]");
- println!(" openhuman screen-intelligence status [-v]");
- println!(" openhuman screen-intelligence capture [--keep] [-v]");
- println!(" openhuman screen-intelligence start [--ttl ] [--no-vision-model] [-v]");
- println!(" openhuman screen-intelligence stop [-v]");
- println!(" openhuman screen-intelligence doctor [-v]");
- println!(" openhuman screen-intelligence vision [--limit ] [-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 Session TTL (default: 300)");
- println!(" --limit 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");
-}
diff --git a/src/openhuman/screen_intelligence/cli/capture.rs b/src/openhuman/screen_intelligence/cli/capture.rs
new file mode 100644
index 000000000..ff6e69ee6
--- /dev/null
+++ b/src/openhuman/screen_intelligence/cli/capture.rs
@@ -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 ] [-v]");
+ println!();
+ println!("Print recent vision summaries from the active session.");
+ println!();
+ println!(" --limit 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::())
+ } 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::()
+ )
+ } else {
+ s.actionable_notes.clone()
+ };
+ eprintln!(" notes: {truncated}");
+ }
+ eprintln!();
+ }
+ }
+
+ // Machine-readable output.
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&result).unwrap_or_default()
+ );
+ Ok(())
+ })
+}
diff --git a/src/openhuman/screen_intelligence/cli/doctor.rs b/src/openhuman/screen_intelligence/cli/doctor.rs
new file mode 100644
index 000000000..4c2203898
--- /dev/null
+++ b/src/openhuman/screen_intelligence/cli/doctor.rs
@@ -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(())
+ })
+}
diff --git a/src/openhuman/screen_intelligence/cli/mod.rs b/src/openhuman/screen_intelligence/cli/mod.rs
new file mode 100644
index 000000000..b60bdb4bf
--- /dev/null
+++ b/src/openhuman/screen_intelligence/cli/mod.rs
@@ -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 ] [--keep] [-v]
+//! openhuman screen-intelligence status [-v]
+//! openhuman screen-intelligence capture [--keep] [-v]
+//! openhuman screen-intelligence start [--ttl ] [-v]
+//! openhuman screen-intelligence stop [-v]
+//! openhuman screen-intelligence doctor [-v]
+//! openhuman screen-intelligence vision [--limit ] [-v]
+
+use anyhow::Result;
+use std::sync::Arc;
+
+mod capture;
+mod doctor;
+mod server;
+mod session;
+
+/// Entry point for `openhuman screen-intelligence `.
+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)> {
+ 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> {
+ 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> {
+ 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 ] [--no-vision-model] [-v]");
+ println!(" openhuman screen-intelligence status [-v]");
+ println!(" openhuman screen-intelligence capture [--keep] [-v]");
+ println!(" openhuman screen-intelligence start [--ttl ] [--no-vision-model] [-v]");
+ println!(" openhuman screen-intelligence stop [-v]");
+ println!(" openhuman screen-intelligence doctor [-v]");
+ println!(" openhuman screen-intelligence vision [--limit ] [-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 Session TTL (default: 300)");
+ println!(" --limit 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");
+}
diff --git a/src/openhuman/screen_intelligence/cli/server.rs b/src/openhuman/screen_intelligence/cli/server.rs
new file mode 100644
index 000000000..f63aa0ee9
--- /dev/null
+++ b/src/openhuman/screen_intelligence/cli/server.rs
@@ -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 ] [--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 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}"))
+ })
+}
diff --git a/src/openhuman/screen_intelligence/cli/session.rs b/src/openhuman/screen_intelligence/cli/session.rs
new file mode 100644
index 000000000..f03833355
--- /dev/null
+++ b/src/openhuman/screen_intelligence/cli/session.rs
@@ -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 ] [--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 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::())
+ } 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(())
+ })
+}
diff --git a/src/openhuman/screen_intelligence/engine.rs b/src/openhuman/screen_intelligence/engine.rs
index 2caa2bc16..c6be82216 100644
--- a/src/openhuman/screen_intelligence/engine.rs
+++ b/src/openhuman/screen_intelligence/engine.rs
@@ -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> {
+ 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,
diff --git a/src/openhuman/screen_intelligence/mod.rs b/src/openhuman/screen_intelligence/mod.rs
index a8559f93f..040d07f5d 100644
--- a/src/openhuman/screen_intelligence/mod.rs
+++ b/src/openhuman/screen_intelligence/mod.rs
@@ -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;
diff --git a/src/core/text_input_cli.rs b/src/openhuman/text_input/cli.rs
similarity index 99%
rename from src/core/text_input_cli.rs
rename to src/openhuman/text_input/cli.rs
index 139232ed2..de0416621 100644
--- a/src/core/text_input_cli.rs
+++ b/src/openhuman/text_input/cli.rs
@@ -14,7 +14,7 @@
use anyhow::Result;
/// Entry point for `openhuman text-input `.
-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(());
diff --git a/src/openhuman/text_input/mod.rs b/src/openhuman/text_input/mod.rs
index de25cd5f1..c35c6cd63 100644
--- a/src/openhuman/text_input/mod.rs
+++ b/src/openhuman/text_input/mod.rs
@@ -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;
diff --git a/src/core/tree_summarizer_cli.rs b/src/openhuman/tree_summarizer/cli.rs
similarity index 99%
rename from src/core/tree_summarizer_cli.rs
rename to src/openhuman/tree_summarizer/cli.rs
index 9ddc4f30c..8e5a8a3c7 100644
--- a/src/core/tree_summarizer_cli.rs
+++ b/src/openhuman/tree_summarizer/cli.rs
@@ -13,7 +13,7 @@
use anyhow::Result;
/// Entry point for `openhuman tree-summarizer `.
-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(());
diff --git a/src/openhuman/tree_summarizer/mod.rs b/src/openhuman/tree_summarizer/mod.rs
index f9215777a..986b201df 100644
--- a/src/openhuman/tree_summarizer/mod.rs
+++ b/src/openhuman/tree_summarizer/mod.rs
@@ -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;
diff --git a/src/openhuman/voice/cli.rs b/src/openhuman/voice/cli.rs
new file mode 100644
index 000000000..38408b621
--- /dev/null
+++ b/src/openhuman/voice/cli.rs
@@ -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 Key combination (default from config, usually `fn`)
+/// --mode 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 = None;
+ let mut mode: Option = 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 ] [--mode ] [--skip-cleanup] [-v]");
+ println!();
+ println!(" --hotkey Key combination (default: fn)");
+ println!(" --mode 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.");
+}
diff --git a/src/openhuman/voice/mod.rs b/src/openhuman/voice/mod.rs
index ec77c37db..a84c30b94 100644
--- a/src/openhuman/voice/mod.rs
+++ b/src/openhuman/voice/mod.rs
@@ -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;