mindroom 0.0.0__py3-none-any.whl → 0.1.1__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 (155) hide show
  1. mindroom/__init__.py +3 -0
  2. mindroom/agent_prompts.py +963 -0
  3. mindroom/agents.py +248 -0
  4. mindroom/ai.py +421 -0
  5. mindroom/api/__init__.py +1 -0
  6. mindroom/api/credentials.py +137 -0
  7. mindroom/api/google_integration.py +355 -0
  8. mindroom/api/google_tools_helper.py +40 -0
  9. mindroom/api/homeassistant_integration.py +421 -0
  10. mindroom/api/integrations.py +189 -0
  11. mindroom/api/main.py +506 -0
  12. mindroom/api/matrix_operations.py +219 -0
  13. mindroom/api/tools.py +94 -0
  14. mindroom/background_tasks.py +87 -0
  15. mindroom/bot.py +2470 -0
  16. mindroom/cli.py +86 -0
  17. mindroom/commands.py +377 -0
  18. mindroom/config.py +343 -0
  19. mindroom/config_commands.py +324 -0
  20. mindroom/config_confirmation.py +411 -0
  21. mindroom/constants.py +52 -0
  22. mindroom/credentials.py +146 -0
  23. mindroom/credentials_sync.py +134 -0
  24. mindroom/custom_tools/__init__.py +8 -0
  25. mindroom/custom_tools/config_manager.py +765 -0
  26. mindroom/custom_tools/gmail.py +92 -0
  27. mindroom/custom_tools/google_calendar.py +92 -0
  28. mindroom/custom_tools/google_sheets.py +92 -0
  29. mindroom/custom_tools/homeassistant.py +341 -0
  30. mindroom/error_handling.py +35 -0
  31. mindroom/file_watcher.py +49 -0
  32. mindroom/interactive.py +313 -0
  33. mindroom/logging_config.py +207 -0
  34. mindroom/matrix/__init__.py +1 -0
  35. mindroom/matrix/client.py +782 -0
  36. mindroom/matrix/event_info.py +173 -0
  37. mindroom/matrix/identity.py +149 -0
  38. mindroom/matrix/large_messages.py +267 -0
  39. mindroom/matrix/mentions.py +141 -0
  40. mindroom/matrix/message_builder.py +94 -0
  41. mindroom/matrix/message_content.py +209 -0
  42. mindroom/matrix/presence.py +178 -0
  43. mindroom/matrix/rooms.py +311 -0
  44. mindroom/matrix/state.py +77 -0
  45. mindroom/matrix/typing.py +91 -0
  46. mindroom/matrix/users.py +217 -0
  47. mindroom/memory/__init__.py +21 -0
  48. mindroom/memory/config.py +137 -0
  49. mindroom/memory/functions.py +396 -0
  50. mindroom/py.typed +0 -0
  51. mindroom/response_tracker.py +128 -0
  52. mindroom/room_cleanup.py +139 -0
  53. mindroom/routing.py +107 -0
  54. mindroom/scheduling.py +758 -0
  55. mindroom/stop.py +207 -0
  56. mindroom/streaming.py +203 -0
  57. mindroom/teams.py +749 -0
  58. mindroom/thread_utils.py +318 -0
  59. mindroom/tools/__init__.py +520 -0
  60. mindroom/tools/agentql.py +64 -0
  61. mindroom/tools/airflow.py +57 -0
  62. mindroom/tools/apify.py +49 -0
  63. mindroom/tools/arxiv.py +64 -0
  64. mindroom/tools/aws_lambda.py +41 -0
  65. mindroom/tools/aws_ses.py +57 -0
  66. mindroom/tools/baidusearch.py +87 -0
  67. mindroom/tools/brightdata.py +116 -0
  68. mindroom/tools/browserbase.py +62 -0
  69. mindroom/tools/cal_com.py +98 -0
  70. mindroom/tools/calculator.py +112 -0
  71. mindroom/tools/cartesia.py +84 -0
  72. mindroom/tools/composio.py +166 -0
  73. mindroom/tools/config_manager.py +44 -0
  74. mindroom/tools/confluence.py +73 -0
  75. mindroom/tools/crawl4ai.py +101 -0
  76. mindroom/tools/csv.py +104 -0
  77. mindroom/tools/custom_api.py +106 -0
  78. mindroom/tools/dalle.py +85 -0
  79. mindroom/tools/daytona.py +180 -0
  80. mindroom/tools/discord.py +81 -0
  81. mindroom/tools/docker.py +73 -0
  82. mindroom/tools/duckdb.py +124 -0
  83. mindroom/tools/duckduckgo.py +99 -0
  84. mindroom/tools/e2b.py +121 -0
  85. mindroom/tools/eleven_labs.py +77 -0
  86. mindroom/tools/email.py +74 -0
  87. mindroom/tools/exa.py +246 -0
  88. mindroom/tools/fal.py +50 -0
  89. mindroom/tools/file.py +80 -0
  90. mindroom/tools/financial_datasets_api.py +112 -0
  91. mindroom/tools/firecrawl.py +124 -0
  92. mindroom/tools/gemini.py +85 -0
  93. mindroom/tools/giphy.py +49 -0
  94. mindroom/tools/github.py +376 -0
  95. mindroom/tools/gmail.py +102 -0
  96. mindroom/tools/google_calendar.py +55 -0
  97. mindroom/tools/google_maps.py +112 -0
  98. mindroom/tools/google_sheets.py +86 -0
  99. mindroom/tools/googlesearch.py +83 -0
  100. mindroom/tools/groq.py +77 -0
  101. mindroom/tools/hackernews.py +54 -0
  102. mindroom/tools/jina.py +108 -0
  103. mindroom/tools/jira.py +70 -0
  104. mindroom/tools/linear.py +103 -0
  105. mindroom/tools/linkup.py +65 -0
  106. mindroom/tools/lumalabs.py +71 -0
  107. mindroom/tools/mem0.py +82 -0
  108. mindroom/tools/modelslabs.py +85 -0
  109. mindroom/tools/moviepy_video_tools.py +62 -0
  110. mindroom/tools/newspaper4k.py +63 -0
  111. mindroom/tools/openai.py +143 -0
  112. mindroom/tools/openweather.py +89 -0
  113. mindroom/tools/oxylabs.py +54 -0
  114. mindroom/tools/pandas.py +35 -0
  115. mindroom/tools/pubmed.py +64 -0
  116. mindroom/tools/python.py +120 -0
  117. mindroom/tools/reddit.py +155 -0
  118. mindroom/tools/replicate.py +56 -0
  119. mindroom/tools/resend.py +55 -0
  120. mindroom/tools/scrapegraph.py +87 -0
  121. mindroom/tools/searxng.py +120 -0
  122. mindroom/tools/serpapi.py +55 -0
  123. mindroom/tools/serper.py +81 -0
  124. mindroom/tools/shell.py +46 -0
  125. mindroom/tools/slack.py +80 -0
  126. mindroom/tools/sleep.py +38 -0
  127. mindroom/tools/spider.py +62 -0
  128. mindroom/tools/sql.py +138 -0
  129. mindroom/tools/tavily.py +104 -0
  130. mindroom/tools/telegram.py +54 -0
  131. mindroom/tools/todoist.py +103 -0
  132. mindroom/tools/trello.py +121 -0
  133. mindroom/tools/twilio.py +97 -0
  134. mindroom/tools/web_browser_tools.py +37 -0
  135. mindroom/tools/webex.py +63 -0
  136. mindroom/tools/website.py +45 -0
  137. mindroom/tools/whatsapp.py +81 -0
  138. mindroom/tools/wikipedia.py +45 -0
  139. mindroom/tools/x.py +97 -0
  140. mindroom/tools/yfinance.py +121 -0
  141. mindroom/tools/youtube.py +81 -0
  142. mindroom/tools/zendesk.py +62 -0
  143. mindroom/tools/zep.py +107 -0
  144. mindroom/tools/zoom.py +62 -0
  145. mindroom/tools_metadata.json +7643 -0
  146. mindroom/tools_metadata.py +220 -0
  147. mindroom/topic_generator.py +153 -0
  148. mindroom/voice_handler.py +266 -0
  149. mindroom-0.1.1.dist-info/METADATA +425 -0
  150. mindroom-0.1.1.dist-info/RECORD +152 -0
  151. {mindroom-0.0.0.dist-info → mindroom-0.1.1.dist-info}/WHEEL +1 -2
  152. mindroom-0.1.1.dist-info/entry_points.txt +2 -0
  153. mindroom-0.0.0.dist-info/METADATA +0 -24
  154. mindroom-0.0.0.dist-info/RECORD +0 -4
  155. mindroom-0.0.0.dist-info/top_level.txt +0 -1
mindroom/config.py ADDED
@@ -0,0 +1,343 @@
1
+ """Pydantic models for configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cached_property
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import yaml
10
+ from pydantic import BaseModel, Field
11
+
12
+ from .constants import DEFAULT_AGENTS_CONFIG, MATRIX_HOMESERVER, ROUTER_AGENT_NAME
13
+ from .logging_config import get_logger
14
+
15
+ if TYPE_CHECKING:
16
+ from .matrix.identity import MatrixID
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ class AgentConfig(BaseModel):
22
+ """Configuration for a single agent."""
23
+
24
+ display_name: str = Field(description="Human-readable name for the agent")
25
+ role: str = Field(default="", description="Description of the agent's purpose")
26
+ tools: list[str] = Field(default_factory=list, description="List of tool names")
27
+ instructions: list[str] = Field(default_factory=list, description="Agent instructions")
28
+ rooms: list[str] = Field(default_factory=list, description="List of room IDs or names to auto-join")
29
+ num_history_runs: int | None = Field(default=None, description="Number of history runs to include")
30
+ markdown: bool | None = Field(default=None, description="Whether to use markdown formatting")
31
+ add_history_to_messages: bool | None = Field(default=None, description="Whether to add history to messages")
32
+ model: str = Field(default="default", description="Model name")
33
+
34
+
35
+ class DefaultsConfig(BaseModel):
36
+ """Default configuration values for agents."""
37
+
38
+ num_history_runs: int = Field(default=5, description="Default number of history runs")
39
+ markdown: bool = Field(default=True, description="Default markdown setting")
40
+ add_history_to_messages: bool = Field(default=True, description="Default history setting")
41
+ show_stop_button: bool = Field(default=False, description="Whether to automatically show stop button on messages")
42
+
43
+
44
+ class EmbedderConfig(BaseModel):
45
+ """Configuration for memory embedder."""
46
+
47
+ model: str = Field(default="text-embedding-3-small", description="Model name for embeddings")
48
+ api_key: str | None = Field(default=None, description="API key (usually from environment variable)")
49
+ host: str | None = Field(default=None, description="Host URL for self-hosted models like Ollama")
50
+
51
+
52
+ class MemoryEmbedderConfig(BaseModel):
53
+ """Memory embedder configuration."""
54
+
55
+ provider: str = Field(default="openai", description="Embedder provider (openai, huggingface, etc)")
56
+ config: EmbedderConfig = Field(default_factory=EmbedderConfig, description="Provider-specific config")
57
+
58
+
59
+ class MemoryLLMConfig(BaseModel):
60
+ """Memory LLM configuration."""
61
+
62
+ provider: str = Field(default="ollama", description="LLM provider (ollama, openai, anthropic)")
63
+ config: dict[str, Any] = Field(default_factory=dict, description="Provider-specific LLM config")
64
+
65
+
66
+ class MemoryConfig(BaseModel):
67
+ """Memory system configuration."""
68
+
69
+ embedder: MemoryEmbedderConfig = Field(
70
+ default_factory=MemoryEmbedderConfig,
71
+ description="Embedder configuration for memory",
72
+ )
73
+ llm: MemoryLLMConfig | None = Field(default=None, description="LLM configuration for memory")
74
+
75
+
76
+ class ModelConfig(BaseModel):
77
+ """Configuration for an AI model."""
78
+
79
+ provider: str = Field(description="Model provider (openai, anthropic, ollama, etc)")
80
+ id: str = Field(description="Model ID specific to the provider")
81
+ host: str | None = Field(default=None, description="Optional host URL (e.g., for Ollama)")
82
+ api_key: str | None = Field(default=None, description="Optional API key (usually from env vars)")
83
+ extra_kwargs: dict[str, Any] | None = Field(
84
+ default=None,
85
+ description="Additional provider-specific parameters passed directly to the model",
86
+ )
87
+
88
+
89
+ class RouterConfig(BaseModel):
90
+ """Configuration for the router system."""
91
+
92
+ model: str = Field(default="default", description="Model to use for routing decisions")
93
+
94
+
95
+ class TeamConfig(BaseModel):
96
+ """Configuration for a team of agents."""
97
+
98
+ display_name: str = Field(description="Human-readable name for the team")
99
+ role: str = Field(description="Description of the team's purpose")
100
+ agents: list[str] = Field(description="List of agent names that compose this team")
101
+ rooms: list[str] = Field(default_factory=list, description="List of room IDs or names to auto-join")
102
+ model: str | None = Field(default="default", description="Default model for this team (optional)")
103
+ mode: str = Field(default="coordinate", description="Team collaboration mode: coordinate or collaborate")
104
+
105
+
106
+ class VoiceSTTConfig(BaseModel):
107
+ """Configuration for voice speech-to-text."""
108
+
109
+ provider: str = Field(default="openai", description="STT provider (openai or compatible)")
110
+ model: str = Field(default="whisper-1", description="STT model name")
111
+ api_key: str | None = Field(default=None, description="API key for STT service")
112
+ host: str | None = Field(default=None, description="Host URL for self-hosted STT")
113
+
114
+
115
+ class VoiceLLMConfig(BaseModel):
116
+ """Configuration for voice command intelligence."""
117
+
118
+ model: str = Field(default="default", description="Model for command recognition")
119
+ confidence_threshold: float = Field(default=0.7, description="Confidence threshold for commands")
120
+
121
+
122
+ class VoiceConfig(BaseModel):
123
+ """Configuration for voice message handling."""
124
+
125
+ enabled: bool = Field(default=False, description="Enable voice message processing")
126
+ stt: VoiceSTTConfig = Field(default_factory=VoiceSTTConfig, description="STT configuration")
127
+ intelligence: VoiceLLMConfig = Field(
128
+ default_factory=VoiceLLMConfig,
129
+ description="Command intelligence configuration",
130
+ )
131
+
132
+
133
+ class AuthorizationConfig(BaseModel):
134
+ """Authorization configuration with fine-grained permissions."""
135
+
136
+ global_users: list[str] = Field(
137
+ default_factory=list,
138
+ description="Users with access to all rooms (e.g., '@user:example.com')",
139
+ )
140
+ room_permissions: dict[str, list[str]] = Field(
141
+ default_factory=dict,
142
+ description="Room-specific user permissions. Keys are room IDs, values are lists of authorized user IDs",
143
+ )
144
+ default_room_access: bool = Field(
145
+ default=False,
146
+ description="Default permission for rooms not explicitly configured",
147
+ )
148
+
149
+
150
+ class Config(BaseModel):
151
+ """Complete configuration from YAML."""
152
+
153
+ agents: dict[str, AgentConfig] = Field(default_factory=dict, description="Agent configurations")
154
+ teams: dict[str, TeamConfig] = Field(default_factory=dict, description="Team configurations")
155
+ room_models: dict[str, str] = Field(default_factory=dict, description="Room-specific model overrides")
156
+ defaults: DefaultsConfig = Field(default_factory=DefaultsConfig, description="Default values")
157
+ memory: MemoryConfig = Field(default_factory=MemoryConfig, description="Memory configuration")
158
+ models: dict[str, ModelConfig] = Field(default_factory=dict, description="Model configurations")
159
+ router: RouterConfig = Field(default_factory=RouterConfig, description="Router configuration")
160
+ voice: VoiceConfig = Field(default_factory=VoiceConfig, description="Voice configuration")
161
+ timezone: str = Field(
162
+ default="UTC",
163
+ description="Timezone for displaying scheduled tasks (e.g., 'America/New_York')",
164
+ )
165
+ authorization: AuthorizationConfig = Field(
166
+ default_factory=AuthorizationConfig,
167
+ description="Authorization configuration with fine-grained permissions",
168
+ )
169
+
170
+ @cached_property
171
+ def domain(self) -> str:
172
+ """Extract the domain from the MATRIX_HOMESERVER."""
173
+ from .matrix.identity import extract_server_name_from_homeserver # noqa: PLC0415
174
+
175
+ return extract_server_name_from_homeserver(MATRIX_HOMESERVER)
176
+
177
+ @cached_property
178
+ def ids(self) -> dict[str, MatrixID]:
179
+ """Get MatrixID objects for all agents and teams.
180
+
181
+ Returns:
182
+ Dictionary mapping agent/team names to their MatrixID objects.
183
+
184
+ """
185
+ from .matrix.identity import MatrixID # noqa: PLC0415
186
+
187
+ mapping: dict[str, MatrixID] = {}
188
+
189
+ # Add all agents
190
+ for agent_name in self.agents:
191
+ mapping[agent_name] = MatrixID.from_agent(agent_name, self.domain)
192
+
193
+ # Add router agent separately (it's not in config.agents)
194
+ mapping[ROUTER_AGENT_NAME] = MatrixID.from_agent(ROUTER_AGENT_NAME, self.domain)
195
+
196
+ # Add all teams
197
+ for team_name in self.teams:
198
+ mapping[team_name] = MatrixID.from_agent(team_name, self.domain)
199
+ return mapping
200
+
201
+ @classmethod
202
+ def from_yaml(cls, config_path: Path | None = None) -> Config:
203
+ """Create a Config instance from YAML data."""
204
+ path = config_path or DEFAULT_AGENTS_CONFIG
205
+
206
+ if not path.exists():
207
+ msg = f"Agent configuration file not found: {path}"
208
+ raise FileNotFoundError(msg)
209
+
210
+ with path.open() as f:
211
+ data = yaml.safe_load(f)
212
+
213
+ # Handle None values for optional dictionaries
214
+ if data.get("teams") is None:
215
+ data["teams"] = {}
216
+ if data.get("room_models") is None:
217
+ data["room_models"] = {}
218
+
219
+ config = cls(**data)
220
+ logger.info(f"Loaded agent configuration from {path}")
221
+ logger.info(f"Found {len(config.agents)} agent configurations")
222
+ return config
223
+
224
+ def get_agent(self, agent_name: str) -> AgentConfig:
225
+ """Get an agent configuration by name.
226
+
227
+ Args:
228
+ agent_name: Name of the agent
229
+
230
+ Returns:
231
+ Agent configuration
232
+
233
+ Raises:
234
+ ValueError: If agent not found
235
+
236
+ """
237
+ if agent_name not in self.agents:
238
+ available = ", ".join(sorted(self.agents.keys()))
239
+ msg = f"Unknown agent: {agent_name}. Available agents: {available}"
240
+ raise ValueError(msg)
241
+ return self.agents[agent_name]
242
+
243
+ def get_all_configured_rooms(self) -> set[str]:
244
+ """Extract all room aliases configured for agents and teams.
245
+
246
+ Returns:
247
+ Set of all unique room aliases from agent and team configurations
248
+
249
+ """
250
+ all_room_aliases = set()
251
+ for agent_config in self.agents.values():
252
+ all_room_aliases.update(agent_config.rooms)
253
+ for team_config in self.teams.values():
254
+ all_room_aliases.update(team_config.rooms)
255
+ return all_room_aliases
256
+
257
+ def get_entity_model_name(self, entity_name: str) -> str:
258
+ """Get the model name for an agent, team, or router.
259
+
260
+ Args:
261
+ entity_name: Name of the entity (agent, team, or router)
262
+
263
+ Returns:
264
+ Model name (e.g., "default", "gpt-4", etc.)
265
+
266
+ Raises:
267
+ ValueError: If entity_name is not found in configuration
268
+
269
+ """
270
+ # Router uses router model
271
+ if entity_name == ROUTER_AGENT_NAME:
272
+ return self.router.model
273
+ # Teams use their configured model (required to have one)
274
+ if entity_name in self.teams:
275
+ model = self.teams[entity_name].model
276
+ if model is None:
277
+ msg = f"Team {entity_name} has no model configured"
278
+ raise ValueError(msg)
279
+ return model
280
+ # Regular agents use their configured model
281
+ if entity_name in self.agents:
282
+ return self.agents[entity_name].model
283
+
284
+ # Entity not found in any category
285
+ available = sorted(set(self.agents.keys()) | set(self.teams.keys()) | {ROUTER_AGENT_NAME})
286
+ msg = f"Unknown entity: {entity_name}. Available entities: {', '.join(available)}"
287
+ raise ValueError(msg)
288
+
289
+ def get_configured_bots_for_room(self, room_id: str) -> set[str]:
290
+ """Get the set of bot usernames that should be in a specific room.
291
+
292
+ Args:
293
+ room_id: The Matrix room ID
294
+
295
+ Returns:
296
+ Set of bot usernames (without domain) that should be in this room
297
+
298
+ """
299
+ from .matrix.rooms import resolve_room_aliases # noqa: PLC0415
300
+
301
+ configured_bots = set()
302
+
303
+ # Check which agents should be in this room
304
+ for agent_name, agent_config in self.agents.items():
305
+ resolved_rooms = set(resolve_room_aliases(agent_config.rooms))
306
+ if room_id in resolved_rooms:
307
+ configured_bots.add(f"mindroom_{agent_name}")
308
+
309
+ # Check which teams should be in this room
310
+ for team_name, team_config in self.teams.items():
311
+ resolved_rooms = set(resolve_room_aliases(team_config.rooms))
312
+ if room_id in resolved_rooms:
313
+ configured_bots.add(f"mindroom_{team_name}")
314
+
315
+ # Router should be in any room that has any configured agents/teams
316
+ if configured_bots: # If any bots are configured for this room
317
+ configured_bots.add(f"mindroom_{ROUTER_AGENT_NAME}")
318
+
319
+ return configured_bots
320
+
321
+ def save_to_yaml(self, config_path: Path | None = None) -> None:
322
+ """Save the config to a YAML file, excluding None values.
323
+
324
+ Args:
325
+ config_path: Path to save the config to. If None, uses DEFAULT_AGENTS_CONFIG.
326
+
327
+ """
328
+ path = config_path or DEFAULT_AGENTS_CONFIG
329
+ config_dict = self.model_dump(exclude_none=True)
330
+ path_obj = Path(path)
331
+ path_obj.parent.mkdir(parents=True, exist_ok=True)
332
+ tmp_path = path_obj.with_suffix(path_obj.suffix + ".tmp")
333
+ with tmp_path.open("w", encoding="utf-8") as f:
334
+ yaml.dump(
335
+ config_dict,
336
+ f,
337
+ default_flow_style=False,
338
+ sort_keys=True,
339
+ allow_unicode=True, # Preserve Unicode characters like ë
340
+ width=120, # Wider lines to reduce wrapping
341
+ )
342
+ tmp_path.replace(path_obj)
343
+ logger.info(f"Saved configuration to {path}")
@@ -0,0 +1,324 @@
1
+ """Configuration command handling for user-driven config changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from pathlib import Path # noqa: TC003
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from pydantic import ValidationError
11
+
12
+ from .config import Config
13
+ from .constants import DEFAULT_AGENTS_CONFIG
14
+ from .logging_config import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ def parse_config_args(args_text: str) -> tuple[str, list[str]]:
20
+ """Parse config command arguments.
21
+
22
+ Args:
23
+ args_text: Raw argument text from command
24
+
25
+ Returns:
26
+ Tuple of (operation, arguments)
27
+
28
+ """
29
+ if not args_text:
30
+ return "show", []
31
+
32
+ # Use shlex to handle quoted strings properly
33
+ try:
34
+ parts = shlex.split(args_text)
35
+ except ValueError as e:
36
+ # Handle parsing errors (e.g., unmatched quotes)
37
+ # Return a special operation that will trigger an error message
38
+ return "parse_error", [str(e)]
39
+
40
+ if not parts:
41
+ return "show", []
42
+
43
+ operation = parts[0].lower()
44
+ args = parts[1:] if len(parts) > 1 else []
45
+ return operation, args
46
+
47
+
48
+ def get_nested_value(data: Any, path: str) -> Any: # noqa: ANN401
49
+ """Get a value from nested dict using dot notation.
50
+
51
+ Args:
52
+ data: The dictionary to search
53
+ path: Dot-separated path (e.g., "agents.analyst.display_name")
54
+
55
+ Returns:
56
+ The value at the path
57
+
58
+ Raises:
59
+ KeyError: If path doesn't exist
60
+
61
+ """
62
+ keys = path.split(".")
63
+ current = data
64
+
65
+ for key in keys:
66
+ # Handle array indexing
67
+ if key.isdigit(): # noqa: SIM108
68
+ current = current[int(key)]
69
+ else:
70
+ current = current[key]
71
+
72
+ return current
73
+
74
+
75
+ def set_nested_value(data: Any, path: str, value: Any) -> None: # noqa: ANN401
76
+ """Set a value in nested dict using dot notation.
77
+
78
+ Args:
79
+ data: The dictionary to modify
80
+ path: Dot-separated path (e.g., "agents.analyst.display_name")
81
+ value: Value to set
82
+
83
+ Raises:
84
+ KeyError: If parent path doesn't exist
85
+
86
+ """
87
+ keys = path.split(".")
88
+ current = data
89
+
90
+ # Navigate to the parent of the target
91
+ for key in keys[:-1]:
92
+ if key.isdigit():
93
+ current = current[int(key)]
94
+ elif key not in current:
95
+ # Auto-create missing intermediate dicts
96
+ current[key] = {}
97
+ current = current[key]
98
+ else:
99
+ current = current[key]
100
+
101
+ # Set the final value
102
+ final_key = keys[-1]
103
+ if final_key.isdigit():
104
+ current[int(final_key)] = value
105
+ else:
106
+ current[final_key] = value
107
+
108
+
109
+ def parse_value(value_str: str) -> Any: # noqa: ANN401
110
+ """Parse a string value into appropriate Python type.
111
+
112
+ Args:
113
+ value_str: String representation of value
114
+
115
+ Returns:
116
+ Parsed value (str, int, float, bool, list, or dict)
117
+
118
+ """
119
+ # Try to parse as YAML first (handles unquoted strings in arrays/dicts)
120
+ # YAML is a superset of JSON, so this handles both formats
121
+ # Examples that work:
122
+ # [item1, item2] -> ['item1', 'item2']
123
+ # ["item1", "item2"] -> ['item1', 'item2']
124
+ # {key: value} -> {'key': 'value'}
125
+ # {"key": "value"} -> {'key': 'value'}
126
+ try:
127
+ return yaml.safe_load(value_str)
128
+ except yaml.YAMLError:
129
+ pass
130
+
131
+ # If YAML parsing fails, return as string
132
+ # This handles cases where the string itself contains special YAML characters
133
+ return value_str
134
+
135
+
136
+ def format_value(value: Any) -> str: # noqa: ANN401
137
+ """Format a value for display as YAML.
138
+
139
+ Args:
140
+ value: Value to format
141
+
142
+ Returns:
143
+ YAML formatted string representation
144
+
145
+ """
146
+ # Use yaml.dump for consistent formatting
147
+ yaml_str = yaml.dump(value, default_flow_style=False, sort_keys=False, allow_unicode=True)
148
+ # Remove trailing newline and document end marker that yaml.dump adds
149
+ yaml_str = yaml_str.rstrip()
150
+ if yaml_str.endswith("..."):
151
+ yaml_str = yaml_str[:-3].rstrip()
152
+ return yaml_str
153
+
154
+
155
+ async def handle_config_command(args_text: str, config_path: Path | None = None) -> tuple[str, dict[str, Any] | None]: # noqa: C901, PLR0911, PLR0912
156
+ """Handle config command execution.
157
+
158
+ Args:
159
+ args_text: The command arguments
160
+ config_path: Optional path to config file
161
+
162
+ Returns:
163
+ Tuple of (response message, config change dict or None)
164
+ The config change dict contains info needed for confirmation
165
+
166
+ """
167
+ operation, args = parse_config_args(args_text)
168
+ path = config_path or DEFAULT_AGENTS_CONFIG
169
+
170
+ # Load current config
171
+ config = Config.from_yaml(path)
172
+ config_dict = config.model_dump(exclude_none=True)
173
+
174
+ if operation == "show":
175
+ # Show entire config
176
+ yaml_str = yaml.dump(config_dict, default_flow_style=False, sort_keys=False, allow_unicode=True)
177
+ return f"**Current Configuration:**\n```yaml\n{yaml_str}```", None
178
+
179
+ if operation == "get":
180
+ if not args:
181
+ return (
182
+ "❌ Please specify a configuration path to get\nExample: `!config get agents.analyst.display_name`",
183
+ None,
184
+ )
185
+
186
+ config_path_str = args[0]
187
+ try:
188
+ value = get_nested_value(config_dict, config_path_str)
189
+ except (KeyError, IndexError) as e:
190
+ return f"❌ Configuration path not found: `{config_path_str}`\nError: {e}", None
191
+ else:
192
+ formatted = format_value(value)
193
+ return f"**Configuration value for `{config_path_str}`:**\n```yaml\n{formatted}\n```", None
194
+
195
+ elif operation == "set":
196
+ if len(args) < 2:
197
+ return (
198
+ '❌ Please specify a path and value\nExample: `!config set agents.analyst.display_name "New Name"`',
199
+ None,
200
+ )
201
+
202
+ config_path_str = args[0]
203
+ # Join remaining args as the value (handles unquoted strings with spaces)
204
+ value_str = " ".join(args[1:])
205
+
206
+ # Parse the value - YAML parsing handles both quoted and unquoted formats
207
+ value = parse_value(value_str)
208
+
209
+ # Get the current value for comparison
210
+ try:
211
+ old_value = get_nested_value(config_dict, config_path_str)
212
+ except (KeyError, IndexError):
213
+ old_value = None # Path doesn't exist yet
214
+
215
+ # Create a copy to test the change
216
+ test_config_dict = config_dict.copy()
217
+
218
+ try:
219
+ # Verify the path exists or can be created
220
+ set_nested_value(test_config_dict, config_path_str, value)
221
+
222
+ # Validate the modified config
223
+ Config(**test_config_dict) # This will raise ValidationError if invalid
224
+ except (KeyError, IndexError) as e:
225
+ return f"❌ Configuration path error: `{config_path_str}`\nError: {e}", None
226
+ except ValidationError as e:
227
+ # Validation failed - explain why
228
+ errors = []
229
+ for error in e.errors():
230
+ location = " → ".join(str(loc) for loc in error["loc"])
231
+ errors.append(f"• {location}: {error['msg']}")
232
+ error_msg = "\n".join(errors)
233
+ return f"❌ Invalid configuration:\n{error_msg}\n\nChanges were NOT applied.", None
234
+ else:
235
+ # Format the preview message
236
+ formatted_old = format_value(old_value) if old_value is not None else "Not set"
237
+ formatted_new = format_value(value)
238
+
239
+ preview_msg = (
240
+ f"**Configuration Change Preview**\n\n"
241
+ f"📝 **Path:** `{config_path_str}`\n\n"
242
+ f"**Current value:**\n```yaml\n{formatted_old}\n```\n"
243
+ f"**New value:**\n```yaml\n{formatted_new}\n```\n\n"
244
+ f"React with ✅ to confirm or ❌ to cancel this change."
245
+ )
246
+
247
+ # Return the preview and the change info for confirmation
248
+ change_info = {
249
+ "config_path": config_path_str,
250
+ "old_value": old_value,
251
+ "new_value": value,
252
+ "path": str(path),
253
+ }
254
+
255
+ return preview_msg, change_info
256
+
257
+ elif operation == "parse_error":
258
+ # Handle parsing errors (e.g., unmatched quotes)
259
+ error_msg = args[0] if args else "Unknown parsing error"
260
+ return (
261
+ f"❌ **Command parsing error:**\n{error_msg}\n\n"
262
+ "**Common issues:**\n"
263
+ "• Unmatched quotes: Make sure quotes are properly paired\n"
264
+ '• For JSON arrays/objects, use matching quotes: `["item1", "item2"]`\n'
265
+ "• Or use single quotes consistently: `['item1', 'item2']`\n\n"
266
+ "**Example:**\n"
267
+ '`!config set agents.analyst.tools ["tool1", "tool2"]`'
268
+ ), None
269
+
270
+ else:
271
+ available_ops = ["show", "get", "set"]
272
+ return (
273
+ f"❌ Unknown operation: '{operation}'\n"
274
+ f"Available operations: {', '.join(available_ops)}\n\n"
275
+ "Try `!help config` for usage examples."
276
+ ), None
277
+
278
+
279
+ async def apply_config_change(
280
+ config_path_str: str,
281
+ new_value: Any, # noqa: ANN401
282
+ config_file_path: Path | None = None,
283
+ ) -> str:
284
+ """Apply a confirmed configuration change.
285
+
286
+ Args:
287
+ config_path_str: The configuration path (e.g., "agents.analyst.role")
288
+ new_value: The new value to set
289
+ config_file_path: Optional path to config file
290
+
291
+ Returns:
292
+ Success or error message
293
+
294
+ """
295
+ path = config_file_path or DEFAULT_AGENTS_CONFIG
296
+
297
+ try:
298
+ # Load the current configuration
299
+ config = Config.from_yaml(path)
300
+ config_dict = config.model_dump()
301
+
302
+ # Apply the specific change
303
+ set_nested_value(config_dict, config_path_str, new_value)
304
+
305
+ # Validate the modified config
306
+ try:
307
+ new_config = Config(**config_dict)
308
+ except ValidationError as ve:
309
+ errors = ["❌ Configuration validation failed:"]
310
+ for error in ve.errors():
311
+ location = " → ".join(str(loc) for loc in error["loc"])
312
+ errors.append(f"• {location}: {error['msg']}")
313
+ error_msg = "\n".join(errors)
314
+ return f"{error_msg}\n\nChanges were NOT applied."
315
+
316
+ # Save to file
317
+ new_config.save_to_yaml(path)
318
+ return ( # noqa: TRY300
319
+ f"✅ **Configuration updated successfully!**\n\n"
320
+ f"Changes saved to {path} and will affect new agent interactions."
321
+ )
322
+ except Exception as e:
323
+ logger.exception("Failed to apply config change")
324
+ return f"❌ Failed to apply configuration change: {e}"