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.
Files changed (120) hide show
  1. monoco/core/automation/__init__.py +0 -11
  2. monoco/core/automation/handlers.py +108 -26
  3. monoco/core/config.py +28 -10
  4. monoco/core/daemon/__init__.py +5 -0
  5. monoco/core/daemon/pid.py +290 -0
  6. monoco/core/injection.py +86 -8
  7. monoco/core/integrations.py +0 -24
  8. monoco/core/router/__init__.py +1 -39
  9. monoco/core/router/action.py +3 -142
  10. monoco/core/scheduler/events.py +28 -2
  11. monoco/core/setup.py +9 -0
  12. monoco/core/sync.py +199 -4
  13. monoco/core/watcher/__init__.py +6 -0
  14. monoco/core/watcher/base.py +18 -1
  15. monoco/core/watcher/im.py +460 -0
  16. monoco/core/watcher/memo.py +40 -48
  17. monoco/daemon/app.py +3 -60
  18. monoco/daemon/commands.py +459 -25
  19. monoco/daemon/scheduler.py +1 -16
  20. monoco/daemon/services.py +15 -0
  21. monoco/features/agent/resources/en/AGENTS.md +14 -14
  22. monoco/features/agent/resources/en/skills/monoco_role_engineer/SKILL.md +101 -0
  23. monoco/features/agent/resources/en/skills/monoco_role_manager/SKILL.md +95 -0
  24. monoco/features/agent/resources/en/skills/monoco_role_planner/SKILL.md +177 -0
  25. monoco/features/agent/resources/en/skills/monoco_role_reviewer/SKILL.md +139 -0
  26. monoco/features/agent/resources/zh/skills/monoco_role_engineer/SKILL.md +101 -0
  27. monoco/features/agent/resources/zh/skills/monoco_role_manager/SKILL.md +95 -0
  28. monoco/features/agent/resources/zh/skills/monoco_role_planner/SKILL.md +177 -0
  29. monoco/features/agent/resources/zh/skills/monoco_role_reviewer/SKILL.md +139 -0
  30. monoco/features/hooks/__init__.py +61 -6
  31. monoco/features/hooks/commands.py +281 -271
  32. monoco/features/hooks/dispatchers/__init__.py +23 -0
  33. monoco/features/hooks/dispatchers/agent_dispatcher.py +486 -0
  34. monoco/features/hooks/dispatchers/git_dispatcher.py +478 -0
  35. monoco/features/hooks/manager.py +357 -0
  36. monoco/features/hooks/models.py +262 -0
  37. monoco/features/hooks/parser.py +322 -0
  38. monoco/features/hooks/universal_interceptor.py +503 -0
  39. monoco/features/im/__init__.py +67 -0
  40. monoco/features/im/core.py +782 -0
  41. monoco/features/im/models.py +311 -0
  42. monoco/features/issue/commands.py +65 -50
  43. monoco/features/issue/core.py +199 -99
  44. monoco/features/issue/domain_commands.py +0 -19
  45. monoco/features/issue/resources/en/AGENTS.md +17 -122
  46. monoco/features/issue/resources/hooks/agent/before-tool.sh +102 -0
  47. monoco/features/issue/resources/hooks/agent/session-start.sh +88 -0
  48. monoco/features/issue/resources/hooks/{post-checkout.sh → git/git-post-checkout.sh} +10 -9
  49. monoco/features/issue/resources/hooks/git/git-pre-commit.sh +31 -0
  50. monoco/features/issue/resources/hooks/{pre-push.sh → git/git-pre-push.sh} +7 -13
  51. monoco/features/issue/resources/zh/AGENTS.md +18 -123
  52. monoco/features/memo/cli.py +15 -64
  53. monoco/features/memo/core.py +6 -34
  54. monoco/features/memo/models.py +24 -15
  55. monoco/features/memo/resources/en/AGENTS.md +31 -0
  56. monoco/features/memo/resources/zh/AGENTS.md +28 -5
  57. monoco/main.py +5 -3
  58. {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/METADATA +1 -1
  59. monoco_toolkit-0.4.0.dist-info/RECORD +170 -0
  60. monoco/core/automation/config.py +0 -338
  61. monoco/core/execution.py +0 -67
  62. monoco/core/executor/__init__.py +0 -38
  63. monoco/core/executor/agent_action.py +0 -254
  64. monoco/core/executor/git_action.py +0 -303
  65. monoco/core/executor/im_action.py +0 -309
  66. monoco/core/executor/pytest_action.py +0 -218
  67. monoco/core/router/router.py +0 -392
  68. monoco/features/agent/resources/atoms/atom-code-dev.yaml +0 -61
  69. monoco/features/agent/resources/atoms/atom-issue-lifecycle.yaml +0 -73
  70. monoco/features/agent/resources/atoms/atom-knowledge.yaml +0 -55
  71. monoco/features/agent/resources/atoms/atom-review.yaml +0 -60
  72. monoco/features/agent/resources/en/skills/monoco_atom_core/SKILL.md +0 -99
  73. monoco/features/agent/resources/en/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
  74. monoco/features/agent/resources/en/skills/monoco_workflow_agent_manager/SKILL.md +0 -93
  75. monoco/features/agent/resources/en/skills/monoco_workflow_agent_planner/SKILL.md +0 -85
  76. monoco/features/agent/resources/en/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -114
  77. monoco/features/agent/resources/workflows/workflow-dev.yaml +0 -83
  78. monoco/features/agent/resources/workflows/workflow-issue-create.yaml +0 -72
  79. monoco/features/agent/resources/workflows/workflow-review.yaml +0 -94
  80. monoco/features/agent/resources/zh/roles/monoco_role_engineer.yaml +0 -49
  81. monoco/features/agent/resources/zh/roles/monoco_role_manager.yaml +0 -46
  82. monoco/features/agent/resources/zh/roles/monoco_role_planner.yaml +0 -46
  83. monoco/features/agent/resources/zh/roles/monoco_role_reviewer.yaml +0 -47
  84. monoco/features/agent/resources/zh/skills/monoco_atom_core/SKILL.md +0 -99
  85. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
  86. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_manager/SKILL.md +0 -88
  87. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_planner/SKILL.md +0 -259
  88. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -137
  89. monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +0 -278
  90. monoco/features/glossary/resources/en/skills/monoco_atom_glossary/SKILL.md +0 -35
  91. monoco/features/glossary/resources/zh/skills/monoco_atom_glossary/SKILL.md +0 -35
  92. monoco/features/hooks/adapter.py +0 -67
  93. monoco/features/hooks/core.py +0 -441
  94. monoco/features/i18n/resources/en/skills/monoco_atom_i18n/SKILL.md +0 -96
  95. monoco/features/i18n/resources/en/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
  96. monoco/features/i18n/resources/zh/skills/monoco_atom_i18n/SKILL.md +0 -96
  97. monoco/features/i18n/resources/zh/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
  98. monoco/features/issue/resources/en/skills/monoco_atom_issue/SKILL.md +0 -165
  99. monoco/features/issue/resources/en/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
  100. monoco/features/issue/resources/en/skills/monoco_workflow_issue_development/SKILL.md +0 -224
  101. monoco/features/issue/resources/en/skills/monoco_workflow_issue_management/SKILL.md +0 -159
  102. monoco/features/issue/resources/en/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
  103. monoco/features/issue/resources/hooks/pre-commit.sh +0 -41
  104. monoco/features/issue/resources/zh/skills/monoco_atom_issue_lifecycle/SKILL.md +0 -190
  105. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
  106. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_development/SKILL.md +0 -224
  107. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_management/SKILL.md +0 -159
  108. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
  109. monoco/features/memo/resources/en/skills/monoco_atom_memo/SKILL.md +0 -77
  110. monoco/features/memo/resources/en/skills/monoco_workflow_note_processing/SKILL.md +0 -140
  111. monoco/features/memo/resources/zh/skills/monoco_atom_memo/SKILL.md +0 -77
  112. monoco/features/memo/resources/zh/skills/monoco_workflow_note_processing/SKILL.md +0 -140
  113. monoco/features/spike/resources/en/skills/monoco_atom_spike/SKILL.md +0 -76
  114. monoco/features/spike/resources/en/skills/monoco_workflow_research/SKILL.md +0 -121
  115. monoco/features/spike/resources/zh/skills/monoco_atom_spike/SKILL.md +0 -76
  116. monoco/features/spike/resources/zh/skills/monoco_workflow_research/SKILL.md +0 -121
  117. monoco_toolkit-0.3.12.dist-info/RECORD +0 -202
  118. {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/WHEEL +0 -0
  119. {monoco_toolkit-0.3.12.dist-info → monoco_toolkit-0.4.0.dist-info}/entry_points.txt +0 -0
  120. {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}")
@@ -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 entries
75
- - Pending memo count changes
76
- - Threshold crossing events
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 entries (lines starting with "- " that aren't checkboxes)
88
- MEMO_PATTERN = re.compile(r"^-\s+(?!\[)[^\n]*$", re.MULTILINE)
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._last_pending_count = 0
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
- pending_count = self._count_pending_memos(content)
113
+ memo_count = self._count_memos(content)
112
114
 
113
115
  # Check if count changed
114
- if pending_count != self._last_pending_count:
115
- await self._handle_count_change(pending_count)
116
- self._last_pending_count = pending_count
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, pending_count: int) -> None:
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 = pending_count >= self.threshold
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=pending_count,
133
+ pending_count=memo_count,
132
134
  threshold=self.threshold,
133
135
  metadata={
134
- "previous_count": self._last_pending_count,
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: {pending_count} >= {self.threshold}")
141
+ logger.info(f"Memo threshold crossed: {memo_count} >= {self.threshold}")
140
142
 
141
- elif pending_count > self._last_pending_count:
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=pending_count,
148
+ pending_count=memo_count,
147
149
  threshold=self.threshold,
148
150
  metadata={
149
- "previous_count": self._last_pending_count,
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: {pending_count} total")
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 _count_pending_memos(self, content: str) -> int:
164
+ def _count_memos(self, content: str) -> int:
159
165
  """
160
- Count pending (unchecked) memos in content.
166
+ Count memos in content by matching memo headers.
161
167
 
162
- Counts:
163
- - Lines starting with "- " that are not checkboxes
164
- - Checkbox items with empty or "-" state
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
- count = 0
167
- lines = content.split("\n")
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
- return count
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._last_pending_count >= threshold
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
- "pending_count": self._last_pending_count,
188
+ "memo_count": self._last_memo_count,
197
189
  "threshold": self.threshold,
198
190
  "threshold_crossed": self._threshold_crossed,
199
191
  })