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.
- package/CHANGELOG.md +120 -155
- package/README.md +115 -89
- package/api_server.py +2 -12
- package/docs/PATTERN-LEARNING.md +64 -199
- package/docs/example_graph_usage.py +4 -6
- package/install.sh +59 -0
- package/mcp_server.py +83 -7
- package/package.json +1 -8
- package/scripts/generate-thumbnails.py +3 -5
- package/skills/slm-build-graph/SKILL.md +1 -1
- package/skills/slm-list-recent/SKILL.md +1 -1
- package/skills/slm-recall/SKILL.md +1 -1
- package/skills/slm-remember/SKILL.md +1 -1
- package/skills/slm-show-patterns/SKILL.md +1 -1
- package/skills/slm-status/SKILL.md +1 -1
- package/skills/slm-switch-profile/SKILL.md +1 -1
- package/src/agent_registry.py +7 -18
- package/src/auth_middleware.py +3 -5
- package/src/auto_backup.py +3 -7
- package/src/behavioral/__init__.py +49 -0
- package/src/behavioral/behavioral_listener.py +203 -0
- package/src/behavioral/behavioral_patterns.py +275 -0
- package/src/behavioral/cross_project_transfer.py +206 -0
- package/src/behavioral/outcome_inference.py +194 -0
- package/src/behavioral/outcome_tracker.py +193 -0
- package/src/behavioral/tests/__init__.py +4 -0
- package/src/behavioral/tests/test_behavioral_integration.py +108 -0
- package/src/behavioral/tests/test_behavioral_patterns.py +150 -0
- package/src/behavioral/tests/test_cross_project_transfer.py +142 -0
- package/src/behavioral/tests/test_mcp_behavioral.py +139 -0
- package/src/behavioral/tests/test_mcp_report_outcome.py +117 -0
- package/src/behavioral/tests/test_outcome_inference.py +107 -0
- package/src/behavioral/tests/test_outcome_tracker.py +96 -0
- package/src/cache_manager.py +4 -6
- package/src/compliance/__init__.py +48 -0
- package/src/compliance/abac_engine.py +149 -0
- package/src/compliance/abac_middleware.py +116 -0
- package/src/compliance/audit_db.py +215 -0
- package/src/compliance/audit_logger.py +148 -0
- package/src/compliance/retention_manager.py +289 -0
- package/src/compliance/retention_scheduler.py +186 -0
- package/src/compliance/tests/__init__.py +4 -0
- package/src/compliance/tests/test_abac_enforcement.py +95 -0
- package/src/compliance/tests/test_abac_engine.py +124 -0
- package/src/compliance/tests/test_abac_mcp_integration.py +118 -0
- package/src/compliance/tests/test_audit_db.py +123 -0
- package/src/compliance/tests/test_audit_logger.py +98 -0
- package/src/compliance/tests/test_mcp_audit.py +128 -0
- package/src/compliance/tests/test_mcp_retention_policy.py +125 -0
- package/src/compliance/tests/test_retention_manager.py +131 -0
- package/src/compliance/tests/test_retention_scheduler.py +99 -0
- package/src/db_connection_manager.py +2 -12
- package/src/embedding_engine.py +61 -669
- package/src/embeddings/__init__.py +47 -0
- package/src/embeddings/cache.py +70 -0
- package/src/embeddings/cli.py +113 -0
- package/src/embeddings/constants.py +47 -0
- package/src/embeddings/database.py +91 -0
- package/src/embeddings/engine.py +247 -0
- package/src/embeddings/model_loader.py +145 -0
- package/src/event_bus.py +3 -13
- package/src/graph/__init__.py +36 -0
- package/src/graph/build_helpers.py +74 -0
- package/src/graph/cli.py +87 -0
- package/src/graph/cluster_builder.py +188 -0
- package/src/graph/cluster_summary.py +148 -0
- package/src/graph/constants.py +47 -0
- package/src/graph/edge_builder.py +162 -0
- package/src/graph/entity_extractor.py +95 -0
- package/src/graph/graph_core.py +226 -0
- package/src/graph/graph_search.py +231 -0
- package/src/graph/hierarchical.py +207 -0
- package/src/graph/schema.py +99 -0
- package/src/graph_engine.py +45 -1451
- package/src/hnsw_index.py +3 -7
- package/src/hybrid_search.py +36 -683
- package/src/learning/__init__.py +27 -12
- package/src/learning/adaptive_ranker.py +50 -12
- package/src/learning/cross_project_aggregator.py +2 -12
- package/src/learning/engagement_tracker.py +2 -12
- package/src/learning/feature_extractor.py +175 -43
- package/src/learning/feedback_collector.py +7 -12
- package/src/learning/learning_db.py +180 -12
- package/src/learning/project_context_manager.py +2 -12
- package/src/learning/source_quality_scorer.py +2 -12
- package/src/learning/synthetic_bootstrap.py +2 -12
- package/src/learning/tests/__init__.py +2 -0
- package/src/learning/tests/test_adaptive_ranker.py +2 -6
- package/src/learning/tests/test_adaptive_ranker_v28.py +60 -0
- package/src/learning/tests/test_aggregator.py +2 -6
- package/src/learning/tests/test_auto_retrain_v28.py +35 -0
- package/src/learning/tests/test_e2e_ranking_v28.py +82 -0
- package/src/learning/tests/test_feature_extractor_v28.py +93 -0
- package/src/learning/tests/test_feedback_collector.py +2 -6
- package/src/learning/tests/test_learning_db.py +2 -6
- package/src/learning/tests/test_learning_db_v28.py +110 -0
- package/src/learning/tests/test_learning_init_v28.py +48 -0
- package/src/learning/tests/test_outcome_signals.py +48 -0
- package/src/learning/tests/test_project_context.py +2 -6
- package/src/learning/tests/test_schema_migration.py +319 -0
- package/src/learning/tests/test_signal_inference.py +11 -13
- package/src/learning/tests/test_source_quality.py +2 -6
- package/src/learning/tests/test_synthetic_bootstrap.py +3 -7
- package/src/learning/tests/test_workflow_miner.py +2 -6
- package/src/learning/workflow_pattern_miner.py +2 -12
- package/src/lifecycle/__init__.py +54 -0
- package/src/lifecycle/bounded_growth.py +239 -0
- package/src/lifecycle/compaction_engine.py +226 -0
- package/src/lifecycle/lifecycle_engine.py +302 -0
- package/src/lifecycle/lifecycle_evaluator.py +225 -0
- package/src/lifecycle/lifecycle_scheduler.py +130 -0
- package/src/lifecycle/retention_policy.py +285 -0
- package/src/lifecycle/tests/__init__.py +4 -0
- package/src/lifecycle/tests/test_bounded_growth.py +193 -0
- package/src/lifecycle/tests/test_compaction.py +179 -0
- package/src/lifecycle/tests/test_lifecycle_engine.py +137 -0
- package/src/lifecycle/tests/test_lifecycle_evaluation.py +177 -0
- package/src/lifecycle/tests/test_lifecycle_scheduler.py +127 -0
- package/src/lifecycle/tests/test_lifecycle_search.py +109 -0
- package/src/lifecycle/tests/test_mcp_compact.py +149 -0
- package/src/lifecycle/tests/test_mcp_lifecycle_status.py +114 -0
- package/src/lifecycle/tests/test_retention_policy.py +162 -0
- package/src/mcp_tools_v28.py +280 -0
- package/src/memory-profiles.py +2 -12
- package/src/memory-reset.py +2 -12
- package/src/memory_compression.py +2 -12
- package/src/memory_store_v2.py +76 -20
- package/src/migrate_v1_to_v2.py +2 -12
- package/src/pattern_learner.py +29 -975
- package/src/patterns/__init__.py +24 -0
- package/src/patterns/analyzers.py +247 -0
- package/src/patterns/learner.py +267 -0
- package/src/patterns/scoring.py +167 -0
- package/src/patterns/store.py +223 -0
- package/src/patterns/terminology.py +138 -0
- package/src/provenance_tracker.py +4 -14
- package/src/query_optimizer.py +4 -6
- package/src/rate_limiter.py +2 -6
- package/src/search/__init__.py +20 -0
- package/src/search/cli.py +77 -0
- package/src/search/constants.py +26 -0
- package/src/search/engine.py +239 -0
- package/src/search/fusion.py +122 -0
- package/src/search/index_loader.py +112 -0
- package/src/search/methods.py +162 -0
- package/src/search_engine_v2.py +4 -6
- package/src/setup_validator.py +7 -13
- package/src/subscription_manager.py +2 -12
- package/src/tree/__init__.py +59 -0
- package/src/tree/builder.py +183 -0
- package/src/tree/nodes.py +196 -0
- package/src/tree/queries.py +252 -0
- package/src/tree/schema.py +76 -0
- package/src/tree_manager.py +10 -711
- package/src/trust/__init__.py +45 -0
- package/src/trust/constants.py +66 -0
- package/src/trust/queries.py +157 -0
- package/src/trust/schema.py +95 -0
- package/src/trust/scorer.py +299 -0
- package/src/trust/signals.py +95 -0
- package/src/trust_scorer.py +39 -697
- package/src/webhook_dispatcher.py +2 -12
- package/ui/app.js +1 -1
- package/ui/js/agents.js +1 -1
- package/ui_server.py +2 -14
- package/ATTRIBUTION.md +0 -140
- package/docs/ARCHITECTURE-V2.5.md +0 -190
- package/docs/GRAPH-ENGINE.md +0 -503
- package/docs/architecture-diagram.drawio +0 -405
- 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
|