empathy-framework 5.1.1__py3-none-any.whl → 5.2.1__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.
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/METADATA +52 -3
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/RECORD +69 -28
- empathy_os/cli_router.py +9 -0
- empathy_os/core_modules/__init__.py +15 -0
- empathy_os/mcp/__init__.py +10 -0
- empathy_os/mcp/server.py +506 -0
- empathy_os/memory/control_panel.py +1 -131
- empathy_os/memory/control_panel_support.py +145 -0
- empathy_os/memory/encryption.py +159 -0
- empathy_os/memory/long_term.py +41 -626
- empathy_os/memory/long_term_types.py +99 -0
- empathy_os/memory/mixins/__init__.py +25 -0
- empathy_os/memory/mixins/backend_init_mixin.py +244 -0
- empathy_os/memory/mixins/capabilities_mixin.py +199 -0
- empathy_os/memory/mixins/handoff_mixin.py +208 -0
- empathy_os/memory/mixins/lifecycle_mixin.py +49 -0
- empathy_os/memory/mixins/long_term_mixin.py +352 -0
- empathy_os/memory/mixins/promotion_mixin.py +109 -0
- empathy_os/memory/mixins/short_term_mixin.py +182 -0
- empathy_os/memory/short_term.py +7 -0
- empathy_os/memory/simple_storage.py +302 -0
- empathy_os/memory/storage_backend.py +167 -0
- empathy_os/memory/unified.py +21 -1120
- empathy_os/meta_workflows/cli_commands/__init__.py +56 -0
- empathy_os/meta_workflows/cli_commands/agent_commands.py +321 -0
- empathy_os/meta_workflows/cli_commands/analytics_commands.py +442 -0
- empathy_os/meta_workflows/cli_commands/config_commands.py +232 -0
- empathy_os/meta_workflows/cli_commands/memory_commands.py +182 -0
- empathy_os/meta_workflows/cli_commands/template_commands.py +354 -0
- empathy_os/meta_workflows/cli_commands/workflow_commands.py +382 -0
- empathy_os/meta_workflows/cli_meta_workflows.py +52 -1802
- empathy_os/models/telemetry/__init__.py +71 -0
- empathy_os/models/telemetry/analytics.py +594 -0
- empathy_os/models/telemetry/backend.py +196 -0
- empathy_os/models/telemetry/data_models.py +431 -0
- empathy_os/models/telemetry/storage.py +489 -0
- empathy_os/orchestration/__init__.py +35 -0
- empathy_os/orchestration/execution_strategies.py +481 -0
- empathy_os/orchestration/meta_orchestrator.py +488 -1
- empathy_os/routing/workflow_registry.py +36 -0
- empathy_os/telemetry/cli.py +19 -724
- empathy_os/telemetry/commands/__init__.py +14 -0
- empathy_os/telemetry/commands/dashboard_commands.py +696 -0
- empathy_os/tools.py +183 -0
- empathy_os/workflows/__init__.py +5 -0
- empathy_os/workflows/autonomous_test_gen.py +860 -161
- empathy_os/workflows/base.py +6 -2
- empathy_os/workflows/code_review.py +4 -1
- empathy_os/workflows/document_gen/__init__.py +25 -0
- empathy_os/workflows/document_gen/config.py +30 -0
- empathy_os/workflows/document_gen/report_formatter.py +162 -0
- empathy_os/workflows/document_gen/workflow.py +1426 -0
- empathy_os/workflows/document_gen.py +22 -1598
- empathy_os/workflows/security_audit.py +2 -2
- empathy_os/workflows/security_audit_phase3.py +7 -4
- empathy_os/workflows/seo_optimization.py +633 -0
- empathy_os/workflows/test_gen/__init__.py +52 -0
- empathy_os/workflows/test_gen/ast_analyzer.py +249 -0
- empathy_os/workflows/test_gen/config.py +88 -0
- empathy_os/workflows/test_gen/data_models.py +38 -0
- empathy_os/workflows/test_gen/report_formatter.py +289 -0
- empathy_os/workflows/test_gen/test_templates.py +381 -0
- empathy_os/workflows/test_gen/workflow.py +655 -0
- empathy_os/workflows/test_gen.py +42 -1905
- empathy_os/memory/types 2.py +0 -441
- empathy_os/models/telemetry.py +0 -1660
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/WHEEL +0 -0
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/entry_points.txt +0 -0
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/licenses/LICENSE +0 -0
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +0 -0
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/top_level.txt +0 -0
empathy_os/memory/unified.py
CHANGED
|
@@ -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 .
|
|
43
|
-
from .
|
|
44
|
-
from .
|
|
45
|
-
|
|
46
|
-
|
|
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()
|