mindroom 0.0.0__py3-none-any.whl → 0.1.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 (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.0.dist-info/METADATA +425 -0
  150. mindroom-0.1.0.dist-info/RECORD +152 -0
  151. {mindroom-0.0.0.dist-info → mindroom-0.1.0.dist-info}/WHEEL +1 -2
  152. mindroom-0.1.0.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/stop.py ADDED
@@ -0,0 +1,207 @@
1
+ """Minimal stop button functionality for the bot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ import nio
10
+
11
+ if TYPE_CHECKING:
12
+ from nio import AsyncClient
13
+
14
+ import structlog
15
+
16
+ logger = structlog.get_logger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class TrackedMessage:
21
+ """Track a message with stop button."""
22
+
23
+ message_id: str
24
+ room_id: str
25
+ task: asyncio.Task[None]
26
+ reaction_event_id: str | None = None
27
+
28
+
29
+ class StopManager:
30
+ """Manager for handling stop reactions."""
31
+
32
+ def __init__(self) -> None:
33
+ """Initialize the stop manager."""
34
+ # Track multiple concurrent messages by message_id
35
+ self.tracked_messages: dict[str, TrackedMessage] = {}
36
+ # Keep references to cleanup tasks
37
+ self.cleanup_tasks: list[asyncio.Task] = []
38
+ logger.info("StopManager initialized")
39
+
40
+ def set_current(
41
+ self,
42
+ message_id: str,
43
+ room_id: str,
44
+ task: asyncio.Task[None],
45
+ reaction_event_id: str | None = None,
46
+ ) -> None:
47
+ """Track a message generation."""
48
+ self.tracked_messages[message_id] = TrackedMessage(
49
+ message_id=message_id,
50
+ room_id=room_id,
51
+ task=task,
52
+ reaction_event_id=reaction_event_id,
53
+ )
54
+ logger.info(
55
+ "Tracking message generation",
56
+ message_id=message_id,
57
+ room_id=room_id,
58
+ reaction_event_id=reaction_event_id,
59
+ total_tracked=len(self.tracked_messages),
60
+ )
61
+
62
+ def clear_message(
63
+ self,
64
+ message_id: str,
65
+ client: AsyncClient,
66
+ remove_button: bool = True,
67
+ delay: float = 5.0,
68
+ ) -> None:
69
+ """Clear tracking for a specific message and optionally remove stop button.
70
+
71
+ Args:
72
+ message_id: The message ID to clear
73
+ client: Matrix client for removing stop button
74
+ remove_button: Whether to remove the stop button (default True)
75
+ delay: Seconds to wait before clearing (default 5.0)
76
+
77
+ """
78
+
79
+ async def delayed_clear() -> None:
80
+ """Clear the message and remove stop button after a delay."""
81
+ if remove_button and message_id in self.tracked_messages:
82
+ tracked = self.tracked_messages[message_id]
83
+ if tracked.reaction_event_id:
84
+ logger.info("Removing stop button in cleanup", message_id=message_id)
85
+ try:
86
+ await client.room_redact(
87
+ room_id=tracked.room_id,
88
+ event_id=tracked.reaction_event_id,
89
+ reason="Response completed",
90
+ )
91
+ tracked.reaction_event_id = None
92
+ except Exception as e:
93
+ logger.warning(f"Failed to remove stop button in cleanup: {e}")
94
+
95
+ await asyncio.sleep(delay)
96
+ if message_id in self.tracked_messages:
97
+ logger.info("Clearing tracked message after delay", message_id=message_id, delay=delay)
98
+ del self.tracked_messages[message_id]
99
+
100
+ if message_id in self.tracked_messages:
101
+ logger.info(
102
+ "Scheduling message cleanup",
103
+ message_id=message_id,
104
+ delay=delay,
105
+ remove_button=remove_button,
106
+ )
107
+ task = asyncio.create_task(delayed_clear())
108
+ self.cleanup_tasks.append(task)
109
+ # Clean up old completed tasks
110
+ self.cleanup_tasks = [t for t in self.cleanup_tasks if not t.done()]
111
+ else:
112
+ logger.debug("Message not tracked, skipping cleanup", message_id=message_id)
113
+
114
+ async def handle_stop_reaction(self, message_id: str) -> bool:
115
+ """Handle a stop reaction for a message.
116
+
117
+ Returns True if the task was cancelled, False otherwise.
118
+ """
119
+ logger.info(
120
+ "Handling stop reaction",
121
+ message_id=message_id,
122
+ tracked_messages=list(self.tracked_messages.keys()),
123
+ )
124
+
125
+ if message_id in self.tracked_messages:
126
+ tracked = self.tracked_messages[message_id]
127
+ if tracked.task and not tracked.task.done():
128
+ logger.info("Cancelling task for message", message_id=message_id)
129
+ tracked.task.cancel()
130
+ # Don't clear here - let the finally block handle it
131
+ return True
132
+ logger.info(
133
+ "Task already completed or missing",
134
+ message_id=message_id,
135
+ task_exists=tracked.task is not None,
136
+ task_done=tracked.task.done() if tracked.task else None,
137
+ )
138
+ else:
139
+ logger.warning("Stop reaction for untracked message", message_id=message_id)
140
+ return False
141
+
142
+ async def add_stop_button(self, client: AsyncClient, room_id: str, message_id: str) -> str | None:
143
+ """Add a stop button reaction to a message.
144
+
145
+ Returns:
146
+ The event ID of the reaction if successful, None otherwise.
147
+
148
+ """
149
+ logger.info("Adding stop button", room_id=room_id, message_id=message_id)
150
+ try:
151
+ response = await client.room_send(
152
+ room_id=room_id,
153
+ message_type="m.reaction",
154
+ content={
155
+ "m.relates_to": {
156
+ "rel_type": "m.annotation",
157
+ "event_id": message_id,
158
+ "key": "🛑",
159
+ },
160
+ },
161
+ )
162
+ if isinstance(response, nio.RoomSendResponse):
163
+ event_id = str(response.event_id)
164
+ logger.info("Stop button added successfully", reaction_event_id=event_id, message_id=message_id)
165
+ # Update the tracked message with the reaction event ID
166
+ if message_id in self.tracked_messages:
167
+ self.tracked_messages[message_id].reaction_event_id = event_id
168
+ return event_id
169
+ logger.warning("Failed to add stop button - no event_id in response", response=response)
170
+ except Exception as e:
171
+ logger.exception("Exception adding stop button", error=str(e))
172
+ return None
173
+
174
+ async def remove_stop_button(self, client: AsyncClient, message_id: str | None = None) -> None:
175
+ """Remove the stop button reaction immediately when user clicks it.
176
+
177
+ Args:
178
+ client: The Matrix client
179
+ message_id: The message ID to remove the button from
180
+
181
+ """
182
+ if message_id and message_id in self.tracked_messages:
183
+ tracked = self.tracked_messages[message_id]
184
+ if tracked.reaction_event_id and tracked.room_id:
185
+ logger.info(
186
+ "Removing stop button immediately (user clicked)",
187
+ message_id=message_id,
188
+ reaction_event_id=tracked.reaction_event_id,
189
+ )
190
+ try:
191
+ await client.room_redact(
192
+ room_id=tracked.room_id,
193
+ event_id=tracked.reaction_event_id,
194
+ reason="User clicked stop",
195
+ )
196
+ tracked.reaction_event_id = None
197
+ logger.info("Stop button removed successfully")
198
+ except Exception as e:
199
+ logger.exception("Failed to remove stop button", error=str(e))
200
+ else:
201
+ logger.debug(
202
+ "Stop button already removed or missing",
203
+ message_id=message_id,
204
+ has_reaction_id=tracked.reaction_event_id is not None,
205
+ )
206
+ else:
207
+ logger.debug("Message not tracked, cannot remove stop button", message_id=message_id)
mindroom/streaming.py ADDED
@@ -0,0 +1,203 @@
1
+ """Streaming response implementation for real-time message updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from agno.run.response import RunResponseContentEvent, ToolCallCompletedEvent, ToolCallStartedEvent
10
+
11
+ from . import interactive
12
+ from .ai import _format_tool_completed_message, _format_tool_started_message
13
+ from .logging_config import get_logger
14
+ from .matrix.client import edit_message, send_message
15
+ from .matrix.mentions import format_message_with_mentions
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import AsyncIterator
19
+
20
+ import nio
21
+
22
+ from .config import Config
23
+
24
+ from .matrix.client import get_latest_thread_event_id_if_needed
25
+
26
+ logger = get_logger(__name__)
27
+
28
+ # Global constant for the in-progress marker
29
+ IN_PROGRESS_MARKER = " ⋯"
30
+
31
+
32
+ @dataclass
33
+ class StreamingResponse:
34
+ """Manages a streaming response with incremental message updates."""
35
+
36
+ room_id: str
37
+ reply_to_event_id: str | None
38
+ thread_id: str | None
39
+ sender_domain: str
40
+ config: Config
41
+ accumulated_text: str = ""
42
+ event_id: str | None = None # None until first message sent
43
+ last_update: float = 0.0
44
+ update_interval: float = 1.0
45
+ latest_thread_event_id: str | None = None # For MSC3440 compliance
46
+
47
+ def _update(self, new_chunk: str) -> None:
48
+ """Append new chunk to accumulated text."""
49
+ self.accumulated_text += new_chunk
50
+
51
+ async def update_content(self, new_chunk: str, client: nio.AsyncClient) -> None:
52
+ """Add new content and potentially update the message."""
53
+ self._update(new_chunk)
54
+
55
+ current_time = time.time()
56
+ if current_time - self.last_update >= self.update_interval:
57
+ await self._send_or_edit_message(client)
58
+ self.last_update = current_time
59
+
60
+ async def finalize(self, client: nio.AsyncClient) -> None:
61
+ """Send final message update."""
62
+ await self._send_or_edit_message(client, is_final=True)
63
+
64
+ async def _send_or_edit_message(self, client: nio.AsyncClient, is_final: bool = False) -> None:
65
+ """Send new message or edit existing one."""
66
+ if not self.accumulated_text.strip():
67
+ return
68
+
69
+ # Always ensure we have a thread_id - use the original message as thread root if needed
70
+ effective_thread_id = self.thread_id if self.thread_id else self.reply_to_event_id
71
+
72
+ # Add in-progress marker during streaming (not on final update)
73
+ text_to_send = self.accumulated_text
74
+ if not is_final:
75
+ text_to_send += IN_PROGRESS_MARKER
76
+
77
+ # Format the text (handles interactive questions if present)
78
+ response = interactive.parse_and_format_interactive(text_to_send, extract_mapping=False)
79
+ display_text = response.formatted_text
80
+
81
+ # Only use latest_thread_event_id for the initial message (not edits)
82
+ latest_for_message = self.latest_thread_event_id if self.event_id is None else None
83
+
84
+ content = format_message_with_mentions(
85
+ config=self.config,
86
+ text=display_text,
87
+ sender_domain=self.sender_domain,
88
+ thread_event_id=effective_thread_id,
89
+ reply_to_event_id=self.reply_to_event_id,
90
+ latest_thread_event_id=latest_for_message,
91
+ )
92
+
93
+ if self.event_id is None:
94
+ # First message - send new
95
+ logger.debug("Sending initial streaming message")
96
+ response_event_id = await send_message(client, self.room_id, content)
97
+ if response_event_id:
98
+ self.event_id = response_event_id
99
+ logger.debug("Initial streaming message sent", event_id=self.event_id)
100
+ else:
101
+ logger.error("Failed to send initial streaming message")
102
+ else:
103
+ # Subsequent updates - edit existing message
104
+ logger.debug("Editing streaming message", event_id=self.event_id)
105
+ response_event_id = await edit_message(client, self.room_id, self.event_id, content, display_text)
106
+ if not response_event_id:
107
+ logger.error("Failed to edit streaming message")
108
+
109
+
110
+ class ReplacementStreamingResponse(StreamingResponse):
111
+ """StreamingResponse variant that replaces content instead of appending.
112
+
113
+ Useful for structured live rendering where the full document is rebuilt
114
+ on each tick and we want the message to reflect the latest full view,
115
+ not incremental concatenation.
116
+ """
117
+
118
+ def _update(self, new_chunk: str) -> None:
119
+ """Replace accumulated text with new chunk."""
120
+ self.accumulated_text = new_chunk
121
+
122
+
123
+ async def send_streaming_response(
124
+ client: nio.AsyncClient,
125
+ room_id: str,
126
+ reply_to_event_id: str | None,
127
+ thread_id: str | None,
128
+ sender_domain: str,
129
+ config: Config,
130
+ response_stream: AsyncIterator[object],
131
+ streaming_cls: type[StreamingResponse] = StreamingResponse,
132
+ header: str | None = None,
133
+ existing_event_id: str | None = None,
134
+ ) -> tuple[str | None, str]:
135
+ """Stream chunks to a Matrix room, returning (event_id, accumulated_text).
136
+
137
+ Args:
138
+ client: Matrix client
139
+ room_id: Destination room
140
+ reply_to_event_id: Event to reply to (can be None when in a thread)
141
+ thread_id: Thread root if already in a thread
142
+ sender_domain: Sender's homeserver domain for mention formatting
143
+ config: App config for mention formatting
144
+ response_stream: Async iterator yielding text chunks or response events
145
+ streaming_cls: StreamingResponse class to use (default: StreamingResponse, alternative: ReplacementStreamingResponse)
146
+ header: Optional text prefix to send before chunks
147
+ existing_event_id: If editing an existing message, pass its ID
148
+
149
+ Returns:
150
+ Tuple of (final event_id or None, full accumulated text)
151
+
152
+ """
153
+ latest_thread_event_id = await get_latest_thread_event_id_if_needed(
154
+ client,
155
+ room_id,
156
+ thread_id,
157
+ reply_to_event_id,
158
+ existing_event_id,
159
+ )
160
+
161
+ streaming = streaming_cls(
162
+ room_id=room_id,
163
+ reply_to_event_id=reply_to_event_id,
164
+ thread_id=thread_id,
165
+ sender_domain=sender_domain,
166
+ config=config,
167
+ latest_thread_event_id=latest_thread_event_id,
168
+ )
169
+
170
+ # Ensure the first chunk triggers an initial send immediately
171
+ streaming.last_update = float("-inf")
172
+
173
+ if existing_event_id:
174
+ streaming.event_id = existing_event_id
175
+ streaming.accumulated_text = ""
176
+
177
+ if header:
178
+ await streaming.update_content(header, client)
179
+
180
+ async for chunk in response_stream:
181
+ # Handle different types of chunks from the stream
182
+ if isinstance(chunk, str):
183
+ text_chunk = chunk
184
+ elif isinstance(chunk, RunResponseContentEvent) and chunk.content:
185
+ text_chunk = str(chunk.content)
186
+ elif isinstance(chunk, ToolCallStartedEvent):
187
+ text_chunk = _format_tool_started_message(chunk)
188
+ elif isinstance(chunk, ToolCallCompletedEvent):
189
+ text_chunk = _format_tool_completed_message(chunk)
190
+ else:
191
+ # Fallback for other event types - try to extract content
192
+ content = getattr(chunk, "content", None)
193
+ text_chunk = str(content) if content is not None else ""
194
+ if not text_chunk:
195
+ logger.debug(f"Unhandled streaming event type: {type(chunk).__name__}")
196
+ continue
197
+
198
+ if text_chunk:
199
+ await streaming.update_content(text_chunk, client)
200
+
201
+ await streaming.finalize(client)
202
+
203
+ return streaming.event_id, streaming.accumulated_text