alma-memory 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ """
2
+ ALMA Learning Validation.
3
+
4
+ Enforces scope constraints and validates learning requests.
5
+ """
6
+
7
+ import logging
8
+ from typing import Optional, List, Dict, Any, Set
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+
12
+ from alma.types import MemoryScope
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ValidationResult(Enum):
18
+ """Result of a validation check."""
19
+ ALLOWED = "allowed"
20
+ DENIED_OUT_OF_SCOPE = "denied_out_of_scope"
21
+ DENIED_FORBIDDEN = "denied_forbidden"
22
+ DENIED_UNKNOWN_AGENT = "denied_unknown_agent"
23
+ WARNING_NO_SCOPE = "warning_no_scope"
24
+
25
+
26
+ @dataclass
27
+ class ValidationReport:
28
+ """Detailed report of a validation check."""
29
+ result: ValidationResult
30
+ agent: str
31
+ domain: str
32
+ reason: str
33
+ allowed_domains: List[str] = field(default_factory=list)
34
+ forbidden_domains: List[str] = field(default_factory=list)
35
+
36
+ @property
37
+ def is_allowed(self) -> bool:
38
+ """Check if the validation passed."""
39
+ return self.result in (ValidationResult.ALLOWED, ValidationResult.WARNING_NO_SCOPE)
40
+
41
+ def to_dict(self) -> Dict[str, Any]:
42
+ """Convert to dictionary."""
43
+ return {
44
+ "result": self.result.value,
45
+ "agent": self.agent,
46
+ "domain": self.domain,
47
+ "reason": self.reason,
48
+ "is_allowed": self.is_allowed,
49
+ "allowed_domains": self.allowed_domains,
50
+ "forbidden_domains": self.forbidden_domains,
51
+ }
52
+
53
+
54
+ class ScopeValidator:
55
+ """
56
+ Validates that learning requests are within agent scope.
57
+
58
+ Provides strict enforcement with detailed reporting.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ scopes: Dict[str, MemoryScope],
64
+ strict_mode: bool = True,
65
+ allow_unknown_agents: bool = False,
66
+ ):
67
+ """
68
+ Initialize validator.
69
+
70
+ Args:
71
+ scopes: Dict of agent_name -> MemoryScope
72
+ strict_mode: If True, deny requests for unknown domains
73
+ allow_unknown_agents: If True, allow learning for agents without scopes
74
+ """
75
+ self.scopes = scopes
76
+ self.strict_mode = strict_mode
77
+ self.allow_unknown_agents = allow_unknown_agents
78
+
79
+ # Track validation statistics
80
+ self._stats = {
81
+ "total_validations": 0,
82
+ "allowed": 0,
83
+ "denied": 0,
84
+ "warnings": 0,
85
+ }
86
+
87
+ def validate(
88
+ self,
89
+ agent: str,
90
+ domain: str,
91
+ task_type: Optional[str] = None,
92
+ ) -> ValidationReport:
93
+ """
94
+ Validate a learning request.
95
+
96
+ Args:
97
+ agent: Agent attempting to learn
98
+ domain: Knowledge domain to learn in
99
+ task_type: Optional task type for context
100
+
101
+ Returns:
102
+ ValidationReport with detailed results
103
+ """
104
+ self._stats["total_validations"] += 1
105
+
106
+ # Check if agent has a scope
107
+ scope = self.scopes.get(agent)
108
+
109
+ if scope is None:
110
+ if self.allow_unknown_agents:
111
+ self._stats["warnings"] += 1
112
+ logger.warning(f"Agent '{agent}' has no defined scope, allowing anyway")
113
+ return ValidationReport(
114
+ result=ValidationResult.WARNING_NO_SCOPE,
115
+ agent=agent,
116
+ domain=domain,
117
+ reason=f"Agent '{agent}' has no defined scope",
118
+ )
119
+ else:
120
+ self._stats["denied"] += 1
121
+ logger.warning(f"Agent '{agent}' denied: no scope defined")
122
+ return ValidationReport(
123
+ result=ValidationResult.DENIED_UNKNOWN_AGENT,
124
+ agent=agent,
125
+ domain=domain,
126
+ reason=f"Agent '{agent}' has no defined scope and unknown agents are not allowed",
127
+ )
128
+
129
+ # Check if domain is explicitly forbidden
130
+ if domain in scope.cannot_learn:
131
+ self._stats["denied"] += 1
132
+ logger.warning(
133
+ f"Agent '{agent}' denied learning in '{domain}': explicitly forbidden"
134
+ )
135
+ return ValidationReport(
136
+ result=ValidationResult.DENIED_FORBIDDEN,
137
+ agent=agent,
138
+ domain=domain,
139
+ reason=f"Domain '{domain}' is explicitly forbidden for agent '{agent}'",
140
+ allowed_domains=scope.can_learn,
141
+ forbidden_domains=scope.cannot_learn,
142
+ )
143
+
144
+ # Check if domain is in allowed list
145
+ if scope.can_learn: # If list is not empty, it's an allowlist
146
+ if domain not in scope.can_learn:
147
+ # Check for partial matches (e.g., "form_testing" contains "testing")
148
+ partial_match = any(
149
+ allowed in domain or domain in allowed
150
+ for allowed in scope.can_learn
151
+ )
152
+
153
+ if not partial_match and self.strict_mode:
154
+ self._stats["denied"] += 1
155
+ logger.warning(
156
+ f"Agent '{agent}' denied learning in '{domain}': not in allowed list"
157
+ )
158
+ return ValidationReport(
159
+ result=ValidationResult.DENIED_OUT_OF_SCOPE,
160
+ agent=agent,
161
+ domain=domain,
162
+ reason=f"Domain '{domain}' is not in agent '{agent}'s allowed domains",
163
+ allowed_domains=scope.can_learn,
164
+ forbidden_domains=scope.cannot_learn,
165
+ )
166
+
167
+ # Allowed
168
+ self._stats["allowed"] += 1
169
+ return ValidationReport(
170
+ result=ValidationResult.ALLOWED,
171
+ agent=agent,
172
+ domain=domain,
173
+ reason="Learning allowed",
174
+ allowed_domains=scope.can_learn,
175
+ forbidden_domains=scope.cannot_learn,
176
+ )
177
+
178
+ def validate_batch(
179
+ self,
180
+ agent: str,
181
+ domains: List[str],
182
+ ) -> Dict[str, ValidationReport]:
183
+ """
184
+ Validate multiple domains at once.
185
+
186
+ Args:
187
+ agent: Agent attempting to learn
188
+ domains: List of domains to validate
189
+
190
+ Returns:
191
+ Dict of domain -> ValidationReport
192
+ """
193
+ return {domain: self.validate(agent, domain) for domain in domains}
194
+
195
+ def get_allowed_domains(self, agent: str) -> Set[str]:
196
+ """Get all allowed domains for an agent."""
197
+ scope = self.scopes.get(agent)
198
+ if scope is None:
199
+ return set()
200
+ return set(scope.can_learn)
201
+
202
+ def get_forbidden_domains(self, agent: str) -> Set[str]:
203
+ """Get all forbidden domains for an agent."""
204
+ scope = self.scopes.get(agent)
205
+ if scope is None:
206
+ return set()
207
+ return set(scope.cannot_learn)
208
+
209
+ def is_allowed(self, agent: str, domain: str) -> bool:
210
+ """Quick check if learning is allowed (no detailed report)."""
211
+ return self.validate(agent, domain).is_allowed
212
+
213
+ def get_stats(self) -> Dict[str, Any]:
214
+ """Get validation statistics."""
215
+ total = self._stats["total_validations"]
216
+ return {
217
+ **self._stats,
218
+ "allow_rate": self._stats["allowed"] / total if total > 0 else 0,
219
+ "deny_rate": self._stats["denied"] / total if total > 0 else 0,
220
+ }
221
+
222
+ def reset_stats(self):
223
+ """Reset validation statistics."""
224
+ self._stats = {
225
+ "total_validations": 0,
226
+ "allowed": 0,
227
+ "denied": 0,
228
+ "warnings": 0,
229
+ }
230
+
231
+
232
+ class TaskTypeValidator:
233
+ """
234
+ Validates and normalizes task types.
235
+
236
+ Ensures consistent categorization across learning events.
237
+ """
238
+
239
+ # Standard task type categories
240
+ STANDARD_TYPES = {
241
+ "testing": ["test", "validate", "verify", "check", "qa"],
242
+ "form_testing": ["form", "input", "field", "validation"],
243
+ "api_testing": ["api", "endpoint", "rest", "graphql", "request"],
244
+ "database_validation": ["database", "query", "sql", "schema"],
245
+ "ui_testing": ["ui", "component", "button", "click", "element"],
246
+ "performance_testing": ["performance", "load", "stress", "speed"],
247
+ "security_testing": ["security", "auth", "permission", "xss", "injection"],
248
+ "accessibility_testing": ["accessibility", "a11y", "aria", "screen reader"],
249
+ }
250
+
251
+ def __init__(self, custom_types: Optional[Dict[str, List[str]]] = None):
252
+ """
253
+ Initialize validator.
254
+
255
+ Args:
256
+ custom_types: Additional custom type mappings
257
+ """
258
+ self.type_mappings = {**self.STANDARD_TYPES}
259
+ if custom_types:
260
+ self.type_mappings.update(custom_types)
261
+
262
+ # Build reverse lookup
263
+ self._keyword_to_type: Dict[str, str] = {}
264
+ for task_type, keywords in self.type_mappings.items():
265
+ for keyword in keywords:
266
+ self._keyword_to_type[keyword.lower()] = task_type
267
+
268
+ def infer_type(self, task_description: str) -> str:
269
+ """
270
+ Infer task type from description.
271
+
272
+ Args:
273
+ task_description: Description of the task
274
+
275
+ Returns:
276
+ Inferred task type or "general"
277
+ """
278
+ task_lower = task_description.lower()
279
+
280
+ # Check for keyword matches
281
+ scores: Dict[str, int] = {}
282
+ for task_type, keywords in self.type_mappings.items():
283
+ score = sum(1 for kw in keywords if kw in task_lower)
284
+ if score > 0:
285
+ scores[task_type] = score
286
+
287
+ if scores:
288
+ # Return the type with highest score
289
+ return max(scores.keys(), key=lambda k: scores[k])
290
+
291
+ return "general"
292
+
293
+ def normalize_type(self, task_type: str) -> str:
294
+ """
295
+ Normalize a task type to standard category.
296
+
297
+ Args:
298
+ task_type: Raw task type
299
+
300
+ Returns:
301
+ Normalized task type
302
+ """
303
+ type_lower = task_type.lower().replace("_", " ").replace("-", " ")
304
+
305
+ # Check direct match
306
+ if type_lower in self.type_mappings:
307
+ return type_lower
308
+
309
+ # Check keyword lookup
310
+ for word in type_lower.split():
311
+ if word in self._keyword_to_type:
312
+ return self._keyword_to_type[word]
313
+
314
+ return task_type # Return original if no match
315
+
316
+ def validate_type(self, task_type: str) -> bool:
317
+ """Check if task type is recognized."""
318
+ normalized = self.normalize_type(task_type)
319
+ return normalized in self.type_mappings or task_type == "general"
320
+
321
+
322
+ def validate_learning_request(
323
+ agent: str,
324
+ domain: str,
325
+ scopes: Dict[str, MemoryScope],
326
+ strict: bool = True,
327
+ ) -> ValidationReport:
328
+ """
329
+ Convenience function for one-off validation.
330
+
331
+ Args:
332
+ agent: Agent attempting to learn
333
+ domain: Knowledge domain
334
+ scopes: Dict of agent scopes
335
+ strict: Use strict mode
336
+
337
+ Returns:
338
+ ValidationReport
339
+ """
340
+ validator = ScopeValidator(scopes, strict_mode=strict)
341
+ return validator.validate(agent, domain)
alma/mcp/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ ALMA MCP Server Module.
3
+
4
+ Exposes ALMA functionality to any Claude Code instance via the
5
+ Model Context Protocol (MCP).
6
+
7
+ Usage:
8
+ # stdio mode (for Claude Code integration)
9
+ python -m alma.mcp --config .alma/config.yaml
10
+
11
+ # HTTP mode (for remote access)
12
+ python -m alma.mcp --http --port 8765
13
+
14
+ Integration with Claude Code (.mcp.json):
15
+ {
16
+ "mcpServers": {
17
+ "alma-memory": {
18
+ "command": "python",
19
+ "args": ["-m", "alma.mcp", "--config", ".alma/config.yaml"]
20
+ }
21
+ }
22
+ }
23
+ """
24
+
25
+ from alma.mcp.server import ALMAMCPServer
26
+ from alma.mcp.tools import (
27
+ alma_retrieve,
28
+ alma_learn,
29
+ alma_add_preference,
30
+ alma_add_knowledge,
31
+ alma_forget,
32
+ alma_stats,
33
+ alma_health,
34
+ )
35
+
36
+ __all__ = [
37
+ "ALMAMCPServer",
38
+ "alma_retrieve",
39
+ "alma_learn",
40
+ "alma_add_preference",
41
+ "alma_add_knowledge",
42
+ "alma_forget",
43
+ "alma_stats",
44
+ "alma_health",
45
+ ]
alma/mcp/__main__.py ADDED
@@ -0,0 +1,155 @@
1
+ """
2
+ ALMA MCP Server CLI Entry Point.
3
+
4
+ Usage:
5
+ # stdio mode (for Claude Code)
6
+ python -m alma.mcp --config .alma/config.yaml
7
+
8
+ # HTTP mode (for remote access)
9
+ python -m alma.mcp --http --port 8765
10
+
11
+ # With verbose logging
12
+ python -m alma.mcp --config .alma/config.yaml --verbose
13
+ """
14
+
15
+ import argparse
16
+ import asyncio
17
+ import logging
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from alma import ALMA
22
+ from alma.mcp.server import ALMAMCPServer
23
+
24
+
25
+ def setup_logging(verbose: bool = False):
26
+ """Configure logging."""
27
+ level = logging.DEBUG if verbose else logging.INFO
28
+ logging.basicConfig(
29
+ level=level,
30
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
31
+ stream=sys.stderr, # Log to stderr to avoid interfering with stdio
32
+ )
33
+
34
+
35
+ def main():
36
+ """Main entry point."""
37
+ parser = argparse.ArgumentParser(
38
+ description="ALMA MCP Server - Memory for AI Agents",
39
+ formatter_class=argparse.RawDescriptionHelpFormatter,
40
+ epilog="""
41
+ Examples:
42
+ # Start in stdio mode for Claude Code
43
+ python -m alma.mcp --config .alma/config.yaml
44
+
45
+ # Start in HTTP mode on port 8765
46
+ python -m alma.mcp --http --port 8765
47
+
48
+ # Use with verbose logging
49
+ python -m alma.mcp --config .alma/config.yaml --verbose
50
+ """,
51
+ )
52
+
53
+ parser.add_argument(
54
+ "--config",
55
+ type=str,
56
+ default=".alma/config.yaml",
57
+ help="Path to ALMA config file (default: .alma/config.yaml)",
58
+ )
59
+
60
+ parser.add_argument(
61
+ "--http",
62
+ action="store_true",
63
+ help="Run in HTTP mode instead of stdio",
64
+ )
65
+
66
+ parser.add_argument(
67
+ "--host",
68
+ type=str,
69
+ default="0.0.0.0",
70
+ help="HTTP server host (default: 0.0.0.0)",
71
+ )
72
+
73
+ parser.add_argument(
74
+ "--port",
75
+ type=int,
76
+ default=8765,
77
+ help="HTTP server port (default: 8765)",
78
+ )
79
+
80
+ parser.add_argument(
81
+ "--verbose", "-v",
82
+ action="store_true",
83
+ help="Enable verbose logging",
84
+ )
85
+
86
+ args = parser.parse_args()
87
+
88
+ # Setup logging
89
+ setup_logging(args.verbose)
90
+ logger = logging.getLogger(__name__)
91
+
92
+ # Load ALMA from config
93
+ config_path = Path(args.config)
94
+ if not config_path.exists():
95
+ logger.error(f"Config file not found: {config_path}")
96
+ logger.info("Creating default config...")
97
+
98
+ # Create minimal default config
99
+ config_path.parent.mkdir(parents=True, exist_ok=True)
100
+ with open(config_path, "w") as f:
101
+ f.write("""# ALMA Configuration
102
+ alma:
103
+ project_id: "default"
104
+ storage: "file"
105
+ storage_dir: ".alma"
106
+ embedding_provider: "local"
107
+
108
+ agents:
109
+ helena:
110
+ can_learn:
111
+ - testing_strategies
112
+ - selector_patterns
113
+ - form_testing
114
+ - accessibility_testing
115
+ cannot_learn:
116
+ - backend_logic
117
+ - database_queries
118
+ min_occurrences_for_heuristic: 3
119
+
120
+ victor:
121
+ can_learn:
122
+ - api_design_patterns
123
+ - authentication_patterns
124
+ - error_handling
125
+ - database_query_patterns
126
+ cannot_learn:
127
+ - frontend_styling
128
+ - ui_testing
129
+ min_occurrences_for_heuristic: 3
130
+ """)
131
+ logger.info(f"Created default config at {config_path}")
132
+
133
+ try:
134
+ alma = ALMA.from_config(str(config_path))
135
+ logger.info(f"Loaded ALMA from {config_path}")
136
+ logger.info(f"Project: {alma.project_id}")
137
+ logger.info(f"Agents: {list(alma.scopes.keys())}")
138
+
139
+ except Exception as e:
140
+ logger.exception(f"Failed to load ALMA: {e}")
141
+ sys.exit(1)
142
+
143
+ # Create and run server
144
+ server = ALMAMCPServer(alma=alma)
145
+
146
+ if args.http:
147
+ logger.info(f"Starting HTTP server on {args.host}:{args.port}")
148
+ asyncio.run(server.run_http(host=args.host, port=args.port))
149
+ else:
150
+ logger.info("Starting stdio server for Claude Code integration")
151
+ asyncio.run(server.run_stdio())
152
+
153
+
154
+ if __name__ == "__main__":
155
+ main()
alma/mcp/resources.py ADDED
@@ -0,0 +1,121 @@
1
+ """
2
+ ALMA MCP Resource Definitions.
3
+
4
+ Provides read-only resources that can be accessed via MCP protocol.
5
+ Resources represent configuration and metadata about the ALMA instance.
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, Any, List
10
+
11
+ from alma import ALMA
12
+ from alma.types import MemoryScope
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def get_config_resource(alma: ALMA) -> Dict[str, Any]:
18
+ """
19
+ Get current ALMA configuration as a resource.
20
+
21
+ This exposes non-sensitive configuration information about
22
+ the ALMA instance.
23
+
24
+ Args:
25
+ alma: ALMA instance
26
+
27
+ Returns:
28
+ Dict with configuration details
29
+ """
30
+ return {
31
+ "uri": "alma://config",
32
+ "name": "ALMA Configuration",
33
+ "description": "Current configuration of the ALMA memory system",
34
+ "mimeType": "application/json",
35
+ "content": {
36
+ "project_id": alma.project_id,
37
+ "storage_type": type(alma.storage).__name__,
38
+ "registered_agents": list(alma.scopes.keys()),
39
+ "scopes": {
40
+ name: {
41
+ "can_learn": scope.can_learn,
42
+ "cannot_learn": scope.cannot_learn,
43
+ "min_occurrences_for_heuristic": scope.min_occurrences_for_heuristic,
44
+ }
45
+ for name, scope in alma.scopes.items()
46
+ },
47
+ },
48
+ }
49
+
50
+
51
+ def get_agents_resource(alma: ALMA) -> Dict[str, Any]:
52
+ """
53
+ Get registered agents and their scopes as a resource.
54
+
55
+ This provides detailed information about each registered agent
56
+ and their learning permissions.
57
+
58
+ Args:
59
+ alma: ALMA instance
60
+
61
+ Returns:
62
+ Dict with agent details
63
+ """
64
+ agents = []
65
+
66
+ for name, scope in alma.scopes.items():
67
+ # Get stats for this agent
68
+ try:
69
+ stats = alma.get_stats(agent=name)
70
+ except Exception:
71
+ stats = {}
72
+
73
+ agents.append({
74
+ "name": name,
75
+ "scope": {
76
+ "can_learn": scope.can_learn,
77
+ "cannot_learn": scope.cannot_learn,
78
+ "min_occurrences_for_heuristic": scope.min_occurrences_for_heuristic,
79
+ },
80
+ "stats": {
81
+ "heuristics_count": stats.get("heuristics_count", 0),
82
+ "outcomes_count": stats.get("outcomes_count", 0),
83
+ "domain_knowledge_count": stats.get("domain_knowledge_count", 0),
84
+ "anti_patterns_count": stats.get("anti_patterns_count", 0),
85
+ },
86
+ })
87
+
88
+ return {
89
+ "uri": "alma://agents",
90
+ "name": "ALMA Registered Agents",
91
+ "description": "All registered agents and their memory scopes",
92
+ "mimeType": "application/json",
93
+ "content": {
94
+ "project_id": alma.project_id,
95
+ "agent_count": len(agents),
96
+ "agents": agents,
97
+ },
98
+ }
99
+
100
+
101
+ def list_resources() -> List[Dict[str, Any]]:
102
+ """
103
+ List all available resources.
104
+
105
+ Returns:
106
+ List of resource descriptors
107
+ """
108
+ return [
109
+ {
110
+ "uri": "alma://config",
111
+ "name": "ALMA Configuration",
112
+ "description": "Current configuration of the ALMA memory system",
113
+ "mimeType": "application/json",
114
+ },
115
+ {
116
+ "uri": "alma://agents",
117
+ "name": "ALMA Registered Agents",
118
+ "description": "All registered agents and their memory scopes",
119
+ "mimeType": "application/json",
120
+ },
121
+ ]