superlocalmemory 2.7.6 → 2.8.0

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 (170) hide show
  1. package/CHANGELOG.md +120 -155
  2. package/README.md +115 -89
  3. package/api_server.py +2 -12
  4. package/docs/PATTERN-LEARNING.md +64 -199
  5. package/docs/example_graph_usage.py +4 -6
  6. package/install.sh +59 -0
  7. package/mcp_server.py +83 -7
  8. package/package.json +1 -8
  9. package/scripts/generate-thumbnails.py +3 -5
  10. package/skills/slm-build-graph/SKILL.md +1 -1
  11. package/skills/slm-list-recent/SKILL.md +1 -1
  12. package/skills/slm-recall/SKILL.md +1 -1
  13. package/skills/slm-remember/SKILL.md +1 -1
  14. package/skills/slm-show-patterns/SKILL.md +1 -1
  15. package/skills/slm-status/SKILL.md +1 -1
  16. package/skills/slm-switch-profile/SKILL.md +1 -1
  17. package/src/agent_registry.py +7 -18
  18. package/src/auth_middleware.py +3 -5
  19. package/src/auto_backup.py +3 -7
  20. package/src/behavioral/__init__.py +49 -0
  21. package/src/behavioral/behavioral_listener.py +203 -0
  22. package/src/behavioral/behavioral_patterns.py +275 -0
  23. package/src/behavioral/cross_project_transfer.py +206 -0
  24. package/src/behavioral/outcome_inference.py +194 -0
  25. package/src/behavioral/outcome_tracker.py +193 -0
  26. package/src/behavioral/tests/__init__.py +4 -0
  27. package/src/behavioral/tests/test_behavioral_integration.py +108 -0
  28. package/src/behavioral/tests/test_behavioral_patterns.py +150 -0
  29. package/src/behavioral/tests/test_cross_project_transfer.py +142 -0
  30. package/src/behavioral/tests/test_mcp_behavioral.py +139 -0
  31. package/src/behavioral/tests/test_mcp_report_outcome.py +117 -0
  32. package/src/behavioral/tests/test_outcome_inference.py +107 -0
  33. package/src/behavioral/tests/test_outcome_tracker.py +96 -0
  34. package/src/cache_manager.py +4 -6
  35. package/src/compliance/__init__.py +48 -0
  36. package/src/compliance/abac_engine.py +149 -0
  37. package/src/compliance/abac_middleware.py +116 -0
  38. package/src/compliance/audit_db.py +215 -0
  39. package/src/compliance/audit_logger.py +148 -0
  40. package/src/compliance/retention_manager.py +289 -0
  41. package/src/compliance/retention_scheduler.py +186 -0
  42. package/src/compliance/tests/__init__.py +4 -0
  43. package/src/compliance/tests/test_abac_enforcement.py +95 -0
  44. package/src/compliance/tests/test_abac_engine.py +124 -0
  45. package/src/compliance/tests/test_abac_mcp_integration.py +118 -0
  46. package/src/compliance/tests/test_audit_db.py +123 -0
  47. package/src/compliance/tests/test_audit_logger.py +98 -0
  48. package/src/compliance/tests/test_mcp_audit.py +128 -0
  49. package/src/compliance/tests/test_mcp_retention_policy.py +125 -0
  50. package/src/compliance/tests/test_retention_manager.py +131 -0
  51. package/src/compliance/tests/test_retention_scheduler.py +99 -0
  52. package/src/db_connection_manager.py +2 -12
  53. package/src/embedding_engine.py +61 -669
  54. package/src/embeddings/__init__.py +47 -0
  55. package/src/embeddings/cache.py +70 -0
  56. package/src/embeddings/cli.py +113 -0
  57. package/src/embeddings/constants.py +47 -0
  58. package/src/embeddings/database.py +91 -0
  59. package/src/embeddings/engine.py +247 -0
  60. package/src/embeddings/model_loader.py +145 -0
  61. package/src/event_bus.py +3 -13
  62. package/src/graph/__init__.py +36 -0
  63. package/src/graph/build_helpers.py +74 -0
  64. package/src/graph/cli.py +87 -0
  65. package/src/graph/cluster_builder.py +188 -0
  66. package/src/graph/cluster_summary.py +148 -0
  67. package/src/graph/constants.py +47 -0
  68. package/src/graph/edge_builder.py +162 -0
  69. package/src/graph/entity_extractor.py +95 -0
  70. package/src/graph/graph_core.py +226 -0
  71. package/src/graph/graph_search.py +231 -0
  72. package/src/graph/hierarchical.py +207 -0
  73. package/src/graph/schema.py +99 -0
  74. package/src/graph_engine.py +45 -1451
  75. package/src/hnsw_index.py +3 -7
  76. package/src/hybrid_search.py +36 -683
  77. package/src/learning/__init__.py +27 -12
  78. package/src/learning/adaptive_ranker.py +50 -12
  79. package/src/learning/cross_project_aggregator.py +2 -12
  80. package/src/learning/engagement_tracker.py +2 -12
  81. package/src/learning/feature_extractor.py +175 -43
  82. package/src/learning/feedback_collector.py +7 -12
  83. package/src/learning/learning_db.py +180 -12
  84. package/src/learning/project_context_manager.py +2 -12
  85. package/src/learning/source_quality_scorer.py +2 -12
  86. package/src/learning/synthetic_bootstrap.py +2 -12
  87. package/src/learning/tests/__init__.py +2 -0
  88. package/src/learning/tests/test_adaptive_ranker.py +2 -6
  89. package/src/learning/tests/test_adaptive_ranker_v28.py +60 -0
  90. package/src/learning/tests/test_aggregator.py +2 -6
  91. package/src/learning/tests/test_auto_retrain_v28.py +35 -0
  92. package/src/learning/tests/test_e2e_ranking_v28.py +82 -0
  93. package/src/learning/tests/test_feature_extractor_v28.py +93 -0
  94. package/src/learning/tests/test_feedback_collector.py +2 -6
  95. package/src/learning/tests/test_learning_db.py +2 -6
  96. package/src/learning/tests/test_learning_db_v28.py +110 -0
  97. package/src/learning/tests/test_learning_init_v28.py +48 -0
  98. package/src/learning/tests/test_outcome_signals.py +48 -0
  99. package/src/learning/tests/test_project_context.py +2 -6
  100. package/src/learning/tests/test_schema_migration.py +319 -0
  101. package/src/learning/tests/test_signal_inference.py +11 -13
  102. package/src/learning/tests/test_source_quality.py +2 -6
  103. package/src/learning/tests/test_synthetic_bootstrap.py +3 -7
  104. package/src/learning/tests/test_workflow_miner.py +2 -6
  105. package/src/learning/workflow_pattern_miner.py +2 -12
  106. package/src/lifecycle/__init__.py +54 -0
  107. package/src/lifecycle/bounded_growth.py +239 -0
  108. package/src/lifecycle/compaction_engine.py +226 -0
  109. package/src/lifecycle/lifecycle_engine.py +302 -0
  110. package/src/lifecycle/lifecycle_evaluator.py +225 -0
  111. package/src/lifecycle/lifecycle_scheduler.py +130 -0
  112. package/src/lifecycle/retention_policy.py +285 -0
  113. package/src/lifecycle/tests/__init__.py +4 -0
  114. package/src/lifecycle/tests/test_bounded_growth.py +193 -0
  115. package/src/lifecycle/tests/test_compaction.py +179 -0
  116. package/src/lifecycle/tests/test_lifecycle_engine.py +137 -0
  117. package/src/lifecycle/tests/test_lifecycle_evaluation.py +177 -0
  118. package/src/lifecycle/tests/test_lifecycle_scheduler.py +127 -0
  119. package/src/lifecycle/tests/test_lifecycle_search.py +109 -0
  120. package/src/lifecycle/tests/test_mcp_compact.py +149 -0
  121. package/src/lifecycle/tests/test_mcp_lifecycle_status.py +114 -0
  122. package/src/lifecycle/tests/test_retention_policy.py +162 -0
  123. package/src/mcp_tools_v28.py +280 -0
  124. package/src/memory-profiles.py +2 -12
  125. package/src/memory-reset.py +2 -12
  126. package/src/memory_compression.py +2 -12
  127. package/src/memory_store_v2.py +76 -20
  128. package/src/migrate_v1_to_v2.py +2 -12
  129. package/src/pattern_learner.py +29 -975
  130. package/src/patterns/__init__.py +24 -0
  131. package/src/patterns/analyzers.py +247 -0
  132. package/src/patterns/learner.py +267 -0
  133. package/src/patterns/scoring.py +167 -0
  134. package/src/patterns/store.py +223 -0
  135. package/src/patterns/terminology.py +138 -0
  136. package/src/provenance_tracker.py +4 -14
  137. package/src/query_optimizer.py +4 -6
  138. package/src/rate_limiter.py +2 -6
  139. package/src/search/__init__.py +20 -0
  140. package/src/search/cli.py +77 -0
  141. package/src/search/constants.py +26 -0
  142. package/src/search/engine.py +239 -0
  143. package/src/search/fusion.py +122 -0
  144. package/src/search/index_loader.py +112 -0
  145. package/src/search/methods.py +162 -0
  146. package/src/search_engine_v2.py +4 -6
  147. package/src/setup_validator.py +7 -13
  148. package/src/subscription_manager.py +2 -12
  149. package/src/tree/__init__.py +59 -0
  150. package/src/tree/builder.py +183 -0
  151. package/src/tree/nodes.py +196 -0
  152. package/src/tree/queries.py +252 -0
  153. package/src/tree/schema.py +76 -0
  154. package/src/tree_manager.py +10 -711
  155. package/src/trust/__init__.py +45 -0
  156. package/src/trust/constants.py +66 -0
  157. package/src/trust/queries.py +157 -0
  158. package/src/trust/schema.py +95 -0
  159. package/src/trust/scorer.py +299 -0
  160. package/src/trust/signals.py +95 -0
  161. package/src/trust_scorer.py +39 -697
  162. package/src/webhook_dispatcher.py +2 -12
  163. package/ui/app.js +1 -1
  164. package/ui/js/agents.js +1 -1
  165. package/ui_server.py +2 -14
  166. package/ATTRIBUTION.md +0 -140
  167. package/docs/ARCHITECTURE-V2.5.md +0 -190
  168. package/docs/GRAPH-ENGINE.md +0 -503
  169. package/docs/architecture-diagram.drawio +0 -405
  170. package/docs/plans/2026-02-13-benchmark-suite.md +0 -1349
@@ -0,0 +1,149 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
3
+ """Tests for compact_memories MCP tool handler.
4
+
5
+ Validates the MCP wrapper around LifecycleEvaluator + LifecycleEngine —
6
+ tests dry_run mode, recommendations output, and live transition execution.
7
+ """
8
+ import asyncio
9
+ import os
10
+ import shutil
11
+ import sqlite3
12
+ import sys
13
+ import tempfile
14
+ from datetime import datetime, timedelta
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
20
+
21
+
22
+ def _create_memory_db(db_path: str) -> None:
23
+ """Create memory.db with a mix of stale and fresh memories."""
24
+ conn = sqlite3.connect(db_path)
25
+ conn.execute(
26
+ """CREATE TABLE memories (
27
+ id INTEGER PRIMARY KEY,
28
+ content TEXT,
29
+ importance INTEGER DEFAULT 5,
30
+ lifecycle_state TEXT DEFAULT 'active',
31
+ lifecycle_history TEXT DEFAULT '[]',
32
+ lifecycle_updated_at TIMESTAMP,
33
+ last_accessed TIMESTAMP,
34
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
35
+ access_count INTEGER DEFAULT 0,
36
+ profile TEXT DEFAULT 'default',
37
+ access_level TEXT DEFAULT 'public'
38
+ )"""
39
+ )
40
+ now = datetime.now()
41
+ # Stale: 45 days old, low importance -> should be recommended for active->warm
42
+ conn.execute(
43
+ "INSERT INTO memories (content, importance, lifecycle_state, last_accessed, created_at) "
44
+ "VALUES (?, ?, ?, ?, ?)",
45
+ (
46
+ "stale memory",
47
+ 3,
48
+ "active",
49
+ (now - timedelta(days=45)).isoformat(),
50
+ (now - timedelta(days=100)).isoformat(),
51
+ ),
52
+ )
53
+ # Fresh: just accessed, high importance -> should NOT be recommended
54
+ conn.execute(
55
+ "INSERT INTO memories (content, importance, lifecycle_state, last_accessed, created_at) "
56
+ "VALUES (?, ?, ?, ?, ?)",
57
+ ("fresh memory", 8, "active", now.isoformat(), now.isoformat()),
58
+ )
59
+ conn.commit()
60
+ conn.close()
61
+
62
+
63
+ class TestMCPCompact:
64
+ """Tests for the compact_memories tool handler."""
65
+
66
+ def setup_method(self):
67
+ self.tmp_dir = tempfile.mkdtemp()
68
+ self.db_path = os.path.join(self.tmp_dir, "memory.db")
69
+ _create_memory_db(self.db_path)
70
+
71
+ def teardown_method(self):
72
+ shutil.rmtree(self.tmp_dir, ignore_errors=True)
73
+
74
+ def _run(self, coro):
75
+ return asyncio.get_event_loop().run_until_complete(coro)
76
+
77
+ def test_dry_run_default(self):
78
+ """Default call should be dry_run=True."""
79
+ import mcp_tools_v28 as tools
80
+ tools.DEFAULT_MEMORY_DB = self.db_path
81
+
82
+ result = self._run(tools.compact_memories())
83
+ assert result["success"] is True
84
+ assert result["dry_run"] is True
85
+
86
+ def test_dry_run_shows_recommendations(self):
87
+ """Dry run should report recommendation count."""
88
+ import mcp_tools_v28 as tools
89
+ tools.DEFAULT_MEMORY_DB = self.db_path
90
+
91
+ result = self._run(tools.compact_memories(dry_run=True))
92
+ assert "recommendations" in result
93
+ assert isinstance(result["recommendations"], int)
94
+
95
+ def test_dry_run_has_stale_recommendation(self):
96
+ """The stale memory should appear in recommendations."""
97
+ import mcp_tools_v28 as tools
98
+ tools.DEFAULT_MEMORY_DB = self.db_path
99
+
100
+ result = self._run(tools.compact_memories(dry_run=True))
101
+ assert result["recommendations"] >= 1
102
+ # The stale memory (id=1) should be recommended active -> warm
103
+ detail_ids = [d["memory_id"] for d in result.get("details", [])]
104
+ assert 1 in detail_ids
105
+
106
+ def test_dry_run_details_structure(self):
107
+ """Each detail entry should have the right keys."""
108
+ import mcp_tools_v28 as tools
109
+ tools.DEFAULT_MEMORY_DB = self.db_path
110
+
111
+ result = self._run(tools.compact_memories(dry_run=True))
112
+ if result["recommendations"] > 0:
113
+ detail = result["details"][0]
114
+ assert "memory_id" in detail
115
+ assert "from" in detail
116
+ assert "to" in detail
117
+ assert "reason" in detail
118
+
119
+ def test_execute_transitions(self):
120
+ """Non-dry-run should actually transition memories."""
121
+ import mcp_tools_v28 as tools
122
+ tools.DEFAULT_MEMORY_DB = self.db_path
123
+
124
+ result = self._run(tools.compact_memories(dry_run=False))
125
+ assert result["success"] is True
126
+ assert result["dry_run"] is False
127
+ assert "transitioned" in result
128
+ assert "evaluated" in result
129
+
130
+ def test_execute_changes_state(self):
131
+ """After execution, the stale memory should be in 'warm' state."""
132
+ import mcp_tools_v28 as tools
133
+ tools.DEFAULT_MEMORY_DB = self.db_path
134
+
135
+ self._run(tools.compact_memories(dry_run=False))
136
+ # Verify the stale memory transitioned
137
+ result = self._run(tools.get_lifecycle_status(memory_id=1))
138
+ assert result["success"] is True
139
+ assert result["lifecycle_state"] == "warm"
140
+
141
+ def test_fresh_memory_untouched(self):
142
+ """After execution, the fresh memory should remain 'active'."""
143
+ import mcp_tools_v28 as tools
144
+ tools.DEFAULT_MEMORY_DB = self.db_path
145
+
146
+ self._run(tools.compact_memories(dry_run=False))
147
+ result = self._run(tools.get_lifecycle_status(memory_id=2))
148
+ assert result["success"] is True
149
+ assert result["lifecycle_state"] == "active"
@@ -0,0 +1,114 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
3
+ """Tests for get_lifecycle_status MCP tool handler.
4
+
5
+ Validates the MCP wrapper around LifecycleEngine — tests state distribution
6
+ retrieval, single memory state lookup, and nonexistent memory handling.
7
+ """
8
+ import asyncio
9
+ import os
10
+ import shutil
11
+ import sqlite3
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
19
+
20
+
21
+ def _create_memory_db(db_path: str) -> None:
22
+ """Create a minimal memory.db with lifecycle columns for testing."""
23
+ conn = sqlite3.connect(db_path)
24
+ conn.execute(
25
+ """CREATE TABLE memories (
26
+ id INTEGER PRIMARY KEY,
27
+ content TEXT,
28
+ lifecycle_state TEXT DEFAULT 'active',
29
+ lifecycle_history TEXT DEFAULT '[]',
30
+ lifecycle_updated_at TIMESTAMP,
31
+ importance INTEGER DEFAULT 5,
32
+ last_accessed TIMESTAMP,
33
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
34
+ access_count INTEGER DEFAULT 0,
35
+ profile TEXT DEFAULT 'default',
36
+ access_level TEXT DEFAULT 'public'
37
+ )"""
38
+ )
39
+ conn.execute(
40
+ "INSERT INTO memories (content, lifecycle_state) VALUES ('test mem 1', 'active')"
41
+ )
42
+ conn.execute(
43
+ "INSERT INTO memories (content, lifecycle_state) VALUES ('test mem 2', 'warm')"
44
+ )
45
+ conn.execute(
46
+ "INSERT INTO memories (content, lifecycle_state) VALUES ('test mem 3', 'active')"
47
+ )
48
+ conn.commit()
49
+ conn.close()
50
+
51
+
52
+ class TestMCPLifecycleStatus:
53
+ """Tests for the get_lifecycle_status tool handler."""
54
+
55
+ def setup_method(self):
56
+ self.tmp_dir = tempfile.mkdtemp()
57
+ self.db_path = os.path.join(self.tmp_dir, "memory.db")
58
+ _create_memory_db(self.db_path)
59
+
60
+ def teardown_method(self):
61
+ shutil.rmtree(self.tmp_dir, ignore_errors=True)
62
+
63
+ def _run(self, coro):
64
+ return asyncio.get_event_loop().run_until_complete(coro)
65
+
66
+ def test_get_distribution(self):
67
+ """Without memory_id, should return state distribution."""
68
+ import mcp_tools_v28 as tools
69
+ tools.DEFAULT_MEMORY_DB = self.db_path
70
+
71
+ result = self._run(tools.get_lifecycle_status())
72
+ assert result["success"] is True
73
+ assert "distribution" in result
74
+ assert result["distribution"]["active"] == 2
75
+ assert result["distribution"]["warm"] == 1
76
+ assert result["total_memories"] == 3
77
+
78
+ def test_get_single_active_memory(self):
79
+ """With memory_id=1, should return lifecycle_state='active'."""
80
+ import mcp_tools_v28 as tools
81
+ tools.DEFAULT_MEMORY_DB = self.db_path
82
+
83
+ result = self._run(tools.get_lifecycle_status(memory_id=1))
84
+ assert result["success"] is True
85
+ assert result["memory_id"] == 1
86
+ assert result["lifecycle_state"] == "active"
87
+
88
+ def test_get_single_warm_memory(self):
89
+ """With memory_id=2, should return lifecycle_state='warm'."""
90
+ import mcp_tools_v28 as tools
91
+ tools.DEFAULT_MEMORY_DB = self.db_path
92
+
93
+ result = self._run(tools.get_lifecycle_status(memory_id=2))
94
+ assert result["success"] is True
95
+ assert result["lifecycle_state"] == "warm"
96
+
97
+ def test_nonexistent_memory(self):
98
+ """Looking up a nonexistent memory_id should return success=False."""
99
+ import mcp_tools_v28 as tools
100
+ tools.DEFAULT_MEMORY_DB = self.db_path
101
+
102
+ result = self._run(tools.get_lifecycle_status(memory_id=999))
103
+ assert result["success"] is False
104
+ assert "not found" in result["error"]
105
+
106
+ def test_distribution_all_states_present(self):
107
+ """Distribution dict should always contain all 5 lifecycle states."""
108
+ import mcp_tools_v28 as tools
109
+ tools.DEFAULT_MEMORY_DB = self.db_path
110
+
111
+ result = self._run(tools.get_lifecycle_status())
112
+ dist = result["distribution"]
113
+ for state in ("active", "warm", "cold", "archived", "tombstoned"):
114
+ assert state in dist
@@ -0,0 +1,162 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
3
+ """Tests for retention policy management.
4
+ """
5
+ import sqlite3
6
+ import tempfile
7
+ import os
8
+ import sys
9
+ import json
10
+ from pathlib import Path
11
+
12
+ # Ensure src/ is importable and takes precedence (matches existing test pattern)
13
+ SRC_DIR = Path(__file__).resolve().parent.parent.parent # src/
14
+ _src_str = str(SRC_DIR)
15
+ if _src_str not in sys.path:
16
+ sys.path.insert(0, _src_str)
17
+
18
+
19
+ class TestRetentionPolicy:
20
+ """Test retention policy loading, evaluation, and enforcement."""
21
+
22
+ def setup_method(self):
23
+ self.tmp_dir = tempfile.mkdtemp()
24
+ self.db_path = os.path.join(self.tmp_dir, "test.db")
25
+ conn = sqlite3.connect(self.db_path)
26
+ conn.execute("""
27
+ CREATE TABLE memories (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ content TEXT NOT NULL,
30
+ importance INTEGER DEFAULT 5,
31
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
32
+ last_accessed TIMESTAMP,
33
+ access_count INTEGER DEFAULT 0,
34
+ lifecycle_state TEXT DEFAULT 'active',
35
+ lifecycle_updated_at TIMESTAMP,
36
+ lifecycle_history TEXT DEFAULT '[]',
37
+ access_level TEXT DEFAULT 'public',
38
+ profile TEXT DEFAULT 'default',
39
+ tags TEXT DEFAULT '[]',
40
+ project_name TEXT
41
+ )
42
+ """)
43
+ conn.execute("INSERT INTO memories (content, tags, project_name) VALUES ('general memory', '[]', 'myproject')")
44
+ conn.execute("INSERT INTO memories (content, tags, project_name) VALUES ('medical record', '[\"hipaa\"]', 'healthcare')")
45
+ conn.execute("INSERT INTO memories (content, tags, project_name) VALUES ('user PII data', '[\"gdpr\",\"pii\"]', 'eu-app')")
46
+ conn.commit()
47
+ conn.close()
48
+
49
+ def teardown_method(self):
50
+ import shutil
51
+ shutil.rmtree(self.tmp_dir, ignore_errors=True)
52
+
53
+ def test_create_policy(self):
54
+ """Can create a retention policy programmatically."""
55
+ from lifecycle.retention_policy import RetentionPolicyManager
56
+ mgr = RetentionPolicyManager(self.db_path)
57
+ policy_id = mgr.create_policy(
58
+ name="GDPR Erasure",
59
+ retention_days=0,
60
+ framework="gdpr",
61
+ action="tombstone",
62
+ applies_to={"tags": ["gdpr"]},
63
+ )
64
+ assert isinstance(policy_id, int)
65
+ assert policy_id > 0
66
+
67
+ def test_list_policies(self):
68
+ """Can list all policies."""
69
+ from lifecycle.retention_policy import RetentionPolicyManager
70
+ mgr = RetentionPolicyManager(self.db_path)
71
+ mgr.create_policy(name="Policy A", retention_days=30, framework="internal", action="archive", applies_to={})
72
+ mgr.create_policy(name="Policy B", retention_days=365, framework="hipaa", action="retain", applies_to={})
73
+ policies = mgr.list_policies()
74
+ assert len(policies) == 2
75
+ names = {p["name"] for p in policies}
76
+ assert "Policy A" in names
77
+ assert "Policy B" in names
78
+
79
+ def test_evaluate_memory_matching_tag(self):
80
+ """Policy with tag filter matches memories with that tag."""
81
+ from lifecycle.retention_policy import RetentionPolicyManager
82
+ mgr = RetentionPolicyManager(self.db_path)
83
+ mgr.create_policy(
84
+ name="HIPAA Retention",
85
+ retention_days=2555, # ~7 years
86
+ framework="hipaa",
87
+ action="retain",
88
+ applies_to={"tags": ["hipaa"]},
89
+ )
90
+ result = mgr.evaluate_memory(2) # Memory 2 has hipaa tag
91
+ assert result is not None
92
+ assert result["policy_name"] == "HIPAA Retention"
93
+ assert result["action"] == "retain"
94
+
95
+ def test_evaluate_memory_no_match(self):
96
+ """Memory without matching tags returns None."""
97
+ from lifecycle.retention_policy import RetentionPolicyManager
98
+ mgr = RetentionPolicyManager(self.db_path)
99
+ mgr.create_policy(
100
+ name="HIPAA Retention",
101
+ retention_days=2555,
102
+ framework="hipaa",
103
+ action="retain",
104
+ applies_to={"tags": ["hipaa"]},
105
+ )
106
+ result = mgr.evaluate_memory(1) # Memory 1 has no hipaa tag
107
+ assert result is None
108
+
109
+ def test_gdpr_erasure_policy(self):
110
+ """GDPR erasure: retention_days=0, action=tombstone."""
111
+ from lifecycle.retention_policy import RetentionPolicyManager
112
+ mgr = RetentionPolicyManager(self.db_path)
113
+ mgr.create_policy(
114
+ name="GDPR Right to Erasure",
115
+ retention_days=0,
116
+ framework="gdpr",
117
+ action="tombstone",
118
+ applies_to={"tags": ["gdpr"]},
119
+ )
120
+ result = mgr.evaluate_memory(3) # Memory 3 has gdpr tag
121
+ assert result is not None
122
+ assert result["action"] == "tombstone"
123
+
124
+ def test_strictest_policy_wins(self):
125
+ """When multiple policies match, the strictest (shortest retention) wins."""
126
+ from lifecycle.retention_policy import RetentionPolicyManager
127
+ mgr = RetentionPolicyManager(self.db_path)
128
+ mgr.create_policy(name="Lenient", retention_days=365, framework="internal", action="archive", applies_to={"tags": ["gdpr"]})
129
+ mgr.create_policy(name="Strict", retention_days=0, framework="gdpr", action="tombstone", applies_to={"tags": ["gdpr"]})
130
+ result = mgr.evaluate_memory(3)
131
+ assert result["policy_name"] == "Strict"
132
+ assert result["action"] == "tombstone"
133
+
134
+ def test_load_policies_from_json(self):
135
+ """Can load policies from a JSON file."""
136
+ from lifecycle.retention_policy import RetentionPolicyManager
137
+ mgr = RetentionPolicyManager(self.db_path)
138
+ policy_file = os.path.join(self.tmp_dir, "policies.json")
139
+ with open(policy_file, "w") as f:
140
+ json.dump([
141
+ {"name": "EU AI Act", "retention_days": 3650, "framework": "eu_ai_act", "action": "retain", "applies_to": {"project_name": "eu-app"}},
142
+ ], f)
143
+ count = mgr.load_policies(policy_file)
144
+ assert count == 1
145
+ policies = mgr.list_policies()
146
+ assert len(policies) == 1
147
+
148
+ def test_missing_policy_file_no_error(self):
149
+ """Missing policy file returns 0 loaded, no crash."""
150
+ from lifecycle.retention_policy import RetentionPolicyManager
151
+ mgr = RetentionPolicyManager(self.db_path)
152
+ count = mgr.load_policies("/nonexistent/path/policies.json")
153
+ assert count == 0
154
+
155
+ def test_get_protected_memory_ids(self):
156
+ """get_protected_memory_ids returns set of IDs protected by retention policies."""
157
+ from lifecycle.retention_policy import RetentionPolicyManager
158
+ mgr = RetentionPolicyManager(self.db_path)
159
+ mgr.create_policy(name="Retain HIPAA", retention_days=2555, framework="hipaa", action="retain", applies_to={"tags": ["hipaa"]})
160
+ protected = mgr.get_protected_memory_ids()
161
+ assert 2 in protected # Memory 2 has hipaa tag
162
+ assert 1 not in protected # Memory 1 has no matching tags