empathy-framework 5.1.1__py3-none-any.whl → 5.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {empathy_framework-5.1.1.dist-info → empathy_framework-5.3.0.dist-info}/METADATA +79 -6
  2. {empathy_framework-5.1.1.dist-info → empathy_framework-5.3.0.dist-info}/RECORD +83 -64
  3. empathy_os/__init__.py +1 -1
  4. empathy_os/cache/hybrid.py +5 -1
  5. empathy_os/cli/commands/batch.py +8 -0
  6. empathy_os/cli/commands/profiling.py +4 -0
  7. empathy_os/cli/commands/workflow.py +8 -4
  8. empathy_os/cli_router.py +9 -0
  9. empathy_os/config.py +15 -2
  10. empathy_os/core_modules/__init__.py +15 -0
  11. empathy_os/dashboard/simple_server.py +62 -30
  12. empathy_os/mcp/__init__.py +10 -0
  13. empathy_os/mcp/server.py +506 -0
  14. empathy_os/memory/control_panel.py +1 -131
  15. empathy_os/memory/control_panel_support.py +145 -0
  16. empathy_os/memory/encryption.py +159 -0
  17. empathy_os/memory/long_term.py +46 -631
  18. empathy_os/memory/long_term_types.py +99 -0
  19. empathy_os/memory/mixins/__init__.py +25 -0
  20. empathy_os/memory/mixins/backend_init_mixin.py +249 -0
  21. empathy_os/memory/mixins/capabilities_mixin.py +208 -0
  22. empathy_os/memory/mixins/handoff_mixin.py +208 -0
  23. empathy_os/memory/mixins/lifecycle_mixin.py +49 -0
  24. empathy_os/memory/mixins/long_term_mixin.py +352 -0
  25. empathy_os/memory/mixins/promotion_mixin.py +109 -0
  26. empathy_os/memory/mixins/short_term_mixin.py +182 -0
  27. empathy_os/memory/short_term.py +61 -12
  28. empathy_os/memory/simple_storage.py +302 -0
  29. empathy_os/memory/storage_backend.py +167 -0
  30. empathy_os/memory/types.py +8 -3
  31. empathy_os/memory/unified.py +21 -1120
  32. empathy_os/meta_workflows/cli_commands/__init__.py +56 -0
  33. empathy_os/meta_workflows/cli_commands/agent_commands.py +321 -0
  34. empathy_os/meta_workflows/cli_commands/analytics_commands.py +442 -0
  35. empathy_os/meta_workflows/cli_commands/config_commands.py +232 -0
  36. empathy_os/meta_workflows/cli_commands/memory_commands.py +182 -0
  37. empathy_os/meta_workflows/cli_commands/template_commands.py +354 -0
  38. empathy_os/meta_workflows/cli_commands/workflow_commands.py +382 -0
  39. empathy_os/meta_workflows/cli_meta_workflows.py +52 -1802
  40. empathy_os/models/telemetry/__init__.py +71 -0
  41. empathy_os/models/telemetry/analytics.py +594 -0
  42. empathy_os/models/telemetry/backend.py +196 -0
  43. empathy_os/models/telemetry/data_models.py +431 -0
  44. empathy_os/models/telemetry/storage.py +489 -0
  45. empathy_os/orchestration/__init__.py +35 -0
  46. empathy_os/orchestration/execution_strategies.py +481 -0
  47. empathy_os/orchestration/meta_orchestrator.py +488 -1
  48. empathy_os/routing/workflow_registry.py +36 -0
  49. empathy_os/telemetry/agent_coordination.py +2 -3
  50. empathy_os/telemetry/agent_tracking.py +26 -7
  51. empathy_os/telemetry/approval_gates.py +18 -24
  52. empathy_os/telemetry/cli.py +19 -724
  53. empathy_os/telemetry/commands/__init__.py +14 -0
  54. empathy_os/telemetry/commands/dashboard_commands.py +696 -0
  55. empathy_os/telemetry/event_streaming.py +7 -3
  56. empathy_os/telemetry/feedback_loop.py +28 -15
  57. empathy_os/tools.py +183 -0
  58. empathy_os/workflows/__init__.py +5 -0
  59. empathy_os/workflows/autonomous_test_gen.py +860 -161
  60. empathy_os/workflows/base.py +6 -2
  61. empathy_os/workflows/code_review.py +4 -1
  62. empathy_os/workflows/document_gen/__init__.py +25 -0
  63. empathy_os/workflows/document_gen/config.py +30 -0
  64. empathy_os/workflows/document_gen/report_formatter.py +162 -0
  65. empathy_os/workflows/{document_gen.py → document_gen/workflow.py} +5 -184
  66. empathy_os/workflows/output.py +4 -1
  67. empathy_os/workflows/progress.py +8 -2
  68. empathy_os/workflows/security_audit.py +2 -2
  69. empathy_os/workflows/security_audit_phase3.py +7 -4
  70. empathy_os/workflows/seo_optimization.py +633 -0
  71. empathy_os/workflows/test_gen/__init__.py +52 -0
  72. empathy_os/workflows/test_gen/ast_analyzer.py +249 -0
  73. empathy_os/workflows/test_gen/config.py +88 -0
  74. empathy_os/workflows/test_gen/data_models.py +38 -0
  75. empathy_os/workflows/test_gen/report_formatter.py +289 -0
  76. empathy_os/workflows/test_gen/test_templates.py +381 -0
  77. empathy_os/workflows/test_gen/workflow.py +655 -0
  78. empathy_os/workflows/test_gen.py +42 -1905
  79. empathy_os/cli/parsers/cache 2.py +0 -65
  80. empathy_os/cli_router 2.py +0 -416
  81. empathy_os/dashboard/app 2.py +0 -512
  82. empathy_os/dashboard/simple_server 2.py +0 -403
  83. empathy_os/dashboard/standalone_server 2.py +0 -536
  84. empathy_os/memory/types 2.py +0 -441
  85. empathy_os/models/adaptive_routing 2.py +0 -437
  86. empathy_os/models/telemetry.py +0 -1660
  87. empathy_os/project_index/scanner_parallel 2.py +0 -291
  88. empathy_os/telemetry/agent_coordination 2.py +0 -478
  89. empathy_os/telemetry/agent_tracking 2.py +0 -350
  90. empathy_os/telemetry/approval_gates 2.py +0 -563
  91. empathy_os/telemetry/event_streaming 2.py +0 -405
  92. empathy_os/telemetry/feedback_loop 2.py +0 -557
  93. empathy_os/vscode_bridge 2.py +0 -173
  94. empathy_os/workflows/progressive/__init__ 2.py +0 -92
  95. empathy_os/workflows/progressive/cli 2.py +0 -242
  96. empathy_os/workflows/progressive/core 2.py +0 -488
  97. empathy_os/workflows/progressive/orchestrator 2.py +0 -701
  98. empathy_os/workflows/progressive/reports 2.py +0 -528
  99. empathy_os/workflows/progressive/telemetry 2.py +0 -280
  100. empathy_os/workflows/progressive/test_gen 2.py +0 -514
  101. empathy_os/workflows/progressive/workflow 2.py +0 -628
  102. {empathy_framework-5.1.1.dist-info → empathy_framework-5.3.0.dist-info}/WHEEL +0 -0
  103. {empathy_framework-5.1.1.dist-info → empathy_framework-5.3.0.dist-info}/entry_points.txt +0 -0
  104. {empathy_framework-5.1.1.dist-info → empathy_framework-5.3.0.dist-info}/licenses/LICENSE +0 -0
  105. {empathy_framework-5.1.1.dist-info → empathy_framework-5.3.0.dist-info}/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +0 -0
  106. {empathy_framework-5.1.1.dist-info → empathy_framework-5.3.0.dist-info}/top_level.txt +0 -0
@@ -26,30 +26,28 @@ Copyright 2025 Smart AI Memory, LLC
26
26
  Licensed under Fair Source 0.9
27
27
  """
28
28
 
29
- import heapq
30
- import json
31
29
  import os
32
- import uuid
33
- from collections.abc import Iterator
34
30
  from dataclasses import dataclass, field
35
- from datetime import datetime
36
31
  from enum import Enum
37
- from pathlib import Path
38
32
  from typing import Any
39
33
 
40
34
  import structlog
41
35
 
42
- from .claude_memory import ClaudeMemoryConfig
43
- from .config import get_redis_memory
44
- from .file_session import FileSessionConfig, FileSessionMemory
45
- from .long_term import Classification, LongTermMemory, SecureMemDocsIntegration
46
- from .redis_bootstrap import RedisStartMethod, RedisStatus, ensure_redis
36
+ from .file_session import FileSessionMemory
37
+ from .long_term import LongTermMemory, SecureMemDocsIntegration
38
+ from .mixins import (
39
+ BackendInitMixin,
40
+ CapabilitiesMixin,
41
+ HandoffAndExportMixin,
42
+ LifecycleMixin,
43
+ LongTermOperationsMixin,
44
+ PatternPromotionMixin,
45
+ ShortTermOperationsMixin,
46
+ )
47
+ from .redis_bootstrap import RedisStatus
47
48
  from .short_term import (
48
49
  AccessTier,
49
- AgentCredentials,
50
50
  RedisShortTermMemory,
51
- StagedPattern,
52
- TTLStrategy,
53
51
  )
54
52
 
55
53
  logger = structlog.get_logger(__name__)
@@ -146,7 +144,15 @@ class MemoryConfig:
146
144
 
147
145
 
148
146
  @dataclass
149
- class UnifiedMemory:
147
+ class UnifiedMemory(
148
+ BackendInitMixin,
149
+ ShortTermOperationsMixin,
150
+ LongTermOperationsMixin,
151
+ PatternPromotionMixin,
152
+ CapabilitiesMixin,
153
+ HandoffAndExportMixin,
154
+ LifecycleMixin,
155
+ ):
150
156
  """Unified interface for short-term and long-term memory.
151
157
 
152
158
  Provides:
@@ -174,1108 +180,3 @@ class UnifiedMemory:
174
180
  def __post_init__(self):
175
181
  """Initialize memory backends based on configuration."""
176
182
  self._initialize_backends()
177
-
178
- def _initialize_backends(self):
179
- """Initialize short-term and long-term memory backends.
180
-
181
- File-First Architecture:
182
- 1. FileSessionMemory is always initialized (primary storage)
183
- 2. Redis is optional (for real-time features like pub/sub)
184
- 3. Falls back gracefully when Redis is unavailable
185
- """
186
- if self._initialized:
187
- return
188
-
189
- # Initialize file-based session memory (PRIMARY - always available)
190
- if self.config.file_session_enabled:
191
- try:
192
- file_config = FileSessionConfig(base_dir=self.config.file_session_dir)
193
- self._file_session = FileSessionMemory(
194
- user_id=self.user_id,
195
- config=file_config,
196
- )
197
- logger.info(
198
- "file_session_memory_initialized",
199
- base_dir=self.config.file_session_dir,
200
- session_id=self._file_session._state.session_id,
201
- )
202
- except Exception as e:
203
- logger.error("file_session_memory_failed", error=str(e))
204
- self._file_session = None
205
-
206
- # Initialize Redis short-term memory (OPTIONAL - for real-time features)
207
- try:
208
- if self.config.redis_mock:
209
- self._short_term = RedisShortTermMemory(use_mock=True)
210
- self._redis_status = RedisStatus(
211
- available=False,
212
- method=RedisStartMethod.MOCK,
213
- message="Mock mode explicitly enabled",
214
- )
215
- elif self.config.redis_url:
216
- self._short_term = get_redis_memory(url=self.config.redis_url)
217
- self._redis_status = RedisStatus(
218
- available=True,
219
- method=RedisStartMethod.ALREADY_RUNNING,
220
- message="Connected via REDIS_URL",
221
- )
222
- # Use auto-start if enabled
223
- elif self.config.redis_auto_start:
224
- self._redis_status = ensure_redis(
225
- host=self.config.redis_host,
226
- port=self.config.redis_port,
227
- auto_start=True,
228
- verbose=True,
229
- )
230
- if self._redis_status.available:
231
- self._short_term = RedisShortTermMemory(
232
- host=self.config.redis_host,
233
- port=self.config.redis_port,
234
- use_mock=False,
235
- )
236
- else:
237
- # File session is primary, so Redis mock is not needed
238
- self._short_term = None
239
- self._redis_status = RedisStatus(
240
- available=False,
241
- method=RedisStartMethod.MOCK,
242
- message="Redis unavailable, using file-based storage",
243
- )
244
- else:
245
- # Try to connect to existing Redis
246
- try:
247
- self._short_term = get_redis_memory()
248
- if self._short_term.is_connected():
249
- self._redis_status = RedisStatus(
250
- available=True,
251
- method=RedisStartMethod.ALREADY_RUNNING,
252
- message="Connected to existing Redis",
253
- )
254
- else:
255
- self._short_term = None
256
- self._redis_status = RedisStatus(
257
- available=False,
258
- method=RedisStartMethod.MOCK,
259
- message="Redis not available, using file-based storage",
260
- )
261
- except Exception:
262
- self._short_term = None
263
- self._redis_status = RedisStatus(
264
- available=False,
265
- method=RedisStartMethod.MOCK,
266
- message="Redis not available, using file-based storage",
267
- )
268
-
269
- logger.info(
270
- "short_term_memory_initialized",
271
- redis_available=self._redis_status.available if self._redis_status else False,
272
- file_session_available=self._file_session is not None,
273
- redis_method=self._redis_status.method.value if self._redis_status else "none",
274
- environment=self.config.environment.value,
275
- )
276
-
277
- # Fail if Redis is required but not available
278
- if self.config.redis_required and not (
279
- self._redis_status and self._redis_status.available
280
- ):
281
- raise RuntimeError("Redis is required but not available")
282
-
283
- except RuntimeError:
284
- raise # Re-raise required Redis error
285
- except Exception as e:
286
- logger.warning("redis_initialization_failed", error=str(e))
287
- self._short_term = None
288
- self._redis_status = RedisStatus(
289
- available=False,
290
- method=RedisStartMethod.MOCK,
291
- message=f"Failed to initialize: {e}",
292
- )
293
-
294
- # Initialize long-term memory (SecureMemDocs)
295
- try:
296
- claude_config = ClaudeMemoryConfig(
297
- enabled=self.config.claude_memory_enabled,
298
- load_enterprise=self.config.load_enterprise_memory,
299
- load_project=self.config.load_project_memory,
300
- load_user=self.config.load_user_memory,
301
- )
302
- self._long_term = SecureMemDocsIntegration(
303
- claude_memory_config=claude_config,
304
- storage_dir=self.config.storage_dir,
305
- enable_encryption=self.config.encryption_enabled,
306
- )
307
-
308
- logger.info(
309
- "long_term_memory_initialized",
310
- storage_dir=self.config.storage_dir,
311
- encryption=self.config.encryption_enabled,
312
- )
313
- except Exception as e:
314
- logger.error("long_term_memory_failed", error=str(e))
315
- self._long_term = None
316
-
317
- # Initialize simple long-term memory (for testing and simple use cases)
318
- try:
319
- self._simple_long_term = LongTermMemory(storage_path=self.config.storage_dir)
320
- logger.debug("simple_long_term_memory_initialized")
321
- except Exception as e:
322
- logger.error("simple_long_term_memory_failed", error=str(e))
323
- self._simple_long_term = None
324
-
325
- self._initialized = True
326
-
327
- @property
328
- def credentials(self) -> AgentCredentials:
329
- """Get agent credentials for short-term memory operations."""
330
- return AgentCredentials(agent_id=self.user_id, tier=self.access_tier)
331
-
332
- def get_backend_status(self) -> dict[str, Any]:
333
- """Get the current status of all memory backends.
334
-
335
- Returns a structured dict suitable for health checks, debugging,
336
- and dashboard display. Can be serialized to JSON.
337
-
338
- Returns:
339
- dict with keys:
340
- - environment: Current environment (development/staging/production)
341
- - short_term: Status of Redis-based short-term memory
342
- - long_term: Status of persistent long-term memory
343
- - initialized: Whether backends have been initialized
344
-
345
- Example:
346
- >>> memory = UnifiedMemory(user_id="agent")
347
- >>> status = memory.get_backend_status()
348
- >>> print(status["short_term"]["available"])
349
- True
350
-
351
- """
352
- short_term_status: dict[str, Any] = {
353
- "available": False,
354
- "mock": True,
355
- "method": "unknown",
356
- "message": "Not initialized",
357
- }
358
-
359
- if self._redis_status:
360
- short_term_status = {
361
- "available": self._redis_status.available,
362
- "mock": not self._redis_status.available
363
- or self._redis_status.method == RedisStartMethod.MOCK,
364
- "method": self._redis_status.method.value,
365
- "message": self._redis_status.message,
366
- }
367
-
368
- long_term_status: dict[str, Any] = {
369
- "available": self._long_term is not None,
370
- "storage_dir": self.config.storage_dir,
371
- "encryption_enabled": self.config.encryption_enabled,
372
- }
373
-
374
- return {
375
- "environment": self.config.environment.value,
376
- "initialized": self._initialized,
377
- "short_term": short_term_status,
378
- "long_term": long_term_status,
379
- }
380
-
381
- # =========================================================================
382
- # SHORT-TERM MEMORY OPERATIONS
383
- # =========================================================================
384
-
385
- def stash(self, key: str, value: Any, ttl_seconds: int | None = None) -> bool:
386
- """Store data in working memory with TTL.
387
-
388
- Uses file-based session as primary storage, with optional Redis for
389
- real-time features. Data is persisted to disk automatically.
390
-
391
- Args:
392
- key: Storage key
393
- value: Data to store (must be JSON-serializable)
394
- ttl_seconds: Time-to-live in seconds (default from config)
395
-
396
- Returns:
397
- True if stored successfully
398
-
399
- """
400
- ttl = ttl_seconds or self.config.default_ttl_seconds
401
-
402
- # Primary: File session memory (always available)
403
- if self._file_session:
404
- self._file_session.stash(key, value, ttl=ttl)
405
-
406
- # Optional: Redis for real-time sync
407
- if self._short_term and self._redis_status and self._redis_status.available:
408
- # Map ttl_seconds to TTLStrategy
409
- ttl_strategy = TTLStrategy.WORKING_RESULTS
410
- if ttl_seconds is not None:
411
- if ttl_seconds <= TTLStrategy.COORDINATION.value:
412
- ttl_strategy = TTLStrategy.COORDINATION
413
- elif ttl_seconds <= TTLStrategy.SESSION.value:
414
- ttl_strategy = TTLStrategy.SESSION
415
- elif ttl_seconds <= TTLStrategy.WORKING_RESULTS.value:
416
- ttl_strategy = TTLStrategy.WORKING_RESULTS
417
- elif ttl_seconds <= TTLStrategy.STAGED_PATTERNS.value:
418
- ttl_strategy = TTLStrategy.STAGED_PATTERNS
419
- else:
420
- ttl_strategy = TTLStrategy.CONFLICT_CONTEXT
421
-
422
- try:
423
- self._short_term.stash(key, value, self.credentials, ttl_strategy)
424
- except Exception as e:
425
- logger.debug("redis_stash_failed", key=key, error=str(e))
426
-
427
- # Return True if at least one backend succeeded
428
- return self._file_session is not None
429
-
430
- def retrieve(self, key: str) -> Any | None:
431
- """Retrieve data from working memory.
432
-
433
- Checks Redis first (if available) for faster access, then falls back
434
- to file-based session storage.
435
-
436
- Args:
437
- key: Storage key
438
-
439
- Returns:
440
- Stored data or None if not found
441
-
442
- """
443
- # Try Redis first (faster, if available)
444
- if self._short_term and self._redis_status and self._redis_status.available:
445
- try:
446
- result = self._short_term.retrieve(key, self.credentials)
447
- if result is not None:
448
- return result
449
- except Exception as e:
450
- logger.debug("redis_retrieve_failed", key=key, error=str(e))
451
-
452
- # Fall back to file session (primary storage)
453
- if self._file_session:
454
- return self._file_session.retrieve(key)
455
-
456
- return None
457
-
458
- def stage_pattern(
459
- self,
460
- pattern_data: dict[str, Any],
461
- pattern_type: str = "general",
462
- ttl_hours: int = 24,
463
- ) -> str | None:
464
- """Stage a pattern for validation before long-term storage.
465
-
466
- Args:
467
- pattern_data: Pattern content and metadata
468
- pattern_type: Type of pattern (algorithm, protocol, etc.)
469
- ttl_hours: Hours before staged pattern expires (not used in current impl)
470
-
471
- Returns:
472
- Staged pattern ID or None if failed
473
-
474
- """
475
- if not self._short_term:
476
- logger.warning("short_term_memory_unavailable")
477
- return None
478
-
479
- # Create a StagedPattern object from the pattern_data dict
480
- pattern_id = f"staged_{uuid.uuid4().hex[:12]}"
481
- staged_pattern = StagedPattern(
482
- pattern_id=pattern_id,
483
- agent_id=self.user_id,
484
- pattern_type=pattern_type,
485
- name=pattern_data.get("name", f"Pattern {pattern_id[:8]}"),
486
- description=pattern_data.get("description", ""),
487
- code=pattern_data.get("code"),
488
- context=pattern_data.get("context", {}),
489
- confidence=pattern_data.get("confidence", 0.5),
490
- staged_at=datetime.now(),
491
- interests=pattern_data.get("interests", []),
492
- )
493
- # Store content in context if provided
494
- if "content" in pattern_data:
495
- staged_pattern.context["content"] = pattern_data["content"]
496
-
497
- success = self._short_term.stage_pattern(staged_pattern, self.credentials)
498
- return pattern_id if success else None
499
-
500
- def get_staged_patterns(self) -> list[dict]:
501
- """Get all staged patterns awaiting validation.
502
-
503
- Returns:
504
- List of staged patterns with metadata
505
-
506
- """
507
- if not self._short_term:
508
- return []
509
-
510
- staged_list = self._short_term.list_staged_patterns(self.credentials)
511
- return [p.to_dict() for p in staged_list]
512
-
513
- # =========================================================================
514
- # LONG-TERM MEMORY OPERATIONS
515
- # =========================================================================
516
-
517
- def persist_pattern(
518
- self,
519
- content: str,
520
- pattern_type: str,
521
- classification: Classification | str | None = None,
522
- auto_classify: bool = True,
523
- metadata: dict[str, Any] | None = None,
524
- ) -> dict[str, Any] | None:
525
- """Store a pattern in long-term memory with security controls.
526
-
527
- Args:
528
- content: Pattern content
529
- pattern_type: Type of pattern (algorithm, protocol, etc.)
530
- classification: Security classification (PUBLIC/INTERNAL/SENSITIVE)
531
- auto_classify: Auto-detect classification from content
532
- metadata: Additional metadata to store
533
-
534
- Returns:
535
- Storage result with pattern_id and classification, or None if failed
536
-
537
- """
538
- if not self._long_term:
539
- logger.error("long_term_memory_unavailable")
540
- return None
541
-
542
- try:
543
- # Convert string classification to enum if needed
544
- explicit_class = None
545
- if classification is not None:
546
- if isinstance(classification, str):
547
- explicit_class = Classification[classification.upper()]
548
- else:
549
- explicit_class = classification
550
-
551
- result = self._long_term.store_pattern(
552
- content=content,
553
- pattern_type=pattern_type,
554
- user_id=self.user_id,
555
- explicit_classification=explicit_class,
556
- auto_classify=auto_classify,
557
- custom_metadata=metadata,
558
- )
559
- logger.info(
560
- "pattern_persisted",
561
- pattern_id=result.get("pattern_id"),
562
- classification=result.get("classification"),
563
- )
564
- return result
565
- except Exception as e:
566
- logger.error("persist_pattern_failed", error=str(e))
567
- return None
568
-
569
- def _cache_pattern(self, pattern_id: str, pattern: dict[str, Any]) -> None:
570
- """Add pattern to LRU cache, evicting oldest if at capacity."""
571
- # Simple LRU: remove oldest entry if at max size
572
- if len(self._pattern_cache) >= self._pattern_cache_max_size:
573
- # Remove first (oldest) item
574
- oldest_key = next(iter(self._pattern_cache))
575
- del self._pattern_cache[oldest_key]
576
-
577
- self._pattern_cache[pattern_id] = pattern
578
-
579
- def recall_pattern(
580
- self,
581
- pattern_id: str,
582
- check_permissions: bool = True,
583
- use_cache: bool = True,
584
- ) -> dict[str, Any] | None:
585
- """Retrieve a pattern from long-term memory.
586
-
587
- Uses LRU cache for frequently accessed patterns to reduce I/O.
588
-
589
- Args:
590
- pattern_id: ID of pattern to retrieve
591
- check_permissions: Verify user has access to pattern
592
- use_cache: Whether to use/update the pattern cache (default: True)
593
-
594
- Returns:
595
- Pattern data with content and metadata, or None if not found
596
-
597
- """
598
- if not self._long_term:
599
- logger.error("long_term_memory_unavailable")
600
- return None
601
-
602
- # Check cache first (if enabled)
603
- if use_cache and pattern_id in self._pattern_cache:
604
- logger.debug("pattern_cache_hit", pattern_id=pattern_id)
605
- return self._pattern_cache[pattern_id]
606
-
607
- try:
608
- pattern = self._long_term.retrieve_pattern(
609
- pattern_id=pattern_id,
610
- user_id=self.user_id,
611
- check_permissions=check_permissions,
612
- )
613
-
614
- # Cache the result (if enabled and pattern found)
615
- if use_cache and pattern:
616
- self._cache_pattern(pattern_id, pattern)
617
-
618
- return pattern
619
- except Exception as e:
620
- logger.error("recall_pattern_failed", pattern_id=pattern_id, error=str(e))
621
- return None
622
-
623
- def clear_pattern_cache(self) -> int:
624
- """Clear the pattern lookup cache.
625
-
626
- Returns:
627
- Number of entries cleared
628
- """
629
- count = len(self._pattern_cache)
630
- self._pattern_cache.clear()
631
- logger.debug("pattern_cache_cleared", entries=count)
632
- return count
633
-
634
- def _score_pattern(
635
- self,
636
- pattern: dict[str, Any],
637
- query_lower: str,
638
- query_words: list[str],
639
- ) -> float:
640
- """Calculate relevance score for a pattern.
641
-
642
- Args:
643
- pattern: Pattern data dictionary
644
- query_lower: Lowercase query string
645
- query_words: Pre-split query words (length >= 3)
646
-
647
- Returns:
648
- Relevance score (0.0 if no match)
649
- """
650
- if not query_lower:
651
- return 1.0 # No query - all patterns have equal score
652
-
653
- content = str(pattern.get("content", "")).lower()
654
- metadata_str = str(pattern.get("metadata", {})).lower()
655
-
656
- score = 0.0
657
-
658
- # Exact phrase match in content (highest score)
659
- if query_lower in content:
660
- score += 10.0
661
-
662
- # Keyword matching (medium score)
663
- for word in query_words:
664
- if word in content:
665
- score += 2.0
666
- if word in metadata_str:
667
- score += 1.0
668
-
669
- return score
670
-
671
- def _filter_and_score_patterns(
672
- self,
673
- query: str | None,
674
- pattern_type: str | None,
675
- classification: Classification | None,
676
- ) -> Iterator[tuple[float, dict[str, Any]]]:
677
- """Generator that filters and scores patterns.
678
-
679
- Memory-efficient: yields (score, pattern) tuples one at a time.
680
- Use with heapq.nlargest() for efficient top-N selection.
681
-
682
- Args:
683
- query: Search query (case-insensitive)
684
- pattern_type: Filter by pattern type
685
- classification: Filter by classification level
686
-
687
- Yields:
688
- Tuples of (score, pattern) for matching patterns
689
- """
690
- query_lower = query.lower() if query else ""
691
- query_words = [w for w in query_lower.split() if len(w) >= 3] if query else []
692
-
693
- for pattern in self._iter_all_patterns():
694
- # Apply filters
695
- if pattern_type and pattern.get("pattern_type") != pattern_type:
696
- continue
697
-
698
- if classification:
699
- pattern_class = pattern.get("classification")
700
- if isinstance(classification, Classification):
701
- if pattern_class != classification.value:
702
- continue
703
- elif pattern_class != classification:
704
- continue
705
-
706
- # Calculate relevance score
707
- score = self._score_pattern(pattern, query_lower, query_words)
708
-
709
- # Skip if no matches found (when query is provided)
710
- if query and score == 0.0:
711
- continue
712
-
713
- yield (score, pattern)
714
-
715
- def search_patterns(
716
- self,
717
- query: str | None = None,
718
- pattern_type: str | None = None,
719
- classification: Classification | None = None,
720
- limit: int = 10,
721
- ) -> list[dict[str, Any]]:
722
- """Search patterns in long-term memory with keyword matching and relevance scoring.
723
-
724
- Implements keyword-based search with:
725
- 1. Full-text search in pattern content and metadata
726
- 2. Filter by pattern_type and classification
727
- 3. Relevance scoring (exact matches rank higher)
728
- 4. Results sorted by relevance
729
-
730
- Memory-efficient: Uses generators and heapq.nlargest() to avoid
731
- loading all patterns into memory. Only keeps top N results.
732
-
733
- Args:
734
- query: Text to search for in pattern content (case-insensitive)
735
- pattern_type: Filter by pattern type (e.g., "meta_workflow_execution")
736
- classification: Filter by classification level
737
- limit: Maximum results to return
738
-
739
- Returns:
740
- List of matching patterns with metadata, sorted by relevance
741
-
742
- Example:
743
- >>> patterns = memory.search_patterns(
744
- ... query="successful workflows",
745
- ... pattern_type="meta_workflow_execution",
746
- ... limit=5
747
- ... )
748
- """
749
- if not self._long_term:
750
- logger.debug("long_term_memory_unavailable")
751
- return []
752
-
753
- try:
754
- # Use heapq.nlargest for memory-efficient top-N selection
755
- # This avoids loading all patterns into memory at once
756
- scored_patterns = heapq.nlargest(
757
- limit,
758
- self._filter_and_score_patterns(query, pattern_type, classification),
759
- key=lambda x: x[0],
760
- )
761
-
762
- # Return patterns without scores
763
- return [pattern for _, pattern in scored_patterns]
764
-
765
- except Exception as e:
766
- logger.error("pattern_search_failed", error=str(e))
767
- return []
768
-
769
- def _get_storage_dir(self) -> Path | None:
770
- """Get the storage directory from long-term memory backend.
771
-
772
- Returns:
773
- Path to storage directory, or None if unavailable.
774
- """
775
- if not self._long_term:
776
- return None
777
-
778
- # Try different ways to access storage directory
779
- if hasattr(self._long_term, "storage_dir"):
780
- return Path(self._long_term.storage_dir)
781
- elif hasattr(self._long_term, "storage"):
782
- if hasattr(self._long_term.storage, "storage_dir"):
783
- return Path(self._long_term.storage.storage_dir)
784
- elif hasattr(self._long_term, "_storage"):
785
- if hasattr(self._long_term._storage, "storage_dir"):
786
- return Path(self._long_term._storage.storage_dir)
787
-
788
- return None
789
-
790
- def _iter_all_patterns(self) -> Iterator[dict[str, Any]]:
791
- """Iterate over all patterns from long-term memory storage.
792
-
793
- Memory-efficient generator that yields patterns one at a time,
794
- avoiding loading all patterns into memory simultaneously.
795
-
796
- Yields:
797
- Pattern data dictionaries
798
-
799
- Note:
800
- This is O(1) memory vs O(n) for _get_all_patterns().
801
- Use this for large datasets or when streaming is acceptable.
802
- """
803
- storage_dir = self._get_storage_dir()
804
- if not storage_dir:
805
- logger.warning("cannot_access_storage_directory")
806
- return
807
-
808
- if not storage_dir.exists():
809
- return
810
-
811
- # Yield patterns one at a time (memory-efficient)
812
- for pattern_file in storage_dir.rglob("*.json"):
813
- try:
814
- with pattern_file.open("r", encoding="utf-8") as f:
815
- yield json.load(f)
816
- except json.JSONDecodeError as e:
817
- logger.debug("pattern_json_decode_failed", file=str(pattern_file), error=str(e))
818
- continue
819
- except Exception as e:
820
- logger.debug("pattern_load_failed", file=str(pattern_file), error=str(e))
821
- continue
822
-
823
- def _get_all_patterns(self) -> list[dict[str, Any]]:
824
- """Get all patterns from long-term memory storage.
825
-
826
- Scans the storage directory for pattern files and loads them.
827
- This is a helper method for search_patterns().
828
-
829
- In production with large datasets, this should be replaced with:
830
- - Database queries with indexes
831
- - Full-text search engine (Elasticsearch, etc.)
832
- - Vector embeddings for semantic search
833
-
834
- Returns:
835
- List of all stored patterns
836
-
837
- Note:
838
- This performs a full scan and is O(n) memory. For large datasets,
839
- use _iter_all_patterns() generator instead.
840
- """
841
- try:
842
- patterns = list(self._iter_all_patterns())
843
- logger.debug("patterns_loaded", count=len(patterns))
844
- return patterns
845
- except Exception as e:
846
- logger.error("get_all_patterns_failed", error=str(e))
847
- return []
848
-
849
- # =========================================================================
850
- # PATTERN PROMOTION (SHORT-TERM → LONG-TERM)
851
- # =========================================================================
852
-
853
- def promote_pattern(
854
- self,
855
- staged_pattern_id: str,
856
- classification: Classification | str | None = None,
857
- auto_classify: bool = True,
858
- ) -> dict[str, Any] | None:
859
- """Promote a staged pattern from short-term to long-term memory.
860
-
861
- Args:
862
- staged_pattern_id: ID of staged pattern to promote
863
- classification: Override classification (or auto-detect)
864
- auto_classify: Auto-detect classification from content
865
-
866
- Returns:
867
- Long-term storage result, or None if failed
868
-
869
- """
870
- if not self._short_term or not self._long_term:
871
- logger.error("memory_backends_unavailable")
872
- return None
873
-
874
- # Retrieve staged pattern
875
- staged_patterns = self.get_staged_patterns()
876
- staged = next(
877
- (p for p in staged_patterns if p.get("pattern_id") == staged_pattern_id),
878
- None,
879
- )
880
-
881
- if not staged:
882
- logger.warning("staged_pattern_not_found", pattern_id=staged_pattern_id)
883
- return None
884
-
885
- # Persist to long-term storage
886
- # Content is stored in context dict by stage_pattern
887
- context = staged.get("context", {})
888
- content = context.get("content", "") or staged.get("description", "")
889
- result = self.persist_pattern(
890
- content=content,
891
- pattern_type=staged.get("pattern_type", "general"),
892
- classification=classification,
893
- auto_classify=auto_classify,
894
- metadata=context,
895
- )
896
-
897
- if result:
898
- # Remove from staging (use promote_pattern which handles deletion)
899
- try:
900
- self._short_term.promote_pattern(staged_pattern_id, self.credentials)
901
- except PermissionError:
902
- # If we can't promote (delete from staging), just log it
903
- logger.warning("could_not_remove_from_staging", pattern_id=staged_pattern_id)
904
- logger.info(
905
- "pattern_promoted",
906
- staged_id=staged_pattern_id,
907
- long_term_id=result.get("pattern_id"),
908
- )
909
-
910
- return result
911
-
912
- # =========================================================================
913
- # UTILITY METHODS
914
- # =========================================================================
915
-
916
- @property
917
- def has_short_term(self) -> bool:
918
- """Check if short-term memory is available."""
919
- return self._short_term is not None
920
-
921
- @property
922
- def has_long_term(self) -> bool:
923
- """Check if long-term memory is available."""
924
- return self._long_term is not None
925
-
926
- @property
927
- def redis_status(self) -> RedisStatus | None:
928
- """Get Redis connection status."""
929
- return self._redis_status
930
-
931
- @property
932
- def using_real_redis(self) -> bool:
933
- """Check if using real Redis (not mock)."""
934
- return (
935
- self._redis_status is not None
936
- and self._redis_status.available
937
- and self._redis_status.method != RedisStartMethod.MOCK
938
- )
939
-
940
- @property
941
- def short_term(self) -> RedisShortTermMemory:
942
- """Get short-term memory backend for direct access (testing).
943
-
944
- Returns:
945
- RedisShortTermMemory instance
946
-
947
- Raises:
948
- RuntimeError: If short-term memory is not initialized
949
-
950
- """
951
- if self._short_term is None:
952
- raise RuntimeError("Short-term memory not initialized")
953
- return self._short_term
954
-
955
- @property
956
- def long_term(self) -> LongTermMemory:
957
- """Get simple long-term memory backend for direct access (testing).
958
-
959
- Returns:
960
- LongTermMemory instance
961
-
962
- Raises:
963
- RuntimeError: If long-term memory is not initialized
964
-
965
- Note:
966
- For production use with security features (PII scrubbing, encryption),
967
- use persist_pattern() and recall_pattern() methods instead.
968
-
969
- """
970
- if self._simple_long_term is None:
971
- raise RuntimeError("Long-term memory not initialized")
972
- return self._simple_long_term
973
-
974
- def health_check(self) -> dict[str, Any]:
975
- """Check health of memory backends.
976
-
977
- Returns:
978
- Status of each memory backend
979
-
980
- """
981
- redis_info: dict[str, Any] = {
982
- "available": self.has_short_term,
983
- "mock_mode": not self.using_real_redis,
984
- }
985
- if self._redis_status:
986
- redis_info["method"] = self._redis_status.method.value
987
- redis_info["host"] = self._redis_status.host
988
- redis_info["port"] = self._redis_status.port
989
-
990
- return {
991
- "file_session": {
992
- "available": self._file_session is not None,
993
- "session_id": self._file_session._state.session_id if self._file_session else None,
994
- "base_dir": self.config.file_session_dir,
995
- },
996
- "short_term": redis_info,
997
- "long_term": {
998
- "available": self.has_long_term,
999
- "storage_dir": self.config.storage_dir,
1000
- "encryption": self.config.encryption_enabled,
1001
- },
1002
- "environment": self.config.environment.value,
1003
- }
1004
-
1005
- # =========================================================================
1006
- # CAPABILITY DETECTION (File-First Architecture)
1007
- # =========================================================================
1008
-
1009
- @property
1010
- def has_file_session(self) -> bool:
1011
- """Check if file-based session memory is available (always True if enabled)."""
1012
- return self._file_session is not None
1013
-
1014
- @property
1015
- def file_session(self) -> FileSessionMemory:
1016
- """Get file session memory backend for direct access.
1017
-
1018
- Returns:
1019
- FileSessionMemory instance
1020
-
1021
- Raises:
1022
- RuntimeError: If file session memory is not initialized
1023
- """
1024
- if self._file_session is None:
1025
- raise RuntimeError("File session memory not initialized")
1026
- return self._file_session
1027
-
1028
- def supports_realtime(self) -> bool:
1029
- """Check if real-time features are available (requires Redis).
1030
-
1031
- Real-time features include:
1032
- - Pub/Sub messaging between agents
1033
- - Cross-session coordination
1034
- - Distributed task queues
1035
-
1036
- Returns:
1037
- True if Redis is available and connected
1038
- """
1039
- return self.using_real_redis
1040
-
1041
- def supports_distributed(self) -> bool:
1042
- """Check if distributed features are available (requires Redis).
1043
-
1044
- Distributed features include:
1045
- - Multi-process coordination
1046
- - Cross-session state sharing
1047
- - Agent discovery
1048
-
1049
- Returns:
1050
- True if Redis is available and connected
1051
- """
1052
- return self.using_real_redis
1053
-
1054
- def supports_persistence(self) -> bool:
1055
- """Check if persistence is available (always True with file-first).
1056
-
1057
- Returns:
1058
- True if file session or long-term memory is available
1059
- """
1060
- return self._file_session is not None or self._long_term is not None
1061
-
1062
- def get_capabilities(self) -> dict[str, bool]:
1063
- """Get a summary of available memory capabilities.
1064
-
1065
- Returns:
1066
- Dictionary mapping capability names to availability
1067
- """
1068
- return {
1069
- "file_session": self.has_file_session,
1070
- "redis": self.using_real_redis,
1071
- "long_term": self.has_long_term,
1072
- "persistence": self.supports_persistence(),
1073
- "realtime": self.supports_realtime(),
1074
- "distributed": self.supports_distributed(),
1075
- "encryption": self.config.encryption_enabled and self.has_long_term,
1076
- }
1077
-
1078
- # =========================================================================
1079
- # COMPACT STATE GENERATION
1080
- # =========================================================================
1081
-
1082
- def generate_compact_state(self) -> str:
1083
- """Generate SBAR-format compact state from current session.
1084
-
1085
- Creates a human-readable summary of the current session state,
1086
- suitable for Claude Code's .claude/compact-state.md file.
1087
-
1088
- Returns:
1089
- Markdown-formatted compact state string
1090
- """
1091
- from datetime import datetime
1092
-
1093
- lines = [
1094
- "# Compact State - Session Handoff",
1095
- "",
1096
- f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M')}",
1097
- ]
1098
-
1099
- # Add session info
1100
- if self._file_session:
1101
- session = self._file_session._state
1102
- lines.extend(
1103
- [
1104
- f"**Session ID:** {session.session_id}",
1105
- f"**User ID:** {session.user_id}",
1106
- "",
1107
- ]
1108
- )
1109
-
1110
- lines.extend(
1111
- [
1112
- "## SBAR Handoff",
1113
- "",
1114
- "### Situation",
1115
- ]
1116
- )
1117
-
1118
- # Get context from file session
1119
- context = {}
1120
- if self._file_session:
1121
- context = self._file_session.get_all_context()
1122
-
1123
- situation = context.get("situation", "Session in progress.")
1124
- background = context.get("background", "No background information recorded.")
1125
- assessment = context.get("assessment", "No assessment recorded.")
1126
- recommendation = context.get("recommendation", "Continue with current task.")
1127
-
1128
- lines.extend(
1129
- [
1130
- situation,
1131
- "",
1132
- "### Background",
1133
- background,
1134
- "",
1135
- "### Assessment",
1136
- assessment,
1137
- "",
1138
- "### Recommendation",
1139
- recommendation,
1140
- "",
1141
- ]
1142
- )
1143
-
1144
- # Add working memory summary
1145
- if self._file_session:
1146
- working_keys = list(self._file_session._state.working_memory.keys())
1147
- if working_keys:
1148
- lines.extend(
1149
- [
1150
- "## Working Memory",
1151
- "",
1152
- f"**Active keys:** {len(working_keys)}",
1153
- "",
1154
- ]
1155
- )
1156
- for key in working_keys[:10]: # Show max 10
1157
- lines.append(f"- `{key}`")
1158
- if len(working_keys) > 10:
1159
- lines.append(f"- ... and {len(working_keys) - 10} more")
1160
- lines.append("")
1161
-
1162
- # Add staged patterns summary
1163
- if self._file_session:
1164
- staged = list(self._file_session._state.staged_patterns.values())
1165
- if staged:
1166
- lines.extend(
1167
- [
1168
- "## Staged Patterns",
1169
- "",
1170
- f"**Pending validation:** {len(staged)}",
1171
- "",
1172
- ]
1173
- )
1174
- for pattern in staged[:5]: # Show max 5
1175
- lines.append(
1176
- f"- {pattern.name} ({pattern.pattern_type}, conf: {pattern.confidence:.2f})"
1177
- )
1178
- if len(staged) > 5:
1179
- lines.append(f"- ... and {len(staged) - 5} more")
1180
- lines.append("")
1181
-
1182
- # Add capabilities
1183
- caps = self.get_capabilities()
1184
- lines.extend(
1185
- [
1186
- "## Capabilities",
1187
- "",
1188
- f"- File session: {'Yes' if caps['file_session'] else 'No'}",
1189
- f"- Redis: {'Yes' if caps['redis'] else 'No'}",
1190
- f"- Long-term memory: {'Yes' if caps['long_term'] else 'No'}",
1191
- f"- Real-time sync: {'Yes' if caps['realtime'] else 'No'}",
1192
- "",
1193
- ]
1194
- )
1195
-
1196
- return "\n".join(lines)
1197
-
1198
- def export_to_claude_md(self, path: str | None = None) -> Path:
1199
- """Export current session state to Claude Code's compact-state.md.
1200
-
1201
- Args:
1202
- path: Path to write to (defaults to config.compact_state_path)
1203
-
1204
- Returns:
1205
- Path where state was written
1206
- """
1207
- from empathy_os.config import _validate_file_path
1208
-
1209
- path = path or self.config.compact_state_path
1210
- validated_path = _validate_file_path(path)
1211
-
1212
- # Ensure parent directory exists
1213
- validated_path.parent.mkdir(parents=True, exist_ok=True)
1214
-
1215
- # Generate and write compact state
1216
- content = self.generate_compact_state()
1217
- validated_path.write_text(content, encoding="utf-8")
1218
-
1219
- logger.info("compact_state_exported", path=str(validated_path))
1220
- return validated_path
1221
-
1222
- def set_handoff(
1223
- self,
1224
- situation: str,
1225
- background: str,
1226
- assessment: str,
1227
- recommendation: str,
1228
- **extra_context,
1229
- ) -> None:
1230
- """Set SBAR handoff context for session continuity.
1231
-
1232
- This data is used by generate_compact_state() and export_to_claude_md().
1233
-
1234
- Args:
1235
- situation: Current situation summary
1236
- background: Relevant background information
1237
- assessment: Assessment of progress/state
1238
- recommendation: Recommended next steps
1239
- **extra_context: Additional context key-value pairs
1240
- """
1241
- if not self._file_session:
1242
- logger.warning("file_session_not_available")
1243
- return
1244
-
1245
- self._file_session.set_context("situation", situation)
1246
- self._file_session.set_context("background", background)
1247
- self._file_session.set_context("assessment", assessment)
1248
- self._file_session.set_context("recommendation", recommendation)
1249
-
1250
- for key, value in extra_context.items():
1251
- self._file_session.set_context(key, value)
1252
-
1253
- # Auto-export if configured
1254
- if self.config.auto_generate_compact_state:
1255
- self.export_to_claude_md()
1256
-
1257
- # =========================================================================
1258
- # LIFECYCLE
1259
- # =========================================================================
1260
-
1261
- def save(self) -> None:
1262
- """Explicitly save all memory state."""
1263
- if self._file_session:
1264
- self._file_session.save()
1265
- logger.debug("memory_saved")
1266
-
1267
- def close(self) -> None:
1268
- """Close all memory backends and save state."""
1269
- if self._file_session:
1270
- self._file_session.close()
1271
-
1272
- if self._short_term and hasattr(self._short_term, "close"):
1273
- self._short_term.close()
1274
-
1275
- logger.info("unified_memory_closed")
1276
-
1277
- def __enter__(self) -> "UnifiedMemory":
1278
- return self
1279
-
1280
- def __exit__(self, exc_type, exc_val, exc_tb) -> None:
1281
- self.close()