kollabor 0.4.9__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 (128) hide show
  1. core/__init__.py +18 -0
  2. core/application.py +578 -0
  3. core/cli.py +193 -0
  4. core/commands/__init__.py +43 -0
  5. core/commands/executor.py +277 -0
  6. core/commands/menu_renderer.py +319 -0
  7. core/commands/parser.py +186 -0
  8. core/commands/registry.py +331 -0
  9. core/commands/system_commands.py +479 -0
  10. core/config/__init__.py +7 -0
  11. core/config/llm_task_config.py +110 -0
  12. core/config/loader.py +501 -0
  13. core/config/manager.py +112 -0
  14. core/config/plugin_config_manager.py +346 -0
  15. core/config/plugin_schema.py +424 -0
  16. core/config/service.py +399 -0
  17. core/effects/__init__.py +1 -0
  18. core/events/__init__.py +12 -0
  19. core/events/bus.py +129 -0
  20. core/events/executor.py +154 -0
  21. core/events/models.py +258 -0
  22. core/events/processor.py +176 -0
  23. core/events/registry.py +289 -0
  24. core/fullscreen/__init__.py +19 -0
  25. core/fullscreen/command_integration.py +290 -0
  26. core/fullscreen/components/__init__.py +12 -0
  27. core/fullscreen/components/animation.py +258 -0
  28. core/fullscreen/components/drawing.py +160 -0
  29. core/fullscreen/components/matrix_components.py +177 -0
  30. core/fullscreen/manager.py +302 -0
  31. core/fullscreen/plugin.py +204 -0
  32. core/fullscreen/renderer.py +282 -0
  33. core/fullscreen/session.py +324 -0
  34. core/io/__init__.py +52 -0
  35. core/io/buffer_manager.py +362 -0
  36. core/io/config_status_view.py +272 -0
  37. core/io/core_status_views.py +410 -0
  38. core/io/input_errors.py +313 -0
  39. core/io/input_handler.py +2655 -0
  40. core/io/input_mode_manager.py +402 -0
  41. core/io/key_parser.py +344 -0
  42. core/io/layout.py +587 -0
  43. core/io/message_coordinator.py +204 -0
  44. core/io/message_renderer.py +601 -0
  45. core/io/modal_interaction_handler.py +315 -0
  46. core/io/raw_input_processor.py +946 -0
  47. core/io/status_renderer.py +845 -0
  48. core/io/terminal_renderer.py +586 -0
  49. core/io/terminal_state.py +551 -0
  50. core/io/visual_effects.py +734 -0
  51. core/llm/__init__.py +26 -0
  52. core/llm/api_communication_service.py +863 -0
  53. core/llm/conversation_logger.py +473 -0
  54. core/llm/conversation_manager.py +414 -0
  55. core/llm/file_operations_executor.py +1401 -0
  56. core/llm/hook_system.py +402 -0
  57. core/llm/llm_service.py +1629 -0
  58. core/llm/mcp_integration.py +386 -0
  59. core/llm/message_display_service.py +450 -0
  60. core/llm/model_router.py +214 -0
  61. core/llm/plugin_sdk.py +396 -0
  62. core/llm/response_parser.py +848 -0
  63. core/llm/response_processor.py +364 -0
  64. core/llm/tool_executor.py +520 -0
  65. core/logging/__init__.py +19 -0
  66. core/logging/setup.py +208 -0
  67. core/models/__init__.py +5 -0
  68. core/models/base.py +23 -0
  69. core/plugins/__init__.py +13 -0
  70. core/plugins/collector.py +212 -0
  71. core/plugins/discovery.py +386 -0
  72. core/plugins/factory.py +263 -0
  73. core/plugins/registry.py +152 -0
  74. core/storage/__init__.py +5 -0
  75. core/storage/state_manager.py +84 -0
  76. core/ui/__init__.py +6 -0
  77. core/ui/config_merger.py +176 -0
  78. core/ui/config_widgets.py +369 -0
  79. core/ui/live_modal_renderer.py +276 -0
  80. core/ui/modal_actions.py +162 -0
  81. core/ui/modal_overlay_renderer.py +373 -0
  82. core/ui/modal_renderer.py +591 -0
  83. core/ui/modal_state_manager.py +443 -0
  84. core/ui/widget_integration.py +222 -0
  85. core/ui/widgets/__init__.py +27 -0
  86. core/ui/widgets/base_widget.py +136 -0
  87. core/ui/widgets/checkbox.py +85 -0
  88. core/ui/widgets/dropdown.py +140 -0
  89. core/ui/widgets/label.py +78 -0
  90. core/ui/widgets/slider.py +185 -0
  91. core/ui/widgets/text_input.py +224 -0
  92. core/utils/__init__.py +11 -0
  93. core/utils/config_utils.py +656 -0
  94. core/utils/dict_utils.py +212 -0
  95. core/utils/error_utils.py +275 -0
  96. core/utils/key_reader.py +171 -0
  97. core/utils/plugin_utils.py +267 -0
  98. core/utils/prompt_renderer.py +151 -0
  99. kollabor-0.4.9.dist-info/METADATA +298 -0
  100. kollabor-0.4.9.dist-info/RECORD +128 -0
  101. kollabor-0.4.9.dist-info/WHEEL +5 -0
  102. kollabor-0.4.9.dist-info/entry_points.txt +2 -0
  103. kollabor-0.4.9.dist-info/licenses/LICENSE +21 -0
  104. kollabor-0.4.9.dist-info/top_level.txt +4 -0
  105. kollabor_cli_main.py +20 -0
  106. plugins/__init__.py +1 -0
  107. plugins/enhanced_input/__init__.py +18 -0
  108. plugins/enhanced_input/box_renderer.py +103 -0
  109. plugins/enhanced_input/box_styles.py +142 -0
  110. plugins/enhanced_input/color_engine.py +165 -0
  111. plugins/enhanced_input/config.py +150 -0
  112. plugins/enhanced_input/cursor_manager.py +72 -0
  113. plugins/enhanced_input/geometry.py +81 -0
  114. plugins/enhanced_input/state.py +130 -0
  115. plugins/enhanced_input/text_processor.py +115 -0
  116. plugins/enhanced_input_plugin.py +385 -0
  117. plugins/fullscreen/__init__.py +9 -0
  118. plugins/fullscreen/example_plugin.py +327 -0
  119. plugins/fullscreen/matrix_plugin.py +132 -0
  120. plugins/hook_monitoring_plugin.py +1299 -0
  121. plugins/query_enhancer_plugin.py +350 -0
  122. plugins/save_conversation_plugin.py +502 -0
  123. plugins/system_commands_plugin.py +93 -0
  124. plugins/tmux_plugin.py +795 -0
  125. plugins/workflow_enforcement_plugin.py +629 -0
  126. system_prompt/default.md +1286 -0
  127. system_prompt/default_win.md +265 -0
  128. system_prompt/example_with_trender.md +47 -0
core/config/service.py ADDED
@@ -0,0 +1,399 @@
1
+ """Configuration service providing high-level configuration operations."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional, Callable
8
+
9
+ try:
10
+ from watchdog.observers import Observer
11
+ from watchdog.events import FileSystemEventHandler
12
+ WATCHDOG_AVAILABLE = True
13
+ except ImportError:
14
+ WATCHDOG_AVAILABLE = False
15
+ Observer = None
16
+ FileSystemEventHandler = None
17
+
18
+ from .manager import ConfigManager
19
+ from .loader import ConfigLoader
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ if WATCHDOG_AVAILABLE:
25
+ class ConfigFileWatcher(FileSystemEventHandler):
26
+ """File system event handler for configuration file changes."""
27
+
28
+ def __init__(self, config_service: 'ConfigService'):
29
+ super().__init__()
30
+ self.config_service = config_service
31
+ self.last_modified = 0
32
+ self.debounce_delay = 0.5 # 500ms debounce
33
+
34
+ def on_modified(self, event):
35
+ """Handle file modification events."""
36
+ if event.is_directory:
37
+ return
38
+
39
+ if event.src_path == str(self.config_service.config_manager.config_path):
40
+ current_time = time.time()
41
+
42
+ # Debounce rapid file changes
43
+ if current_time - self.last_modified > self.debounce_delay:
44
+ self.last_modified = current_time
45
+ logger.info("Configuration file changed, triggering reload")
46
+ # Schedule the reload in a thread-safe way
47
+ try:
48
+ loop = asyncio.get_running_loop()
49
+ loop.call_soon_threadsafe(
50
+ lambda: asyncio.create_task(self.config_service._handle_file_change())
51
+ )
52
+ except RuntimeError:
53
+ # No event loop running, fall back to sync reload
54
+ logger.warning("No event loop available, performing synchronous reload")
55
+ self.config_service.reload()
56
+ else:
57
+ class ConfigFileWatcher:
58
+ """Stub class when watchdog is not available."""
59
+ def __init__(self, config_service: 'ConfigService'):
60
+ pass
61
+
62
+
63
+ class ConfigService:
64
+ """High-level configuration service providing a clean API.
65
+
66
+ This service coordinates between the file-based ConfigManager and
67
+ the plugin-aware ConfigLoader to provide a simple interface for
68
+ all configuration operations.
69
+ """
70
+
71
+ def __init__(self, config_path: Path, plugin_registry=None):
72
+ """Initialize the configuration service.
73
+
74
+ Args:
75
+ config_path: Path to the configuration file.
76
+ plugin_registry: Optional plugin registry for plugin configs.
77
+ """
78
+ self.config_manager = ConfigManager(config_path)
79
+ self.config_loader = ConfigLoader(self.config_manager, plugin_registry)
80
+ self.plugin_registry = plugin_registry
81
+
82
+ # Cached configuration for fallback
83
+ self._cached_config = None
84
+ self._config_error = None
85
+ self._reload_callbacks = []
86
+
87
+ # File watching setup
88
+ self._file_watcher = None
89
+ self._observer = None
90
+
91
+ # Load initial configuration
92
+ self._initialize_config()
93
+
94
+ # Start file watching if successful
95
+ self._start_file_watching()
96
+
97
+ logger.info(f"Configuration service initialized: {config_path}")
98
+
99
+ def _initialize_config(self) -> None:
100
+ """Initialize configuration on service startup."""
101
+ try:
102
+ if self.config_manager.config_path.exists():
103
+ # Load existing config and merge with defaults
104
+ complete_config = self.config_loader.load_complete_config()
105
+ self.config_manager.config = complete_config
106
+ self._cached_config = complete_config.copy()
107
+ self._config_error = None
108
+ logger.info("Loaded and merged existing configuration")
109
+ else:
110
+ # Create new config with defaults and plugin configs
111
+ complete_config = self.config_loader.load_complete_config()
112
+ self.config_manager.config = complete_config
113
+ self._cached_config = complete_config.copy()
114
+ self.config_loader.save_merged_config(complete_config)
115
+ self._config_error = None
116
+ logger.info("Created new configuration file")
117
+ except Exception as e:
118
+ self._config_error = str(e)
119
+ logger.error(f"Failed to initialize configuration: {e}")
120
+ if self._cached_config:
121
+ logger.warning("Using cached configuration as fallback")
122
+ self.config_manager.config = self._cached_config
123
+ else:
124
+ # Use minimal base config as last resort
125
+ base_config = self.config_loader.get_base_config()
126
+ self.config_manager.config = base_config
127
+ self._cached_config = base_config.copy()
128
+ logger.warning("Using base configuration as fallback")
129
+
130
+ def get(self, key_path: str, default: Any = None) -> Any:
131
+ """Get a configuration value using dot notation.
132
+
133
+ Args:
134
+ key_path: Dot-separated path to the config value.
135
+ default: Default value if key not found.
136
+
137
+ Returns:
138
+ Configuration value or default.
139
+ """
140
+ return self.config_manager.get(key_path, default)
141
+
142
+ def set(self, key_path: str, value: Any) -> bool:
143
+ """Set a configuration value using dot notation.
144
+
145
+ Args:
146
+ key_path: Dot-separated path to the config value.
147
+ value: Value to set.
148
+
149
+ Returns:
150
+ True if set successful, False otherwise.
151
+ """
152
+ success = self.config_manager.set(key_path, value)
153
+ if success:
154
+ logger.debug(f"Configuration updated: {key_path}")
155
+ return success
156
+
157
+ def reload(self) -> bool:
158
+ """Reload configuration from file and plugins.
159
+
160
+ Returns:
161
+ True if reload successful, False otherwise.
162
+ """
163
+ try:
164
+ complete_config = self.config_loader.load_complete_config()
165
+
166
+ # Validate the new configuration
167
+ old_config = self.config_manager.config
168
+ self.config_manager.config = complete_config
169
+ validation_result = self.validate_config()
170
+
171
+ if validation_result["valid"]:
172
+ # Success - update cache and clear error
173
+ self._cached_config = complete_config.copy()
174
+ self._config_error = None
175
+ logger.info("Configuration reloaded successfully")
176
+ self._notify_reload_callbacks()
177
+ return True
178
+ else:
179
+ # Validation failed - revert to cached config
180
+ self.config_manager.config = old_config
181
+ error_msg = f"Invalid configuration: {validation_result['errors']}"
182
+ self._config_error = error_msg
183
+ logger.error(error_msg)
184
+ return False
185
+
186
+ except Exception as e:
187
+ error_msg = f"Failed to reload configuration: {e}"
188
+ self._config_error = error_msg
189
+ logger.error(error_msg)
190
+
191
+ # Fallback to cached config if available
192
+ if self._cached_config:
193
+ logger.warning("Using cached configuration as fallback")
194
+ self.config_manager.config = self._cached_config
195
+
196
+ return False
197
+
198
+ def update_from_plugins(self) -> bool:
199
+ """Update configuration with newly discovered plugins.
200
+
201
+ Returns:
202
+ True if update successful, False otherwise.
203
+ """
204
+ return self.config_loader.update_with_plugins()
205
+
206
+ def get_config_summary(self) -> Dict[str, Any]:
207
+ """Get a summary of the current configuration.
208
+
209
+ Returns:
210
+ Dictionary with configuration metadata.
211
+ """
212
+ config = self.config_manager.config
213
+ plugin_count = len(self.plugin_registry.list_plugins()) if self.plugin_registry else 0
214
+
215
+ return {
216
+ "config_file": str(self.config_manager.config_path),
217
+ "file_exists": self.config_manager.config_path.exists(),
218
+ "plugin_count": plugin_count,
219
+ "config_sections": list(config.keys()) if config else [],
220
+ "total_keys": self._count_keys(config),
221
+ }
222
+
223
+ def _count_keys(self, config: Dict[str, Any]) -> int:
224
+ """Recursively count all keys in configuration."""
225
+ count = 0
226
+ for key, value in config.items():
227
+ count += 1
228
+ if isinstance(value, dict):
229
+ count += self._count_keys(value)
230
+ return count
231
+
232
+ def validate_config(self) -> Dict[str, Any]:
233
+ """Validate current configuration structure.
234
+
235
+ Returns:
236
+ Dictionary with validation results.
237
+ """
238
+ validation_result = {
239
+ "valid": True,
240
+ "errors": [],
241
+ "warnings": []
242
+ }
243
+
244
+ config = self.config_manager.config
245
+
246
+ # Check for required sections
247
+ required_sections = ["terminal", "input", "logging", "application"]
248
+ for section in required_sections:
249
+ if section not in config:
250
+ validation_result["errors"].append(f"Missing required section: {section}")
251
+ validation_result["valid"] = False
252
+
253
+ # Check for required terminal settings
254
+ if "terminal" in config:
255
+ required_terminal_keys = ["render_fps", "thinking_effect"]
256
+ for key in required_terminal_keys:
257
+ if key not in config["terminal"]:
258
+ validation_result["warnings"].append(f"Missing terminal.{key}, using default")
259
+
260
+ # Check for valid FPS value
261
+ fps = self.get("terminal.render_fps")
262
+ if fps is not None and (not isinstance(fps, int) or fps <= 0 or fps > 120):
263
+ validation_result["warnings"].append(f"Invalid render_fps: {fps}, should be 1-120")
264
+
265
+ logger.debug(f"Configuration validation: {validation_result}")
266
+ return validation_result
267
+
268
+ def backup_config(self, backup_suffix: str = ".backup") -> Optional[Path]:
269
+ """Create a backup of the current configuration file.
270
+
271
+ Args:
272
+ backup_suffix: Suffix to add to backup filename.
273
+
274
+ Returns:
275
+ Path to backup file if successful, None otherwise.
276
+ """
277
+ if not self.config_manager.config_path.exists():
278
+ logger.warning("Cannot backup non-existent config file")
279
+ return None
280
+
281
+ try:
282
+ backup_path = self.config_manager.config_path.with_suffix(
283
+ self.config_manager.config_path.suffix + backup_suffix
284
+ )
285
+
286
+ import shutil
287
+ shutil.copy2(self.config_manager.config_path, backup_path)
288
+
289
+ logger.info(f"Configuration backed up to: {backup_path}")
290
+ return backup_path
291
+
292
+ except Exception as e:
293
+ logger.error(f"Failed to backup configuration: {e}")
294
+ return None
295
+
296
+ def restore_from_backup(self, backup_path: Path) -> bool:
297
+ """Restore configuration from a backup file.
298
+
299
+ Args:
300
+ backup_path: Path to backup file.
301
+
302
+ Returns:
303
+ True if restore successful, False otherwise.
304
+ """
305
+ if not backup_path.exists():
306
+ logger.error(f"Backup file does not exist: {backup_path}")
307
+ return False
308
+
309
+ try:
310
+ import shutil
311
+ shutil.copy2(backup_path, self.config_manager.config_path)
312
+
313
+ # Reload configuration after restore
314
+ return self.reload()
315
+
316
+ except Exception as e:
317
+ logger.error(f"Failed to restore from backup: {e}")
318
+ return False
319
+
320
+ def _start_file_watching(self) -> None:
321
+ """Start watching the configuration file for changes."""
322
+ if not WATCHDOG_AVAILABLE:
323
+ logger.debug("Watchdog not available, file watching disabled")
324
+ return
325
+
326
+ # Prevent duplicate watchers
327
+ if self._observer is not None:
328
+ logger.debug("File watcher already running, skipping initialization")
329
+ return
330
+
331
+ try:
332
+ self._file_watcher = ConfigFileWatcher(self)
333
+ self._observer = Observer()
334
+ self._observer.schedule(
335
+ self._file_watcher,
336
+ str(self.config_manager.config_path.parent),
337
+ recursive=False
338
+ )
339
+ self._observer.start()
340
+ logger.debug("Configuration file watcher started")
341
+ except RuntimeError as e:
342
+ if "already scheduled" in str(e):
343
+ logger.debug("File watcher path already being watched by another instance")
344
+ else:
345
+ logger.warning(f"Could not start configuration file watcher: {e}")
346
+ except Exception as e:
347
+ logger.warning(f"Could not start configuration file watcher: {e}")
348
+
349
+ def _stop_file_watching(self) -> None:
350
+ """Stop watching the configuration file."""
351
+ if self._observer:
352
+ self._observer.stop()
353
+ self._observer.join()
354
+ self._observer = None
355
+ self._file_watcher = None
356
+ logger.debug("Configuration file watcher stopped")
357
+
358
+ async def _handle_file_change(self) -> None:
359
+ """Handle configuration file changes with hot reload."""
360
+ success = self.reload()
361
+ if not success:
362
+ logger.warning("Configuration reload failed, using cached fallback")
363
+
364
+ def register_reload_callback(self, callback: Callable[[], None]) -> None:
365
+ """Register a callback to be notified when configuration reloads.
366
+
367
+ Args:
368
+ callback: Function to call after successful configuration reload.
369
+ """
370
+ self._reload_callbacks.append(callback)
371
+
372
+ def _notify_reload_callbacks(self) -> None:
373
+ """Notify all registered callbacks about configuration reload."""
374
+ for callback in self._reload_callbacks:
375
+ try:
376
+ callback()
377
+ except Exception as e:
378
+ logger.error(f"Config reload callback failed: {e}")
379
+
380
+ def get_config_error(self) -> Optional[str]:
381
+ """Get the current configuration error, if any.
382
+
383
+ Returns:
384
+ Error message string if there's a config error, None otherwise.
385
+ """
386
+ return self._config_error
387
+
388
+ def has_config_error(self) -> bool:
389
+ """Check if there's a current configuration error.
390
+
391
+ Returns:
392
+ True if there's an error, False otherwise.
393
+ """
394
+ return self._config_error is not None
395
+
396
+ def shutdown(self) -> None:
397
+ """Shutdown the configuration service and file watcher."""
398
+ self._stop_file_watching()
399
+ logger.info("Configuration service shutdown")
@@ -0,0 +1 @@
1
+ """Visual effects module for special animations and displays."""
@@ -0,0 +1,12 @@
1
+ """Event system subsystem for Kollabor CLI."""
2
+
3
+ from .bus import EventBus
4
+ from .models import Event, EventType, Hook, HookStatus, HookPriority
5
+ from .registry import HookRegistry
6
+ from .executor import HookExecutor
7
+ from .processor import EventProcessor
8
+
9
+ __all__ = [
10
+ 'EventBus', 'Event', 'EventType', 'Hook', 'HookStatus', 'HookPriority',
11
+ 'HookRegistry', 'HookExecutor', 'EventProcessor'
12
+ ]
core/events/bus.py ADDED
@@ -0,0 +1,129 @@
1
+ """Event system for plugin communication."""
2
+
3
+ import logging
4
+ from typing import Any, Dict
5
+
6
+ from .models import EventType, Hook
7
+ from .registry import HookRegistry
8
+ from .executor import HookExecutor
9
+ from .processor import EventProcessor
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class EventBus:
15
+ """Simplified event bus system for plugin communication.
16
+
17
+ Coordinates between specialized components for hook registration
18
+ and event processing with clean separation of concerns.
19
+ """
20
+
21
+ def __init__(self) -> None:
22
+ """Initialize the event bus with specialized components."""
23
+ self.hook_registry = HookRegistry()
24
+ self.hook_executor = HookExecutor()
25
+ self.event_processor = EventProcessor(self.hook_registry, self.hook_executor)
26
+ logger.info("Event bus initialized with specialized components")
27
+
28
+ async def register_hook(self, hook: Hook) -> bool:
29
+ """Register a hook with the event bus.
30
+
31
+ Args:
32
+ hook: The hook to register.
33
+
34
+ Returns:
35
+ True if registration successful, False otherwise.
36
+ """
37
+ success = self.hook_registry.register_hook(hook)
38
+ if success:
39
+ logger.debug(f"Successfully registered hook: {hook.plugin_name}.{hook.name}")
40
+ else:
41
+ logger.error(f"Failed to register hook: {hook.plugin_name}.{hook.name}")
42
+ return success
43
+
44
+ async def unregister_hook(self, plugin_name: str, hook_name: str) -> bool:
45
+ """Unregister a hook from the event bus.
46
+
47
+ Args:
48
+ plugin_name: Name of the plugin that owns the hook.
49
+ hook_name: Name of the hook.
50
+
51
+ Returns:
52
+ True if unregistration successful, False otherwise.
53
+ """
54
+ return self.hook_registry.unregister_hook(plugin_name, hook_name)
55
+
56
+ async def emit_with_hooks(self, event_type: EventType, data: Dict[str, Any], source: str) -> Dict[str, Any]:
57
+ """Emit an event with pre/post hook processing.
58
+
59
+ Args:
60
+ event_type: Type of event to emit.
61
+ data: Event data.
62
+ source: Source of the event.
63
+
64
+ Returns:
65
+ Results from hook processing.
66
+ """
67
+ return await self.event_processor.process_event_with_phases(event_type, data, source)
68
+
69
+ def get_hook_status(self) -> Dict[str, Any]:
70
+ """Get current status of all registered hooks.
71
+
72
+ Returns:
73
+ Dictionary with hook status information.
74
+ """
75
+ return self.hook_registry.get_hook_status_summary()
76
+
77
+ def get_registry_stats(self) -> Dict[str, Any]:
78
+ """Get comprehensive registry statistics.
79
+
80
+ Returns:
81
+ Dictionary with detailed registry statistics.
82
+ """
83
+ return self.hook_registry.get_registry_stats()
84
+
85
+ def enable_hook(self, plugin_name: str, hook_name: str) -> bool:
86
+ """Enable a registered hook.
87
+
88
+ Args:
89
+ plugin_name: Name of the plugin that owns the hook.
90
+ hook_name: Name of the hook.
91
+
92
+ Returns:
93
+ True if hook was enabled, False otherwise.
94
+ """
95
+ return self.hook_registry.enable_hook(plugin_name, hook_name)
96
+
97
+ def disable_hook(self, plugin_name: str, hook_name: str) -> bool:
98
+ """Disable a registered hook.
99
+
100
+ Args:
101
+ plugin_name: Name of the plugin that owns the hook.
102
+ hook_name: Name of the hook.
103
+
104
+ Returns:
105
+ True if hook was disabled, False otherwise.
106
+ """
107
+ return self.hook_registry.disable_hook(plugin_name, hook_name)
108
+
109
+ def get_hooks_for_event(self, event_type: EventType) -> int:
110
+ """Get the number of hooks registered for an event type.
111
+
112
+ Args:
113
+ event_type: The event type to check.
114
+
115
+ Returns:
116
+ Number of hooks registered for the event type.
117
+ """
118
+ hooks = self.hook_registry.get_hooks_for_event(event_type)
119
+ return len(hooks)
120
+
121
+ def add_event_type_mapping(self, main_event: EventType, pre_event: EventType, post_event: EventType) -> None:
122
+ """Add a new event type mapping for pre/post processing.
123
+
124
+ Args:
125
+ main_event: The main event type.
126
+ pre_event: The pre-processing event type.
127
+ post_event: The post-processing event type.
128
+ """
129
+ self.event_processor.add_event_type_mapping(main_event, pre_event, post_event)
@@ -0,0 +1,154 @@
1
+ """Hook executor for individual hook execution with error handling."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from typing import Any, Dict, List
7
+
8
+ from .models import Event, Hook, HookStatus
9
+ from ..utils.error_utils import log_and_continue
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class HookExecutor:
15
+ """Executes individual hooks with timeout and error handling.
16
+
17
+ This class is responsible for the safe execution of a single hook,
18
+ including timeout management, error handling, and status tracking.
19
+ """
20
+
21
+ def __init__(self):
22
+ """Initialize the hook executor."""
23
+ logger.debug("HookExecutor initialized")
24
+
25
+ async def execute_hook(self, hook: Hook, event: Event) -> Dict[str, Any]:
26
+ """Execute a single hook with error handling and timeout.
27
+
28
+ Args:
29
+ hook: The hook to execute.
30
+ event: The event being processed.
31
+
32
+ Returns:
33
+ Dictionary with execution result and metadata.
34
+ """
35
+ hook_key = f"{hook.plugin_name}.{hook.name}"
36
+ result_metadata = {
37
+ "hook_key": hook_key,
38
+ "success": False,
39
+ "result": None,
40
+ "error": None,
41
+ "duration_ms": 0
42
+ }
43
+
44
+ if not hook.enabled:
45
+ result_metadata["error"] = "hook_disabled"
46
+ logger.debug(f"Skipping disabled hook: {hook_key}")
47
+ return result_metadata
48
+
49
+ if event.cancelled:
50
+ result_metadata["error"] = "event_cancelled"
51
+ logger.debug(f"Skipping hook due to cancelled event: {hook_key}")
52
+ return result_metadata
53
+
54
+ # Track execution time
55
+ start_time = time.time()
56
+
57
+ try:
58
+ # Update hook status to working
59
+ hook.status = HookStatus.WORKING
60
+
61
+ # Execute hook with timeout
62
+ result = await asyncio.wait_for(
63
+ hook.callback(event.data, event),
64
+ timeout=hook.timeout
65
+ )
66
+
67
+ # Calculate execution time
68
+ end_time = time.time()
69
+ result_metadata["duration_ms"] = max(1, int((end_time - start_time) * 1000))
70
+
71
+ # Mark as successful
72
+ hook.status = HookStatus.COMPLETED
73
+ result_metadata["success"] = True
74
+ result_metadata["result"] = result
75
+ # Handle data transformation if hook returns modified data
76
+ if isinstance(result, dict) and "data" in result:
77
+ self._apply_data_transformation(event, result["data"])
78
+ logger.debug(f"Hook {hook_key} modified event data")
79
+
80
+ except asyncio.TimeoutError:
81
+ end_time = time.time()
82
+ result_metadata["duration_ms"] = max(1, int((end_time - start_time) * 1000))
83
+ result_metadata["error"] = "timeout"
84
+
85
+ hook.status = HookStatus.TIMEOUT
86
+ logger.warning(f"Hook {hook_key} timed out after {hook.timeout}s")
87
+
88
+ # Handle timeout based on error action
89
+ if hook.error_action == "stop":
90
+ event.cancelled = True
91
+ logger.info(f"Event cancelled due to hook timeout: {hook_key}")
92
+
93
+ except Exception as e:
94
+ end_time = time.time()
95
+ result_metadata["duration_ms"] = max(1, int((end_time - start_time) * 1000))
96
+ result_metadata["error"] = str(e)
97
+
98
+ hook.status = HookStatus.FAILED
99
+ log_and_continue(logger, f"executing hook {hook_key}", e)
100
+
101
+ # Handle error based on error action
102
+ if hook.error_action == "stop":
103
+ event.cancelled = True
104
+ logger.info(f"Event cancelled due to hook error: {hook_key}")
105
+
106
+ return result_metadata
107
+
108
+ def _apply_data_transformation(self, event: Event, hook_data: Dict[str, Any]) -> None:
109
+ """Apply data transformation from hook result to event.
110
+
111
+ Args:
112
+ event: The event to modify.
113
+ hook_data: Data transformation from hook.
114
+ """
115
+ try:
116
+ if isinstance(hook_data, dict):
117
+ event.data.update(hook_data)
118
+ else:
119
+ logger.warning(f"Hook returned non-dict data transformation: {type(hook_data)}")
120
+ except Exception as e:
121
+ log_and_continue(logger, "applying hook data transformation", e)
122
+
123
+ def get_execution_stats(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
124
+ """Get execution statistics from a list of hook results.
125
+
126
+ Args:
127
+ results: List of hook execution results.
128
+
129
+ Returns:
130
+ Dictionary with execution statistics.
131
+ """
132
+ if not results:
133
+ return {
134
+ "total_hooks": 0,
135
+ "successful": 0,
136
+ "failed": 0,
137
+ "timed_out": 0,
138
+ "total_duration_ms": 0,
139
+ "avg_duration_ms": 0
140
+ }
141
+
142
+ successful = sum(1 for r in results if r.get("success", False))
143
+ failed = sum(1 for r in results if r.get("error") and r["error"] not in ["timeout", "hook_disabled", "event_cancelled"])
144
+ timed_out = sum(1 for r in results if r.get("error") == "timeout")
145
+ total_duration = sum(r.get("duration_ms", 0) for r in results)
146
+
147
+ return {
148
+ "total_hooks": len(results),
149
+ "successful": successful,
150
+ "failed": failed,
151
+ "timed_out": timed_out,
152
+ "total_duration_ms": total_duration,
153
+ "avg_duration_ms": int(total_duration / len(results)) if results else 0
154
+ }