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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,35 @@ mano-cua stop

> **Note:** Only one task can run at a time per device. If you need to start a new task, first stop the current one with `mano-cua stop`.

## Configuration

Persistent settings are stored in `~/.mano/config.json`.

```bash
# List all keys and current values
mano-cua config --list

# Read or set a value
mano-cua config --get save-trajectory
mano-cua config --set save-trajectory true
```

| Key | Default | Description |
| ----------------- | ------- | ------------------------------------------------------------------------ |
| `max-steps` | `100` | Maximum steps per task |
| `minimize` | `false` | Start with the status panel minimized (`true` / `false`) |
| `save-trajectory` | `false` | Save a per-run trajectory under `~/.mano/trajectory/` (`true` / `false`) |

When `save-trajectory` is `true`, each run writes a session folder (for example `~/.mano/trajectory/sess-<timestamp>-<id>/`) containing:

- `session.json` — task metadata and final status
- `history.jsonl` — step-by-step actions, reasoning, and screenshot paths
- `screenshots/` — PNG captures per step
- `whole.log` — full terminal output (stdout/stderr) for the run
- `report.html` — offline HTML timeline with collapsible screenshots (open in a browser)

Other keys (for example `default-model-path`, `python-path`, `w8a8`) apply to local mode; run `mano-cua config --list` for the full list.

## Local Mode

Runs [Mano-P](https://huggingface.co/Mininglamp-2718/Mano-P) entirely on-device via MLX. No data leaves the machine. Requires macOS with Apple Silicon (M1+).
Expand Down
4 changes: 3 additions & 1 deletion visual/computer/computer_action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ def _type_text(self, text: str):
env["LC_ALL"] = "en_US.UTF-8"
subprocess.run(["pbcopy"], input=text.encode("utf-8"), env=env, check=True)
elif system == "Windows":
subprocess.run(["clip"], input=text.encode("utf-16le"), check=True)
# clip.exe expects UTF-16 with BOM; utf-16le alone is misread as ANSI on
# localized Windows and pastes mojibake into Search / text fields.
subprocess.run(["clip"], input=text.encode("utf-16"), check=True)
else:
subprocess.run(["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True)

Expand Down
2 changes: 1 addition & 1 deletion visual/config/user_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"w8a8": "W8A8 INT8 acceleration: 'auto', 'on', or 'off' (default: auto, requires M5+)",
"max-steps": f"Maximum steps per task (default: {CONFIG_DEFAULTS['max-steps']})",
"minimize": f"Start with minimized UI panel: true/false (default: {CONFIG_DEFAULTS['minimize']})",
"save-trajectory": f"Save screenshots and actions per step: true/false (default: {CONFIG_DEFAULTS['save-trajectory']})",
"save-trajectory": f"Save trajectory (screenshots, history.jsonl, whole.log, report.html): true/false (default: {CONFIG_DEFAULTS['save-trajectory']})",
}


Expand Down
102 changes: 88 additions & 14 deletions visual/model/task_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ def _notify_state_changed(self):
self._on_state_changed(self.state)

# ========== Initialization Methods ==========
def init_task(self, task_name: str, agent: BaseAgent, expected_result: Optional[str] = None, max_steps: int = None):
def init_task(
self,
task_name: str,
agent: BaseAgent,
expected_result: Optional[str] = None,
max_steps: int = None,
cloud_session_id: Optional[str] = None,
):
"""Initialize automation task"""
# Basic configuration
self.state.task_name = task_name
Expand Down Expand Up @@ -96,7 +103,11 @@ def init_task(self, task_name: str, agent: BaseAgent, expected_result: Optional[
"arch": platform.machine(),
"os_version": platform.mac_ver()[0] or platform.version(),
}
if cloud_session_id:
self._session_meta["cloud_session_id"] = cloud_session_id
self._save_session_meta()
from visual.trajectory.log_tee import install_trajectory_tee
install_trajectory_tee(self._trajectory_dir)
print(f"Trajectory: {self._trajectory_dir}")

# Initialize executor
Expand Down Expand Up @@ -127,6 +138,8 @@ def update_progress(self, step_idx: int, action_desc: str, reasoning: str = "",
print(f"[step {step_idx}] Action: {action_desc}")
if reasoning:
print(f"[step {step_idx}] Reasoning: {reasoning}")
if self._save_trajectory and self._trajectory_dir:
self._record_progress_history(step_idx, action_desc, reasoning)
self._notify_state_changed()

# ========== State Management ==========
Expand All @@ -137,6 +150,7 @@ def mark_completed(self):
self.stop_event.set()
self._save_final_trajectory()
self._print_summary("COMPLETED")
self._finalize_trajectory_log()
self._notify_state_changed()

def mark_stopped(self):
Expand All @@ -149,6 +163,7 @@ def mark_stopped(self):
self.agent.stop()
self._save_final_trajectory()
self._print_summary("STOPPED_BY_USER")
self._finalize_trajectory_log()
self._notify_state_changed()

def mark_error(self, error_msg: str):
Expand All @@ -159,6 +174,7 @@ def mark_error(self, error_msg: str):
self.stop_event.set()
self._save_final_trajectory()
self._print_summary("ERROR", error_msg)
self._finalize_trajectory_log()
self._notify_state_changed()

def _print_summary(self, final_status: str, error_msg: str = ""):
Expand All @@ -176,6 +192,12 @@ def _print_summary(self, final_status: str, error_msg: str = ""):
print(f"Error: {error_msg}")
if self.eval_result:
print(f"Evaluation result: {json.dumps(self.eval_result, indent=2, ensure_ascii=False)}")
if self._save_trajectory and self._trajectory_dir:
report_path = os.path.abspath(os.path.join(self._trajectory_dir, "report.html"))
if os.path.isfile(report_path):
from pathlib import Path
print(f"Report: {report_path}")
print(f"Preview: {Path(report_path).as_uri()}")
print(f"{'='*50}\n")

def _mark_evaluating(self):
Expand Down Expand Up @@ -231,7 +253,9 @@ def run_automation_task(self):
if self.state.status == TASK_STATUS["MAX_STEP_REACHED"]:
self.state.is_running = False
self.stop_event.set()
self._save_final_trajectory()
self._print_summary("MAX_STEP_REACHED")
self._finalize_trajectory_log()
self._notify_state_changed()
skip = not (self.expected_result and self.agent.agent_type == "cloud")
if not skip:
Expand Down Expand Up @@ -290,11 +314,15 @@ def _execute_task_steps(self):
# 5. Handle terminal status
if status == "DONE":
if self._save_trajectory and self._trajectory_dir:
self._save_step_trajectory(step_idx + 1, reasoning, actions, action_desc, tool_results)
self._save_step_trajectory(
step_idx, reasoning, actions, action_desc, tool_results, agent_status="DONE"
)
break
elif status == "FAIL":
if self._save_trajectory and self._trajectory_dir:
self._save_step_trajectory(step_idx + 1, reasoning, actions, action_desc, tool_results)
self._save_step_trajectory(
step_idx, reasoning, actions, action_desc, tool_results, agent_status="FAIL"
)
self.mark_error("Agent marked task as failed")
break
elif status == "MAX_STEP_REACHED":
Expand Down Expand Up @@ -350,28 +378,60 @@ def _execute_task_steps(self):
break

# ========== Trajectory Saving ==========
def _save_step_trajectory(self, step_idx, reasoning, actions, action_desc, tool_results):
def _record_progress_history(self, step_idx: int, action_desc: str, reasoning: str):
"""Append a history line for UI progress (may duplicate per-step action lines)."""
from visual.trajectory.history import append_history_line

phase = "init" if action_desc == "Initializing" else "action"
if action_desc in ("DONE", "FAIL"):
phase = "terminal"
append_history_line(
self._trajectory_dir,
step=step_idx,
action_desc=action_desc,
reasoning=reasoning,
actions=[],
phase=phase,
status=None,
screenshot=None,
)

def _save_step_trajectory(
self,
step_idx,
reasoning,
actions,
action_desc,
tool_results,
agent_status=None,
):
"""Save screenshot + action metadata for one step."""
try:
screenshot_rel = None
for tr in reversed(tool_results or []):
b64 = tr.get("screenshot_b64")
if b64:
screenshot_bytes = base64.b64decode(b64)
path = os.path.join(self._trajectory_dir, "screenshots", f"{step_idx}.png")
with open(path, "wb") as f:
f.write(screenshot_bytes)
screenshot_rel = f"screenshots/{step_idx}.png"
break

step_data = {
"step": step_idx,
"reasoning": reasoning,
"action_desc": action_desc,
"actions": [{"name": a.get("name"), "input": a.get("input"), "action_type": a.get("action_type")} for a in actions],
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
history_path = os.path.join(self._trajectory_dir, "history.jsonl")
with open(history_path, "a", encoding="utf-8") as f:
f.write(json.dumps(step_data, ensure_ascii=False) + "\n")
from visual.trajectory.history import append_history_line

phase = "terminal" if agent_status in ("DONE", "FAIL") else "action"
desc = action_desc if action_desc else (agent_status or "")
append_history_line(
self._trajectory_dir,
step=step_idx,
action_desc=desc,
reasoning=reasoning or "",
actions=actions or [],
phase=phase,
status=agent_status,
screenshot=screenshot_rel,
)
except Exception as e:
print(f"Warning: failed to save trajectory step {step_idx}: {e}")

Expand All @@ -394,9 +454,23 @@ def _save_final_trajectory(self):
"finished_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
})
self._save_session_meta()

from visual.trajectory.report_generator import generate_report

generate_report(self._trajectory_dir)
except Exception as e:
print(f"Warning: failed to save final trajectory: {e}")

def _finalize_trajectory_log(self):
"""Close stdout tee after summary is printed."""
if not self._save_trajectory:
return
try:
from visual.trajectory.log_tee import uninstall_trajectory_tee
uninstall_trajectory_tee()
except Exception as e:
print(f"Warning: failed to close trajectory log: {e}")

def _save_session_meta(self):
with open(os.path.join(self._trajectory_dir, "session.json"), "w", encoding="utf-8") as f:
json.dump(self._session_meta, f, indent=2, ensure_ascii=False)
Empty file added visual/model/tests/__init__.py
Empty file.
17 changes: 17 additions & 0 deletions visual/trajectory/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Trajectory logging, history, and HTML report generation."""

from visual.trajectory.log_tee import (
enable_early_trajectory_buffer,
install_trajectory_tee,
is_trajectory_logging_active,
uninstall_trajectory_tee,
)
from visual.trajectory.report_generator import generate_report

__all__ = [
"enable_early_trajectory_buffer",
"install_trajectory_tee",
"uninstall_trajectory_tee",
"is_trajectory_logging_active",
"generate_report",
]
58 changes: 58 additions & 0 deletions visual/trajectory/action_styles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Map action descriptions to report styling kinds."""

from typing import Any, List, Optional

ACTION_KIND_INITIALIZING = "initializing"
ACTION_KIND_OPEN_APP = "open_app"
ACTION_KIND_KEY = "key"
ACTION_KIND_CLICK = "click"
ACTION_KIND_TYPE = "type"
ACTION_KIND_DONE = "done"
ACTION_KIND_FAIL = "fail"
ACTION_KIND_OTHER = "other"


def infer_action_kind(
action_desc: str,
actions: Optional[List[dict]] = None,
) -> str:
"""Derive action_kind from human-readable action_desc and optional structured actions."""
desc = (action_desc or "").strip()
desc_lower = desc.lower()

if desc == "Initializing" or desc_lower == "initializing":
return ACTION_KIND_INITIALIZING
if desc == "DONE" or desc_lower == "done":
return ACTION_KIND_DONE
if desc == "FAIL" or desc_lower == "fail":
return ACTION_KIND_FAIL
if desc_lower.startswith("open app:") or desc_lower.startswith("open app "):
return ACTION_KIND_OPEN_APP
if desc_lower.startswith("key:"):
return ACTION_KIND_KEY
if "click" in desc_lower:
return ACTION_KIND_CLICK
if desc_lower.startswith("type:") or desc_lower.startswith("type "):
return ACTION_KIND_TYPE

if actions:
first = actions[0] or {}
name = (first.get("name") or "").lower()
if name == "open_app":
return ACTION_KIND_OPEN_APP
if name == "computer":
inp = first.get("input") or {}
act = (inp.get("action") or "").lower()
if act == "key":
return ACTION_KIND_KEY
if act in ("click", "left_click", "right_click", "double_click"):
return ACTION_KIND_CLICK
if act == "type":
return ACTION_KIND_TYPE

return ACTION_KIND_OTHER


def css_class_for_kind(action_kind: str) -> str:
"""Return CSS class name for an action_kind."""
return f"action-{action_kind.replace('_', '-')}"
47 changes: 47 additions & 0 deletions visual/trajectory/history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Append enhanced lines to trajectory history.jsonl."""

import json
import os
import time
from typing import Any, Dict, List, Optional

from visual.trajectory.action_styles import infer_action_kind


def append_history_line(
trajectory_dir: str,
*,
step: int,
action_desc: str,
reasoning: str = "",
actions: Optional[List[Dict[str, Any]]] = None,
phase: str = "action",
status: Optional[str] = None,
screenshot: Optional[str] = None,
) -> None:
"""Append one JSON line to history.jsonl with the enhanced schema."""
actions = actions or []
line: Dict[str, Any] = {
"step": step,
"reasoning": reasoning or "",
"action_desc": action_desc,
"actions": [
{
"name": a.get("name"),
"input": a.get("input"),
"action_type": a.get("action_type"),
}
for a in actions
],
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"phase": phase,
"action_kind": infer_action_kind(action_desc, actions),
}
if status is not None:
line["status"] = status
if screenshot is not None:
line["screenshot"] = screenshot

history_path = os.path.join(trajectory_dir, "history.jsonl")
with open(history_path, "a", encoding="utf-8") as f:
f.write(json.dumps(line, ensure_ascii=False) + "\n")
Loading