droidrun 0.3.10.dev3__py3-none-any.whl → 0.3.10.dev5__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 (54) hide show
  1. droidrun/agent/codeact/__init__.py +1 -4
  2. droidrun/agent/codeact/codeact_agent.py +95 -86
  3. droidrun/agent/codeact/events.py +1 -2
  4. droidrun/agent/context/__init__.py +5 -9
  5. droidrun/agent/context/episodic_memory.py +1 -3
  6. droidrun/agent/context/task_manager.py +8 -2
  7. droidrun/agent/droid/droid_agent.py +102 -141
  8. droidrun/agent/droid/events.py +45 -14
  9. droidrun/agent/executor/__init__.py +6 -4
  10. droidrun/agent/executor/events.py +29 -9
  11. droidrun/agent/executor/executor_agent.py +86 -28
  12. droidrun/agent/executor/prompts.py +8 -2
  13. droidrun/agent/manager/__init__.py +6 -7
  14. droidrun/agent/manager/events.py +16 -4
  15. droidrun/agent/manager/manager_agent.py +130 -69
  16. droidrun/agent/manager/prompts.py +1 -159
  17. droidrun/agent/utils/chat_utils.py +64 -2
  18. droidrun/agent/utils/device_state_formatter.py +54 -26
  19. droidrun/agent/utils/executer.py +66 -80
  20. droidrun/agent/utils/inference.py +11 -10
  21. droidrun/agent/utils/tools.py +58 -6
  22. droidrun/agent/utils/trajectory.py +18 -12
  23. droidrun/cli/logs.py +118 -56
  24. droidrun/cli/main.py +154 -136
  25. droidrun/config_manager/__init__.py +9 -7
  26. droidrun/config_manager/app_card_loader.py +148 -0
  27. droidrun/config_manager/config_manager.py +200 -102
  28. droidrun/config_manager/path_resolver.py +104 -0
  29. droidrun/config_manager/prompt_loader.py +75 -0
  30. droidrun/macro/__init__.py +1 -1
  31. droidrun/macro/cli.py +23 -18
  32. droidrun/telemetry/__init__.py +2 -2
  33. droidrun/telemetry/events.py +3 -3
  34. droidrun/telemetry/tracker.py +1 -1
  35. droidrun/tools/adb.py +1 -1
  36. droidrun/tools/ios.py +3 -2
  37. {droidrun-0.3.10.dev3.dist-info → droidrun-0.3.10.dev5.dist-info}/METADATA +10 -4
  38. droidrun-0.3.10.dev5.dist-info/RECORD +61 -0
  39. droidrun/agent/codeact/prompts.py +0 -26
  40. droidrun/agent/context/agent_persona.py +0 -16
  41. droidrun/agent/context/context_injection_manager.py +0 -66
  42. droidrun/agent/context/personas/__init__.py +0 -11
  43. droidrun/agent/context/personas/app_starter.py +0 -44
  44. droidrun/agent/context/personas/big_agent.py +0 -96
  45. droidrun/agent/context/personas/default.py +0 -95
  46. droidrun/agent/context/personas/ui_expert.py +0 -108
  47. droidrun/agent/planner/__init__.py +0 -13
  48. droidrun/agent/planner/events.py +0 -21
  49. droidrun/agent/planner/planner_agent.py +0 -311
  50. droidrun/agent/planner/prompts.py +0 -124
  51. droidrun-0.3.10.dev3.dist-info/RECORD +0 -70
  52. {droidrun-0.3.10.dev3.dist-info → droidrun-0.3.10.dev5.dist-info}/WHEEL +0 -0
  53. {droidrun-0.3.10.dev3.dist-info → droidrun-0.3.10.dev5.dist-info}/entry_points.txt +0 -0
  54. {droidrun-0.3.10.dev3.dist-info → droidrun-0.3.10.dev5.dist-info}/licenses/LICENSE +0 -0
droidrun/cli/main.py CHANGED
@@ -8,15 +8,25 @@ import os
8
8
  import warnings
9
9
  from contextlib import nullcontext
10
10
  from functools import wraps
11
+ from pathlib import Path
11
12
 
12
13
  import click
13
14
  from adbutils import adb
14
15
  from rich.console import Console
15
16
 
16
- from droidrun.agent.context.personas import BIG_AGENT, DEFAULT
17
17
  from droidrun.agent.droid import DroidAgent
18
18
  from droidrun.agent.utils.llm_picker import load_llm
19
19
  from droidrun.cli.logs import LogHandler
20
+ from droidrun.config_manager.config_manager import (
21
+ AgentConfig,
22
+ CodeActConfig,
23
+ ManagerConfig,
24
+ ExecutorConfig,
25
+ DeviceConfig,
26
+ LoggingConfig,
27
+ ToolsConfig,
28
+ TracingConfig,
29
+ )
20
30
  from droidrun.macro.cli import macro_cli
21
31
  from droidrun.portal import (
22
32
  PORTAL_PACKAGE_NAME,
@@ -28,8 +38,8 @@ from droidrun.portal import (
28
38
  )
29
39
  from droidrun.telemetry import print_telemetry_message
30
40
  from droidrun.tools import AdbTools, IOSTools
31
- from droidrun.config_manager import config
32
- from droidrun.config_manager.config_manager import VisionConfig
41
+ from droidrun.config_manager import ConfigManager
42
+
33
43
 
34
44
  # Suppress all warnings
35
45
  warnings.filterwarnings("ignore")
@@ -39,11 +49,11 @@ os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false"
39
49
  console = Console()
40
50
 
41
51
 
42
- def configure_logging(goal: str, debug: bool):
52
+ def configure_logging(goal: str, debug: bool, rich_text: bool = True):
43
53
  logger = logging.getLogger("droidrun")
44
54
  logger.handlers = []
45
55
 
46
- handler = LogHandler(goal)
56
+ handler = LogHandler(goal, rich_text=rich_text)
47
57
  handler.setFormatter(
48
58
  logging.Formatter("%(levelname)s %(name)s %(message)s", "%H:%M:%S")
49
59
  if debug
@@ -74,20 +84,21 @@ def coro(f):
74
84
  @coro
75
85
  async def run_command(
76
86
  command: str,
77
- device: str | None,
78
- provider: str | None,
79
- model: str | None,
80
- steps: int | None,
81
- base_url: str | None,
82
- api_base: str | None,
83
- vision: bool | None,
84
- manager_vision: bool | None,
85
- executor_vision: bool | None,
86
- codeact_vision: bool | None,
87
- reasoning: bool | None,
88
- tracing: bool | None,
89
- debug: bool | None,
90
- use_tcp: bool | None,
87
+ config_path: str | None = None,
88
+ device: str | None = None,
89
+ provider: str | None = None,
90
+ model: str | None = None,
91
+ steps: int | None = None,
92
+ base_url: str | None = None,
93
+ api_base: str | None = None,
94
+ vision: bool | None = None,
95
+ manager_vision: bool | None = None,
96
+ executor_vision: bool | None = None,
97
+ codeact_vision: bool | None = None,
98
+ reasoning: bool | None = None,
99
+ tracing: bool | None = None,
100
+ debug: bool | None = None,
101
+ use_tcp: bool | None = None,
91
102
  save_trajectory: str | None = None,
92
103
  ios: bool = False,
93
104
  allow_drag: bool | None = None,
@@ -95,9 +106,11 @@ async def run_command(
95
106
  **kwargs,
96
107
  ):
97
108
  """Run a command on your Android device using natural language."""
109
+ # Load custom config if provided
110
+ config = ConfigManager(config_path)
98
111
  # Initialize logging first (use config default if debug not specified)
99
112
  debug_mode = debug if debug is not None else config.logging.debug
100
- log_handler = configure_logging(command, debug_mode)
113
+ log_handler = configure_logging(command, debug_mode, config.logging.rich_text)
101
114
  logger = logging.getLogger("droidrun")
102
115
 
103
116
  log_handler.update_step("Initializing...")
@@ -108,115 +121,105 @@ async def run_command(
108
121
  print_telemetry_message()
109
122
 
110
123
  # ================================================================
111
- # STEP 1: Load base configuration from config.yaml
124
+ # STEP 1: Build config objects with CLI overrides
112
125
  # ================================================================
113
-
114
- max_steps = config.agent.max_steps
115
- reasoning_mode = config.agent.reasoning
116
- vision_config = config.agent.vision
117
- device_serial = config.device.serial
118
- use_tcp_mode = config.device.use_tcp
119
- debug_mode = config.logging.debug
120
- save_traj = config.logging.save_trajectory
121
- tracing_enabled = config.tracing.enabled
122
- allow_drag_tool = config.tools.allow_drag
123
-
124
- # ================================================================
125
- # STEP 2: Apply CLI overrides if explicitly specified
126
- # ================================================================
127
-
128
- if steps is not None:
129
- max_steps = steps
130
- logger.debug(f"CLI override: max_steps={max_steps}")
131
-
132
- if reasoning is not None:
133
- reasoning_mode = reasoning
134
- logger.debug(f"CLI override: reasoning={reasoning_mode}")
135
-
136
- if debug is not None:
137
- debug_mode = debug
138
- logger.debug(f"CLI override: debug={debug_mode}")
139
-
140
- if tracing is not None:
141
- tracing_enabled = tracing
142
- logger.debug(f"CLI override: tracing={tracing_enabled}")
143
-
144
- if save_trajectory is not None:
145
- save_traj = save_trajectory
146
- logger.debug(f"CLI override: save_trajectory={save_traj}")
147
-
148
- if use_tcp is not None:
149
- use_tcp_mode = use_tcp
150
- logger.debug(f"CLI override: use_tcp={use_tcp_mode}")
151
-
152
- if device is not None:
153
- device_serial = device
154
- logger.debug(f"CLI override: device={device_serial}")
155
-
156
- if allow_drag is not None:
157
- allow_drag_tool = allow_drag
158
- logger.debug(f"CLI override: allow_drag={allow_drag_tool}")
159
-
160
- # Override vision settings
126
+
127
+ # Build agent-specific configs with vision overrides
161
128
  if vision is not None:
162
- # User specified --vision, apply to all agents
163
- vision_config = VisionConfig(manager=vision, executor=vision, codeact=vision)
129
+ # --vision flag overrides all agents
130
+ manager_vision_val = vision
131
+ executor_vision_val = vision
132
+ codeact_vision_val = vision
164
133
  logger.debug(f"CLI override: vision={vision} (all agents)")
165
134
  else:
166
- # Check for per-agent vision overrides
167
- vision_config = VisionConfig(
168
- manager=vision_config.manager,
169
- executor=vision_config.executor,
170
- codeact=vision_config.codeact
171
- )
172
- if manager_vision is not None:
173
- vision_config.manager = manager_vision
174
- logger.debug(f"CLI override: manager_vision={manager_vision}")
175
-
176
- if executor_vision is not None:
177
- vision_config.executor = executor_vision
178
- logger.debug(f"CLI override: executor_vision={executor_vision}")
179
-
180
- if codeact_vision is not None:
181
- vision_config.codeact = codeact_vision
182
- logger.debug(f"CLI override: codeact_vision={codeact_vision}")
183
-
135
+ # Use individual overrides or config defaults
136
+ manager_vision_val = manager_vision if manager_vision is not None else config.agent.manager.vision
137
+ executor_vision_val = executor_vision if executor_vision is not None else config.agent.executor.vision
138
+ codeact_vision_val = codeact_vision if codeact_vision is not None else config.agent.codeact.vision
139
+
140
+ manager_cfg = ManagerConfig(
141
+ vision=manager_vision_val,
142
+ system_prompt=config.agent.manager.system_prompt
143
+ )
144
+
145
+ executor_cfg = ExecutorConfig(
146
+ vision=executor_vision_val,
147
+ system_prompt=config.agent.executor.system_prompt
148
+ )
149
+
150
+ codeact_cfg = CodeActConfig(
151
+ vision=codeact_vision_val,
152
+ system_prompt=config.agent.codeact.system_prompt,
153
+ user_prompt=config.agent.codeact.user_prompt
154
+ )
155
+
156
+ agent_cfg = AgentConfig(
157
+ max_steps=steps if steps is not None else config.agent.max_steps,
158
+ reasoning=reasoning if reasoning is not None else config.agent.reasoning,
159
+ after_sleep_action=config.agent.after_sleep_action,
160
+ wait_for_stable_ui=config.agent.wait_for_stable_ui,
161
+ prompts_dir=config.agent.prompts_dir,
162
+ manager=manager_cfg,
163
+ executor=executor_cfg,
164
+ codeact=codeact_cfg,
165
+ app_cards=config.agent.app_cards,
166
+ )
167
+
168
+ device_cfg = DeviceConfig(
169
+ serial=device if device is not None else config.device.serial,
170
+ use_tcp=use_tcp if use_tcp is not None else config.device.use_tcp,
171
+ )
172
+
173
+ tools_cfg = ToolsConfig(
174
+ allow_drag=allow_drag if allow_drag is not None else config.tools.allow_drag,
175
+ )
176
+
177
+ logging_cfg = LoggingConfig(
178
+ debug=debug if debug is not None else config.logging.debug,
179
+ save_trajectory=save_trajectory if save_trajectory is not None else config.logging.save_trajectory,
180
+ rich_text=config.logging.rich_text,
181
+ )
182
+
183
+ tracing_cfg = TracingConfig(
184
+ enabled=tracing if tracing is not None else config.tracing.enabled,
185
+ )
186
+
184
187
  # ================================================================
185
188
  # STEP 3: Load LLMs
186
189
  # ================================================================
187
-
190
+
188
191
  log_handler.update_step("Loading LLMs...")
189
-
192
+
190
193
  # Check if user wants custom LLM for all agents
191
194
  if provider is not None or model is not None:
192
195
  # User specified custom provider/model - use for all agents
193
196
  logger.info("🔧 Using custom LLM for all agents")
194
-
197
+
195
198
  # Use provided values or fall back to first profile's defaults
196
199
  if provider is None:
197
200
  provider = list(config.llm_profiles.values())[0].provider
198
201
  if model is None:
199
202
  model = list(config.llm_profiles.values())[0].model
200
-
203
+
201
204
  # Build kwargs
202
205
  llm_kwargs = {}
203
206
  if temperature is not None:
204
207
  llm_kwargs['temperature'] = temperature
205
208
  else:
206
- llm_kwargs['temperature'] = kwargs.get('temperature', 1)
209
+ llm_kwargs['temperature'] = kwargs.get('temperature', 0.3)
207
210
  if base_url is not None:
208
211
  llm_kwargs['base_url'] = base_url
209
212
  if api_base is not None:
210
213
  llm_kwargs['api_base'] = api_base
211
214
  llm_kwargs.update(kwargs)
212
-
215
+
213
216
  # Load single LLM for all agents
214
217
  custom_llm = load_llm(
215
218
  provider_name=provider,
216
219
  model=model,
217
220
  **llm_kwargs
218
221
  )
219
-
222
+
220
223
  # Use same LLM for all agents
221
224
  llms = {
222
225
  'manager': custom_llm,
@@ -229,87 +232,82 @@ async def run_command(
229
232
  else:
230
233
  # No custom provider/model - use profiles from config
231
234
  logger.info("📋 Loading LLMs from config profiles...")
232
-
235
+
233
236
  profile_names = ['manager', 'executor', 'codeact', 'text_manipulator', 'app_opener']
234
-
237
+
235
238
  # Apply temperature override to all profiles if specified
236
239
  overrides = {}
237
240
  if temperature is not None:
238
241
  overrides = {name: {'temperature': temperature} for name in profile_names}
239
-
242
+
240
243
  llms = config.load_all_llms(profile_names=profile_names, **overrides)
241
244
  logger.info(f"🧠 Loaded {len(llms)} agent-specific LLMs from profiles")
242
-
245
+
243
246
  # ================================================================
244
247
  # STEP 4: Setup device and tools
245
248
  # ================================================================
246
-
249
+
247
250
  log_handler.update_step("Setting up tools...")
248
-
251
+
252
+ device_serial = device_cfg.serial
249
253
  if device_serial is None and not ios:
250
254
  logger.info("🔍 Finding connected device...")
251
255
  devices = adb.list()
252
256
  if not devices:
253
257
  raise ValueError("No connected devices found.")
254
258
  device_serial = devices[0].serial
259
+ device_cfg = DeviceConfig(serial=device_serial, use_tcp=device_cfg.use_tcp)
255
260
  logger.info(f"📱 Using device: {device_serial}")
256
261
  elif device_serial is None and ios:
257
- raise ValueError(
258
- "iOS device not specified. Please specify device base url via --device"
259
- )
262
+ raise ValueError("iOS device not specified. Please specify device base url via --device")
260
263
  else:
261
264
  logger.info(f"📱 Using device: {device_serial}")
262
-
265
+
263
266
  tools = (
264
267
  AdbTools(
265
268
  serial=device_serial,
266
- use_tcp=use_tcp_mode,
269
+ use_tcp=device_cfg.use_tcp,
267
270
  app_opener_llm=llms.get('app_opener'),
268
271
  text_manipulator_llm=llms.get('text_manipulator')
269
272
  )
270
273
  if not ios
271
274
  else IOSTools(url=device_serial)
272
275
  )
273
-
274
- # Set excluded tools based on config/CLI
275
- excluded_tools = [] if allow_drag_tool else ["drag"]
276
-
277
- # Select personas based on drag flag
278
- personas = [BIG_AGENT] if allow_drag_tool else [DEFAULT]
279
-
276
+
277
+ excluded_tools = [] if tools_cfg.allow_drag else ["drag"]
278
+
280
279
  # ================================================================
281
280
  # STEP 5: Initialize DroidAgent with all settings
282
281
  # ================================================================
283
-
282
+
284
283
  log_handler.update_step("Initializing DroidAgent...")
285
-
286
- mode = "planning with reasoning" if reasoning_mode else "direct execution"
284
+
285
+ mode = "planning with reasoning" if agent_cfg.reasoning else "direct execution"
287
286
  logger.info(f"🤖 Agent mode: {mode}")
288
- logger.info(f"👁️ Vision settings: Manager={vision_config.manager}, "
289
- f"Executor={vision_config.executor}, CodeAct={vision_config.codeact}")
290
-
291
- if tracing_enabled:
287
+ logger.info(f"👁️ Vision settings: Manager={agent_cfg.manager.vision}, "
288
+ f"Executor={agent_cfg.executor.vision}, CodeAct={agent_cfg.codeact.vision}")
289
+
290
+ if tracing_cfg.enabled:
292
291
  logger.info("🔍 Tracing enabled")
293
-
292
+
294
293
  droid_agent = DroidAgent(
295
294
  goal=command,
296
295
  llms=llms,
297
- vision=vision_config,
298
296
  tools=tools,
299
- personas=personas,
297
+ config=config,
298
+ agent_config=agent_cfg,
299
+ device_config=device_cfg,
300
+ tools_config=tools_cfg,
301
+ logging_config=logging_cfg,
302
+ tracing_config=tracing_cfg,
300
303
  excluded_tools=excluded_tools,
301
- max_steps=max_steps,
302
304
  timeout=1000,
303
- reasoning=reasoning_mode,
304
- enable_tracing=tracing_enabled,
305
- debug=debug_mode,
306
- save_trajectories=save_traj,
307
305
  )
308
-
306
+
309
307
  # ================================================================
310
308
  # STEP 6: Run agent
311
309
  # ================================================================
312
-
310
+
313
311
  logger.info("▶️ Starting agent execution...")
314
312
  logger.info("Press Ctrl+C to stop")
315
313
  log_handler.update_step("Running agent...")
@@ -332,24 +330,23 @@ async def run_command(
332
330
  log_handler.is_success = False
333
331
  log_handler.current_step = f"Error: {e}"
334
332
  logger.error(f"💥 Error: {e}")
335
- if debug_mode:
333
+ if logging_cfg.debug:
336
334
  import traceback
337
-
338
335
  logger.debug(traceback.format_exc())
339
336
 
340
337
  except Exception as e:
341
338
  log_handler.current_step = f"Error: {e}"
342
339
  logger.error(f"💥 Setup error: {e}")
340
+ debug_mode = debug if debug is not None else config.logging.debug
343
341
  if debug_mode:
344
342
  import traceback
345
-
346
343
  logger.debug(traceback.format_exc())
347
344
 
348
345
 
349
346
  class DroidRunCLI(click.Group):
350
347
  def parse_args(self, ctx, args):
351
348
  # If the first arg is not an option and not a known command, treat as 'run'
352
- if args and """not args[0].startswith("-")""" and args[0] not in self.commands:
349
+ if args and """not args[0].startswith("-")""" and args[0] not in self.commands: # TODO: the string always evaluates to True
353
350
  args.insert(0, "run")
354
351
 
355
352
  return super().parse_args(ctx, args)
@@ -431,6 +428,7 @@ def cli(
431
428
 
432
429
  @cli.command()
433
430
  @click.argument("command", type=str)
431
+ @click.option("--config", "-c", help="Path to custom config file", default=None)
434
432
  @click.option("--device", "-d", help="Device serial number or IP address", default=None)
435
433
  @click.option(
436
434
  "--provider",
@@ -512,6 +510,7 @@ def cli(
512
510
  @click.option("--ios", type=bool, default=None, help="Run on iOS device")
513
511
  def run(
514
512
  command: str,
513
+ config: str | None,
515
514
  device: str | None,
516
515
  provider: str | None,
517
516
  model: str | None,
@@ -532,9 +531,11 @@ def run(
532
531
  ios: bool | None,
533
532
  ):
534
533
  """Run a command on your Android device using natural language."""
534
+
535
535
  # Call our standalone function
536
536
  return run_command(
537
537
  command,
538
+ config,
538
539
  device,
539
540
  provider,
540
541
  model,
@@ -612,6 +613,23 @@ def disconnect(serial: str):
612
613
  )
613
614
  def setup(path: str | None, device: str | None, debug: bool):
614
615
  """Install and enable the DroidRun Portal on a device."""
616
+ from droidrun.config_manager.path_resolver import PathResolver
617
+
618
+ # Ensure config.yaml exists (check working dir, then project dir)
619
+ try:
620
+ config_path = PathResolver.resolve("config.yaml")
621
+ console.print(f"[blue]Using existing config: {config_path}[/]")
622
+ except FileNotFoundError:
623
+ # Config not found, try to create from example
624
+ try:
625
+ example_path = PathResolver.resolve("config_example.yaml")
626
+ config_path = PathResolver.resolve("config.yaml", create_if_missing=True)
627
+
628
+ import shutil
629
+ shutil.copy2(example_path, config_path)
630
+ console.print(f"[blue]Created config.yaml from example at: {config_path}[/]")
631
+ except FileNotFoundError:
632
+ console.print("[yellow]Warning: config_example.yaml not found, config.yaml not created[/]")
615
633
  try:
616
634
  if not device:
617
635
  devices = adb.list()
@@ -757,5 +775,5 @@ if __name__ == "__main__":
757
775
  save_trajectory = "none"
758
776
  allow_drag = False
759
777
  run_command(
760
- command=command
778
+ command
761
779
  )
@@ -1,25 +1,27 @@
1
+ from droidrun.config_manager.app_card_loader import AppCardLoader
1
2
  from droidrun.config_manager.config_manager import (
3
+ AgentConfig,
4
+ AppCardConfig,
2
5
  ConfigManager,
3
- config,
6
+ DeviceConfig,
4
7
  DroidRunConfig,
5
8
  LLMProfile,
6
- AgentConfig,
7
- DeviceConfig,
8
- TelemetryConfig,
9
- TracingConfig,
10
9
  LoggingConfig,
10
+ TelemetryConfig,
11
11
  ToolsConfig,
12
+ TracingConfig,
12
13
  )
13
14
 
14
15
  __all__ = [
15
16
  "ConfigManager",
16
- "config",
17
17
  "DroidRunConfig",
18
18
  "LLMProfile",
19
19
  "AgentConfig",
20
+ "AppCardConfig",
20
21
  "DeviceConfig",
21
22
  "TelemetryConfig",
22
23
  "TracingConfig",
23
24
  "LoggingConfig",
24
25
  "ToolsConfig",
25
- ]
26
+ "AppCardLoader",
27
+ ]
@@ -0,0 +1,148 @@
1
+ """
2
+ App card loading utility for package-specific prompts.
3
+
4
+ Supports flexible file path resolution and caches loaded content.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Dict, Optional
10
+
11
+ from droidrun.config_manager.path_resolver import PathResolver
12
+
13
+
14
+ class AppCardLoader:
15
+ """Load app cards based on package names with content caching."""
16
+
17
+ _mapping_cache: Optional[Dict[str, str]] = None
18
+ _cache_dir: Optional[str] = None
19
+ _content_cache: Dict[str, str] = {}
20
+
21
+ @staticmethod
22
+ def load_app_card(
23
+ package_name: str, app_cards_dir: str = "config/app_cards"
24
+ ) -> str:
25
+ """
26
+ Load app card for a package name.
27
+
28
+ Path resolution:
29
+ - Checks working directory first (for user overrides)
30
+ - Falls back to project directory (for default cards)
31
+ - Supports absolute paths (used as-is)
32
+
33
+ File loading from app_cards.json:
34
+ 1. Relative to app_cards_dir (most common):
35
+ {"com.google.gm": "gmail.md"}
36
+ → {app_cards_dir}/gmail.md
37
+
38
+ 2. Relative path (checks working dir, then project dir):
39
+ {"com.google.gm": "config/custom_cards/gmail.md"}
40
+
41
+ 3. Absolute path:
42
+ {"com.google.gm": "/usr/share/droidrun/cards/gmail.md"}
43
+
44
+ Args:
45
+ package_name: Android package name (e.g., "com.google.android.gm")
46
+ app_cards_dir: Directory path (relative or absolute)
47
+
48
+ Returns:
49
+ App card content or empty string if not found
50
+ """
51
+ if not package_name:
52
+ return ""
53
+
54
+ # Check content cache first (key: package_name:app_cards_dir)
55
+ cache_key = f"{package_name}:{app_cards_dir}"
56
+ if cache_key in AppCardLoader._content_cache:
57
+ return AppCardLoader._content_cache[cache_key]
58
+
59
+ # Load mapping (with cache)
60
+ mapping = AppCardLoader._load_mapping(app_cards_dir)
61
+
62
+ # Get file path from mapping
63
+ if package_name not in mapping:
64
+ # Cache the empty result to avoid repeated lookups
65
+ AppCardLoader._content_cache[cache_key] = ""
66
+ return ""
67
+
68
+ file_path_str = mapping[package_name]
69
+ file_path = Path(file_path_str)
70
+
71
+ # Determine resolution strategy
72
+ if file_path.is_absolute():
73
+ # Absolute path: use as-is
74
+ app_card_path = file_path
75
+ elif file_path_str.startswith(("config/", "prompts/", "docs/")):
76
+ # Project-relative path: resolve with unified resolver
77
+ app_card_path = PathResolver.resolve(file_path_str)
78
+ else:
79
+ # App_cards-relative: resolve dir first, then append filename
80
+ cards_dir_resolved = PathResolver.resolve(app_cards_dir)
81
+ app_card_path = cards_dir_resolved / file_path_str
82
+
83
+ # Read file
84
+ try:
85
+ if not app_card_path.exists():
86
+ # Cache the empty result
87
+ AppCardLoader._content_cache[cache_key] = ""
88
+ return ""
89
+
90
+ content = app_card_path.read_text(encoding="utf-8")
91
+ # Cache the content
92
+ AppCardLoader._content_cache[cache_key] = content
93
+ return content
94
+ except Exception:
95
+ # Cache the empty result on error
96
+ AppCardLoader._content_cache[cache_key] = ""
97
+ return ""
98
+
99
+ @staticmethod
100
+ def _load_mapping(app_cards_dir: str) -> Dict[str, str]:
101
+ """Load and cache the app_cards.json mapping."""
102
+ # Cache invalidation: if dir changed, reload
103
+ if (
104
+ AppCardLoader._mapping_cache is not None
105
+ and AppCardLoader._cache_dir == app_cards_dir
106
+ ):
107
+ return AppCardLoader._mapping_cache
108
+
109
+ # Resolve app cards directory
110
+ cards_dir_resolved = PathResolver.resolve(app_cards_dir)
111
+ mapping_path = cards_dir_resolved / "app_cards.json"
112
+
113
+ try:
114
+ if not mapping_path.exists():
115
+ AppCardLoader._mapping_cache = {}
116
+ AppCardLoader._cache_dir = app_cards_dir
117
+ return {}
118
+
119
+ with open(mapping_path, "r", encoding="utf-8") as f:
120
+ mapping = json.load(f)
121
+
122
+ AppCardLoader._mapping_cache = mapping
123
+ AppCardLoader._cache_dir = app_cards_dir
124
+ return mapping
125
+ except Exception:
126
+ AppCardLoader._mapping_cache = {}
127
+ AppCardLoader._cache_dir = app_cards_dir
128
+ return {}
129
+
130
+ @staticmethod
131
+ def clear_cache() -> None:
132
+ """Clear all caches (useful for testing or runtime reloading)."""
133
+ AppCardLoader._mapping_cache = None
134
+ AppCardLoader._cache_dir = None
135
+ AppCardLoader._content_cache.clear()
136
+
137
+ @staticmethod
138
+ def get_cache_stats() -> Dict[str, int]:
139
+ """
140
+ Get cache statistics.
141
+
142
+ Returns:
143
+ Dict with cache stats (useful for debugging)
144
+ """
145
+ return {
146
+ "mapping_cached": 1 if AppCardLoader._mapping_cache is not None else 0,
147
+ "content_entries": len(AppCardLoader._content_cache),
148
+ }