mnemo-dev 0.1.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.
mnemo/clients.py ADDED
@@ -0,0 +1,147 @@
1
+ """AI client configuration targets for Mnemo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ClientTarget:
14
+ """Configuration and context-file locations for an MCP client."""
15
+
16
+ key: str
17
+ display_name: str
18
+ mcp_config_path: Path | None
19
+ context_file: str | None
20
+ context_label: str
21
+
22
+
23
+ CLIENTS: dict[str, ClientTarget] = {
24
+ "amazonq": ClientTarget(
25
+ key="amazonq",
26
+ display_name="Amazon Q",
27
+ mcp_config_path=Path.home() / ".aws" / "amazonq" / "mcp.json",
28
+ context_file=".amazonq/rules/mnemo.md",
29
+ context_label="rule",
30
+ ),
31
+ "cursor": ClientTarget(
32
+ key="cursor",
33
+ display_name="Cursor",
34
+ mcp_config_path=Path.home() / ".cursor" / "mcp.json",
35
+ context_file=".cursorrules",
36
+ context_label="rules file",
37
+ ),
38
+ "claude-code": ClientTarget(
39
+ key="claude-code",
40
+ display_name="Claude Code",
41
+ mcp_config_path=Path.home() / ".claude" / "mcp.json",
42
+ context_file="CLAUDE.md",
43
+ context_label="project instructions",
44
+ ),
45
+ "kiro": ClientTarget(
46
+ key="kiro",
47
+ display_name="Kiro",
48
+ mcp_config_path=Path.home() / ".kiro" / "mcp.json",
49
+ context_file=".kiro/rules/mnemo.md",
50
+ context_label="rule",
51
+ ),
52
+ "copilot": ClientTarget(
53
+ key="copilot",
54
+ display_name="GitHub Copilot",
55
+ mcp_config_path=Path.home() / ".config" / "github-copilot" / "mcp.json",
56
+ context_file=".github/copilot-instructions.md",
57
+ context_label="instructions",
58
+ ),
59
+ "generic": ClientTarget(
60
+ key="generic",
61
+ display_name="Generic MCP Client",
62
+ mcp_config_path=None,
63
+ context_file="MNEMO.md",
64
+ context_label="instructions",
65
+ ),
66
+ }
67
+
68
+ DEFAULT_CLIENT = "amazonq"
69
+ CLIENT_CHOICES = tuple(CLIENTS.keys()) + ("all",)
70
+
71
+
72
+ def resolve_clients(selection: str) -> list[ClientTarget]:
73
+ """Resolve a CLI client selection into concrete client targets."""
74
+ normalized = selection.lower().strip()
75
+ if normalized == "all":
76
+ return list(CLIENTS.values())
77
+ try:
78
+ return [CLIENTS[normalized]]
79
+ except KeyError as exc:
80
+ valid = ", ".join(CLIENT_CHOICES)
81
+ raise ValueError(f"Unknown client '{selection}'. Choose one of: {valid}") from exc
82
+
83
+
84
+ def find_mnemo_mcp_command() -> str:
85
+ """Find the installed mnemo-mcp executable, falling back to PATH lookup by name."""
86
+ if getattr(sys, "frozen", False):
87
+ return str(Path(sys.executable))
88
+
89
+ mnemo_bin = shutil.which("mnemo-mcp")
90
+ if mnemo_bin:
91
+ return mnemo_bin
92
+
93
+ executable_name = "mnemo-mcp.exe" if sys.platform.startswith("win") else "mnemo-mcp"
94
+ candidates = [
95
+ Path(sys.prefix) / "Scripts" / executable_name,
96
+ Path(sys.prefix) / "bin" / executable_name,
97
+ Path.home() / ".local" / "bin" / executable_name,
98
+ Path.home() / "Library" / "Python" / "3.12" / "bin" / executable_name,
99
+ Path.home() / "Library" / "Python" / "3.11" / "bin" / executable_name,
100
+ Path.home() / "AppData" / "Roaming" / "Python" / "Python312" / "Scripts" / executable_name,
101
+ Path.home() / "AppData" / "Roaming" / "Python" / "Python311" / "Scripts" / executable_name,
102
+ Path.home() / "AppData" / "Roaming" / "Python" / "Python310" / "Scripts" / executable_name,
103
+ ]
104
+ for candidate in candidates:
105
+ if candidate.exists():
106
+ return str(candidate)
107
+ return "mnemo-mcp"
108
+
109
+
110
+ def setup_mcp_config(target: ClientTarget, command: str | None = None) -> bool:
111
+ """Register Mnemo in a client's MCP config.
112
+
113
+ Returns True when the config file changed.
114
+ """
115
+ if target.mcp_config_path is None:
116
+ return False
117
+
118
+ config_path = target.mcp_config_path
119
+ config_path.parent.mkdir(parents=True, exist_ok=True)
120
+
121
+ config = {}
122
+ if config_path.exists():
123
+ try:
124
+ config = json.loads(config_path.read_text(encoding="utf-8"))
125
+ except json.JSONDecodeError:
126
+ config = {}
127
+
128
+ config.setdefault("mcpServers", {})
129
+ server = {
130
+ "command": command or find_mnemo_mcp_command(),
131
+ "args": [],
132
+ "env": {},
133
+ }
134
+
135
+ if config["mcpServers"].get("mnemo") == server:
136
+ return False
137
+
138
+ config["mcpServers"]["mnemo"] = server
139
+ config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
140
+ return True
141
+
142
+
143
+ def context_path(repo_root: Path, target: ClientTarget) -> Path | None:
144
+ """Return the repo-local context file path for a client."""
145
+ if not target.context_file:
146
+ return None
147
+ return repo_root / target.context_file
@@ -0,0 +1,68 @@
1
+ """Code review context - store PR summaries, feedback, and rejected suggestions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from ..storage import Collections, get_storage
9
+
10
+
11
+ def _load_reviews(repo_root: Path) -> list[dict]:
12
+ data = get_storage(repo_root).read_collection(Collections.REVIEWS)
13
+ return data if isinstance(data, list) else []
14
+
15
+
16
+ def _save_reviews(repo_root: Path, reviews: list[dict]) -> None:
17
+ get_storage(repo_root).write_collection(Collections.REVIEWS, reviews[-100:])
18
+
19
+
20
+ def add_review(
21
+ repo_root: Path,
22
+ summary: str,
23
+ files: list[str] | None = None,
24
+ feedback: str = "",
25
+ outcome: str = "approved",
26
+ ) -> dict:
27
+ """Store a code review summary."""
28
+ reviews = _load_reviews(repo_root)
29
+ entry = {
30
+ "id": len(reviews) + 1,
31
+ "timestamp": time.time(),
32
+ "summary": summary,
33
+ "files": files or [],
34
+ "feedback": feedback,
35
+ "outcome": outcome,
36
+ }
37
+ reviews.append(entry)
38
+ _save_reviews(repo_root, reviews)
39
+ return entry
40
+
41
+
42
+ def get_reviews_for_file(repo_root: Path, filepath: str) -> list[dict]:
43
+ """Get all review feedback related to a specific file."""
44
+ reviews = _load_reviews(repo_root)
45
+ return [review for review in reviews if filepath in (review.get("files") or [])]
46
+
47
+
48
+ def get_rejected_suggestions(repo_root: Path) -> list[dict]:
49
+ """Get suggestions that were rejected so assistants should not repeat them."""
50
+ reviews = _load_reviews(repo_root)
51
+ return [review for review in reviews if review.get("outcome") == "rejected"]
52
+
53
+
54
+ def format_reviews(repo_root: Path) -> str:
55
+ """Format review history as markdown."""
56
+ reviews = _load_reviews(repo_root)
57
+ if not reviews:
58
+ return "No code review history stored."
59
+
60
+ lines = ["# Code Review History\n"]
61
+ for review in reviews[-20:]:
62
+ status = f"[{review['outcome']}]" if review.get("outcome") else ""
63
+ lines.append(f"- {review['summary']} {status}")
64
+ if review.get("feedback"):
65
+ lines.append(f" Feedback: {review['feedback']}")
66
+ if review.get("files"):
67
+ lines.append(f" Files: {', '.join(review['files'])}")
68
+ return "\n".join(lines)
mnemo/config.py ADDED
@@ -0,0 +1,30 @@
1
+ """Mnemo configuration and constants."""
2
+
3
+ from pathlib import Path
4
+
5
+ MNEMO_DIR = ".mnemo"
6
+ MEMORY_FILE = "memory.json"
7
+ REPO_MAP_FILE = "repo_map.json"
8
+ DECISIONS_FILE = "decisions.json"
9
+ CONTEXT_FILE = "context.json"
10
+
11
+ SUPPORTED_EXTENSIONS = {
12
+ ".py": "python",
13
+ ".js": "javascript",
14
+ ".ts": "typescript",
15
+ ".tsx": "typescript",
16
+ ".jsx": "javascript",
17
+ ".go": "go",
18
+ ".cs": "csharp",
19
+ }
20
+
21
+ IGNORE_DIRS = {
22
+ "node_modules", ".git", "__pycache__", ".venv", "venv",
23
+ "dist", "build", ".mnemo", ".tox", ".mypy_cache", "egg-info",
24
+ "bin", "obj", "packages", ".vs",
25
+ "wwwroot", "publish", "artifacts", "TestResults",
26
+ }
27
+
28
+
29
+ def mnemo_path(repo_root: Path) -> Path:
30
+ return repo_root / MNEMO_DIR
@@ -0,0 +1,126 @@
1
+ """Dependency Graph — map service-to-service relationships and impact analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from ..config import IGNORE_DIRS, mnemo_path
10
+
11
+
12
+ def _should_ignore(path: Path) -> bool:
13
+ return any(part in IGNORE_DIRS for part in path.parts)
14
+
15
+
16
+ def build_dependency_graph(repo_root: Path) -> dict[str, dict]:
17
+ """Build a graph of service dependencies from code analysis."""
18
+ graph: dict[str, dict] = {}
19
+
20
+ # Detect services (top-level directories with Program.cs or .csproj)
21
+ services = set()
22
+ for f in repo_root.rglob("Program.cs"):
23
+ if not _should_ignore(f):
24
+ services.add(f.relative_to(repo_root).parts[0])
25
+ for f in repo_root.rglob("*.csproj"):
26
+ if not _should_ignore(f):
27
+ services.add(f.relative_to(repo_root).parts[0])
28
+
29
+ for service in services:
30
+ svc_path = repo_root / service
31
+ graph[service] = {"calls": [], "called_by": [], "packages": [], "internal_deps": []}
32
+
33
+ for cs_file in svc_path.rglob("*.cs"):
34
+ if _should_ignore(cs_file):
35
+ continue
36
+ try:
37
+ content = cs_file.read_text(errors="replace")
38
+ except (OSError, PermissionError):
39
+ continue
40
+
41
+ # Detect HTTP calls to other services
42
+ for other_svc in services:
43
+ if other_svc == service:
44
+ continue
45
+ if other_svc.lower() in content.lower():
46
+ if other_svc not in graph[service]["calls"]:
47
+ graph[service]["calls"].append(other_svc)
48
+
49
+ # Detect project references
50
+ refs = re.findall(r'using\s+([\w.]+Service)', content)
51
+ for ref in refs:
52
+ if ref not in graph[service]["internal_deps"]:
53
+ graph[service]["internal_deps"].append(ref)
54
+
55
+ # Parse .csproj for package deps
56
+ for csproj in svc_path.rglob("*.csproj"):
57
+ if _should_ignore(csproj):
58
+ continue
59
+ try:
60
+ content = csproj.read_text(errors="replace")
61
+ packages = re.findall(r'<PackageReference\s+Include="([^"]+)"', content)
62
+ graph[service]["packages"] = packages
63
+ except (OSError, PermissionError):
64
+ pass
65
+
66
+ # Build reverse graph (called_by)
67
+ for svc, info in graph.items():
68
+ for target in info["calls"]:
69
+ if target in graph:
70
+ if svc not in graph[target]["called_by"]:
71
+ graph[target]["called_by"].append(svc)
72
+
73
+ return graph
74
+
75
+
76
+ def impact_analysis(repo_root: Path, service_or_file: str) -> str:
77
+ """Answer: what breaks if I change this?"""
78
+ graph = build_dependency_graph(repo_root)
79
+ query = service_or_file.lower()
80
+
81
+ # Find matching service
82
+ target_svc = None
83
+ for svc in graph:
84
+ if query in svc.lower():
85
+ target_svc = svc
86
+ break
87
+
88
+ if not target_svc:
89
+ return f"No service matching '{service_or_file}' found."
90
+
91
+ info = graph[target_svc]
92
+ lines = [f"# Impact Analysis: {target_svc}\n"]
93
+
94
+ if info["called_by"]:
95
+ lines.append("## Services that depend on this (will break if you change the API):")
96
+ for dep in info["called_by"]:
97
+ lines.append(f"- **{dep}**")
98
+ lines.append("")
99
+
100
+ if info["calls"]:
101
+ lines.append("## Services this depends on:")
102
+ for dep in info["calls"]:
103
+ lines.append(f"- {dep}")
104
+ lines.append("")
105
+
106
+ if not info["called_by"] and not info["calls"]:
107
+ lines.append("No known dependencies. This service appears isolated.")
108
+
109
+ return "\n".join(lines)
110
+
111
+
112
+ def format_graph(repo_root: Path) -> str:
113
+ """Format the full dependency graph as markdown."""
114
+ graph = build_dependency_graph(repo_root)
115
+ if not graph:
116
+ return "No services detected."
117
+
118
+ lines = ["# Dependency Graph\n"]
119
+ for svc in sorted(graph.keys()):
120
+ info = graph[svc]
121
+ calls = f" → {', '.join(info['calls'])}" if info["calls"] else ""
122
+ called_by = f" ← {', '.join(info['called_by'])}" if info["called_by"] else ""
123
+ lines.append(f"**{svc}**{calls}{called_by}")
124
+ if info["packages"]:
125
+ lines.append(f" Packages: {', '.join(info['packages'][:5])}")
126
+ return "\n".join(lines)
mnemo/doctor.py ADDED
@@ -0,0 +1,118 @@
1
+ """Installation and setup diagnostics for Mnemo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ from .clients import DEFAULT_CLIENT, ClientTarget, context_path, find_mnemo_mcp_command, resolve_clients
12
+ from .config import mnemo_path
13
+
14
+
15
+ def _status(ok: bool) -> str:
16
+ return "OK" if ok else "WARN"
17
+
18
+
19
+ def _check_mcp_config(target: ClientTarget) -> tuple[bool, str]:
20
+ if target.mcp_config_path is None:
21
+ return True, "manual MCP config required"
22
+ if not target.mcp_config_path.exists():
23
+ return False, f"missing {target.mcp_config_path}"
24
+ try:
25
+ config = json.loads(target.mcp_config_path.read_text(encoding="utf-8"))
26
+ except json.JSONDecodeError:
27
+ return False, f"invalid JSON in {target.mcp_config_path}"
28
+ if "mnemo" not in config.get("mcpServers", {}):
29
+ return False, f"mnemo server not registered in {target.mcp_config_path}"
30
+ return True, str(target.mcp_config_path)
31
+
32
+
33
+ def _check_mcp_alive(command: str) -> bool:
34
+ """Send a ping to the MCP server to check if it responds."""
35
+ init_msg = json.dumps({
36
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
37
+ "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "doctor"}}
38
+ })
39
+ try:
40
+ result = subprocess.run(
41
+ [command, "mcp"] if command != "mnemo-mcp" else [command],
42
+ input=init_msg, capture_output=True, text=True, timeout=5,
43
+ )
44
+ return '"serverInfo"' in result.stdout
45
+ except Exception:
46
+ return False
47
+
48
+
49
+ def _check_context_file(repo_root: Path, target: ClientTarget) -> tuple[bool, str]:
50
+ path = context_path(repo_root, target)
51
+ if path is None:
52
+ return True, "no context file required"
53
+ if path.exists():
54
+ return True, str(path.relative_to(repo_root))
55
+ return False, f"missing {path.relative_to(repo_root)}"
56
+
57
+
58
+ def doctor(repo_root: Path, client: str = DEFAULT_CLIENT) -> str:
59
+ """Return a human-readable setup diagnostic report."""
60
+ targets = resolve_clients(client)
61
+ lines = ["# Mnemo Doctor", ""]
62
+
63
+ python_ok = sys.version_info >= (3, 10)
64
+ lines.append(f"[{_status(python_ok)}] Python: {sys.version.split()[0]} (requires 3.10+)")
65
+ lines.append(f"[OK] Runtime mode: {'binary' if getattr(sys, 'frozen', False) else 'python-package'}")
66
+
67
+ try:
68
+ package_version = version("mnemo")
69
+ lines.append(f"[OK] Mnemo package: {package_version}")
70
+ except PackageNotFoundError:
71
+ lines.append("[WARN] Mnemo package: not installed as a package")
72
+
73
+ try:
74
+ import chromadb
75
+ lines.append(f"[OK] Semantic search: chromadb {chromadb.__version__}")
76
+ except ImportError:
77
+ lines.append("[OK] Semantic search: keyword fallback (chromadb will auto-install on first use)")
78
+
79
+ command = find_mnemo_mcp_command()
80
+ command_ok = command != "mnemo-mcp"
81
+ lines.append(f"[{_status(command_ok)}] mnemo-mcp command: {command}")
82
+
83
+ # Live MCP server check
84
+ mcp_alive = _check_mcp_alive(command)
85
+ lines.append(f"[{_status(mcp_alive)}] MCP server responds: {'yes' if mcp_alive else 'no — restart your IDE'}")
86
+
87
+ base = mnemo_path(repo_root)
88
+ initialized = base.exists()
89
+ lines.append(f"[{_status(initialized)}] Repository initialized: {base}")
90
+
91
+ if initialized:
92
+ for filename in ("context.json", "memory.json", "decisions.json", "hashes.json"):
93
+ path = base / filename
94
+ exists = path.exists()
95
+ lines.append(f"[{_status(exists)}] {filename}: {path}")
96
+
97
+ lines.append("")
98
+ lines.append("## Client Setup")
99
+ for target in targets:
100
+ context_ok, context_message = _check_context_file(repo_root, target)
101
+ config_ok, config_message = _check_mcp_config(target)
102
+ lines.append(f"[{_status(context_ok)}] {target.display_name} context: {context_message}")
103
+ lines.append(f"[{_status(config_ok)}] {target.display_name} MCP config: {config_message}")
104
+
105
+ if not initialized:
106
+ lines.append("")
107
+ lines.append("Run `mnemo init` in this repository.")
108
+ else:
109
+ missing_clients = [
110
+ target.display_name
111
+ for target in targets
112
+ if not _check_context_file(repo_root, target)[0] or not _check_mcp_config(target)[0]
113
+ ]
114
+ if missing_clients:
115
+ lines.append("")
116
+ lines.append(f"Run `mnemo init --client {client}` to repair missing client setup.")
117
+
118
+ return "\n".join(lines)
@@ -0,0 +1,47 @@
1
+ """Embedding provider abstractions for local semantic retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from dataclasses import dataclass
7
+ import re
8
+ from typing import Protocol
9
+
10
+
11
+ def _tokenize(text: str) -> list[str]:
12
+ normalized = text.lower().replace("_", " ")
13
+ return re.findall(r"[a-zA-Z0-9]+", normalized)
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SparseEmbedding:
18
+ """Lightweight token-count embedding used for fallback retrieval."""
19
+
20
+ counts: dict[str, int]
21
+
22
+ def score(self, other: "SparseEmbedding") -> float:
23
+ if not self.counts or not other.counts:
24
+ return 0.0
25
+ overlap = sum(min(self.counts.get(tok, 0), other.counts.get(tok, 0)) for tok in self.counts)
26
+ norm = max(sum(self.counts.values()), 1)
27
+ return overlap / norm
28
+
29
+
30
+ class EmbeddingProvider(Protocol):
31
+ """Embedding provider protocol."""
32
+
33
+ def embed(self, text: str) -> SparseEmbedding:
34
+ ...
35
+
36
+ def embed_many(self, texts: list[str]) -> list[SparseEmbedding]:
37
+ ...
38
+
39
+
40
+ class KeywordEmbeddingProvider:
41
+ """Cheap keyword provider used as default and in tests."""
42
+
43
+ def embed(self, text: str) -> SparseEmbedding:
44
+ return SparseEmbedding(dict(Counter(_tokenize(text))))
45
+
46
+ def embed_many(self, texts: list[str]) -> list[SparseEmbedding]:
47
+ return [self.embed(text) for text in texts]
@@ -0,0 +1,81 @@
1
+ """Error memory - store error, cause, and fix mappings for instant recall."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from ..storage import Collections, get_storage
9
+
10
+
11
+ def _load_errors(repo_root: Path) -> list[dict]:
12
+ data = get_storage(repo_root).read_collection(Collections.ERRORS)
13
+ return data if isinstance(data, list) else []
14
+
15
+
16
+ def _save_errors(repo_root: Path, errors: list[dict]) -> None:
17
+ get_storage(repo_root).write_collection(Collections.ERRORS, errors[-200:])
18
+
19
+
20
+ def add_error(
21
+ repo_root: Path,
22
+ error: str,
23
+ cause: str,
24
+ fix: str,
25
+ file: str = "",
26
+ tags: list[str] | None = None,
27
+ ) -> dict:
28
+ """Store an error, cause, and fix mapping."""
29
+ errors = _load_errors(repo_root)
30
+ entry = {
31
+ "id": len(errors) + 1,
32
+ "timestamp": time.time(),
33
+ "error": error,
34
+ "cause": cause,
35
+ "fix": fix,
36
+ "file": file,
37
+ "tags": tags or [],
38
+ }
39
+ errors.append(entry)
40
+ _save_errors(repo_root, errors)
41
+ return entry
42
+
43
+
44
+ def search_errors(repo_root: Path, query: str) -> str:
45
+ """Search error memory for matching errors."""
46
+ errors = _load_errors(repo_root)
47
+ query_lower = query.lower()
48
+
49
+ matches = [
50
+ error
51
+ for error in errors
52
+ if query_lower in error["error"].lower()
53
+ or query_lower in error.get("cause", "").lower()
54
+ or query_lower in error.get("file", "").lower()
55
+ or any(query_lower in tag.lower() for tag in error.get("tags", []))
56
+ ]
57
+
58
+ if not matches:
59
+ return f"No known errors matching '{query}'."
60
+
61
+ lines = [f"# Known Errors matching '{query}'\n"]
62
+ for error in matches[-10:]:
63
+ lines.append(f"## {error['error']}")
64
+ lines.append(f"**Cause:** {error['cause']}")
65
+ lines.append(f"**Fix:** {error['fix']}")
66
+ if error.get("file"):
67
+ lines.append(f"**File:** {error['file']}")
68
+ lines.append("")
69
+ return "\n".join(lines)
70
+
71
+
72
+ def format_errors(repo_root: Path) -> str:
73
+ """Format all stored errors as markdown."""
74
+ errors = _load_errors(repo_root)
75
+ if not errors:
76
+ return "No errors stored."
77
+
78
+ lines = ["# Error Memory\n"]
79
+ for error in errors[-20:]:
80
+ lines.append(f"- **{error['error']}** -> {error['fix']}")
81
+ return "\n".join(lines)