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.
- mindroom/__init__.py +3 -0
- mindroom/agent_prompts.py +963 -0
- mindroom/agents.py +248 -0
- mindroom/ai.py +421 -0
- mindroom/api/__init__.py +1 -0
- mindroom/api/credentials.py +137 -0
- mindroom/api/google_integration.py +355 -0
- mindroom/api/google_tools_helper.py +40 -0
- mindroom/api/homeassistant_integration.py +421 -0
- mindroom/api/integrations.py +189 -0
- mindroom/api/main.py +506 -0
- mindroom/api/matrix_operations.py +219 -0
- mindroom/api/tools.py +94 -0
- mindroom/background_tasks.py +87 -0
- mindroom/bot.py +2470 -0
- mindroom/cli.py +86 -0
- mindroom/commands.py +377 -0
- mindroom/config.py +343 -0
- mindroom/config_commands.py +324 -0
- mindroom/config_confirmation.py +411 -0
- mindroom/constants.py +52 -0
- mindroom/credentials.py +146 -0
- mindroom/credentials_sync.py +134 -0
- mindroom/custom_tools/__init__.py +8 -0
- mindroom/custom_tools/config_manager.py +765 -0
- mindroom/custom_tools/gmail.py +92 -0
- mindroom/custom_tools/google_calendar.py +92 -0
- mindroom/custom_tools/google_sheets.py +92 -0
- mindroom/custom_tools/homeassistant.py +341 -0
- mindroom/error_handling.py +35 -0
- mindroom/file_watcher.py +49 -0
- mindroom/interactive.py +313 -0
- mindroom/logging_config.py +207 -0
- mindroom/matrix/__init__.py +1 -0
- mindroom/matrix/client.py +782 -0
- mindroom/matrix/event_info.py +173 -0
- mindroom/matrix/identity.py +149 -0
- mindroom/matrix/large_messages.py +267 -0
- mindroom/matrix/mentions.py +141 -0
- mindroom/matrix/message_builder.py +94 -0
- mindroom/matrix/message_content.py +209 -0
- mindroom/matrix/presence.py +178 -0
- mindroom/matrix/rooms.py +311 -0
- mindroom/matrix/state.py +77 -0
- mindroom/matrix/typing.py +91 -0
- mindroom/matrix/users.py +217 -0
- mindroom/memory/__init__.py +21 -0
- mindroom/memory/config.py +137 -0
- mindroom/memory/functions.py +396 -0
- mindroom/py.typed +0 -0
- mindroom/response_tracker.py +128 -0
- mindroom/room_cleanup.py +139 -0
- mindroom/routing.py +107 -0
- mindroom/scheduling.py +758 -0
- mindroom/stop.py +207 -0
- mindroom/streaming.py +203 -0
- mindroom/teams.py +749 -0
- mindroom/thread_utils.py +318 -0
- mindroom/tools/__init__.py +520 -0
- mindroom/tools/agentql.py +64 -0
- mindroom/tools/airflow.py +57 -0
- mindroom/tools/apify.py +49 -0
- mindroom/tools/arxiv.py +64 -0
- mindroom/tools/aws_lambda.py +41 -0
- mindroom/tools/aws_ses.py +57 -0
- mindroom/tools/baidusearch.py +87 -0
- mindroom/tools/brightdata.py +116 -0
- mindroom/tools/browserbase.py +62 -0
- mindroom/tools/cal_com.py +98 -0
- mindroom/tools/calculator.py +112 -0
- mindroom/tools/cartesia.py +84 -0
- mindroom/tools/composio.py +166 -0
- mindroom/tools/config_manager.py +44 -0
- mindroom/tools/confluence.py +73 -0
- mindroom/tools/crawl4ai.py +101 -0
- mindroom/tools/csv.py +104 -0
- mindroom/tools/custom_api.py +106 -0
- mindroom/tools/dalle.py +85 -0
- mindroom/tools/daytona.py +180 -0
- mindroom/tools/discord.py +81 -0
- mindroom/tools/docker.py +73 -0
- mindroom/tools/duckdb.py +124 -0
- mindroom/tools/duckduckgo.py +99 -0
- mindroom/tools/e2b.py +121 -0
- mindroom/tools/eleven_labs.py +77 -0
- mindroom/tools/email.py +74 -0
- mindroom/tools/exa.py +246 -0
- mindroom/tools/fal.py +50 -0
- mindroom/tools/file.py +80 -0
- mindroom/tools/financial_datasets_api.py +112 -0
- mindroom/tools/firecrawl.py +124 -0
- mindroom/tools/gemini.py +85 -0
- mindroom/tools/giphy.py +49 -0
- mindroom/tools/github.py +376 -0
- mindroom/tools/gmail.py +102 -0
- mindroom/tools/google_calendar.py +55 -0
- mindroom/tools/google_maps.py +112 -0
- mindroom/tools/google_sheets.py +86 -0
- mindroom/tools/googlesearch.py +83 -0
- mindroom/tools/groq.py +77 -0
- mindroom/tools/hackernews.py +54 -0
- mindroom/tools/jina.py +108 -0
- mindroom/tools/jira.py +70 -0
- mindroom/tools/linear.py +103 -0
- mindroom/tools/linkup.py +65 -0
- mindroom/tools/lumalabs.py +71 -0
- mindroom/tools/mem0.py +82 -0
- mindroom/tools/modelslabs.py +85 -0
- mindroom/tools/moviepy_video_tools.py +62 -0
- mindroom/tools/newspaper4k.py +63 -0
- mindroom/tools/openai.py +143 -0
- mindroom/tools/openweather.py +89 -0
- mindroom/tools/oxylabs.py +54 -0
- mindroom/tools/pandas.py +35 -0
- mindroom/tools/pubmed.py +64 -0
- mindroom/tools/python.py +120 -0
- mindroom/tools/reddit.py +155 -0
- mindroom/tools/replicate.py +56 -0
- mindroom/tools/resend.py +55 -0
- mindroom/tools/scrapegraph.py +87 -0
- mindroom/tools/searxng.py +120 -0
- mindroom/tools/serpapi.py +55 -0
- mindroom/tools/serper.py +81 -0
- mindroom/tools/shell.py +46 -0
- mindroom/tools/slack.py +80 -0
- mindroom/tools/sleep.py +38 -0
- mindroom/tools/spider.py +62 -0
- mindroom/tools/sql.py +138 -0
- mindroom/tools/tavily.py +104 -0
- mindroom/tools/telegram.py +54 -0
- mindroom/tools/todoist.py +103 -0
- mindroom/tools/trello.py +121 -0
- mindroom/tools/twilio.py +97 -0
- mindroom/tools/web_browser_tools.py +37 -0
- mindroom/tools/webex.py +63 -0
- mindroom/tools/website.py +45 -0
- mindroom/tools/whatsapp.py +81 -0
- mindroom/tools/wikipedia.py +45 -0
- mindroom/tools/x.py +97 -0
- mindroom/tools/yfinance.py +121 -0
- mindroom/tools/youtube.py +81 -0
- mindroom/tools/zendesk.py +62 -0
- mindroom/tools/zep.py +107 -0
- mindroom/tools/zoom.py +62 -0
- mindroom/tools_metadata.json +7643 -0
- mindroom/tools_metadata.py +220 -0
- mindroom/topic_generator.py +153 -0
- mindroom/voice_handler.py +266 -0
- mindroom-0.1.0.dist-info/METADATA +425 -0
- mindroom-0.1.0.dist-info/RECORD +152 -0
- {mindroom-0.0.0.dist-info โ mindroom-0.1.0.dist-info}/WHEEL +1 -2
- mindroom-0.1.0.dist-info/entry_points.txt +2 -0
- mindroom-0.0.0.dist-info/METADATA +0 -24
- mindroom-0.0.0.dist-info/RECORD +0 -4
- mindroom-0.0.0.dist-info/top_level.txt +0 -1
mindroom/file_watcher.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Simple file watcher utility without external dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def watch_file(
|
|
18
|
+
file_path: Path | str,
|
|
19
|
+
callback: Callable[[], Awaitable[None]],
|
|
20
|
+
stop_event: asyncio.Event | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Watch a file for changes and call callback when modified.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
file_path: Path to the file to watch
|
|
26
|
+
callback: Async function to call when file changes
|
|
27
|
+
stop_event: Optional event to signal when to stop watching
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
file_path = Path(file_path)
|
|
31
|
+
last_mtime = file_path.stat().st_mtime if file_path.exists() else 0
|
|
32
|
+
|
|
33
|
+
while stop_event is None or not stop_event.is_set():
|
|
34
|
+
await asyncio.sleep(1.0) # Check every second
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
if file_path.exists():
|
|
38
|
+
current_mtime = file_path.stat().st_mtime
|
|
39
|
+
if current_mtime != last_mtime:
|
|
40
|
+
last_mtime = current_mtime
|
|
41
|
+
await callback()
|
|
42
|
+
except (OSError, PermissionError):
|
|
43
|
+
# File might have been deleted or become unreadable
|
|
44
|
+
# Reset mtime so we detect when it comes back
|
|
45
|
+
last_mtime = 0
|
|
46
|
+
except Exception:
|
|
47
|
+
# Don't let callback errors stop the watcher
|
|
48
|
+
# The callback should handle its own errors
|
|
49
|
+
logger.exception("Exception during file watcher callback - continuing to watch")
|
mindroom/interactive.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Interactive Q&A system using Matrix reactions as clickable buttons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
9
|
+
|
|
10
|
+
import nio
|
|
11
|
+
|
|
12
|
+
from .logging_config import get_logger
|
|
13
|
+
from .matrix.event_info import EventInfo
|
|
14
|
+
from .matrix.identity import is_agent_id
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .config import Config
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InteractiveQuestion(NamedTuple):
|
|
23
|
+
"""Represents an active interactive question."""
|
|
24
|
+
|
|
25
|
+
room_id: str
|
|
26
|
+
thread_id: str | None
|
|
27
|
+
options: dict[str, str] # emoji/number -> value mapping
|
|
28
|
+
creator_agent: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InteractiveResponse(NamedTuple):
|
|
32
|
+
"""Result of parsing and formatting an interactive response."""
|
|
33
|
+
|
|
34
|
+
formatted_text: str
|
|
35
|
+
option_map: dict[str, str] | None
|
|
36
|
+
options_list: list[dict[str, str]] | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Track active interactive questions by event_id
|
|
40
|
+
_active_questions: dict[str, InteractiveQuestion] = {}
|
|
41
|
+
|
|
42
|
+
# Constants
|
|
43
|
+
# Match interactive code blocks
|
|
44
|
+
INTERACTIVE_PATTERN = r"```(?:interactive\s*)?\n(?:interactive\s*\n)?(.*?)\n```"
|
|
45
|
+
MAX_OPTIONS = 5
|
|
46
|
+
DEFAULT_QUESTION = "Please choose an option:"
|
|
47
|
+
INSTRUCTION_TEXT = "React with an emoji or type the number to respond."
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def should_create_interactive_question(response_text: str) -> bool:
|
|
51
|
+
"""Check if the response contains an interactive question in JSON format.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
response_text: The AI's response text
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True if an interactive code block is found
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
return bool(re.search(INTERACTIVE_PATTERN, response_text, re.DOTALL))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def handle_reaction(
|
|
64
|
+
client: nio.AsyncClient,
|
|
65
|
+
event: nio.ReactionEvent,
|
|
66
|
+
agent_name: str,
|
|
67
|
+
config: Config,
|
|
68
|
+
) -> tuple[str, str | None] | None:
|
|
69
|
+
"""Handle a reaction event that might be an answer to a question.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
client: The Matrix client
|
|
73
|
+
event: The reaction event
|
|
74
|
+
agent_name: The name of the agent handling this
|
|
75
|
+
config: Application configuration
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Tuple of (selected_value, thread_id) if this was a valid response, None otherwise
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
question = _active_questions.get(event.reacts_to)
|
|
82
|
+
if not question:
|
|
83
|
+
logger.debug(
|
|
84
|
+
"Reaction to unknown message",
|
|
85
|
+
reacts_to=event.reacts_to,
|
|
86
|
+
sender=event.sender,
|
|
87
|
+
reaction=event.key,
|
|
88
|
+
active_questions=list(_active_questions.keys()),
|
|
89
|
+
)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
# Only the agent who created the question should respond to reactions
|
|
93
|
+
if agent_name != question.creator_agent:
|
|
94
|
+
logger.debug(
|
|
95
|
+
"Ignoring reaction to question created by another agent",
|
|
96
|
+
reacting_agent=agent_name,
|
|
97
|
+
question_creator=question.creator_agent,
|
|
98
|
+
reaction=event.key,
|
|
99
|
+
)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
reaction_key = event.key
|
|
103
|
+
if reaction_key not in question.options:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# Don't process our own reactions
|
|
107
|
+
if event.sender == client.user_id:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
# Ignore reactions from other agents
|
|
111
|
+
if is_agent_id(event.sender, config):
|
|
112
|
+
logger.debug("Ignoring reaction from agent", sender=event.sender, reaction=reaction_key)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
selected_value = question.options[reaction_key]
|
|
116
|
+
|
|
117
|
+
logger.info(
|
|
118
|
+
"Received answer via reaction",
|
|
119
|
+
user=event.sender,
|
|
120
|
+
reaction=reaction_key,
|
|
121
|
+
value=selected_value,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Store the response for the agent to process
|
|
125
|
+
# The agent will continue the conversation based on this selection
|
|
126
|
+
# No confirmation message needed - the emoji reaction itself is the user's response
|
|
127
|
+
|
|
128
|
+
with suppress(KeyError):
|
|
129
|
+
del _active_questions[event.reacts_to]
|
|
130
|
+
|
|
131
|
+
# Return the selected value and thread_id so the agent can respond
|
|
132
|
+
return (selected_value, question.thread_id)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def handle_text_response(
|
|
136
|
+
client: nio.AsyncClient,
|
|
137
|
+
room: nio.MatrixRoom,
|
|
138
|
+
event: nio.RoomMessageText,
|
|
139
|
+
agent_name: str,
|
|
140
|
+
) -> tuple[str, str | None] | None:
|
|
141
|
+
"""Handle text responses to interactive questions (e.g., "1", "2", "3").
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
client: The Matrix client
|
|
145
|
+
room: The room the message occurred in
|
|
146
|
+
event: The message event
|
|
147
|
+
agent_name: The name of the agent handling this
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Tuple of (selected_value, thread_id) if this was a valid response, None otherwise
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
message_text = event.body.strip()
|
|
154
|
+
|
|
155
|
+
# Look for numeric responses
|
|
156
|
+
if not message_text.isdigit() or len(message_text) > 1:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
thread_info = EventInfo.from_event(event.source)
|
|
160
|
+
thread_id = thread_info.thread_id
|
|
161
|
+
|
|
162
|
+
# Find matching active questions in this room/thread
|
|
163
|
+
for question_event_id, question in _active_questions.items():
|
|
164
|
+
if question.room_id != room.room_id:
|
|
165
|
+
continue
|
|
166
|
+
if question.thread_id != thread_id:
|
|
167
|
+
continue
|
|
168
|
+
if message_text not in question.options:
|
|
169
|
+
continue
|
|
170
|
+
if event.sender == client.user_id:
|
|
171
|
+
continue
|
|
172
|
+
# Only respond if this agent created the question
|
|
173
|
+
if agent_name != question.creator_agent:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Found a matching question
|
|
177
|
+
selected_value = question.options[message_text]
|
|
178
|
+
|
|
179
|
+
logger.info(
|
|
180
|
+
"Received answer via text",
|
|
181
|
+
user=event.sender,
|
|
182
|
+
text=message_text,
|
|
183
|
+
value=selected_value,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
del _active_questions[question_event_id]
|
|
187
|
+
|
|
188
|
+
return (selected_value, question.thread_id)
|
|
189
|
+
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_and_format_interactive(response_text: str, extract_mapping: bool = False) -> InteractiveResponse:
|
|
194
|
+
"""Parse and format interactive content from response text.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
response_text: The response text containing interactive JSON
|
|
198
|
+
extract_mapping: Whether to extract option mapping and return options list
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
InteractiveResponse with formatted_text, option_map, and options_list
|
|
202
|
+
|
|
203
|
+
"""
|
|
204
|
+
# Find the first interactive block for processing
|
|
205
|
+
first_match = re.search(INTERACTIVE_PATTERN, response_text, re.DOTALL)
|
|
206
|
+
|
|
207
|
+
if not first_match:
|
|
208
|
+
return InteractiveResponse(response_text, None, None)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
interactive_data = json.loads(first_match.group(1))
|
|
212
|
+
except json.JSONDecodeError:
|
|
213
|
+
return InteractiveResponse(response_text, None, None)
|
|
214
|
+
|
|
215
|
+
question = interactive_data.get("question", DEFAULT_QUESTION)
|
|
216
|
+
options = interactive_data.get("options", [])
|
|
217
|
+
|
|
218
|
+
if not options:
|
|
219
|
+
return InteractiveResponse(response_text, None, None)
|
|
220
|
+
|
|
221
|
+
options = options[:MAX_OPTIONS]
|
|
222
|
+
clean_response = response_text.replace(first_match.group(0), "").strip()
|
|
223
|
+
|
|
224
|
+
option_lines = []
|
|
225
|
+
option_map: dict[str, str] | None = {} if extract_mapping else None
|
|
226
|
+
|
|
227
|
+
for i, opt in enumerate(options, 1):
|
|
228
|
+
emoji_char = opt.get("emoji", "โ")
|
|
229
|
+
label = opt.get("label", "Option")
|
|
230
|
+
option_lines.append(f"{i}. {emoji_char} {label}")
|
|
231
|
+
|
|
232
|
+
if extract_mapping and option_map is not None:
|
|
233
|
+
value = opt.get("value", label.lower())
|
|
234
|
+
option_map[emoji_char] = value
|
|
235
|
+
option_map[str(i)] = value
|
|
236
|
+
|
|
237
|
+
# Combine everything into the final message
|
|
238
|
+
message_parts = []
|
|
239
|
+
if clean_response:
|
|
240
|
+
message_parts.append(clean_response)
|
|
241
|
+
message_parts.append("") # Empty line
|
|
242
|
+
message_parts.append(question)
|
|
243
|
+
message_parts.append("") # Empty line
|
|
244
|
+
message_parts.extend(option_lines)
|
|
245
|
+
message_parts.append("") # Empty line
|
|
246
|
+
message_parts.append(INSTRUCTION_TEXT)
|
|
247
|
+
|
|
248
|
+
final_text = "\n".join(message_parts)
|
|
249
|
+
|
|
250
|
+
return InteractiveResponse(final_text, option_map, options if extract_mapping else None)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def register_interactive_question(
|
|
254
|
+
event_id: str,
|
|
255
|
+
room_id: str,
|
|
256
|
+
thread_id: str | None,
|
|
257
|
+
option_map: dict[str, str],
|
|
258
|
+
agent_name: str,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Register an interactive question for tracking.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
event_id: The event ID of the message with the question
|
|
264
|
+
room_id: The room ID
|
|
265
|
+
thread_id: Thread ID if in a thread
|
|
266
|
+
option_map: Mapping of emoji/number to values
|
|
267
|
+
agent_name: The agent that created the question
|
|
268
|
+
|
|
269
|
+
"""
|
|
270
|
+
_active_questions[event_id] = InteractiveQuestion(
|
|
271
|
+
room_id=room_id,
|
|
272
|
+
thread_id=thread_id,
|
|
273
|
+
options=option_map,
|
|
274
|
+
creator_agent=agent_name,
|
|
275
|
+
)
|
|
276
|
+
logger.info("Registered interactive question", event_id=event_id, options=len(option_map))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def add_reaction_buttons(
|
|
280
|
+
client: nio.AsyncClient,
|
|
281
|
+
room_id: str,
|
|
282
|
+
event_id: str,
|
|
283
|
+
options: list[dict[str, str]],
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Add reaction buttons to a message.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
client: The Matrix client
|
|
289
|
+
room_id: The room ID
|
|
290
|
+
event_id: The event ID of the message to add reactions to
|
|
291
|
+
options: List of option dictionaries with 'emoji' keys
|
|
292
|
+
|
|
293
|
+
"""
|
|
294
|
+
for opt in options:
|
|
295
|
+
emoji_char = opt.get("emoji", "โ")
|
|
296
|
+
reaction_response = await client.room_send(
|
|
297
|
+
room_id=room_id,
|
|
298
|
+
message_type="m.reaction",
|
|
299
|
+
content={
|
|
300
|
+
"m.relates_to": {
|
|
301
|
+
"rel_type": "m.annotation",
|
|
302
|
+
"event_id": event_id,
|
|
303
|
+
"key": emoji_char,
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
if not isinstance(reaction_response, nio.RoomSendResponse):
|
|
308
|
+
logger.warning("Failed to add reaction", emoji=emoji_char, error=str(reaction_response))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def cleanup() -> None:
|
|
312
|
+
"""Clean up when shutting down."""
|
|
313
|
+
_active_questions.clear()
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Logging configuration for mindroom using structlog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import logging.config
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from mindroom.constants import STORAGE_PATH
|
|
14
|
+
|
|
15
|
+
__all__ = ["emoji", "get_logger", "setup_logging"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NioValidationFilter(logging.Filter):
|
|
19
|
+
"""Filter out harmless nio validation warnings that confuse AI agents."""
|
|
20
|
+
|
|
21
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
22
|
+
"""Filter out specific nio validation warnings.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
False to suppress the log record, True to keep it
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
# Filter out only the specific user_id and room_id validation warnings from nio
|
|
29
|
+
if record.name == "nio.responses":
|
|
30
|
+
msg = record.getMessage()
|
|
31
|
+
if "Error validating response: 'user_id' is a required property" in msg:
|
|
32
|
+
# This warning occurs when Matrix server responses don't include user_id
|
|
33
|
+
# which happens during registration checks. It's harmless.
|
|
34
|
+
return False
|
|
35
|
+
if "Error validating response: 'room_id' is a required property" in msg:
|
|
36
|
+
# Similar harmless warning for room_id
|
|
37
|
+
return False
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def emoji(agent_name: str) -> str:
|
|
42
|
+
"""Get an emoji-prefixed agent name string with consistent emoji based on the name.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
agent_name: The agent name to add emoji to
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The agent name with a unique emoji prefix
|
|
49
|
+
|
|
50
|
+
"""
|
|
51
|
+
# Emojis for different agents
|
|
52
|
+
emojis = [
|
|
53
|
+
"๐ค", # robot
|
|
54
|
+
"๐งฎ", # abacus
|
|
55
|
+
"๐ก", # light bulb
|
|
56
|
+
"๐ง", # wrench
|
|
57
|
+
"๐", # chart
|
|
58
|
+
"๐ฏ", # target
|
|
59
|
+
"๐", # rocket
|
|
60
|
+
"โก", # lightning
|
|
61
|
+
"๐", # magnifying glass
|
|
62
|
+
"๐", # memo
|
|
63
|
+
"๐จ", # artist palette
|
|
64
|
+
"๐งช", # test tube
|
|
65
|
+
"๐ช", # circus tent
|
|
66
|
+
"๐", # star
|
|
67
|
+
"๐ฎ", # crystal ball
|
|
68
|
+
"๐ ๏ธ", # hammer and wrench
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# Use hash to get consistent emoji for each agent
|
|
72
|
+
hash_value = int(hashlib.sha256(agent_name.encode()).hexdigest(), 16)
|
|
73
|
+
emoji_index = hash_value % len(emojis)
|
|
74
|
+
emoji = emojis[emoji_index]
|
|
75
|
+
|
|
76
|
+
return f"{emoji} {agent_name}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def setup_logging(level: str = "INFO") -> None:
|
|
80
|
+
"""Configure structlog for mindroom with file and console output.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
level: Minimum logging level (e.g., "DEBUG", "INFO", "WARNING", "ERROR")
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
# Create logs directory if it doesn't exist
|
|
87
|
+
logs_dir = Path(STORAGE_PATH) / "logs"
|
|
88
|
+
logs_dir.mkdir(exist_ok=True, parents=True)
|
|
89
|
+
|
|
90
|
+
# Create timestamped log file
|
|
91
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
|
92
|
+
log_file = logs_dir / f"mindroom_{timestamp}.log"
|
|
93
|
+
|
|
94
|
+
# Shared processors that don't affect output format
|
|
95
|
+
timestamper = structlog.processors.TimeStamper(fmt="iso")
|
|
96
|
+
pre_chain = [
|
|
97
|
+
structlog.stdlib.add_log_level,
|
|
98
|
+
timestamper,
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
# Configure logging with both console and file handlers
|
|
102
|
+
logging.config.dictConfig(
|
|
103
|
+
{
|
|
104
|
+
"version": 1,
|
|
105
|
+
"disable_existing_loggers": False,
|
|
106
|
+
"formatters": {
|
|
107
|
+
"plain": {
|
|
108
|
+
"()": structlog.stdlib.ProcessorFormatter,
|
|
109
|
+
"processors": [
|
|
110
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
111
|
+
structlog.dev.ConsoleRenderer(colors=False),
|
|
112
|
+
],
|
|
113
|
+
"foreign_pre_chain": pre_chain,
|
|
114
|
+
},
|
|
115
|
+
"colored": {
|
|
116
|
+
"()": structlog.stdlib.ProcessorFormatter,
|
|
117
|
+
"processors": [
|
|
118
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
119
|
+
structlog.dev.ConsoleRenderer(
|
|
120
|
+
colors=True,
|
|
121
|
+
exception_formatter=structlog.dev.RichTracebackFormatter(
|
|
122
|
+
# The locals can be very large, so we hide them by default
|
|
123
|
+
show_locals=False,
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
],
|
|
127
|
+
"foreign_pre_chain": pre_chain,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
"filters": {
|
|
131
|
+
"nio_validation": {
|
|
132
|
+
"()": NioValidationFilter,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
"handlers": {
|
|
136
|
+
"console": {
|
|
137
|
+
"level": level.upper(),
|
|
138
|
+
"class": "logging.StreamHandler",
|
|
139
|
+
"stream": "ext://sys.stderr",
|
|
140
|
+
"formatter": "colored",
|
|
141
|
+
"filters": ["nio_validation"],
|
|
142
|
+
},
|
|
143
|
+
"file": {
|
|
144
|
+
"level": level.upper(),
|
|
145
|
+
"class": "logging.FileHandler",
|
|
146
|
+
"filename": str(log_file),
|
|
147
|
+
"mode": "a",
|
|
148
|
+
"encoding": "utf-8",
|
|
149
|
+
"formatter": "plain",
|
|
150
|
+
"filters": ["nio_validation"],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
"loggers": {
|
|
154
|
+
"": { # Root logger
|
|
155
|
+
"handlers": ["console", "file"],
|
|
156
|
+
"level": level.upper(),
|
|
157
|
+
"propagate": False,
|
|
158
|
+
},
|
|
159
|
+
# Reduce verbosity of nio (Matrix) library
|
|
160
|
+
"nio": {
|
|
161
|
+
"level": "WARNING",
|
|
162
|
+
},
|
|
163
|
+
"nio.client": {
|
|
164
|
+
"level": "WARNING",
|
|
165
|
+
},
|
|
166
|
+
"nio.responses": {
|
|
167
|
+
"level": "WARNING",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Configure structlog to use stdlib logging
|
|
174
|
+
structlog.configure(
|
|
175
|
+
processors=[
|
|
176
|
+
structlog.contextvars.merge_contextvars,
|
|
177
|
+
structlog.stdlib.filter_by_level,
|
|
178
|
+
structlog.stdlib.add_logger_name,
|
|
179
|
+
structlog.stdlib.add_log_level,
|
|
180
|
+
timestamper,
|
|
181
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
182
|
+
structlog.processors.StackInfoRenderer(),
|
|
183
|
+
structlog.processors.UnicodeDecoder(),
|
|
184
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
185
|
+
],
|
|
186
|
+
context_class=dict,
|
|
187
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
188
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
189
|
+
cache_logger_on_first_use=True,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Log startup message
|
|
193
|
+
logger = get_logger(__name__)
|
|
194
|
+
logger.info("Logging initialized", log_file=str(log_file), level=level)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_logger(name: str = __name__) -> structlog.stdlib.BoundLogger:
|
|
198
|
+
"""Get a structlog logger instance.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
name: Logger name (typically __name__)
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Configured structlog logger
|
|
205
|
+
|
|
206
|
+
"""
|
|
207
|
+
return structlog.get_logger(name) # type: ignore[no-any-return]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Matrix operations module for mindroom."""
|