minitap-mobile-use 3.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. minitap/mobile_use/__init__.py +0 -0
  2. minitap/mobile_use/agents/contextor/contextor.md +55 -0
  3. minitap/mobile_use/agents/contextor/contextor.py +175 -0
  4. minitap/mobile_use/agents/contextor/types.py +36 -0
  5. minitap/mobile_use/agents/cortex/cortex.md +135 -0
  6. minitap/mobile_use/agents/cortex/cortex.py +152 -0
  7. minitap/mobile_use/agents/cortex/types.py +15 -0
  8. minitap/mobile_use/agents/executor/executor.md +42 -0
  9. minitap/mobile_use/agents/executor/executor.py +87 -0
  10. minitap/mobile_use/agents/executor/tool_node.py +152 -0
  11. minitap/mobile_use/agents/hopper/hopper.md +15 -0
  12. minitap/mobile_use/agents/hopper/hopper.py +44 -0
  13. minitap/mobile_use/agents/orchestrator/human.md +12 -0
  14. minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
  15. minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
  16. minitap/mobile_use/agents/orchestrator/types.py +11 -0
  17. minitap/mobile_use/agents/outputter/human.md +25 -0
  18. minitap/mobile_use/agents/outputter/outputter.py +85 -0
  19. minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
  20. minitap/mobile_use/agents/planner/human.md +14 -0
  21. minitap/mobile_use/agents/planner/planner.md +126 -0
  22. minitap/mobile_use/agents/planner/planner.py +101 -0
  23. minitap/mobile_use/agents/planner/types.py +51 -0
  24. minitap/mobile_use/agents/planner/utils.py +70 -0
  25. minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
  26. minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
  27. minitap/mobile_use/agents/video_analyzer/human.md +5 -0
  28. minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
  29. minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
  30. minitap/mobile_use/clients/browserstack_client.py +477 -0
  31. minitap/mobile_use/clients/idb_client.py +429 -0
  32. minitap/mobile_use/clients/ios_client.py +332 -0
  33. minitap/mobile_use/clients/ios_client_config.py +141 -0
  34. minitap/mobile_use/clients/ui_automator_client.py +330 -0
  35. minitap/mobile_use/clients/wda_client.py +526 -0
  36. minitap/mobile_use/clients/wda_lifecycle.py +367 -0
  37. minitap/mobile_use/config.py +413 -0
  38. minitap/mobile_use/constants.py +3 -0
  39. minitap/mobile_use/context.py +106 -0
  40. minitap/mobile_use/controllers/__init__.py +0 -0
  41. minitap/mobile_use/controllers/android_controller.py +524 -0
  42. minitap/mobile_use/controllers/controller_factory.py +46 -0
  43. minitap/mobile_use/controllers/device_controller.py +182 -0
  44. minitap/mobile_use/controllers/ios_controller.py +436 -0
  45. minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
  46. minitap/mobile_use/controllers/types.py +106 -0
  47. minitap/mobile_use/controllers/unified_controller.py +193 -0
  48. minitap/mobile_use/graph/graph.py +160 -0
  49. minitap/mobile_use/graph/state.py +115 -0
  50. minitap/mobile_use/main.py +309 -0
  51. minitap/mobile_use/sdk/__init__.py +12 -0
  52. minitap/mobile_use/sdk/agent.py +1294 -0
  53. minitap/mobile_use/sdk/builders/__init__.py +10 -0
  54. minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
  55. minitap/mobile_use/sdk/builders/index.py +15 -0
  56. minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
  57. minitap/mobile_use/sdk/constants.py +1 -0
  58. minitap/mobile_use/sdk/examples/README.md +83 -0
  59. minitap/mobile_use/sdk/examples/__init__.py +1 -0
  60. minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
  61. minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
  62. minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
  63. minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
  64. minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
  65. minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
  66. minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
  67. minitap/mobile_use/sdk/services/platform.py +434 -0
  68. minitap/mobile_use/sdk/types/__init__.py +51 -0
  69. minitap/mobile_use/sdk/types/agent.py +84 -0
  70. minitap/mobile_use/sdk/types/exceptions.py +138 -0
  71. minitap/mobile_use/sdk/types/platform.py +183 -0
  72. minitap/mobile_use/sdk/types/task.py +269 -0
  73. minitap/mobile_use/sdk/utils.py +29 -0
  74. minitap/mobile_use/services/accessibility.py +100 -0
  75. minitap/mobile_use/services/llm.py +247 -0
  76. minitap/mobile_use/services/telemetry.py +421 -0
  77. minitap/mobile_use/tools/index.py +67 -0
  78. minitap/mobile_use/tools/mobile/back.py +52 -0
  79. minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
  80. minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
  81. minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
  82. minitap/mobile_use/tools/mobile/launch_app.py +86 -0
  83. minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
  84. minitap/mobile_use/tools/mobile/open_link.py +62 -0
  85. minitap/mobile_use/tools/mobile/press_key.py +83 -0
  86. minitap/mobile_use/tools/mobile/stop_app.py +62 -0
  87. minitap/mobile_use/tools/mobile/swipe.py +156 -0
  88. minitap/mobile_use/tools/mobile/tap.py +154 -0
  89. minitap/mobile_use/tools/mobile/video_recording.py +177 -0
  90. minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
  91. minitap/mobile_use/tools/scratchpad.py +147 -0
  92. minitap/mobile_use/tools/test_utils.py +413 -0
  93. minitap/mobile_use/tools/tool_wrapper.py +16 -0
  94. minitap/mobile_use/tools/types.py +35 -0
  95. minitap/mobile_use/tools/utils.py +336 -0
  96. minitap/mobile_use/utils/app_launch_utils.py +173 -0
  97. minitap/mobile_use/utils/cli_helpers.py +37 -0
  98. minitap/mobile_use/utils/cli_selection.py +143 -0
  99. minitap/mobile_use/utils/conversations.py +31 -0
  100. minitap/mobile_use/utils/decorators.py +124 -0
  101. minitap/mobile_use/utils/errors.py +6 -0
  102. minitap/mobile_use/utils/file.py +13 -0
  103. minitap/mobile_use/utils/logger.py +183 -0
  104. minitap/mobile_use/utils/media.py +186 -0
  105. minitap/mobile_use/utils/recorder.py +52 -0
  106. minitap/mobile_use/utils/requests_utils.py +37 -0
  107. minitap/mobile_use/utils/shell_utils.py +20 -0
  108. minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
  109. minitap/mobile_use/utils/time.py +6 -0
  110. minitap/mobile_use/utils/ui_hierarchy.py +132 -0
  111. minitap/mobile_use/utils/video.py +281 -0
  112. minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
  113. minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
  114. minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
  115. minitap_mobile_use-3.3.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,31 @@
1
+ from typing import TypeGuard
2
+
3
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
4
+
5
+
6
+ def is_ai_message(message: BaseMessage) -> TypeGuard[AIMessage]:
7
+ return isinstance(message, AIMessage)
8
+
9
+
10
+ def is_human_message(message: BaseMessage) -> TypeGuard[HumanMessage]:
11
+ return isinstance(message, HumanMessage)
12
+
13
+
14
+ def is_tool_message(message: BaseMessage) -> TypeGuard[ToolMessage]:
15
+ return isinstance(message, ToolMessage)
16
+
17
+
18
+ def is_tool_for_name(tool_message: ToolMessage, name: str) -> bool:
19
+ return tool_message.name == name
20
+
21
+
22
+ def get_screenshot_message_for_llm(screenshot_base64: str):
23
+ prefix = "" if screenshot_base64.startswith("data:image") else "data:image/jpeg;base64,"
24
+ return HumanMessage(
25
+ content=[
26
+ {
27
+ "type": "image_url",
28
+ "image_url": {"url": f"{prefix}{screenshot_base64}"},
29
+ }
30
+ ]
31
+ )
@@ -0,0 +1,124 @@
1
+ import asyncio
2
+ from functools import wraps
3
+ from typing import Any, TypeVar, cast, overload
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ R = TypeVar("R")
7
+
8
+
9
+ def wrap_with_callbacks_sync(
10
+ fn: Callable[..., R],
11
+ *,
12
+ before: Callable[..., None] | None = None,
13
+ on_success: Callable[[R], None] | None = None,
14
+ on_failure: Callable[[Exception], None] | None = None,
15
+ suppress_exceptions: bool = False,
16
+ ) -> Callable[..., R]:
17
+ @wraps(fn)
18
+ def wrapper(*args: Any, **kwargs: Any) -> R:
19
+ if before:
20
+ before()
21
+ try:
22
+ result = fn(*args, **kwargs)
23
+ if on_success:
24
+ on_success(result)
25
+ return result
26
+ except Exception as e:
27
+ if on_failure:
28
+ on_failure(e)
29
+ if suppress_exceptions:
30
+ return None # type: ignore
31
+ raise
32
+
33
+ return wrapper
34
+
35
+
36
+ def wrap_with_callbacks_async(
37
+ fn: Callable[..., Awaitable[R]],
38
+ *,
39
+ before: Callable[..., None] | None = None,
40
+ on_success: Callable[[R], None] | None = None,
41
+ on_failure: Callable[[Exception], None] | None = None,
42
+ suppress_exceptions: bool = False,
43
+ ) -> Callable[..., Awaitable[R]]:
44
+ @wraps(fn)
45
+ async def wrapper(*args: Any, **kwargs: Any) -> R:
46
+ if before:
47
+ before()
48
+ try:
49
+ result = await fn(*args, **kwargs)
50
+ if on_success:
51
+ on_success(result)
52
+ return result
53
+ except Exception as e:
54
+ if on_failure:
55
+ on_failure(e)
56
+ if suppress_exceptions:
57
+ return None # type: ignore
58
+ raise
59
+
60
+ return wrapper
61
+
62
+
63
+ @overload
64
+ def wrap_with_callbacks(
65
+ fn: Callable[..., Awaitable[R]],
66
+ *,
67
+ before: Callable[[], None] | None = ...,
68
+ on_success: Callable[[R], None] | None = ...,
69
+ on_failure: Callable[[Exception], None] | None = ...,
70
+ suppress_exceptions: bool = ...,
71
+ ) -> Callable[..., Awaitable[R]]: ...
72
+
73
+
74
+ @overload
75
+ def wrap_with_callbacks(
76
+ *,
77
+ before: Callable[..., None] | None = ...,
78
+ on_success: Callable[[Any], None] | None = ...,
79
+ on_failure: Callable[[Exception], None] | None = ...,
80
+ suppress_exceptions: bool = ...,
81
+ ) -> Callable[[Callable[..., R]], Callable[..., R]]: ...
82
+
83
+
84
+ @overload
85
+ def wrap_with_callbacks(
86
+ fn: Callable[..., R],
87
+ *,
88
+ before: Callable[[], None] | None = ...,
89
+ on_success: Callable[[R], None] | None = ...,
90
+ on_failure: Callable[[Exception], None] | None = ...,
91
+ suppress_exceptions: bool = ...,
92
+ ) -> Callable[..., R]: ...
93
+
94
+
95
+ def wrap_with_callbacks(
96
+ fn: Callable[..., Any] | None = None,
97
+ *,
98
+ before: Callable[[], None] | None = None,
99
+ on_success: Callable[[Any], None] | None = None,
100
+ on_failure: Callable[[Exception], None] | None = None,
101
+ suppress_exceptions: bool = False,
102
+ ) -> Any:
103
+ def decorator(func: Callable[..., Any]) -> Any:
104
+ if asyncio.iscoroutinefunction(func):
105
+ return wrap_with_callbacks_async(
106
+ cast(Callable[..., Awaitable[Any]], func),
107
+ before=before,
108
+ on_success=on_success,
109
+ on_failure=on_failure,
110
+ suppress_exceptions=suppress_exceptions,
111
+ )
112
+ else:
113
+ return wrap_with_callbacks_sync(
114
+ cast(Callable[..., Any], func),
115
+ before=before,
116
+ on_success=on_success,
117
+ on_failure=on_failure,
118
+ suppress_exceptions=suppress_exceptions,
119
+ )
120
+
121
+ if fn is None:
122
+ return decorator
123
+ else:
124
+ return decorator(fn)
@@ -0,0 +1,6 @@
1
+ class ControllerErrors(Exception):
2
+ def __init__(self, message: str):
3
+ self.message = message
4
+
5
+ def __str__(self):
6
+ return self.message
@@ -0,0 +1,13 @@
1
+ import json
2
+ import re
3
+ from typing import IO
4
+
5
+
6
+ def strip_json_comments(text: str) -> str:
7
+ text = re.sub(r"//.*?$", "", text, flags=re.MULTILINE)
8
+ text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
9
+ return text
10
+
11
+
12
+ def load_jsonc(file: IO) -> dict:
13
+ return json.loads(strip_json_comments(file.read()))
@@ -0,0 +1,183 @@
1
+ import logging
2
+ import sys
3
+ from enum import Enum
4
+ from pathlib import Path
5
+
6
+ from colorama import Fore, Style, init
7
+
8
+ init(autoreset=True)
9
+
10
+
11
+ class LogLevel(Enum):
12
+ DEBUG = ("DEBUG", Fore.MAGENTA, "🔍")
13
+ INFO = ("INFO", Fore.WHITE, "ℹ")
14
+ SUCCESS = ("SUCCESS", Fore.GREEN, "✓")
15
+ WARNING = ("WARNING", Fore.YELLOW, "⚠")
16
+ ERROR = ("ERROR", Fore.RED, "❌")
17
+ CRITICAL = ("CRITICAL", Fore.RED + Style.BRIGHT, "💥")
18
+
19
+
20
+ class MobileUseLogger:
21
+ def __init__(
22
+ self,
23
+ name: str,
24
+ log_file: str | Path | None = None,
25
+ console_level: str = "INFO",
26
+ file_level: str = "DEBUG",
27
+ enable_file_logging: bool = True,
28
+ ):
29
+ """
30
+ Initialize the MobileUse logger.
31
+
32
+ Args:
33
+ name: Logger name (usually __name__)
34
+ log_file: Path to log file (defaults to logs/{name}.log)
35
+ console_level: Minimum level for console output
36
+ file_level: Minimum level for file output
37
+ enable_file_logging: Whether to enable file logging
38
+ """
39
+ self.name = name
40
+ self.logger = logging.getLogger(name)
41
+ self.logger.setLevel(logging.DEBUG)
42
+
43
+ self.logger.handlers.clear()
44
+
45
+ self._setup_console_handler(console_level)
46
+
47
+ if enable_file_logging:
48
+ self._setup_file_handler(log_file, file_level)
49
+
50
+ def _setup_console_handler(self, level: str):
51
+ console_handler = logging.StreamHandler(sys.stdout)
52
+ console_handler.setLevel(getattr(logging, level.upper()))
53
+
54
+ console_formatter = ColoredFormatter()
55
+ console_handler.setFormatter(console_formatter)
56
+
57
+ self.logger.addHandler(console_handler)
58
+
59
+ def _setup_file_handler(self, log_file: str | Path | None, level: str):
60
+ if log_file is None:
61
+ log_file = Path("logs") / f"{self.name.replace('.', '_')}.log"
62
+
63
+ log_file = Path(log_file)
64
+ log_file.parent.mkdir(parents=True, exist_ok=True)
65
+
66
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
67
+ file_handler.setLevel(getattr(logging, level.upper()))
68
+
69
+ file_formatter = logging.Formatter(
70
+ fmt="%(asctime)s | %(name)s | %(levelname)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
71
+ )
72
+ file_handler.setFormatter(file_formatter)
73
+
74
+ self.logger.addHandler(file_handler)
75
+
76
+ def debug(self, message: str, **kwargs):
77
+ self.logger.debug(message, extra={"log_level": LogLevel.DEBUG}, **kwargs)
78
+
79
+ def info(self, message: str, **kwargs):
80
+ self.logger.info(message, extra={"log_level": LogLevel.INFO}, **kwargs)
81
+
82
+ def success(self, message: str, **kwargs):
83
+ self.logger.info(message, extra={"log_level": LogLevel.SUCCESS}, **kwargs)
84
+
85
+ def warning(self, message: str, **kwargs):
86
+ self.logger.warning(message, extra={"log_level": LogLevel.WARNING}, **kwargs)
87
+
88
+ def error(self, message: str, **kwargs):
89
+ self.logger.error(message, extra={"log_level": LogLevel.ERROR}, **kwargs)
90
+
91
+ def critical(self, message: str, **kwargs):
92
+ self.logger.critical(message, extra={"log_level": LogLevel.CRITICAL}, **kwargs)
93
+
94
+ def header(self, message: str, **_kwargs):
95
+ separator = "=" * 60
96
+ colored_separator = f"{Fore.CYAN}{separator}{Style.RESET_ALL}"
97
+ colored_message = f"{Fore.CYAN}{message}{Style.RESET_ALL}"
98
+
99
+ print(colored_separator)
100
+ print(colored_message)
101
+ print(colored_separator)
102
+ self.logger.info(f"\n{separator}\n{message}\n{separator}")
103
+
104
+
105
+ class ColoredFormatter(logging.Formatter):
106
+ def format(self, record):
107
+ log_level = getattr(record, "log_level", LogLevel.INFO)
108
+ _level_name, color, symbol = log_level.value
109
+
110
+ colored_message = f"{color}{symbol} {record.getMessage()}{Style.RESET_ALL}"
111
+
112
+ return colored_message
113
+
114
+
115
+ _loggers = {}
116
+
117
+
118
+ def get_logger(
119
+ name: str,
120
+ log_file: str | Path | None = None,
121
+ console_level: str = "INFO",
122
+ file_level: str = "DEBUG",
123
+ enable_file_logging: bool = False,
124
+ ) -> MobileUseLogger:
125
+ """
126
+ Get or create a logger instance.
127
+
128
+ Args:
129
+ name: Logger name (usually __name__)
130
+ log_file: Path to log file (defaults to logs/{name}.log)
131
+ console_level: Minimum level for console output
132
+ file_level: Minimum level for file output
133
+ enable_file_logging: Whether to enable file logging
134
+
135
+ Returns:
136
+ MobileUseLogger instance
137
+ """
138
+ if name not in _loggers:
139
+ _loggers[name] = MobileUseLogger(
140
+ name=name,
141
+ log_file=log_file,
142
+ console_level=console_level,
143
+ file_level=file_level,
144
+ enable_file_logging=enable_file_logging,
145
+ )
146
+
147
+ return _loggers[name]
148
+
149
+
150
+ def log_debug(message: str, logger_name: str = "mobile-use"):
151
+ get_logger(logger_name).debug(message)
152
+
153
+
154
+ def log_info(message: str, logger_name: str = "mobile-use"):
155
+ get_logger(logger_name).info(message)
156
+
157
+
158
+ def log_success(message: str, logger_name: str = "mobile-use"):
159
+ get_logger(logger_name).success(message)
160
+
161
+
162
+ def log_warning(message: str, logger_name: str = "mobile-use"):
163
+ get_logger(logger_name).warning(message)
164
+
165
+
166
+ def log_error(message: str, logger_name: str = "mobile-use"):
167
+ get_logger(logger_name).error(message)
168
+
169
+
170
+ def log_critical(message: str, logger_name: str = "mobile-use"):
171
+ get_logger(logger_name).critical(message)
172
+
173
+
174
+ def log_header(message: str, logger_name: str = "mobile-use"):
175
+ get_logger(logger_name).header(message)
176
+
177
+
178
+ def get_server_logger() -> MobileUseLogger:
179
+ return get_logger(
180
+ name="mobile-use.servers",
181
+ console_level="INFO",
182
+ file_level="DEBUG",
183
+ )
@@ -0,0 +1,186 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from PIL import Image
8
+
9
+ USE_FFMPEG_GIF = os.environ.get("USE_FFMPEG_GIF", "").lower() in ("1", "true", "yes")
10
+
11
+
12
+ def quantize_and_save_gif_from_paths(
13
+ image_paths: list[Path],
14
+ output_path: Path,
15
+ colors: int = 128,
16
+ duration: int = 100,
17
+ ) -> None:
18
+ """
19
+ Create an optimized GIF from image file paths.
20
+
21
+ By default uses PIL (loads all frames into memory).
22
+ Set USE_FFMPEG_GIF=1 env var to use ffmpeg for memory-efficient streaming.
23
+
24
+ Args:
25
+ image_paths: List of paths to image files (must be sorted in desired order)
26
+ output_path: Path where the GIF will be saved
27
+ colors: Number of colors to use in quantization (lower = smaller file)
28
+ duration: Duration of each frame in milliseconds
29
+
30
+ Raises:
31
+ ValueError: If image_paths list is empty
32
+ RuntimeError: If ffmpeg fails (when USE_FFMPEG_GIF is enabled)
33
+ """
34
+ if not image_paths:
35
+ raise ValueError("image_paths must not be empty")
36
+
37
+ if USE_FFMPEG_GIF:
38
+ _save_gif_ffmpeg(image_paths, output_path, colors, duration)
39
+ else:
40
+ _save_gif_pillow(image_paths, output_path, colors, duration)
41
+
42
+
43
+ def _save_gif_pillow(
44
+ image_paths: list[Path],
45
+ output_path: Path,
46
+ colors: int,
47
+ duration: int,
48
+ ) -> None:
49
+ """Create GIF using PIL (loads all frames into memory)."""
50
+
51
+ def frame_generator():
52
+ for path in image_paths:
53
+ with Image.open(path) as img:
54
+ if img.mode != "RGB":
55
+ img = img.convert("RGB")
56
+ yield img.quantize(colors=colors, method=2)
57
+
58
+ frames = frame_generator()
59
+ first_frame = next(frames)
60
+ first_frame.save(
61
+ output_path,
62
+ save_all=True,
63
+ append_images=frames,
64
+ loop=0,
65
+ optimize=True,
66
+ duration=duration,
67
+ )
68
+
69
+
70
+ def _save_gif_ffmpeg(
71
+ image_paths: list[Path],
72
+ output_path: Path,
73
+ colors: int,
74
+ duration: int,
75
+ ) -> None:
76
+ """Create GIF using ffmpeg (memory-efficient streaming)."""
77
+ fps = 1000 / duration
78
+
79
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
80
+ for i, path in enumerate(image_paths):
81
+ f.write(f"file '{path.absolute()}'\n")
82
+ # Last file needs no duration (ffmpeg concat demuxer uses it as the final frame)
83
+ if i < len(image_paths) - 1:
84
+ f.write(f"duration {duration / 1000}\n")
85
+ # Repeat last file to ensure it's included (ffmpeg concat demuxer quirk)
86
+ f.write(f"file '{image_paths[-1].absolute()}'\n")
87
+ concat_file = Path(f.name)
88
+
89
+ palette_path = output_path.with_suffix(".palette.png")
90
+
91
+ try:
92
+ result = subprocess.run(
93
+ [
94
+ "ffmpeg",
95
+ "-y",
96
+ "-f",
97
+ "concat",
98
+ "-safe",
99
+ "0",
100
+ "-i",
101
+ str(concat_file),
102
+ "-vf",
103
+ f"palettegen=max_colors={min(colors, 256)}:stats_mode=diff",
104
+ str(palette_path),
105
+ ],
106
+ capture_output=True,
107
+ text=True,
108
+ )
109
+ if result.returncode != 0:
110
+ raise RuntimeError(f"ffmpeg palette generation failed: {result.stderr}")
111
+
112
+ result = subprocess.run(
113
+ [
114
+ "ffmpeg",
115
+ "-y",
116
+ "-f",
117
+ "concat",
118
+ "-safe",
119
+ "0",
120
+ "-i",
121
+ str(concat_file),
122
+ "-i",
123
+ str(palette_path),
124
+ "-lavfi",
125
+ f"fps={fps},paletteuse=dither=bayer:bayer_scale=5",
126
+ "-loop",
127
+ "0",
128
+ str(output_path),
129
+ ],
130
+ capture_output=True,
131
+ text=True,
132
+ )
133
+ if result.returncode != 0:
134
+ raise RuntimeError(f"ffmpeg GIF creation failed: {result.stderr}")
135
+
136
+ finally:
137
+ concat_file.unlink(missing_ok=True)
138
+ if palette_path.exists():
139
+ palette_path.unlink()
140
+
141
+
142
+ def create_gif_from_trace_folder(trace_folder_path: Path):
143
+ image_files: list[Path] = []
144
+
145
+ for file in trace_folder_path.iterdir():
146
+ if file.suffix == ".jpeg":
147
+ image_files.append(file)
148
+
149
+ image_files.sort(key=lambda f: int(f.stem))
150
+
151
+ print(f"Found {len(image_files)} images to compile")
152
+
153
+ if not image_files:
154
+ return
155
+
156
+ gif_path = trace_folder_path / "trace.gif"
157
+ quantize_and_save_gif_from_paths(image_files, gif_path)
158
+ print(f"GIF created at {gif_path}")
159
+
160
+
161
+ def remove_images_from_trace_folder(trace_folder_path: Path):
162
+ for file in trace_folder_path.iterdir():
163
+ if file.suffix == ".jpeg":
164
+ file.unlink()
165
+
166
+
167
+ def create_steps_json_from_trace_folder(trace_folder_path: Path):
168
+ steps = []
169
+ for file in trace_folder_path.iterdir():
170
+ if file.suffix == ".json":
171
+ with open(file, encoding="utf-8", errors="ignore") as f:
172
+ json_content = f.read()
173
+ steps.append({"timestamp": int(file.stem), "data": json_content})
174
+
175
+ steps.sort(key=lambda f: f["timestamp"])
176
+
177
+ print("Found " + str(len(steps)) + " steps to compile")
178
+
179
+ with open(trace_folder_path / "steps.json", "w", encoding="utf-8", errors="ignore") as f:
180
+ f.write(json.dumps(steps))
181
+
182
+
183
+ def remove_steps_json_from_trace_folder(trace_folder_path: Path):
184
+ for file in trace_folder_path.iterdir():
185
+ if file.suffix == ".json" and file.name != "steps.json":
186
+ file.unlink()
@@ -0,0 +1,52 @@
1
+ import base64
2
+ import time
3
+
4
+ from colorama import Fore, Style
5
+ from langchain_core.messages import BaseMessage
6
+
7
+ from minitap.mobile_use.context import MobileUseContext
8
+ from minitap.mobile_use.controllers.controller_factory import create_device_controller
9
+ from minitap.mobile_use.utils.logger import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ async def record_interaction(ctx: MobileUseContext, response: BaseMessage):
15
+ if not ctx.execution_setup:
16
+ raise ValueError("No execution setup found")
17
+ if not ctx.execution_setup.traces_path or not ctx.execution_setup.trace_name:
18
+ raise ValueError("No traces path or trace name found")
19
+
20
+ logger.info("Recording interaction")
21
+ controller = create_device_controller(ctx)
22
+ screenshot_base64 = await controller.screenshot()
23
+ logger.info("Screenshot taken")
24
+ try:
25
+ controller = create_device_controller(ctx)
26
+ compressed_screenshot_base64 = controller.get_compressed_b64_screenshot(screenshot_base64)
27
+ except Exception as e:
28
+ logger.error(f"Error compressing screenshot: {e}")
29
+ return "Could not record this interaction"
30
+ timestamp = time.time()
31
+ folder = ctx.execution_setup.traces_path.joinpath(ctx.execution_setup.trace_name).resolve()
32
+ folder.mkdir(parents=True, exist_ok=True)
33
+ try:
34
+ with open(
35
+ folder.joinpath(f"{int(timestamp)}.jpeg").resolve(),
36
+ "wb",
37
+ ) as f:
38
+ f.write(base64.b64decode(compressed_screenshot_base64))
39
+
40
+ with open(
41
+ folder.joinpath(f"{int(timestamp)}.json").resolve(),
42
+ "w",
43
+ encoding="utf-8",
44
+ ) as f:
45
+ f.write(response.model_dump_json())
46
+ except Exception as e:
47
+ logger.error(f"Error recording interaction: {e}")
48
+ return "Screenshot recorded successfully"
49
+
50
+
51
+ def log_agent_thought(agent_thought: str):
52
+ logger.info(f"💭 {Fore.LIGHTMAGENTA_EX}{agent_thought}{Style.RESET_ALL}")
@@ -0,0 +1,37 @@
1
+ import requests
2
+ from minitap.mobile_use.utils.logger import get_logger
3
+
4
+ logger = get_logger(__name__)
5
+
6
+
7
+ def curl_from_request(req: requests.PreparedRequest) -> str:
8
+ """Converts a requests.PreparedRequest object to a valid cURL command string."""
9
+ command = ["curl", f"-X {req.method}"]
10
+
11
+ for key, value in req.headers.items():
12
+ command.append(f'-H "{key}: {value}"')
13
+
14
+ if req.body:
15
+ body = req.body
16
+ if isinstance(body, bytes):
17
+ body = body.decode("utf-8")
18
+ # Escape single quotes in the body for shell safety
19
+ body = body.replace("'", "'\\''")
20
+ command.append(f"-d '{body}'")
21
+
22
+ command.append(f"'{req.url}'")
23
+
24
+ return " ".join(command)
25
+
26
+
27
+ def logging_hook(response, *args, **kwargs):
28
+ """Hook to log the request as a cURL command."""
29
+ curl_command = curl_from_request(response.request)
30
+ logger.debug(f"\n--- cURL Command ---\n{curl_command}\n--------------------")
31
+
32
+
33
+ def get_session_with_curl_logging() -> requests.Session:
34
+ """Returns a requests.Session with cURL logging enabled."""
35
+ session = requests.Session()
36
+ session.hooks["response"] = [logging_hook]
37
+ return session
@@ -0,0 +1,20 @@
1
+ import subprocess
2
+
3
+
4
+ def run_shell_command_on_host(command: str) -> str:
5
+ """Helper to run a shell command on the host and return the output."""
6
+ try:
7
+ result = subprocess.run(
8
+ command,
9
+ shell=True,
10
+ check=True,
11
+ capture_output=True,
12
+ text=True,
13
+ )
14
+ return result.stdout.strip()
15
+ except subprocess.CalledProcessError as e:
16
+ # Log the error and stderr for better debugging
17
+ error_message = (
18
+ f"Command '{command}' failed with exit code {e.returncode}.\nStderr: {e.stderr.strip()}"
19
+ )
20
+ raise RuntimeError(error_message) from e