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,303 @@
1
+ """
2
+ IssueWatcher - Monitors Issue files for changes.
3
+
4
+ Part of Layer 1 (File Watcher) in the event automation framework.
5
+ Emits events for Issue creation, modification, and deletion,
6
+ as well as field-level changes in YAML Front Matter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Set
15
+
16
+ from monoco.core.scheduler import AgentEventType, EventBus, event_bus
17
+ from monoco.features.issue.domain.parser import MarkdownParser
18
+ from monoco.features.issue.domain.models import Issue
19
+
20
+ from .base import (
21
+ ChangeType,
22
+ FieldChange,
23
+ FileEvent,
24
+ FilesystemWatcher,
25
+ WatchConfig,
26
+ PollingWatcher,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class IssueFileEvent(FileEvent):
33
+ """FileEvent specific to Issue files."""
34
+
35
+ def __init__(
36
+ self,
37
+ path: Path,
38
+ change_type: ChangeType,
39
+ issue_id: Optional[str] = None,
40
+ field_changes: Optional[List[FieldChange]] = None,
41
+ **kwargs,
42
+ ):
43
+ super().__init__(
44
+ path=path,
45
+ change_type=change_type,
46
+ watcher_name="IssueWatcher",
47
+ **kwargs,
48
+ )
49
+ self.issue_id = issue_id
50
+ self.field_changes = field_changes or []
51
+
52
+ def to_agent_event_type(self) -> Optional[AgentEventType]:
53
+ """Convert to appropriate AgentEventType."""
54
+ if self.change_type == ChangeType.CREATED:
55
+ return AgentEventType.ISSUE_CREATED
56
+ elif self.change_type == ChangeType.MODIFIED:
57
+ # Check for specific field changes
58
+ for fc in self.field_changes:
59
+ if fc.field_name == "stage":
60
+ return AgentEventType.ISSUE_STAGE_CHANGED
61
+ elif fc.field_name == "status":
62
+ return AgentEventType.ISSUE_STATUS_CHANGED
63
+ return AgentEventType.ISSUE_UPDATED
64
+ return None
65
+
66
+ def to_payload(self) -> Dict[str, Any]:
67
+ """Convert to payload with Issue-specific fields."""
68
+ payload = super().to_payload()
69
+ payload["issue_id"] = self.issue_id
70
+ payload["field_changes"] = [
71
+ {
72
+ "field": fc.field_name,
73
+ "old_value": fc.old_value,
74
+ "new_value": fc.new_value,
75
+ }
76
+ for fc in self.field_changes
77
+ ]
78
+ return payload
79
+
80
+
81
+ class IssueWatcher(PollingWatcher):
82
+ """
83
+ Watcher for Issue files.
84
+
85
+ Monitors the Issues/ directory for:
86
+ - New Issue file creation
87
+ - Issue file modifications
88
+ - Issue file deletion
89
+ - YAML Front Matter field changes (status, stage, assignee, etc.)
90
+
91
+ Example:
92
+ >>> config = WatchConfig(
93
+ ... path=Path("./Issues"),
94
+ ... patterns=["*.md"],
95
+ ... recursive=True,
96
+ ... )
97
+ >>> watcher = IssueWatcher(config)
98
+ >>> await watcher.start()
99
+ """
100
+
101
+ # Fields to track for changes
102
+ TRACKED_FIELDS = ["status", "stage", "assignee", "criticality", "title"]
103
+
104
+ def __init__(
105
+ self,
106
+ config: WatchConfig,
107
+ event_bus: Optional[EventBus] = None,
108
+ name: str = "IssueWatcher",
109
+ tracked_fields: Optional[List[str]] = None,
110
+ ):
111
+ # Ensure we watch for markdown files in Issues directory
112
+ if not config.patterns:
113
+ config.patterns = ["*.md"]
114
+
115
+ super().__init__(config, event_bus, name)
116
+ self.tracked_fields = tracked_fields or self.TRACKED_FIELDS
117
+ self._issue_cache: Dict[str, Dict[str, Any]] = {} # issue_id -> field values
118
+
119
+ async def _check_changes(self) -> None:
120
+ """Check for Issue file changes."""
121
+ current_states = self._scan_files()
122
+ current_paths = set(current_states.keys())
123
+ cached_paths = set(self._file_states.keys())
124
+
125
+ # Detect new files
126
+ for path in current_paths - cached_paths:
127
+ await self._handle_new_file(path, current_states[path])
128
+
129
+ # Detect deleted files
130
+ for path in cached_paths - current_paths:
131
+ await self._handle_deleted_file(path)
132
+
133
+ # Detect modified files
134
+ for path in current_paths & cached_paths:
135
+ old_state = self._file_states[path]
136
+ new_state = current_states[path]
137
+
138
+ if old_state.get("hash") != new_state.get("hash"):
139
+ await self._handle_modified_file(path, old_state, new_state)
140
+
141
+ # Update cache
142
+ self._file_states = current_states
143
+
144
+ async def _handle_new_file(self, path: Path, state: Dict[str, Any]) -> None:
145
+ """Handle new Issue file creation."""
146
+ content = state.get("content", "")
147
+ issue = self._parse_issue(content, path)
148
+
149
+ if issue:
150
+ # Cache the issue fields
151
+ self._issue_cache[issue.frontmatter.id] = self._extract_tracked_fields(issue)
152
+
153
+ event = IssueFileEvent(
154
+ path=path,
155
+ change_type=ChangeType.CREATED,
156
+ issue_id=issue.frontmatter.id,
157
+ new_content=content,
158
+ metadata={
159
+ "title": issue.frontmatter.title,
160
+ "status": issue.frontmatter.status,
161
+ "stage": issue.frontmatter.stage,
162
+ },
163
+ )
164
+ await self.emit(event)
165
+ logger.debug(f"Issue created: {issue.frontmatter.id}")
166
+
167
+ async def _handle_deleted_file(self, path: Path) -> None:
168
+ """Handle Issue file deletion."""
169
+ # Find issue_id from path (would need to track this)
170
+ event = IssueFileEvent(
171
+ path=path,
172
+ change_type=ChangeType.DELETED,
173
+ metadata={"path": str(path)},
174
+ )
175
+ await self.emit(event)
176
+ logger.debug(f"Issue deleted: {path}")
177
+
178
+ async def _handle_modified_file(
179
+ self,
180
+ path: Path,
181
+ old_state: Dict[str, Any],
182
+ new_state: Dict[str, Any],
183
+ ) -> None:
184
+ """Handle Issue file modification."""
185
+ old_content = old_state.get("content", "")
186
+ new_content = new_state.get("content", "")
187
+
188
+ issue = self._parse_issue(new_content, path)
189
+ if not issue:
190
+ return
191
+
192
+ issue_id = issue.frontmatter.id
193
+
194
+ # Detect field changes
195
+ field_changes = self._detect_field_changes(issue_id, issue)
196
+
197
+ # Update cache
198
+ self._issue_cache[issue_id] = self._extract_tracked_fields(issue)
199
+
200
+ # Emit event with field changes
201
+ event = IssueFileEvent(
202
+ path=path,
203
+ change_type=ChangeType.MODIFIED,
204
+ issue_id=issue_id,
205
+ old_content=old_content,
206
+ new_content=new_content,
207
+ field_changes=field_changes,
208
+ metadata={
209
+ "title": issue.frontmatter.title,
210
+ "status": issue.frontmatter.status,
211
+ "stage": issue.frontmatter.stage,
212
+ },
213
+ )
214
+ await self.emit(event)
215
+
216
+ # Also emit individual field change events
217
+ for fc in field_changes:
218
+ await self._emit_field_change_event(issue, fc)
219
+
220
+ if field_changes:
221
+ logger.debug(f"Issue {issue_id} modified: {[fc.field_name for fc in field_changes]}")
222
+
223
+ async def _emit_field_change_event(
224
+ self,
225
+ issue: Issue,
226
+ field_change: FieldChange,
227
+ ) -> None:
228
+ """Emit a specific field change event."""
229
+ event_type = None
230
+
231
+ if field_change.field_name == "stage":
232
+ event_type = AgentEventType.ISSUE_STAGE_CHANGED
233
+ elif field_change.field_name == "status":
234
+ event_type = AgentEventType.ISSUE_STATUS_CHANGED
235
+
236
+ if event_type and self.event_bus:
237
+ await self.event_bus.publish(
238
+ event_type,
239
+ {
240
+ "issue_id": issue.frontmatter.id,
241
+ "issue_title": issue.frontmatter.title,
242
+ "old_value": field_change.old_value,
243
+ "new_value": field_change.new_value,
244
+ "field": field_change.field_name,
245
+ "path": str(issue.path) if issue.path else None,
246
+ },
247
+ source=f"watcher.{self.name}",
248
+ )
249
+
250
+ def _parse_issue(self, content: str, path: Path) -> Optional[Issue]:
251
+ """Parse Issue from content."""
252
+ try:
253
+ return MarkdownParser.parse(content, str(path))
254
+ except Exception as e:
255
+ logger.debug(f"Could not parse issue from {path}: {e}")
256
+ return None
257
+
258
+ def _extract_tracked_fields(self, issue: Issue) -> Dict[str, Any]:
259
+ """Extract tracked fields from an Issue."""
260
+ fm = issue.frontmatter
261
+ fields = {}
262
+
263
+ for field_name in self.tracked_fields:
264
+ if hasattr(fm, field_name):
265
+ fields[field_name] = getattr(fm, field_name)
266
+
267
+ return fields
268
+
269
+ def _detect_field_changes(
270
+ self,
271
+ issue_id: str,
272
+ issue: Issue,
273
+ ) -> List[FieldChange]:
274
+ """Detect changes in tracked fields."""
275
+ changes = []
276
+ old_fields = self._issue_cache.get(issue_id, {})
277
+ new_fields = self._extract_tracked_fields(issue)
278
+
279
+ for field_name in self.tracked_fields:
280
+ old_value = old_fields.get(field_name)
281
+ new_value = new_fields.get(field_name)
282
+
283
+ if old_value != new_value and old_value is not None:
284
+ changes.append(FieldChange(
285
+ field_name=field_name,
286
+ old_value=old_value,
287
+ new_value=new_value,
288
+ ))
289
+
290
+ return changes
291
+
292
+ def get_issue_state(self, issue_id: str) -> Optional[Dict[str, Any]]:
293
+ """Get cached state for an issue."""
294
+ return self._issue_cache.get(issue_id)
295
+
296
+ def get_stats(self) -> Dict[str, Any]:
297
+ """Get watcher statistics."""
298
+ stats = super().get_stats()
299
+ stats.update({
300
+ "tracked_issues": len(self._issue_cache),
301
+ "tracked_fields": self.tracked_fields,
302
+ })
303
+ return stats
@@ -0,0 +1,192 @@
1
+ """
2
+ MemoWatcher - Monitors Memo inbox for changes.
3
+
4
+ Part of Layer 1 (File Watcher) in the event automation framework.
5
+ Emits events when memo count crosses thresholds.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from monoco.core.scheduler import AgentEventType, EventBus, event_bus
17
+
18
+ from .base import (
19
+ ChangeType,
20
+ FileEvent,
21
+ FilesystemWatcher,
22
+ WatchConfig,
23
+ PollingWatcher,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class MemoFileEvent(FileEvent):
30
+ """FileEvent specific to Memo files."""
31
+
32
+ def __init__(
33
+ self,
34
+ path: Path,
35
+ change_type: ChangeType,
36
+ pending_count: int = 0,
37
+ threshold: int = 5,
38
+ **kwargs,
39
+ ):
40
+ super().__init__(
41
+ path=path,
42
+ change_type=change_type,
43
+ watcher_name="MemoWatcher",
44
+ **kwargs,
45
+ )
46
+ self.pending_count = pending_count
47
+ self.threshold = threshold
48
+
49
+ @property
50
+ def threshold_crossed(self) -> bool:
51
+ """Check if threshold has been crossed."""
52
+ return self.pending_count >= self.threshold
53
+
54
+ def to_agent_event_type(self) -> Optional[AgentEventType]:
55
+ """Convert to appropriate AgentEventType."""
56
+ if self.pending_count >= self.threshold:
57
+ return AgentEventType.MEMO_THRESHOLD
58
+ return AgentEventType.MEMO_CREATED
59
+
60
+ def to_payload(self) -> Dict[str, Any]:
61
+ """Convert to payload with Memo-specific fields."""
62
+ payload = super().to_payload()
63
+ payload["pending_count"] = self.pending_count
64
+ payload["threshold"] = self.threshold
65
+ payload["threshold_crossed"] = self.pending_count >= self.threshold
66
+ return payload
67
+
68
+
69
+ class MemoWatcher(PollingWatcher):
70
+ """
71
+ Watcher for Memo inbox file (Signal Queue Model).
72
+
73
+ Monitors the Memos/inbox.md file for:
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
81
+
82
+ Example:
83
+ >>> config = WatchConfig(
84
+ ... path=Path("./Memos/inbox.md"),
85
+ ... patterns=["*.md"],
86
+ ... )
87
+ >>> watcher = MemoWatcher(config, threshold=5)
88
+ >>> await watcher.start()
89
+ """
90
+
91
+ # Regex to match memo headers (## [uid])
92
+ MEMO_HEADER_PATTERN = re.compile(r"^##\s*\[[a-f0-9]+\]", re.MULTILINE)
93
+
94
+ def __init__(
95
+ self,
96
+ config: WatchConfig,
97
+ event_bus: Optional[EventBus] = None,
98
+ name: str = "MemoWatcher",
99
+ threshold: int = 5,
100
+ ):
101
+ super().__init__(config, event_bus, name)
102
+ self.threshold = threshold
103
+ self._last_memo_count = 0
104
+ self._threshold_crossed = False
105
+
106
+ async def _check_changes(self) -> None:
107
+ """Check for memo changes."""
108
+ if not self.config.path.exists():
109
+ return
110
+
111
+ try:
112
+ content = self._read_file_content(self.config.path) or ""
113
+ memo_count = self._count_memos(content)
114
+
115
+ # Check if count changed
116
+ if memo_count != self._last_memo_count:
117
+ await self._handle_count_change(memo_count)
118
+ self._last_memo_count = memo_count
119
+
120
+ except Exception as e:
121
+ logger.error(f"Error checking memo file: {e}")
122
+
123
+ async def _handle_count_change(self, memo_count: int) -> None:
124
+ """Handle memo count change."""
125
+ # Check for threshold crossing
126
+ threshold_crossed = memo_count >= self.threshold
127
+
128
+ if threshold_crossed and not self._threshold_crossed:
129
+ # Threshold just crossed
130
+ event = MemoFileEvent(
131
+ path=self.config.path,
132
+ change_type=ChangeType.MODIFIED,
133
+ pending_count=memo_count,
134
+ threshold=self.threshold,
135
+ metadata={
136
+ "previous_count": self._last_memo_count,
137
+ "event_type": "threshold_crossed",
138
+ },
139
+ )
140
+ await self.emit(event)
141
+ logger.info(f"Memo threshold crossed: {memo_count} >= {self.threshold}")
142
+
143
+ elif memo_count > self._last_memo_count:
144
+ # New memos added
145
+ event = MemoFileEvent(
146
+ path=self.config.path,
147
+ change_type=ChangeType.MODIFIED,
148
+ pending_count=memo_count,
149
+ threshold=self.threshold,
150
+ metadata={
151
+ "previous_count": self._last_memo_count,
152
+ "event_type": "memos_added",
153
+ },
154
+ )
155
+ await self.emit(event)
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)")
161
+
162
+ self._threshold_crossed = threshold_crossed
163
+
164
+ def _count_memos(self, content: str) -> int:
165
+ """
166
+ Count memos in content by matching memo headers.
167
+
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
171
+ """
172
+ if not content or not content.strip():
173
+ return 0
174
+
175
+ # Count memo headers
176
+ matches = self.MEMO_HEADER_PATTERN.findall(content)
177
+ return len(matches)
178
+
179
+ def set_threshold(self, threshold: int) -> None:
180
+ """Update the threshold value."""
181
+ self.threshold = threshold
182
+ self._threshold_crossed = self._last_memo_count >= threshold
183
+
184
+ def get_stats(self) -> Dict[str, Any]:
185
+ """Get watcher statistics."""
186
+ stats = super().get_stats()
187
+ stats.update({
188
+ "memo_count": self._last_memo_count,
189
+ "threshold": self.threshold,
190
+ "threshold_crossed": self._threshold_crossed,
191
+ })
192
+ return stats