-
-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathconfig.py
More file actions
545 lines (423 loc) · 16.3 KB
/
config.py
File metadata and controls
545 lines (423 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
"""Configuration Management for Empathy Framework
Supports:
- YAML configuration files
- JSON configuration files
- Environment variables
- Default configuration
Copyright 2025 Smart AI Memory, LLC
Licensed under Fair Source 0.9
"""
import json
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from empathy_os.workflows.config import ModelConfig
try:
import yaml
YAML_AVAILABLE = True
except ImportError:
YAML_AVAILABLE = False
def _validate_file_path(path: str, allowed_dir: str | None = None) -> Path:
"""Validate file path to prevent path traversal and arbitrary writes.
Args:
path: File path to validate
allowed_dir: Optional directory to restrict writes to
Returns:
Validated Path object
Raises:
ValueError: If path is invalid or unsafe
"""
if not path or not isinstance(path, str):
raise ValueError("path must be a non-empty string")
# Check for null bytes
if "\x00" in path:
raise ValueError("path contains null bytes")
try:
resolved = Path(path).resolve()
except (OSError, RuntimeError) as e:
raise ValueError(f"Invalid path: {e}")
# Check if within allowed directory
if allowed_dir:
try:
allowed = Path(allowed_dir).resolve()
resolved.relative_to(allowed)
except ValueError:
raise ValueError(f"path must be within {allowed_dir}")
# Check for dangerous system paths
# Note: On macOS, /etc is a symlink to /private/etc, so we check both
dangerous_paths = [
"/etc",
"/sys",
"/proc",
"/dev",
"/private/etc", # macOS: /etc -> /private/etc
"/private/var/root", # macOS: root's home directory
"/usr/bin", # System binaries
"/usr/sbin", # System admin binaries
"/bin", # Essential binaries
"/sbin", # System binaries
]
resolved_str = str(resolved)
for dangerous in dangerous_paths:
if resolved_str.startswith(dangerous + "/") or resolved_str == dangerous:
raise ValueError(f"Cannot write to system directory: {dangerous}")
return resolved
@dataclass
class EmpathyConfig:
"""Configuration for EmpathyOS instance
Can be loaded from:
- YAML file (.empathy.yml, empathy.config.yml)
- JSON file (.empathy.json, empathy.config.json)
- Environment variables (EMPATHY_*)
- Direct instantiation
"""
# Core settings
user_id: str = "default_user"
target_level: int = 3
confidence_threshold: float = 0.75
# Trust settings
trust_building_rate: float = 0.05
trust_erosion_rate: float = 0.10
# Persistence settings
persistence_enabled: bool = True
persistence_backend: str = "sqlite" # "sqlite", "json", "none"
persistence_path: str = "./empathy_data"
# State management
state_persistence: bool = True
state_path: str = "./empathy_state"
# Metrics settings
metrics_enabled: bool = True
metrics_path: str = "./metrics.db"
# Logging settings
log_level: str = "INFO"
log_file: str | None = None
structured_logging: bool = True
# Pattern library settings
pattern_library_enabled: bool = True
pattern_sharing: bool = True
pattern_confidence_threshold: float = 0.3
# Advanced settings
async_enabled: bool = True
feedback_loop_monitoring: bool = True
leverage_point_analysis: bool = True
# Custom metadata
metadata: dict[str, Any] = field(default_factory=dict)
# Model settings
models: list["ModelConfig"] = field(default_factory=list)
default_model: str | None = None
log_path: str | None = None
max_threads: int = 4
model_router: dict[str, Any] | None = None
def __post_init__(self):
"""Post-initialization validation."""
if self.default_model and not any(m.name == self.default_model for m in self.models):
raise ValueError(f"Default model '{self.default_model}' not in models.")
@classmethod
def from_yaml(cls, filepath: str) -> "EmpathyConfig":
"""Load configuration from YAML file
Args:
filepath: Path to YAML configuration file
Returns:
EmpathyConfig instance
Raises:
ImportError: If PyYAML is not installed
FileNotFoundError: If file doesn't exist
Example:
>>> config = EmpathyConfig.from_yaml("empathy.config.yml")
>>> empathy = EmpathyOS(config=config)
Note:
Unknown fields in the YAML file are silently ignored.
This allows config files to contain settings for other
components (e.g., model_preferences, workflows) without
breaking EmpathyConfig loading.
"""
if not YAML_AVAILABLE:
raise ImportError(
"PyYAML is required for YAML configuration. Install with: pip install pyyaml",
)
with open(filepath) as f:
data = yaml.safe_load(f)
# Filter to only known fields (gracefully ignore unknown fields like
# 'provider', 'model_preferences', 'workflows', etc.)
from dataclasses import fields as dataclass_fields
valid_fields = {f.name for f in dataclass_fields(cls)}
filtered_data = {k: v for k, v in data.items() if k in valid_fields}
return cls.from_dict(filtered_data)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EmpathyConfig":
"""Create an EmpathyConfig from a dictionary, ignoring unknown fields."""
known_fields = {f.name for f in cls.__dataclass_fields__.values()}
filtered_data = {k: v for k, v in data.items() if k in known_fields}
# Handle nested ModelConfig objects
if filtered_data.get("models"):
from empathy_os.workflows.config import ModelConfig
filtered_data["models"] = [ModelConfig(**m) for m in filtered_data["models"]]
return cls(**filtered_data)
@classmethod
def from_json(cls, filepath: str) -> "EmpathyConfig":
"""Load configuration from JSON file
Args:
filepath: Path to JSON configuration file
Returns:
EmpathyConfig instance
Example:
>>> config = EmpathyConfig.from_json("empathy.config.json")
>>> empathy = EmpathyOS(config=config)
Note:
Unknown fields in the JSON file are silently ignored.
"""
with open(filepath) as f:
data = json.load(f)
# Filter to only known fields (gracefully ignore unknown fields)
from dataclasses import fields as dataclass_fields
valid_fields = {f.name for f in dataclass_fields(cls)}
filtered_data = {k: v for k, v in data.items() if k in valid_fields}
return cls(**filtered_data)
@classmethod
def from_env(cls, prefix: str = "EMPATHY_") -> "EmpathyConfig":
"""Load configuration from environment variables
Environment variables should be prefixed with EMPATHY_
and match config field names in uppercase.
Example:
EMPATHY_USER_ID=alice
EMPATHY_TARGET_LEVEL=4
EMPATHY_CONFIDENCE_THRESHOLD=0.8
Args:
prefix: Environment variable prefix (default: "EMPATHY_")
Returns:
EmpathyConfig instance
Example:
>>> os.environ["EMPATHY_USER_ID"] = "alice"
>>> config = EmpathyConfig.from_env()
>>> print(config.user_id) # "alice"
"""
from dataclasses import fields as dataclass_fields
# Get valid field names from the dataclass
valid_fields = {f.name for f in dataclass_fields(cls)}
data: dict[str, Any] = {}
# Get all environment variables with prefix
for key, value in os.environ.items():
if key.startswith(prefix):
# Convert EMPATHY_USER_ID -> user_id
field_name = key[len(prefix) :].lower()
# Skip unknown fields (e.g., EMPATHY_MASTER_KEY for encryption)
if field_name not in valid_fields:
continue
# Type conversion based on field name
if field_name in ("target_level",):
data[field_name] = int(value)
elif field_name in (
"confidence_threshold",
"trust_building_rate",
"trust_erosion_rate",
"pattern_confidence_threshold",
):
data[field_name] = float(value)
elif field_name in (
"persistence_enabled",
"state_persistence",
"metrics_enabled",
"structured_logging",
"pattern_library_enabled",
"pattern_sharing",
"async_enabled",
"feedback_loop_monitoring",
"leverage_point_analysis",
):
data[field_name] = value.lower() in ("true", "1", "yes")
else:
data[field_name] = value
return cls(**data)
@classmethod
def from_file(cls, filepath: str | None = None) -> "EmpathyConfig":
"""Automatically detect and load configuration from file
Looks for configuration files in this order:
1. Provided filepath
2. .empathy.yml
3. .empathy.yaml
4. empathy.config.yml
5. empathy.config.yaml
6. .empathy.json
7. empathy.config.json
Args:
filepath: Optional explicit path to config file
Returns:
EmpathyConfig instance, or default if no file found
Example:
>>> config = EmpathyConfig.from_file() # Auto-detect
>>> config = EmpathyConfig.from_file("my-config.yml")
"""
search_paths = [
filepath,
".empathy.yml",
".empathy.yaml",
"empathy.config.yml",
"empathy.config.yaml",
".empathy.json",
"empathy.config.json",
]
for path in search_paths:
if path and Path(path).exists():
if path.endswith((".yml", ".yaml")):
return cls.from_yaml(path)
if path.endswith(".json"):
return cls.from_json(path)
# No config file found - return default
return cls()
def to_yaml(self, filepath: str):
"""Save configuration to YAML file
Args:
filepath: Path to save YAML file
Example:
>>> config = EmpathyConfig(user_id="alice", target_level=4)
>>> config.to_yaml("my-config.yml")
"""
if not YAML_AVAILABLE:
raise ImportError(
"PyYAML is required for YAML export. Install with: pip install pyyaml",
)
validated_path = _validate_file_path(filepath)
data = asdict(self)
with open(validated_path, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
def to_json(self, filepath: str, indent: int = 2):
"""Save configuration to JSON file
Args:
filepath: Path to save JSON file
indent: JSON indentation (default: 2)
Example:
>>> config = EmpathyConfig(user_id="alice", target_level=4)
>>> config.to_json("my-config.json")
"""
validated_path = _validate_file_path(filepath)
data = asdict(self)
with open(validated_path, "w") as f:
json.dump(data, f, indent=indent)
def to_dict(self) -> dict[str, Any]:
"""Convert configuration to dictionary"""
return asdict(self)
def update(self, **kwargs):
"""Update configuration fields
Args:
**kwargs: Fields to update
Example:
>>> config = EmpathyConfig()
>>> config.update(user_id="bob", target_level=5)
"""
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
def merge(self, other: "EmpathyConfig") -> "EmpathyConfig":
"""Merge with another configuration (other takes precedence)
Args:
other: Configuration to merge
Returns:
New merged configuration
Example:
>>> base = EmpathyConfig(user_id="alice")
>>> override = EmpathyConfig(target_level=5)
>>> merged = base.merge(override)
"""
# Start with base values
base_dict = self.to_dict()
other_dict = other.to_dict()
# Get default values for comparison
defaults = EmpathyConfig().to_dict()
# Only update fields from 'other' that differ from defaults
for key, value in other_dict.items():
if value != defaults.get(key):
base_dict[key] = value
return EmpathyConfig(**base_dict)
def validate(self) -> bool:
"""Validate configuration values
Returns:
True if valid, raises ValueError if invalid
Raises:
ValueError: If configuration is invalid
"""
if self.target_level not in range(1, 6):
raise ValueError(f"target_level must be 1-5, got {self.target_level}")
if not 0.0 <= self.confidence_threshold <= 1.0:
raise ValueError(
f"confidence_threshold must be 0.0-1.0, got {self.confidence_threshold}",
)
if not 0.0 <= self.pattern_confidence_threshold <= 1.0:
threshold_val = self.pattern_confidence_threshold
raise ValueError(f"pattern_confidence_threshold must be 0.0-1.0, got {threshold_val}")
if self.persistence_backend not in ("sqlite", "json", "none"):
backend_val = self.persistence_backend
raise ValueError(
f"persistence_backend must be 'sqlite', 'json', or 'none', got {backend_val}",
)
return True
def __repr__(self) -> str:
"""String representation"""
return (
f"EmpathyConfig(user_id={self.user_id!r}, target_level={self.target_level}, "
f"confidence_threshold={self.confidence_threshold})"
)
def load_config(
filepath: str | None = None,
use_env: bool = True,
defaults: dict[str, Any] | None = None,
) -> EmpathyConfig:
"""Load configuration with flexible precedence
Precedence (highest to lowest):
1. Environment variables (if use_env=True)
2. Configuration file (if provided/found)
3. Defaults (if provided)
4. Built-in defaults
Args:
filepath: Optional path to config file
use_env: Whether to check environment variables (default: True)
defaults: Optional default values
Returns:
EmpathyConfig instance
Example:
>>> # Load from file, override with env vars
>>> config = load_config("empathy.yml", use_env=True)
>>> # Load with custom defaults
>>> config = load_config(defaults={"target_level": 4})
"""
# Start with built-in defaults
config = EmpathyConfig()
# Apply custom defaults
if defaults:
config.update(**defaults)
# Load from file if provided/found
# First check if a file actually exists
file_found = False
if filepath and Path(filepath).exists():
file_found = True
else:
# Check default config file locations
for default_path in [
".empathy.yml",
".empathy.yaml",
"empathy.config.yml",
"empathy.config.yaml",
".empathy.json",
"empathy.config.json",
]:
if Path(default_path).exists():
file_found = True
break
if file_found:
try:
file_config = EmpathyConfig.from_file(filepath)
config = config.merge(file_config)
except (FileNotFoundError, json.JSONDecodeError):
pass # Use defaults
# Override with environment variables
if use_env:
try:
env_config = EmpathyConfig.from_env()
config = config.merge(env_config)
except (ValueError, TypeError):
# Graceful fallback: invalid env var type conversion
pass # Use current config if environment parsing fails
# Validate final configuration
config.validate()
return config