monoco-toolkit 0.3.12__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.
- monoco/core/automation/__init__.py +0 -11
- monoco/core/automation/handlers.py +108 -26
- monoco/core/config.py +28 -10
- monoco/core/daemon/__init__.py +5 -0
- monoco/core/daemon/pid.py +290 -0
- monoco/core/injection.py +86 -8
- monoco/core/integrations.py +0 -24
- monoco/core/router/__init__.py +1 -39
- monoco/core/router/action.py +3 -142
- monoco/core/scheduler/events.py +28 -2
- monoco/core/setup.py +9 -0
- monoco/core/sync.py +199 -4
- monoco/core/watcher/__init__.py +6 -0
- monoco/core/watcher/base.py +18 -1
- monoco/core/watcher/im.py +460 -0
- monoco/core/watcher/memo.py +40 -48
- monoco/daemon/app.py +3 -60
- monoco/daemon/commands.py +459 -25
- monoco/daemon/scheduler.py +1 -16
- monoco/daemon/services.py +15 -0
- monoco/features/agent/resources/en/AGENTS.md +14 -14
- monoco/features/agent/resources/en/skills/monoco_role_engineer/SKILL.md +101 -0
- monoco/features/agent/resources/en/skills/monoco_role_manager/SKILL.md +95 -0
- monoco/features/agent/resources/en/skills/monoco_role_planner/SKILL.md +177 -0
- monoco/features/agent/resources/en/skills/monoco_role_reviewer/SKILL.md +139 -0
- monoco/features/agent/resources/zh/skills/monoco_role_engineer/SKILL.md +101 -0
- monoco/features/agent/resources/zh/skills/monoco_role_manager/SKILL.md +95 -0
- monoco/features/agent/resources/zh/skills/monoco_role_planner/SKILL.md +177 -0
- monoco/features/agent/resources/zh/skills/monoco_role_reviewer/SKILL.md +139 -0
- monoco/features/hooks/__init__.py +61 -6
- monoco/features/hooks/commands.py +281 -271
- monoco/features/hooks/dispatchers/__init__.py +23 -0
- monoco/features/hooks/dispatchers/agent_dispatcher.py +486 -0
- monoco/features/hooks/dispatchers/git_dispatcher.py +478 -0
- monoco/features/hooks/manager.py +357 -0
- monoco/features/hooks/models.py +262 -0
- monoco/features/hooks/parser.py +322 -0
- monoco/features/hooks/universal_interceptor.py +503 -0
- monoco/features/im/__init__.py +67 -0
- monoco/features/im/core.py +782 -0
- monoco/features/im/models.py +311 -0
- monoco/features/issue/commands.py +65 -50
- monoco/features/issue/core.py +199 -99
- monoco/features/issue/domain_commands.py +0 -19
- monoco/features/issue/resources/en/AGENTS.md +17 -122
- monoco/features/issue/resources/hooks/agent/before-tool.sh +102 -0
- monoco/features/issue/resources/hooks/agent/session-start.sh +88 -0
- monoco/features/issue/resources/hooks/{post-checkout.sh → git/git-post-checkout.sh} +10 -9
- monoco/features/issue/resources/hooks/git/git-pre-commit.sh +31 -0
- monoco/features/issue/resources/hooks/{pre-push.sh → git/git-pre-push.sh} +7 -13
- monoco/features/issue/resources/zh/AGENTS.md +18 -123
- monoco/features/memo/cli.py +15 -64
- monoco/features/memo/core.py +6 -34
- monoco/features/memo/models.py +24 -15
- monoco/features/memo/resources/en/AGENTS.md +31 -0
- monoco/features/memo/resources/zh/AGENTS.md +28 -5
- monoco/main.py +5 -3
- {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/METADATA +1 -1
- monoco_toolkit-0.4.0.dist-info/RECORD +170 -0
- monoco/core/automation/config.py +0 -338
- monoco/core/execution.py +0 -67
- monoco/core/executor/__init__.py +0 -38
- monoco/core/executor/agent_action.py +0 -254
- monoco/core/executor/git_action.py +0 -303
- monoco/core/executor/im_action.py +0 -309
- monoco/core/executor/pytest_action.py +0 -218
- monoco/core/router/router.py +0 -392
- monoco/features/agent/resources/atoms/atom-code-dev.yaml +0 -61
- monoco/features/agent/resources/atoms/atom-issue-lifecycle.yaml +0 -73
- monoco/features/agent/resources/atoms/atom-knowledge.yaml +0 -55
- monoco/features/agent/resources/atoms/atom-review.yaml +0 -60
- monoco/features/agent/resources/en/skills/monoco_atom_core/SKILL.md +0 -99
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_manager/SKILL.md +0 -93
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_planner/SKILL.md +0 -85
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -114
- monoco/features/agent/resources/workflows/workflow-dev.yaml +0 -83
- monoco/features/agent/resources/workflows/workflow-issue-create.yaml +0 -72
- monoco/features/agent/resources/workflows/workflow-review.yaml +0 -94
- monoco/features/agent/resources/zh/roles/monoco_role_engineer.yaml +0 -49
- monoco/features/agent/resources/zh/roles/monoco_role_manager.yaml +0 -46
- monoco/features/agent/resources/zh/roles/monoco_role_planner.yaml +0 -46
- monoco/features/agent/resources/zh/roles/monoco_role_reviewer.yaml +0 -47
- monoco/features/agent/resources/zh/skills/monoco_atom_core/SKILL.md +0 -99
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_manager/SKILL.md +0 -88
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_planner/SKILL.md +0 -259
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -137
- monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +0 -278
- monoco/features/glossary/resources/en/skills/monoco_atom_glossary/SKILL.md +0 -35
- monoco/features/glossary/resources/zh/skills/monoco_atom_glossary/SKILL.md +0 -35
- monoco/features/hooks/adapter.py +0 -67
- monoco/features/hooks/core.py +0 -441
- monoco/features/i18n/resources/en/skills/monoco_atom_i18n/SKILL.md +0 -96
- monoco/features/i18n/resources/en/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
- monoco/features/i18n/resources/zh/skills/monoco_atom_i18n/SKILL.md +0 -96
- monoco/features/i18n/resources/zh/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
- monoco/features/issue/resources/en/skills/monoco_atom_issue/SKILL.md +0 -165
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_development/SKILL.md +0 -224
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_management/SKILL.md +0 -159
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
- monoco/features/issue/resources/hooks/pre-commit.sh +0 -41
- monoco/features/issue/resources/zh/skills/monoco_atom_issue_lifecycle/SKILL.md +0 -190
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_development/SKILL.md +0 -224
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_management/SKILL.md +0 -159
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
- monoco/features/memo/resources/en/skills/monoco_atom_memo/SKILL.md +0 -77
- monoco/features/memo/resources/en/skills/monoco_workflow_note_processing/SKILL.md +0 -140
- monoco/features/memo/resources/zh/skills/monoco_atom_memo/SKILL.md +0 -77
- monoco/features/memo/resources/zh/skills/monoco_workflow_note_processing/SKILL.md +0 -140
- monoco/features/spike/resources/en/skills/monoco_atom_spike/SKILL.md +0 -76
- monoco/features/spike/resources/en/skills/monoco_workflow_research/SKILL.md +0 -121
- monoco/features/spike/resources/zh/skills/monoco_atom_spike/SKILL.md +0 -76
- monoco/features/spike/resources/zh/skills/monoco_workflow_research/SKILL.md +0 -121
- monoco_toolkit-0.3.12.dist-info/RECORD +0 -202
- {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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}")
|
monoco/core/watcher/memo.py
CHANGED
|
@@ -68,12 +68,16 @@ class MemoFileEvent(FileEvent):
|
|
|
68
68
|
|
|
69
69
|
class MemoWatcher(PollingWatcher):
|
|
70
70
|
"""
|
|
71
|
-
Watcher for Memo inbox file.
|
|
71
|
+
Watcher for Memo inbox file (Signal Queue Model).
|
|
72
72
|
|
|
73
73
|
Monitors the Memos/inbox.md file for:
|
|
74
|
-
- New memo
|
|
75
|
-
-
|
|
76
|
-
|
|
74
|
+
- New memo signals (file non-empty)
|
|
75
|
+
- Threshold crossing events (memo count >= threshold)
|
|
76
|
+
|
|
77
|
+
Signal Queue Semantics (FEAT-0165):
|
|
78
|
+
- File existence = signal pending
|
|
79
|
+
- File empty = no signals
|
|
80
|
+
- Consumer clears file = signals consumed
|
|
77
81
|
|
|
78
82
|
Example:
|
|
79
83
|
>>> config = WatchConfig(
|
|
@@ -84,10 +88,8 @@ class MemoWatcher(PollingWatcher):
|
|
|
84
88
|
>>> await watcher.start()
|
|
85
89
|
"""
|
|
86
90
|
|
|
87
|
-
# Regex to match memo
|
|
88
|
-
|
|
89
|
-
# Regex to match checkbox items
|
|
90
|
-
CHECKBOX_PATTERN = re.compile(r"^-\s*\[([ xX-])\]", re.MULTILINE)
|
|
91
|
+
# Regex to match memo headers (## [uid])
|
|
92
|
+
MEMO_HEADER_PATTERN = re.compile(r"^##\s*\[[a-f0-9]+\]", re.MULTILINE)
|
|
91
93
|
|
|
92
94
|
def __init__(
|
|
93
95
|
self,
|
|
@@ -98,7 +100,7 @@ class MemoWatcher(PollingWatcher):
|
|
|
98
100
|
):
|
|
99
101
|
super().__init__(config, event_bus, name)
|
|
100
102
|
self.threshold = threshold
|
|
101
|
-
self.
|
|
103
|
+
self._last_memo_count = 0
|
|
102
104
|
self._threshold_crossed = False
|
|
103
105
|
|
|
104
106
|
async def _check_changes(self) -> None:
|
|
@@ -108,92 +110,82 @@ class MemoWatcher(PollingWatcher):
|
|
|
108
110
|
|
|
109
111
|
try:
|
|
110
112
|
content = self._read_file_content(self.config.path) or ""
|
|
111
|
-
|
|
113
|
+
memo_count = self._count_memos(content)
|
|
112
114
|
|
|
113
115
|
# Check if count changed
|
|
114
|
-
if
|
|
115
|
-
await self._handle_count_change(
|
|
116
|
-
self.
|
|
116
|
+
if memo_count != self._last_memo_count:
|
|
117
|
+
await self._handle_count_change(memo_count)
|
|
118
|
+
self._last_memo_count = memo_count
|
|
117
119
|
|
|
118
120
|
except Exception as e:
|
|
119
121
|
logger.error(f"Error checking memo file: {e}")
|
|
120
122
|
|
|
121
|
-
async def _handle_count_change(self,
|
|
123
|
+
async def _handle_count_change(self, memo_count: int) -> None:
|
|
122
124
|
"""Handle memo count change."""
|
|
123
125
|
# Check for threshold crossing
|
|
124
|
-
threshold_crossed =
|
|
126
|
+
threshold_crossed = memo_count >= self.threshold
|
|
125
127
|
|
|
126
128
|
if threshold_crossed and not self._threshold_crossed:
|
|
127
129
|
# Threshold just crossed
|
|
128
130
|
event = MemoFileEvent(
|
|
129
131
|
path=self.config.path,
|
|
130
132
|
change_type=ChangeType.MODIFIED,
|
|
131
|
-
pending_count=
|
|
133
|
+
pending_count=memo_count,
|
|
132
134
|
threshold=self.threshold,
|
|
133
135
|
metadata={
|
|
134
|
-
"previous_count": self.
|
|
136
|
+
"previous_count": self._last_memo_count,
|
|
135
137
|
"event_type": "threshold_crossed",
|
|
136
138
|
},
|
|
137
139
|
)
|
|
138
140
|
await self.emit(event)
|
|
139
|
-
logger.info(f"Memo threshold crossed: {
|
|
141
|
+
logger.info(f"Memo threshold crossed: {memo_count} >= {self.threshold}")
|
|
140
142
|
|
|
141
|
-
elif
|
|
143
|
+
elif memo_count > self._last_memo_count:
|
|
142
144
|
# New memos added
|
|
143
145
|
event = MemoFileEvent(
|
|
144
146
|
path=self.config.path,
|
|
145
147
|
change_type=ChangeType.MODIFIED,
|
|
146
|
-
pending_count=
|
|
148
|
+
pending_count=memo_count,
|
|
147
149
|
threshold=self.threshold,
|
|
148
150
|
metadata={
|
|
149
|
-
"previous_count": self.
|
|
151
|
+
"previous_count": self._last_memo_count,
|
|
150
152
|
"event_type": "memos_added",
|
|
151
153
|
},
|
|
152
154
|
)
|
|
153
155
|
await self.emit(event)
|
|
154
|
-
logger.debug(f"New memos added: {
|
|
156
|
+
logger.debug(f"New memos added: {memo_count} total")
|
|
157
|
+
|
|
158
|
+
elif memo_count == 0 and self._last_memo_count > 0:
|
|
159
|
+
# Inbox was cleared (consumed)
|
|
160
|
+
logger.info(f"Inbox cleared (consumed {self._last_memo_count} memos)")
|
|
155
161
|
|
|
156
162
|
self._threshold_crossed = threshold_crossed
|
|
157
163
|
|
|
158
|
-
def
|
|
164
|
+
def _count_memos(self, content: str) -> int:
|
|
159
165
|
"""
|
|
160
|
-
Count
|
|
166
|
+
Count memos in content by matching memo headers.
|
|
161
167
|
|
|
162
|
-
|
|
163
|
-
-
|
|
164
|
-
-
|
|
168
|
+
In Signal Queue Model:
|
|
169
|
+
- Each memo has a header like: ## [uid] YYYY-MM-DD HH:MM:SS
|
|
170
|
+
- We count headers to determine number of pending signals
|
|
165
171
|
"""
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
for line in lines:
|
|
170
|
-
line = line.strip()
|
|
171
|
-
if not line.startswith("-"):
|
|
172
|
-
continue
|
|
173
|
-
|
|
174
|
-
# Check if it's a checkbox
|
|
175
|
-
checkbox_match = self.CHECKBOX_PATTERN.match(line)
|
|
176
|
-
if checkbox_match:
|
|
177
|
-
state = checkbox_match.group(1).lower()
|
|
178
|
-
# Count if not checked (x or X)
|
|
179
|
-
if state not in ("x", "X"):
|
|
180
|
-
count += 1
|
|
181
|
-
else:
|
|
182
|
-
# Regular memo entry
|
|
183
|
-
count += 1
|
|
172
|
+
if not content or not content.strip():
|
|
173
|
+
return 0
|
|
184
174
|
|
|
185
|
-
|
|
175
|
+
# Count memo headers
|
|
176
|
+
matches = self.MEMO_HEADER_PATTERN.findall(content)
|
|
177
|
+
return len(matches)
|
|
186
178
|
|
|
187
179
|
def set_threshold(self, threshold: int) -> None:
|
|
188
180
|
"""Update the threshold value."""
|
|
189
181
|
self.threshold = threshold
|
|
190
|
-
self._threshold_crossed = self.
|
|
182
|
+
self._threshold_crossed = self._last_memo_count >= threshold
|
|
191
183
|
|
|
192
184
|
def get_stats(self) -> Dict[str, Any]:
|
|
193
185
|
"""Get watcher statistics."""
|
|
194
186
|
stats = super().get_stats()
|
|
195
187
|
stats.update({
|
|
196
|
-
"
|
|
188
|
+
"memo_count": self._last_memo_count,
|
|
197
189
|
"threshold": self.threshold,
|
|
198
190
|
"threshold_crossed": self._threshold_crossed,
|
|
199
191
|
})
|