nucleus-mcp 1.0.7 → 1.2.0

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 (135) hide show
  1. package/package.json +2 -4
  2. package/.github/workflows/ci.yml +0 -72
  3. package/.smithery/shttp/module.js +0 -3
  4. package/.smithery/shttp/module.js.map +0 -7
  5. package/.smithery/stdio/index.cjs +0 -74
  6. package/.smithery/stdio/index.cjs.map +0 -7
  7. package/CHANGELOG.md +0 -65
  8. package/CODE_OF_CONDUCT.md +0 -34
  9. package/COMPARISON.md +0 -20
  10. package/CONTRIBUTING.md +0 -137
  11. package/DEVELOPMENT.md +0 -109
  12. package/LICENSE +0 -21
  13. package/README.md +0 -319
  14. package/ROADMAP.md +0 -24
  15. package/SECURITY.md +0 -88
  16. package/docs/COMMUNITY_FAQ.md +0 -48
  17. package/docs/COMPETITIVE_ANALYSIS.md +0 -90
  18. package/docs/DEMO_SCRIPT.md +0 -86
  19. package/docs/ENTERPRISE.md +0 -169
  20. package/docs/FAQ.md +0 -228
  21. package/docs/QUICK_START.md +0 -155
  22. package/docs/README.md +0 -67
  23. package/docs/RECURSIVE_SNAP_DEMO.gif +0 -0
  24. package/docs/social-preview-hq.png +0 -0
  25. package/docs/social-preview.png +0 -0
  26. package/examples/basic_usage.py +0 -84
  27. package/package.json.real +0 -24
  28. package/pyproject.toml +0 -50
  29. package/registry-server.json +0 -20
  30. package/scripts/demo_governance.py +0 -109
  31. package/scripts/sse_bridge.py +0 -87
  32. package/scripts/verify_windows_paths.py +0 -48
  33. package/server.json +0 -20
  34. package/src/mcp_server_nucleus/__init__.py +0 -8583
  35. package/src/mcp_server_nucleus/__main__.py +0 -9
  36. package/src/mcp_server_nucleus/cli.py +0 -1225
  37. package/src/mcp_server_nucleus/commitment_core.py +0 -146
  38. package/src/mcp_server_nucleus/commitment_ledger.py +0 -841
  39. package/src/mcp_server_nucleus/core/__init__.py +0 -6
  40. package/src/mcp_server_nucleus/core/orchestrator.py +0 -9
  41. package/src/mcp_server_nucleus/core/tool_registration_impl.py +0 -99
  42. package/src/mcp_server_nucleus/daemon/safety/lock.py +0 -121
  43. package/src/mcp_server_nucleus/deployment/render_integration.py +0 -4
  44. package/src/mcp_server_nucleus/hypervisor/__init__.py +0 -0
  45. package/src/mcp_server_nucleus/hypervisor/injector.py +0 -83
  46. package/src/mcp_server_nucleus/hypervisor/locker.py +0 -130
  47. package/src/mcp_server_nucleus/hypervisor/watchdog.py +0 -71
  48. package/src/mcp_server_nucleus/runtime/__init__.py +0 -58
  49. package/src/mcp_server_nucleus/runtime/agent.py +0 -517
  50. package/src/mcp_server_nucleus/runtime/agent_pool.py +0 -797
  51. package/src/mcp_server_nucleus/runtime/agents/base.py +0 -34
  52. package/src/mcp_server_nucleus/runtime/agents/oracle/manifest.json +0 -28
  53. package/src/mcp_server_nucleus/runtime/auth.py +0 -98
  54. package/src/mcp_server_nucleus/runtime/autopilot.py +0 -1057
  55. package/src/mcp_server_nucleus/runtime/broker.py +0 -126
  56. package/src/mcp_server_nucleus/runtime/budget.py +0 -153
  57. package/src/mcp_server_nucleus/runtime/capabilities/base.py +0 -20
  58. package/src/mcp_server_nucleus/runtime/capabilities/brain_ops.py +0 -253
  59. package/src/mcp_server_nucleus/runtime/capabilities/code_ops.py +0 -192
  60. package/src/mcp_server_nucleus/runtime/capabilities/depth_tracker.py +0 -81
  61. package/src/mcp_server_nucleus/runtime/capabilities/feature_map.py +0 -274
  62. package/src/mcp_server_nucleus/runtime/capabilities/marketing_engine.py +0 -205
  63. package/src/mcp_server_nucleus/runtime/capabilities/memory_ops.py +0 -79
  64. package/src/mcp_server_nucleus/runtime/capabilities/proof_system.py +0 -127
  65. package/src/mcp_server_nucleus/runtime/capabilities/render_ops.py +0 -80
  66. package/src/mcp_server_nucleus/runtime/capabilities/render_poller_cap.py +0 -266
  67. package/src/mcp_server_nucleus/runtime/capabilities/self_healing.py +0 -110
  68. package/src/mcp_server_nucleus/runtime/capabilities/strategy.py +0 -138
  69. package/src/mcp_server_nucleus/runtime/capabilities/synthesizer.py +0 -134
  70. package/src/mcp_server_nucleus/runtime/capabilities/web_ops.py +0 -94
  71. package/src/mcp_server_nucleus/runtime/common.py +0 -134
  72. package/src/mcp_server_nucleus/runtime/context_manager.py +0 -342
  73. package/src/mcp_server_nucleus/runtime/context_rules.json +0 -86
  74. package/src/mcp_server_nucleus/runtime/crdt_task_store.py +0 -349
  75. package/src/mcp_server_nucleus/runtime/critic.py +0 -150
  76. package/src/mcp_server_nucleus/runtime/daemon.py +0 -142
  77. package/src/mcp_server_nucleus/runtime/dashboard.py +0 -1087
  78. package/src/mcp_server_nucleus/runtime/depth_ops.py +0 -339
  79. package/src/mcp_server_nucleus/runtime/event_ops.py +0 -103
  80. package/src/mcp_server_nucleus/runtime/event_stream.py +0 -213
  81. package/src/mcp_server_nucleus/runtime/export.py +0 -64
  82. package/src/mcp_server_nucleus/runtime/factory.py +0 -594
  83. package/src/mcp_server_nucleus/runtime/federation.py +0 -1040
  84. package/src/mcp_server_nucleus/runtime/fetcher.py +0 -92
  85. package/src/mcp_server_nucleus/runtime/file_monitor.py +0 -208
  86. package/src/mcp_server_nucleus/runtime/firestore_bridge.py +0 -91
  87. package/src/mcp_server_nucleus/runtime/gcloud_ops.py +0 -186
  88. package/src/mcp_server_nucleus/runtime/hooks.py +0 -246
  89. package/src/mcp_server_nucleus/runtime/identity/gatekeeper.py +0 -106
  90. package/src/mcp_server_nucleus/runtime/identity/keygen.py +0 -147
  91. package/src/mcp_server_nucleus/runtime/identity/manifest.py +0 -62
  92. package/src/mcp_server_nucleus/runtime/identity/trust.py +0 -27
  93. package/src/mcp_server_nucleus/runtime/inspector.py +0 -74
  94. package/src/mcp_server_nucleus/runtime/installer.py +0 -81
  95. package/src/mcp_server_nucleus/runtime/ipc_auth.py +0 -377
  96. package/src/mcp_server_nucleus/runtime/lifecycle.py +0 -129
  97. package/src/mcp_server_nucleus/runtime/llm_client.py +0 -408
  98. package/src/mcp_server_nucleus/runtime/llm_tiers.json +0 -88
  99. package/src/mcp_server_nucleus/runtime/locking.py +0 -228
  100. package/src/mcp_server_nucleus/runtime/loops/fixer.py +0 -110
  101. package/src/mcp_server_nucleus/runtime/memory.py +0 -158
  102. package/src/mcp_server_nucleus/runtime/mounter.py +0 -210
  103. package/src/mcp_server_nucleus/runtime/mounter_ops.py +0 -250
  104. package/src/mcp_server_nucleus/runtime/nuke_protocol.py +0 -147
  105. package/src/mcp_server_nucleus/runtime/orchestrator.py +0 -370
  106. package/src/mcp_server_nucleus/runtime/orchestrator_unified.py +0 -263
  107. package/src/mcp_server_nucleus/runtime/orchestrator_v3.py +0 -617
  108. package/src/mcp_server_nucleus/runtime/plugin_loader.py +0 -84
  109. package/src/mcp_server_nucleus/runtime/policy.py +0 -117
  110. package/src/mcp_server_nucleus/runtime/profiling.py +0 -219
  111. package/src/mcp_server_nucleus/runtime/prometheus.py +0 -286
  112. package/src/mcp_server_nucleus/runtime/proposals.py +0 -101
  113. package/src/mcp_server_nucleus/runtime/publisher.py +0 -109
  114. package/src/mcp_server_nucleus/runtime/pulse.py +0 -516
  115. package/src/mcp_server_nucleus/runtime/registry.py +0 -82
  116. package/src/mcp_server_nucleus/runtime/render_ops.py +0 -83
  117. package/src/mcp_server_nucleus/runtime/schema_gen.py +0 -140
  118. package/src/mcp_server_nucleus/runtime/session_ops.py +0 -224
  119. package/src/mcp_server_nucleus/runtime/stdio_server.py +0 -633
  120. package/src/mcp_server_nucleus/runtime/storage.py +0 -83
  121. package/src/mcp_server_nucleus/runtime/strategy.py +0 -91
  122. package/src/mcp_server_nucleus/runtime/swarm.py +0 -170
  123. package/src/mcp_server_nucleus/runtime/sync_ops.py +0 -878
  124. package/src/mcp_server_nucleus/runtime/task_ingestion.py +0 -1020
  125. package/src/mcp_server_nucleus/runtime/task_ops.py +0 -449
  126. package/src/mcp_server_nucleus/runtime/task_scheduler.py +0 -488
  127. package/src/mcp_server_nucleus/runtime/team.py +0 -59
  128. package/src/mcp_server_nucleus/runtime/triggers.py +0 -237
  129. package/src/mcp_server_nucleus/runtime/vector_store.py +0 -125
  130. package/src/mcp_server_nucleus/runtime/watcher.py +0 -112
  131. package/src/mcp_server_nucleus/tool_tiers.py +0 -243
  132. package/tests/__init__.py +0 -1
  133. package/tests/conftest.py +0 -42
  134. package/tests/test_core.py +0 -235
  135. package/tests/test_search.py +0 -69
@@ -1,237 +0,0 @@
1
- """
2
- Nucleus Agent Runtime - Neural Triggers
3
- ========================================
4
- The routing layer that matches events to agents.
5
- Events trigger agents based on configurable rules.
6
-
7
- Location: mcp_server_nucleus/runtime/triggers.py
8
- """
9
-
10
- import json
11
- from pathlib import Path
12
- from typing import Dict, List, Optional
13
-
14
-
15
- def get_triggers_path(brain_path: Path) -> Path:
16
- """Get path to triggers configuration"""
17
- return brain_path / "ledger" / "triggers.json"
18
-
19
-
20
- def load_triggers(brain_path: Path) -> Dict:
21
- """Load trigger definitions from disk"""
22
- triggers_path = get_triggers_path(brain_path)
23
-
24
- if triggers_path.exists():
25
- return json.loads(triggers_path.read_text())
26
-
27
- # Return default triggers
28
- return get_default_triggers()
29
-
30
-
31
- def get_default_triggers() -> Dict:
32
- """
33
- Default trigger definitions based on .brain/agents/*.md protocols.
34
- Maps events to the agents that should handle them.
35
- """
36
- return {
37
- "version": "1.0",
38
- "triggers": [
39
- # Task Assignment (any agent)
40
- {
41
- "id": "task-assigned",
42
- "event_type": "task_assigned",
43
- "condition": "always",
44
- "activates": "{{payload.target_agent}}", # Dynamic from payload
45
- "description": "Route task to specified agent"
46
- },
47
-
48
- # Implementation → Critic Review
49
- {
50
- "id": "impl-complete-review",
51
- "event_type": "implementation_complete",
52
- "condition": "always",
53
- "activates": "critic",
54
- "description": "Developer finished, Critic reviews"
55
- },
56
-
57
- # Strategy Update → Critic Validation
58
- {
59
- "id": "strategy-validation",
60
- "event_type": "strategy_updated",
61
- "condition": "always",
62
- "activates": "critic",
63
- "description": "Strategy changed, Critic validates coherence"
64
- },
65
-
66
- # Spec Ready → Developer Implementation
67
- {
68
- "id": "spec-to-dev",
69
- "event_type": "spec_ready_for_development",
70
- "condition": "always",
71
- "activates": "developer",
72
- "description": "Architect finished spec, Developer implements"
73
- },
74
-
75
- # Review Approved → DevOps Deploy
76
- {
77
- "id": "approved-to-deploy",
78
- "event_type": "review_approved",
79
- "condition": "payload.review_type == 'code'",
80
- "activates": "devops",
81
- "description": "Code approved, DevOps can deploy"
82
- },
83
-
84
- # Critical Events → Synthesizer Escalation
85
- {
86
- "id": "critical-escalation",
87
- "event_type": "*",
88
- "condition": "severity == 'CRITICAL'",
89
- "activates": "synthesizer",
90
- "description": "Any critical event escalates to Synthesizer"
91
- },
92
-
93
- # Sprint Started → Research Kick-off
94
- {
95
- "id": "sprint-research",
96
- "event_type": "sprint_started",
97
- "condition": "always",
98
- "activates": "researcher",
99
- "description": "New sprint, Researcher gathers context"
100
- }
101
- ]
102
- }
103
-
104
-
105
- def save_triggers(brain_path: Path, triggers: Dict) -> None:
106
- """Save trigger definitions to disk"""
107
- triggers_path = get_triggers_path(brain_path)
108
- triggers_path.parent.mkdir(parents=True, exist_ok=True)
109
- triggers_path.write_text(json.dumps(triggers, indent=2))
110
-
111
-
112
- def evaluate_condition(condition: str, event: Dict) -> bool:
113
- """
114
- Evaluate a trigger condition against an event.
115
- Supports:
116
- - "always" - always matches
117
- - "severity == 'CRITICAL'" - severity check
118
- - "payload.field == 'value'" - payload field check
119
- """
120
- if condition == "always":
121
- return True
122
-
123
- # Simple expression evaluation (safe subset)
124
- try:
125
- # Extract variables from event
126
- severity = event.get("severity", "ROUTINE")
127
- payload = event.get("payload", {})
128
- event_type = event.get("event_type", "")
129
-
130
- # Basic condition parsing
131
- if "severity ==" in condition:
132
- target = condition.split("==")[1].strip().strip("'\"")
133
- return severity == target
134
-
135
- if "payload." in condition:
136
- # e.g., "payload.review_type == 'code'"
137
- parts = condition.split("==")
138
- field_path = parts[0].strip().replace("payload.", "")
139
- target = parts[1].strip().strip("'\"")
140
- return payload.get(field_path) == target
141
-
142
- if "event_type ==" in condition:
143
- target = condition.split("==")[1].strip().strip("'\"")
144
- return event_type == target
145
-
146
- except Exception:
147
- pass
148
-
149
- return False
150
-
151
-
152
- def resolve_agent(activates: str, event: Dict) -> Optional[str]:
153
- """
154
- Resolve the agent name from the trigger.
155
- Supports dynamic references like {{payload.target_agent}}
156
- """
157
- if activates.startswith("{{") and activates.endswith("}}"):
158
- # Dynamic reference
159
- path = activates[2:-2].strip()
160
-
161
- if path.startswith("payload."):
162
- field = path.replace("payload.", "")
163
- return event.get("payload", {}).get(field)
164
-
165
- return event.get(path)
166
-
167
- # Static agent name
168
- return activates
169
-
170
-
171
- def match_triggers(brain_path: Path, event: Dict) -> List[Dict]:
172
- """
173
- Find all triggers that match an event.
174
- Returns list of matched triggers with resolved agent names.
175
- """
176
- triggers_config = load_triggers(brain_path)
177
- triggers = triggers_config.get("triggers", [])
178
- matched = []
179
-
180
- event_type = event.get("event_type") or event.get("type", "")
181
-
182
- for trigger in triggers:
183
- trigger_event_type = trigger.get("event_type", "")
184
-
185
- # Check event type match
186
- if trigger_event_type != "*" and trigger_event_type != event_type:
187
- continue
188
-
189
- # Check condition
190
- condition = trigger.get("condition", "always")
191
- if not evaluate_condition(condition, event):
192
- continue
193
-
194
- # Resolve agent
195
- agent = resolve_agent(trigger.get("activates", ""), event)
196
- if not agent:
197
- continue
198
-
199
- matched.append({
200
- "trigger_id": trigger.get("id"),
201
- "agent": agent,
202
- "description": trigger.get("description", ""),
203
- "event": event
204
- })
205
-
206
- return matched
207
-
208
-
209
- def get_agents_for_event(brain_path: Path, event: Dict) -> List[str]:
210
- """
211
- Simple helper: get list of agent names that should be activated for an event.
212
- """
213
- matched = match_triggers(brain_path, event)
214
- return list(set(m["agent"] for m in matched))
215
-
216
-
217
- def add_trigger(brain_path: Path, trigger: Dict) -> None:
218
- """Add a new trigger definition"""
219
- triggers_config = load_triggers(brain_path)
220
- triggers_config["triggers"].append(trigger)
221
- save_triggers(brain_path, triggers_config)
222
-
223
-
224
- def remove_trigger(brain_path: Path, trigger_id: str) -> bool:
225
- """Remove a trigger by ID"""
226
- triggers_config = load_triggers(brain_path)
227
- original_count = len(triggers_config["triggers"])
228
- triggers_config["triggers"] = [
229
- t for t in triggers_config["triggers"]
230
- if t.get("id") != trigger_id
231
- ]
232
-
233
- if len(triggers_config["triggers"]) < original_count:
234
- save_triggers(brain_path, triggers_config)
235
- return True
236
-
237
- return False
@@ -1,125 +0,0 @@
1
-
2
- import logging
3
- import time
4
- from typing import List, Dict, Any
5
- from .llm_client import DualEngineLLM
6
- from .storage import get_firestore_client, STORAGE_TYPE
7
-
8
- logger = logging.getLogger("nucleus.vector_store")
9
-
10
- class VectorStore:
11
- """
12
- Manages Vector Storage and Retrieval using Firestore and Gemini Embeddings.
13
- """
14
- def __init__(self):
15
- # We use a separate collection for memory vectors to avoid polluting the 'brain' file system
16
- self.collection_name = "nucleus_memory"
17
- self.enabled = STORAGE_TYPE == "firestore"
18
-
19
- if self.enabled:
20
- # Only initialize LLM if we are actually going to use it (requires API Key)
21
- self.llm = DualEngineLLM(model_name="text-embedding-004")
22
- else:
23
- self.llm = None
24
- logger.warning("⚠️ VectorStore disabled: NUCLEUS_STORAGE_TYPE is not 'firestore'.")
25
-
26
- def store_memory(self, content: str, metadata: Dict[str, Any] = None) -> str:
27
- """
28
- Embeds and stores a memory chunk.
29
- Returns the Document ID.
30
- """
31
- if not self.enabled:
32
- logger.info(f"Skipping memory storage (Local Mode): {content[:30]}...")
33
- return "local_mock_id"
34
-
35
- metadata = metadata or {}
36
-
37
- try:
38
- # Generate Embedding
39
- embedding_resp = self.llm.embed_content(content, task_type="retrieval_document")
40
- vector = embedding_resp.get('embedding', [])
41
-
42
- if not vector:
43
- raise ValueError("Failed to generate embedding")
44
-
45
- # Store in Firestore
46
- db = get_firestore_client()
47
- doc_ref = db.collection(self.collection_name).document()
48
-
49
- payload = {
50
- "content": content,
51
- "embedding": vector, # Firestore supports Vector types? Or just list of floats.
52
- # Note: Pure Firestore vector search requires VectorValue.
53
- # For MVP with `google-cloud-firestore` standard client, we store as raw list
54
- # OR we need to use the specific Vector helper if available.
55
- # Assuming standard list for now, checking underlying support.
56
- # Wait, standard Firestore check:
57
- # To use KNN, we need `Vector` class from `google.cloud.firestore_v1.vector`.
58
- # Let's try to import it, or fallback.
59
- "metadata": metadata,
60
- "created_at": time.time()
61
- }
62
-
63
- # Helper for Vector type if available
64
- try:
65
- from google.cloud.firestore_v1.vector import Vector
66
- payload["embedding_vector"] = Vector(vector)
67
- except ImportError:
68
- # Fallback/Older lib: Just store list, but search won't work efficiently without it.
69
- pass
70
-
71
- doc_ref.set(payload)
72
- logger.info(f"🧠 Stored memory {doc_ref.id}")
73
- return doc_ref.id
74
-
75
- except Exception as e:
76
- logger.error(f"Failed to store memory: {e}")
77
- raise
78
-
79
- def search_memory(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
80
- """
81
- Semantic search for memories.
82
- """
83
- if not self.enabled:
84
- return [{"content": "Memory disabled in local mode", "score": 0.0}]
85
-
86
- try:
87
- # Embed Query
88
- embedding_resp = self.llm.embed_content(query, task_type="retrieval_query")
89
- query_vector = embedding_resp.get('embedding', [])
90
-
91
- if not query_vector:
92
- return []
93
-
94
- db = get_firestore_client()
95
- coll = db.collection(self.collection_name)
96
-
97
- # KNN Search
98
- # Requres `google-cloud-firestore>=2.14.0`
99
- from google.cloud.firestore_v1.vector import Vector
100
- from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
101
-
102
- # Execute Vector Search
103
- vector_query = coll.find_nearest(
104
- vector_field="embedding_vector",
105
- query_vector=Vector(query_vector),
106
- distance_measure=DistanceMeasure.COSINE,
107
- limit=limit
108
- )
109
-
110
- results = []
111
- docs = vector_query.get()
112
- for doc in docs:
113
- data = doc.to_dict()
114
- results.append({
115
- "id": doc.id,
116
- "content": data.get("content"),
117
- "metadata": data.get("metadata"),
118
- "score": 0.0 # Firestore doesn't return score easily in v1, implying raw sort
119
- })
120
-
121
- return results
122
-
123
- except Exception as e:
124
- logger.error(f"Memory search failed: {e}")
125
- return []
@@ -1,112 +0,0 @@
1
-
2
- """
3
- Async File Watcher for Nucleus.
4
- Provides event-driven monitoring of Brain changes (directives, tasks).
5
-
6
- Strategic Role:
7
- - Moves Daemon from "Sleep Loop" to "Event Driven" (where possible).
8
- - Monitors `directives.md` for policy updates.
9
- - Monitors `task.md` for new commands.
10
- """
11
-
12
- import asyncio
13
- import logging
14
- from pathlib import Path
15
- from typing import Callable, List, Dict
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
- class AsyncFileWatcher:
20
- def __init__(self, watch_paths: List[Path], callback: Callable, polling_interval: float = 2.0):
21
- self.watch_paths = watch_paths
22
- self.callback = callback
23
- self.polling_interval = polling_interval
24
- self.running = False
25
- self._last_mtimes: Dict[Path, float] = {}
26
-
27
- # Try to import watchdog for native OS events, fallback to polling
28
- self.use_watchdog = False
29
- try:
30
- from watchdog.observers import Observer
31
- self.use_watchdog = True
32
- self.observer = Observer()
33
- self._handler = self._create_handler()
34
- except ImportError:
35
- logger.info("Watchdog not installed. Falling back to Polling.")
36
-
37
- def _create_handler(self):
38
- from watchdog.events import FileSystemEventHandler
39
-
40
- class Handler(FileSystemEventHandler):
41
- def __init__(self, callback):
42
- self.cb = callback
43
-
44
- def on_modified(self, event):
45
- if not event.is_directory:
46
- # Debounce or just fire?
47
- # For V1, just fire. simple.
48
- asyncio.run_coroutine_threadsafe(self.cb(Path(event.src_path)), asyncio.get_running_loop())
49
-
50
- return Handler(self.callback)
51
-
52
- async def start(self):
53
- """Start the watcher"""
54
- self.running = True
55
-
56
- if self.use_watchdog:
57
- logger.info("👀 Starting Native File Watcher (Watchdog)")
58
- # Re-import to be safe or use what we confirmed
59
-
60
- # Watch valid directories
61
- watched_dirs = set()
62
- for p in self.watch_paths:
63
- if p.exists():
64
- p_dir = p if p.is_dir() else p.parent
65
- if p_dir not in watched_dirs:
66
- self.observer.schedule(self._handler, str(p_dir), recursive=False)
67
- watched_dirs.add(p_dir)
68
-
69
- try:
70
- self.observer.start()
71
- # Watchdog runs in thread, we just await nothing or keep loop alive elsewhere
72
- except Exception as e:
73
- logger.error(f"Watchdog failed to start: {e}. Fallback to polling.")
74
- self.use_watchdog = False
75
-
76
- if not self.use_watchdog:
77
- logger.info(f"👀 Starting Polling Watcher ({self.polling_interval}s)")
78
- asyncio.create_task(self._poll_loop())
79
-
80
- async def _poll_loop(self):
81
- """Robust polling loop"""
82
- # Init mtimes
83
- for p in self.watch_paths:
84
- if p.exists():
85
- self._last_mtimes[p] = p.stat().st_mtime
86
-
87
- while self.running:
88
- for p in self.watch_paths:
89
- if p.exists():
90
- try:
91
- current_mtime = p.stat().st_mtime
92
- last_mtime = self._last_mtimes.get(p)
93
-
94
- if last_mtime != current_mtime:
95
- self._last_mtimes[p] = current_mtime
96
- # Trigger callback
97
- logger.debug(f"File changed: {p.name}")
98
- if asyncio.iscoroutinefunction(self.callback):
99
- await self.callback(p)
100
- else:
101
- self.callback(p)
102
- except OSError:
103
- pass # File reading error, skip this tick
104
-
105
- await asyncio.sleep(self.polling_interval)
106
-
107
- async def stop(self):
108
- """Stop watching"""
109
- self.running = False
110
- if self.use_watchdog:
111
- self.observer.stop()
112
- self.observer.join()