monoco-toolkit 0.3.11__py3-none-any.whl → 0.4.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 (132) hide show
  1. monoco/core/automation/__init__.py +40 -0
  2. monoco/core/automation/field_watcher.py +296 -0
  3. monoco/core/automation/handlers.py +805 -0
  4. monoco/core/config.py +29 -11
  5. monoco/core/daemon/__init__.py +5 -0
  6. monoco/core/daemon/pid.py +290 -0
  7. monoco/core/git.py +15 -0
  8. monoco/core/hooks/context.py +74 -13
  9. monoco/core/injection.py +86 -8
  10. monoco/core/integrations.py +0 -24
  11. monoco/core/router/__init__.py +17 -0
  12. monoco/core/router/action.py +202 -0
  13. monoco/core/scheduler/__init__.py +63 -0
  14. monoco/core/scheduler/base.py +152 -0
  15. monoco/core/scheduler/engines.py +175 -0
  16. monoco/core/scheduler/events.py +197 -0
  17. monoco/core/scheduler/local.py +377 -0
  18. monoco/core/setup.py +9 -0
  19. monoco/core/sync.py +199 -4
  20. monoco/core/watcher/__init__.py +63 -0
  21. monoco/core/watcher/base.py +382 -0
  22. monoco/core/watcher/dropzone.py +152 -0
  23. monoco/core/watcher/im.py +460 -0
  24. monoco/core/watcher/issue.py +303 -0
  25. monoco/core/watcher/memo.py +192 -0
  26. monoco/core/watcher/task.py +238 -0
  27. monoco/daemon/app.py +3 -60
  28. monoco/daemon/commands.py +459 -25
  29. monoco/daemon/events.py +34 -0
  30. monoco/daemon/scheduler.py +157 -201
  31. monoco/daemon/services.py +42 -243
  32. monoco/features/agent/__init__.py +25 -7
  33. monoco/features/agent/cli.py +91 -57
  34. monoco/features/agent/engines.py +31 -170
  35. monoco/features/agent/resources/en/AGENTS.md +14 -14
  36. monoco/features/agent/resources/en/skills/monoco_role_engineer/SKILL.md +101 -0
  37. monoco/features/agent/resources/en/skills/monoco_role_manager/SKILL.md +95 -0
  38. monoco/features/agent/resources/en/skills/monoco_role_planner/SKILL.md +177 -0
  39. monoco/features/agent/resources/en/skills/monoco_role_reviewer/SKILL.md +139 -0
  40. monoco/features/agent/resources/zh/skills/monoco_role_engineer/SKILL.md +101 -0
  41. monoco/features/agent/resources/zh/skills/monoco_role_manager/SKILL.md +95 -0
  42. monoco/features/agent/resources/zh/skills/monoco_role_planner/SKILL.md +177 -0
  43. monoco/features/agent/resources/zh/skills/monoco_role_reviewer/SKILL.md +139 -0
  44. monoco/features/agent/worker.py +1 -1
  45. monoco/features/hooks/__init__.py +61 -6
  46. monoco/features/hooks/commands.py +281 -271
  47. monoco/features/hooks/dispatchers/__init__.py +23 -0
  48. monoco/features/hooks/dispatchers/agent_dispatcher.py +486 -0
  49. monoco/features/hooks/dispatchers/git_dispatcher.py +478 -0
  50. monoco/features/hooks/manager.py +357 -0
  51. monoco/features/hooks/models.py +262 -0
  52. monoco/features/hooks/parser.py +322 -0
  53. monoco/features/hooks/universal_interceptor.py +503 -0
  54. monoco/features/im/__init__.py +67 -0
  55. monoco/features/im/core.py +782 -0
  56. monoco/features/im/models.py +311 -0
  57. monoco/features/issue/commands.py +133 -60
  58. monoco/features/issue/core.py +385 -40
  59. monoco/features/issue/domain_commands.py +0 -19
  60. monoco/features/issue/resources/en/AGENTS.md +17 -122
  61. monoco/features/issue/resources/hooks/agent/before-tool.sh +102 -0
  62. monoco/features/issue/resources/hooks/agent/session-start.sh +88 -0
  63. monoco/features/issue/resources/hooks/{post-checkout.sh → git/git-post-checkout.sh} +10 -9
  64. monoco/features/issue/resources/hooks/git/git-pre-commit.sh +31 -0
  65. monoco/features/issue/resources/hooks/{pre-push.sh → git/git-pre-push.sh} +7 -13
  66. monoco/features/issue/resources/zh/AGENTS.md +18 -123
  67. monoco/features/memo/cli.py +15 -64
  68. monoco/features/memo/core.py +6 -34
  69. monoco/features/memo/models.py +24 -15
  70. monoco/features/memo/resources/en/AGENTS.md +31 -0
  71. monoco/features/memo/resources/zh/AGENTS.md +28 -5
  72. monoco/features/spike/commands.py +5 -3
  73. monoco/main.py +5 -3
  74. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/METADATA +1 -1
  75. monoco_toolkit-0.4.0.dist-info/RECORD +170 -0
  76. monoco/core/execution.py +0 -67
  77. monoco/features/agent/apoptosis.py +0 -44
  78. monoco/features/agent/manager.py +0 -127
  79. monoco/features/agent/resources/atoms/atom-code-dev.yaml +0 -61
  80. monoco/features/agent/resources/atoms/atom-issue-lifecycle.yaml +0 -73
  81. monoco/features/agent/resources/atoms/atom-knowledge.yaml +0 -55
  82. monoco/features/agent/resources/atoms/atom-review.yaml +0 -60
  83. monoco/features/agent/resources/en/skills/monoco_atom_core/SKILL.md +0 -99
  84. monoco/features/agent/resources/en/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
  85. monoco/features/agent/resources/en/skills/monoco_workflow_agent_manager/SKILL.md +0 -93
  86. monoco/features/agent/resources/en/skills/monoco_workflow_agent_planner/SKILL.md +0 -85
  87. monoco/features/agent/resources/en/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -114
  88. monoco/features/agent/resources/workflows/workflow-dev.yaml +0 -83
  89. monoco/features/agent/resources/workflows/workflow-issue-create.yaml +0 -72
  90. monoco/features/agent/resources/workflows/workflow-review.yaml +0 -94
  91. monoco/features/agent/resources/zh/roles/monoco_role_engineer.yaml +0 -49
  92. monoco/features/agent/resources/zh/roles/monoco_role_manager.yaml +0 -46
  93. monoco/features/agent/resources/zh/roles/monoco_role_planner.yaml +0 -46
  94. monoco/features/agent/resources/zh/roles/monoco_role_reviewer.yaml +0 -47
  95. monoco/features/agent/resources/zh/skills/monoco_atom_core/SKILL.md +0 -99
  96. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
  97. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_manager/SKILL.md +0 -88
  98. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_planner/SKILL.md +0 -259
  99. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -137
  100. monoco/features/agent/session.py +0 -169
  101. monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +0 -278
  102. monoco/features/glossary/resources/en/skills/monoco_atom_glossary/SKILL.md +0 -35
  103. monoco/features/glossary/resources/zh/skills/monoco_atom_glossary/SKILL.md +0 -35
  104. monoco/features/hooks/adapter.py +0 -67
  105. monoco/features/hooks/core.py +0 -441
  106. monoco/features/i18n/resources/en/skills/monoco_atom_i18n/SKILL.md +0 -96
  107. monoco/features/i18n/resources/en/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
  108. monoco/features/i18n/resources/zh/skills/monoco_atom_i18n/SKILL.md +0 -96
  109. monoco/features/i18n/resources/zh/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
  110. monoco/features/issue/resources/en/skills/monoco_atom_issue/SKILL.md +0 -165
  111. monoco/features/issue/resources/en/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
  112. monoco/features/issue/resources/en/skills/monoco_workflow_issue_development/SKILL.md +0 -224
  113. monoco/features/issue/resources/en/skills/monoco_workflow_issue_management/SKILL.md +0 -159
  114. monoco/features/issue/resources/en/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
  115. monoco/features/issue/resources/hooks/pre-commit.sh +0 -41
  116. monoco/features/issue/resources/zh/skills/monoco_atom_issue_lifecycle/SKILL.md +0 -190
  117. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
  118. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_development/SKILL.md +0 -224
  119. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_management/SKILL.md +0 -159
  120. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
  121. monoco/features/memo/resources/en/skills/monoco_atom_memo/SKILL.md +0 -77
  122. monoco/features/memo/resources/en/skills/monoco_workflow_note_processing/SKILL.md +0 -140
  123. monoco/features/memo/resources/zh/skills/monoco_atom_memo/SKILL.md +0 -77
  124. monoco/features/memo/resources/zh/skills/monoco_workflow_note_processing/SKILL.md +0 -140
  125. monoco/features/spike/resources/en/skills/monoco_atom_spike/SKILL.md +0 -76
  126. monoco/features/spike/resources/en/skills/monoco_workflow_research/SKILL.md +0 -121
  127. monoco/features/spike/resources/zh/skills/monoco_atom_spike/SKILL.md +0 -76
  128. monoco/features/spike/resources/zh/skills/monoco_workflow_research/SKILL.md +0 -121
  129. monoco_toolkit-0.3.11.dist-info/RECORD +0 -181
  130. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/WHEEL +0 -0
  131. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/entry_points.txt +0 -0
  132. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,152 @@
1
+ """
2
+ DropzoneWatcher - Adapter for the existing dropzone watcher.
3
+
4
+ Part of Layer 1 (File Watcher) in the event automation framework.
5
+ Wraps the existing ingestion watcher to fit the FilesystemWatcher interface.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, Optional
14
+
15
+ from monoco.core.scheduler import AgentEventType, EventBus, event_bus
16
+ from monoco.core.ingestion.watcher import (
17
+ DropzoneWatcher as LegacyDropzoneWatcher,
18
+ IngestionEvent,
19
+ IngestionEventType,
20
+ )
21
+ from monoco.core.artifacts.manager import ArtifactManager
22
+
23
+ from .base import (
24
+ ChangeType,
25
+ FileEvent,
26
+ FilesystemWatcher,
27
+ WatchConfig,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class DropzoneFileEvent(FileEvent):
34
+ """FileEvent specific to Dropzone."""
35
+
36
+ def __init__(
37
+ self,
38
+ path: Path,
39
+ change_type: ChangeType,
40
+ ingestion_event_type: IngestionEventType,
41
+ artifact_id: Optional[str] = None,
42
+ **kwargs,
43
+ ):
44
+ super().__init__(
45
+ path=path,
46
+ change_type=change_type,
47
+ watcher_name="DropzoneWatcher",
48
+ **kwargs,
49
+ )
50
+ self.ingestion_event_type = ingestion_event_type
51
+ self.artifact_id = artifact_id
52
+
53
+ def to_agent_event_type(self) -> Optional[AgentEventType]:
54
+ """Dropzone events don't map directly to agent events."""
55
+ return None
56
+
57
+ def to_payload(self) -> Dict[str, Any]:
58
+ """Convert to payload with Dropzone-specific fields."""
59
+ payload = super().to_payload()
60
+ payload["ingestion_event_type"] = self.ingestion_event_type.value
61
+ payload["artifact_id"] = self.artifact_id
62
+ return payload
63
+
64
+
65
+ class DropzoneWatcher(FilesystemWatcher):
66
+ """
67
+ Adapter for the existing DropzoneWatcher.
68
+
69
+ Wraps the legacy ingestion watcher to provide a unified interface
70
+ while maintaining backward compatibility.
71
+
72
+ Example:
73
+ >>> from monoco.core.artifacts.manager import ArtifactManager
74
+ >>> artifact_manager = ArtifactManager()
75
+ >>> config = WatchConfig(path=Path("./.monoco/dropzone"))
76
+ >>> watcher = DropzoneWatcher(config, artifact_manager)
77
+ >>> await watcher.start()
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ config: WatchConfig,
83
+ artifact_manager: ArtifactManager,
84
+ event_bus: Optional[EventBus] = None,
85
+ name: str = "DropzoneWatcher",
86
+ ):
87
+ super().__init__(config, event_bus, name)
88
+ self.artifact_manager = artifact_manager
89
+
90
+ # Create legacy watcher
91
+ self._legacy_watcher = LegacyDropzoneWatcher(
92
+ dropzone_path=config.path,
93
+ artifact_manager=artifact_manager,
94
+ process_existing=False,
95
+ )
96
+
97
+ # Set up event forwarding
98
+ self._legacy_watcher.set_event_callback(self._on_ingestion_event)
99
+
100
+ async def start(self) -> None:
101
+ """Start watching the dropzone."""
102
+ if self._running:
103
+ return
104
+
105
+ self._legacy_watcher.start()
106
+ self._running = True
107
+ logger.info(f"Started DropzoneWatcher: {self.config.path}")
108
+
109
+ async def stop(self) -> None:
110
+ """Stop watching the dropzone."""
111
+ if not self._running:
112
+ return
113
+
114
+ self._legacy_watcher.stop()
115
+ self._running = False
116
+ logger.info(f"Stopped DropzoneWatcher: {self.config.path}")
117
+
118
+ def _on_ingestion_event(self, event: IngestionEvent) -> None:
119
+ """Handle ingestion events from legacy watcher."""
120
+ # Map ingestion event type to change type
121
+ change_type_map = {
122
+ IngestionEventType.FILE_DETECTED: ChangeType.CREATED,
123
+ IngestionEventType.CONVERSION_STARTED: ChangeType.MODIFIED,
124
+ IngestionEventType.CONVERSION_COMPLETED: ChangeType.MODIFIED,
125
+ IngestionEventType.CONVERSION_FAILED: ChangeType.MODIFIED,
126
+ IngestionEventType.ARTIFACT_REGISTERED: ChangeType.MODIFIED,
127
+ }
128
+
129
+ change_type = change_type_map.get(event.event_type, ChangeType.MODIFIED)
130
+
131
+ # Create unified event
132
+ file_event = DropzoneFileEvent(
133
+ path=event.file_path,
134
+ change_type=change_type,
135
+ ingestion_event_type=event.event_type,
136
+ artifact_id=event.artifact_id,
137
+ metadata={
138
+ "task_id": event.task_id,
139
+ "error_message": event.error_message,
140
+ **event.metadata,
141
+ },
142
+ )
143
+
144
+ # Emit synchronously (called from sync context)
145
+ asyncio.create_task(self.emit(file_event))
146
+
147
+ def get_stats(self) -> Dict[str, Any]:
148
+ """Get watcher statistics."""
149
+ stats = super().get_stats()
150
+ legacy_stats = self._legacy_watcher.get_stats()
151
+ stats.update(legacy_stats)
152
+ return stats
@@ -0,0 +1,460 @@
1
+ """
2
+ IMWatcher - Monitors IM message flow for Agent triggers (FEAT-0167).
3
+
4
+ Part of Layer 1 (File Watcher) in the event automation framework.
5
+ Emits events when IM messages are received and need Agent processing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Callable
16
+
17
+ from monoco.core.scheduler import AgentEventType, EventBus, event_bus
18
+
19
+ from .base import (
20
+ ChangeType,
21
+ FileEvent,
22
+ PollingWatcher,
23
+ WatchConfig,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class IMFileEvent(FileEvent):
30
+ """FileEvent specific to IM message files."""
31
+
32
+ def __init__(
33
+ self,
34
+ path: Path,
35
+ change_type: ChangeType,
36
+ message_id: str,
37
+ channel_id: str,
38
+ platform: str,
39
+ event_type: str = "message_received",
40
+ **kwargs,
41
+ ):
42
+ super().__init__(
43
+ path=path,
44
+ change_type=change_type,
45
+ watcher_name="IMWatcher",
46
+ **kwargs,
47
+ )
48
+ self.message_id = message_id
49
+ self.channel_id = channel_id
50
+ self.platform = platform
51
+ self.event_type = event_type
52
+
53
+ def to_agent_event_type(self) -> Optional[AgentEventType]:
54
+ """Convert to appropriate AgentEventType."""
55
+ event_map = {
56
+ "message_received": AgentEventType.IM_MESSAGE_RECEIVED,
57
+ "message_replied": AgentEventType.IM_MESSAGE_REPLIED,
58
+ "agent_trigger": AgentEventType.IM_AGENT_TRIGGER,
59
+ "session_started": AgentEventType.IM_SESSION_STARTED,
60
+ "session_closed": AgentEventType.IM_SESSION_CLOSED,
61
+ }
62
+ return event_map.get(self.event_type)
63
+
64
+ def to_payload(self) -> Dict[str, Any]:
65
+ """Convert to payload with IM-specific fields."""
66
+ payload = super().to_payload()
67
+ payload.update({
68
+ "message_id": self.message_id,
69
+ "channel_id": self.channel_id,
70
+ "platform": self.platform,
71
+ "event_type": self.event_type,
72
+ })
73
+ return payload
74
+
75
+
76
+ class IMWatcher(PollingWatcher):
77
+ """
78
+ Watcher for IM message storage.
79
+
80
+ Monitors the .monoco/im/messages/ directory for:
81
+ - New message files
82
+ - Message status changes
83
+ - Agent trigger conditions
84
+
85
+ Emits appropriate events to the EventBus for Agent scheduling.
86
+
87
+ Example:
88
+ >>> config = WatchConfig(
89
+ ... path=Path("./.monoco/im/messages"),
90
+ ... patterns=["*.jsonl"],
91
+ ... poll_interval=2.0,
92
+ ... )
93
+ >>> watcher = IMWatcher(config)
94
+ >>> await watcher.start()
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ config: WatchConfig,
100
+ event_bus: Optional[EventBus] = None,
101
+ name: str = "IMWatcher",
102
+ trigger_on_mention: bool = True,
103
+ ):
104
+ super().__init__(config, event_bus, name)
105
+ self.trigger_on_mention = trigger_on_mention
106
+ self._file_states: Dict[Path, Dict[str, Any]] = {}
107
+ self._processed_messages: set = set()
108
+ self._message_handlers: List[Callable[[Dict[str, Any]], None]] = []
109
+
110
+ def register_message_handler(
111
+ self,
112
+ handler: Callable[[Dict[str, Any]], None]
113
+ ) -> None:
114
+ """
115
+ Register a handler for new messages.
116
+
117
+ The handler will be called with the message data dictionary.
118
+ """
119
+ self._message_handlers.append(handler)
120
+ logger.debug(f"Registered message handler: {handler.__name__}")
121
+
122
+ def unregister_message_handler(
123
+ self,
124
+ handler: Callable[[Dict[str, Any]], None]
125
+ ) -> None:
126
+ """Unregister a message handler."""
127
+ if handler in self._message_handlers:
128
+ self._message_handlers.remove(handler)
129
+
130
+ async def _check_changes(self) -> None:
131
+ """Check for new messages in IM storage."""
132
+ if not self.config.path.exists():
133
+ return
134
+
135
+ try:
136
+ # Scan all message files
137
+ message_files = list(self.config.path.glob("*.jsonl"))
138
+
139
+ for file_path in message_files:
140
+ if not self._should_process(file_path):
141
+ continue
142
+
143
+ await self._process_message_file(file_path)
144
+
145
+ except Exception as e:
146
+ logger.error(f"Error checking IM messages: {e}")
147
+
148
+ async def _process_message_file(self, file_path: Path) -> None:
149
+ """Process a message file for new entries."""
150
+ try:
151
+ # Get current file state
152
+ stat = file_path.stat()
153
+ current_size = stat.st_size
154
+
155
+ # Check if we've seen this file before
156
+ if file_path in self._file_states:
157
+ last_size = self._file_states[file_path].get("size", 0)
158
+ if current_size <= last_size:
159
+ # No new content
160
+ return
161
+
162
+ # Read new messages
163
+ messages = self._read_messages(file_path)
164
+
165
+ for message_data in messages:
166
+ message_id = message_data.get("message_id")
167
+
168
+ if not message_id:
169
+ continue
170
+
171
+ # Skip already processed messages
172
+ if message_id in self._processed_messages:
173
+ continue
174
+
175
+ # Process new message
176
+ await self._handle_new_message(file_path, message_data)
177
+ self._processed_messages.add(message_id)
178
+
179
+ # Update file state
180
+ self._file_states[file_path] = {
181
+ "size": current_size,
182
+ "mtime": stat.st_mtime,
183
+ }
184
+
185
+ except Exception as e:
186
+ logger.error(f"Error processing {file_path}: {e}")
187
+
188
+ def _read_messages(self, file_path: Path) -> List[Dict[str, Any]]:
189
+ """Read all messages from a JSONL file."""
190
+ messages = []
191
+
192
+ try:
193
+ with open(file_path, "r", encoding="utf-8") as f:
194
+ for line in f:
195
+ line = line.strip()
196
+ if not line:
197
+ continue
198
+ try:
199
+ data = json.loads(line)
200
+ messages.append(data)
201
+ except json.JSONDecodeError:
202
+ logger.warning(f"Invalid JSON in {file_path}")
203
+
204
+ except Exception as e:
205
+ logger.error(f"Error reading {file_path}: {e}")
206
+
207
+ return messages
208
+
209
+ async def _handle_new_message(
210
+ self,
211
+ file_path: Path,
212
+ message_data: Dict[str, Any]
213
+ ) -> None:
214
+ """Handle a new message."""
215
+ message_id = message_data.get("message_id")
216
+ channel_id = message_data.get("channel_id")
217
+ platform = message_data.get("platform", "unknown")
218
+ status = message_data.get("status", "received")
219
+
220
+ logger.debug(f"New IM message: {message_id} in {channel_id}")
221
+
222
+ # Call registered handlers
223
+ for handler in self._message_handlers:
224
+ try:
225
+ if asyncio.iscoroutinefunction(handler):
226
+ await handler(message_data)
227
+ else:
228
+ handler(message_data)
229
+ except Exception as e:
230
+ logger.error(f"Message handler error: {e}")
231
+
232
+ # Determine event type based on status
233
+ event_type = self._determine_event_type(message_data)
234
+
235
+ if event_type:
236
+ event = IMFileEvent(
237
+ path=file_path,
238
+ change_type=ChangeType.CREATED,
239
+ message_id=message_id,
240
+ channel_id=channel_id,
241
+ platform=platform,
242
+ event_type=event_type,
243
+ metadata={
244
+ "status": status,
245
+ "sender": message_data.get("sender", {}),
246
+ "content_preview": self._get_content_preview(message_data),
247
+ },
248
+ )
249
+ await self.emit(event)
250
+ logger.info(f"Emitted IM event: {event_type} for message {message_id}")
251
+
252
+ def _determine_event_type(self, message_data: Dict[str, Any]) -> Optional[str]:
253
+ """
254
+ Determine the event type for a message.
255
+
256
+ Returns:
257
+ Event type string or None if no event should be emitted
258
+ """
259
+ status = message_data.get("status", "received")
260
+
261
+ # Map status to event type
262
+ status_map = {
263
+ "received": "message_received",
264
+ "routing": "message_received",
265
+ "agent_processing": "agent_trigger",
266
+ "replied": "message_replied",
267
+ }
268
+
269
+ event_type = status_map.get(status)
270
+
271
+ # Check for agent trigger conditions
272
+ if event_type == "message_received" and self._should_trigger_agent(message_data):
273
+ event_type = "agent_trigger"
274
+
275
+ return event_type
276
+
277
+ def _should_trigger_agent(self, message_data: Dict[str, Any]) -> bool:
278
+ """
279
+ Determine if this message should trigger an Agent.
280
+
281
+ Override this method for custom trigger logic.
282
+ """
283
+ content = message_data.get("content", {})
284
+ text = content.get("text", "")
285
+ mentions = message_data.get("mentions", [])
286
+ mention_all = message_data.get("mention_all", False)
287
+
288
+ # Trigger if @mentioned
289
+ if mentions or mention_all:
290
+ return True
291
+
292
+ # Trigger on specific keywords
293
+ trigger_keywords = [
294
+ "@monoco", "@agent", "@bot",
295
+ "#task", "#issue", "#help",
296
+ ]
297
+
298
+ text_lower = text.lower() if text else ""
299
+ for keyword in trigger_keywords:
300
+ if keyword in text_lower:
301
+ return True
302
+
303
+ return False
304
+
305
+ def _get_content_preview(self, message_data: Dict[str, Any], max_length: int = 50) -> str:
306
+ """Get a preview of the message content."""
307
+ content = message_data.get("content", {})
308
+ text = content.get("text", "")
309
+
310
+ if not text:
311
+ return "[No text content]"
312
+
313
+ if len(text) > max_length:
314
+ return text[:max_length] + "..."
315
+
316
+ return text
317
+
318
+ def get_stats(self) -> Dict[str, Any]:
319
+ """Get watcher statistics."""
320
+ stats = super().get_stats()
321
+ stats.update({
322
+ "processed_messages": len(self._processed_messages),
323
+ "monitored_files": len(self._file_states),
324
+ "trigger_on_mention": self.trigger_on_mention,
325
+ })
326
+ return stats
327
+
328
+ def clear_processed_cache(self) -> None:
329
+ """Clear the processed message cache."""
330
+ self._processed_messages.clear()
331
+ logger.debug("Cleared IM message processed cache")
332
+
333
+
334
+ class IMInboundWatcher(IMWatcher):
335
+ """
336
+ Specialized watcher for inbound IM messages.
337
+
338
+ Watches for messages that need Agent attention and emits
339
+ IM_AGENT_TRIGGER events.
340
+ """
341
+
342
+ def __init__(
343
+ self,
344
+ config: WatchConfig,
345
+ event_bus: Optional[EventBus] = None,
346
+ name: str = "IMInboundWatcher",
347
+ ):
348
+ super().__init__(config, event_bus, name, trigger_on_mention=True)
349
+
350
+ def _should_trigger_agent(self, message_data: Dict[str, Any]) -> bool:
351
+ """Always trigger for inbound messages that need processing."""
352
+ # Only trigger for messages in 'received' or 'routing' status
353
+ status = message_data.get("status", "")
354
+ if status not in ("received", "routing"):
355
+ return False
356
+
357
+ # Check if sender is not an agent/bot
358
+ sender = message_data.get("sender", {})
359
+ sender_type = sender.get("participant_type", "user")
360
+ if sender_type in ("agent", "bot"):
361
+ return False
362
+
363
+ # Check for mentions
364
+ mentions = message_data.get("mentions", [])
365
+ mention_all = message_data.get("mention_all", False)
366
+
367
+ if mentions or mention_all:
368
+ return True
369
+
370
+ # Check for trigger keywords
371
+ content = message_data.get("content", {})
372
+ text = content.get("text", "")
373
+
374
+ if text:
375
+ text_lower = text.lower()
376
+ trigger_keywords = [
377
+ "@monoco", "@agent", "@bot",
378
+ "#task", "#issue", "#help",
379
+ "请帮忙", "help me", " assistance",
380
+ ]
381
+ for keyword in trigger_keywords:
382
+ if keyword in text_lower:
383
+ return True
384
+
385
+ return False
386
+
387
+
388
+ class IMWebhookWatcher(PollingWatcher):
389
+ """
390
+ Watcher for IM webhook configuration changes.
391
+
392
+ Monitors the .monoco/im/webhooks/ directory for:
393
+ - New webhook configurations
394
+ - Webhook configuration updates
395
+ """
396
+
397
+ def __init__(
398
+ self,
399
+ config: WatchConfig,
400
+ event_bus: Optional[EventBus] = None,
401
+ name: str = "IMWebhookWatcher",
402
+ ):
403
+ super().__init__(config, event_bus, name)
404
+ self._webhook_configs: Dict[str, Dict[str, Any]] = {}
405
+
406
+ async def _check_changes(self) -> None:
407
+ """Check for webhook configuration changes."""
408
+ if not self.config.path.exists():
409
+ return
410
+
411
+ try:
412
+ config_files = list(self.config.path.glob("*.json"))
413
+
414
+ for file_path in config_files:
415
+ if not self._should_process(file_path):
416
+ continue
417
+
418
+ await self._process_config_file(file_path)
419
+
420
+ except Exception as e:
421
+ logger.error(f"Error checking webhook configs: {e}")
422
+
423
+ async def _process_config_file(self, file_path: Path) -> None:
424
+ """Process a webhook config file."""
425
+ try:
426
+ stat = file_path.stat()
427
+ mtime = stat.st_mtime
428
+
429
+ # Check if file has been modified
430
+ if file_path.name in self._webhook_configs:
431
+ last_mtime = self._webhook_configs[file_path.name].get("mtime", 0)
432
+ if mtime <= last_mtime:
433
+ return
434
+
435
+ # Read and validate config
436
+ config_data = json.loads(file_path.read_text(encoding="utf-8"))
437
+
438
+ # Store config state
439
+ self._webhook_configs[file_path.name] = {
440
+ "mtime": mtime,
441
+ "config": config_data,
442
+ }
443
+
444
+ # Emit event
445
+ event = FileEvent(
446
+ path=file_path,
447
+ change_type=ChangeType.MODIFIED,
448
+ watcher_name=self.name,
449
+ metadata={
450
+ "platform": config_data.get("platform"),
451
+ "channel_id": config_data.get("channel_id"),
452
+ "event_type": "webhook_config_updated",
453
+ },
454
+ )
455
+ await self.emit(event)
456
+
457
+ logger.info(f"Updated webhook config: {file_path.name}")
458
+
459
+ except Exception as e:
460
+ logger.error(f"Error processing {file_path}: {e}")