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.
- core/__init__.py +18 -0
- core/application.py +578 -0
- core/cli.py +193 -0
- core/commands/__init__.py +43 -0
- core/commands/executor.py +277 -0
- core/commands/menu_renderer.py +319 -0
- core/commands/parser.py +186 -0
- core/commands/registry.py +331 -0
- core/commands/system_commands.py +479 -0
- core/config/__init__.py +7 -0
- core/config/llm_task_config.py +110 -0
- core/config/loader.py +501 -0
- core/config/manager.py +112 -0
- core/config/plugin_config_manager.py +346 -0
- core/config/plugin_schema.py +424 -0
- core/config/service.py +399 -0
- core/effects/__init__.py +1 -0
- core/events/__init__.py +12 -0
- core/events/bus.py +129 -0
- core/events/executor.py +154 -0
- core/events/models.py +258 -0
- core/events/processor.py +176 -0
- core/events/registry.py +289 -0
- core/fullscreen/__init__.py +19 -0
- core/fullscreen/command_integration.py +290 -0
- core/fullscreen/components/__init__.py +12 -0
- core/fullscreen/components/animation.py +258 -0
- core/fullscreen/components/drawing.py +160 -0
- core/fullscreen/components/matrix_components.py +177 -0
- core/fullscreen/manager.py +302 -0
- core/fullscreen/plugin.py +204 -0
- core/fullscreen/renderer.py +282 -0
- core/fullscreen/session.py +324 -0
- core/io/__init__.py +52 -0
- core/io/buffer_manager.py +362 -0
- core/io/config_status_view.py +272 -0
- core/io/core_status_views.py +410 -0
- core/io/input_errors.py +313 -0
- core/io/input_handler.py +2655 -0
- core/io/input_mode_manager.py +402 -0
- core/io/key_parser.py +344 -0
- core/io/layout.py +587 -0
- core/io/message_coordinator.py +204 -0
- core/io/message_renderer.py +601 -0
- core/io/modal_interaction_handler.py +315 -0
- core/io/raw_input_processor.py +946 -0
- core/io/status_renderer.py +845 -0
- core/io/terminal_renderer.py +586 -0
- core/io/terminal_state.py +551 -0
- core/io/visual_effects.py +734 -0
- core/llm/__init__.py +26 -0
- core/llm/api_communication_service.py +863 -0
- core/llm/conversation_logger.py +473 -0
- core/llm/conversation_manager.py +414 -0
- core/llm/file_operations_executor.py +1401 -0
- core/llm/hook_system.py +402 -0
- core/llm/llm_service.py +1629 -0
- core/llm/mcp_integration.py +386 -0
- core/llm/message_display_service.py +450 -0
- core/llm/model_router.py +214 -0
- core/llm/plugin_sdk.py +396 -0
- core/llm/response_parser.py +848 -0
- core/llm/response_processor.py +364 -0
- core/llm/tool_executor.py +520 -0
- core/logging/__init__.py +19 -0
- core/logging/setup.py +208 -0
- core/models/__init__.py +5 -0
- core/models/base.py +23 -0
- core/plugins/__init__.py +13 -0
- core/plugins/collector.py +212 -0
- core/plugins/discovery.py +386 -0
- core/plugins/factory.py +263 -0
- core/plugins/registry.py +152 -0
- core/storage/__init__.py +5 -0
- core/storage/state_manager.py +84 -0
- core/ui/__init__.py +6 -0
- core/ui/config_merger.py +176 -0
- core/ui/config_widgets.py +369 -0
- core/ui/live_modal_renderer.py +276 -0
- core/ui/modal_actions.py +162 -0
- core/ui/modal_overlay_renderer.py +373 -0
- core/ui/modal_renderer.py +591 -0
- core/ui/modal_state_manager.py +443 -0
- core/ui/widget_integration.py +222 -0
- core/ui/widgets/__init__.py +27 -0
- core/ui/widgets/base_widget.py +136 -0
- core/ui/widgets/checkbox.py +85 -0
- core/ui/widgets/dropdown.py +140 -0
- core/ui/widgets/label.py +78 -0
- core/ui/widgets/slider.py +185 -0
- core/ui/widgets/text_input.py +224 -0
- core/utils/__init__.py +11 -0
- core/utils/config_utils.py +656 -0
- core/utils/dict_utils.py +212 -0
- core/utils/error_utils.py +275 -0
- core/utils/key_reader.py +171 -0
- core/utils/plugin_utils.py +267 -0
- core/utils/prompt_renderer.py +151 -0
- kollabor-0.4.9.dist-info/METADATA +298 -0
- kollabor-0.4.9.dist-info/RECORD +128 -0
- kollabor-0.4.9.dist-info/WHEEL +5 -0
- kollabor-0.4.9.dist-info/entry_points.txt +2 -0
- kollabor-0.4.9.dist-info/licenses/LICENSE +21 -0
- kollabor-0.4.9.dist-info/top_level.txt +4 -0
- kollabor_cli_main.py +20 -0
- plugins/__init__.py +1 -0
- plugins/enhanced_input/__init__.py +18 -0
- plugins/enhanced_input/box_renderer.py +103 -0
- plugins/enhanced_input/box_styles.py +142 -0
- plugins/enhanced_input/color_engine.py +165 -0
- plugins/enhanced_input/config.py +150 -0
- plugins/enhanced_input/cursor_manager.py +72 -0
- plugins/enhanced_input/geometry.py +81 -0
- plugins/enhanced_input/state.py +130 -0
- plugins/enhanced_input/text_processor.py +115 -0
- plugins/enhanced_input_plugin.py +385 -0
- plugins/fullscreen/__init__.py +9 -0
- plugins/fullscreen/example_plugin.py +327 -0
- plugins/fullscreen/matrix_plugin.py +132 -0
- plugins/hook_monitoring_plugin.py +1299 -0
- plugins/query_enhancer_plugin.py +350 -0
- plugins/save_conversation_plugin.py +502 -0
- plugins/system_commands_plugin.py +93 -0
- plugins/tmux_plugin.py +795 -0
- plugins/workflow_enforcement_plugin.py +629 -0
- system_prompt/default.md +1286 -0
- system_prompt/default_win.md +265 -0
- 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")
|
core/effects/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Visual effects module for special animations and displays."""
|
core/events/__init__.py
ADDED
|
@@ -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)
|
core/events/executor.py
ADDED
|
@@ -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
|
+
}
|