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/__init__.py +3 -0
- mnemo/analyzers/__init__.py +108 -0
- mnemo/api_discovery/__init__.py +248 -0
- mnemo/chunking.py +136 -0
- mnemo/cli.py +186 -0
- mnemo/clients.py +147 -0
- mnemo/code_review/__init__.py +68 -0
- mnemo/config.py +30 -0
- mnemo/dependency_graph/__init__.py +126 -0
- mnemo/doctor.py +118 -0
- mnemo/embeddings/__init__.py +47 -0
- mnemo/errors/__init__.py +81 -0
- mnemo/health/__init__.py +103 -0
- mnemo/incidents/__init__.py +90 -0
- mnemo/init.py +167 -0
- mnemo/intelligence/__init__.py +323 -0
- mnemo/knowledge/__init__.py +118 -0
- mnemo/mcp_server.py +458 -0
- mnemo/memory.py +250 -0
- mnemo/onboarding/__init__.py +86 -0
- mnemo/repo_map.py +357 -0
- mnemo/retrieval.py +31 -0
- mnemo/sprint/__init__.py +102 -0
- mnemo/storage.py +215 -0
- mnemo/team_graph/__init__.py +96 -0
- mnemo/test_intel/__init__.py +111 -0
- mnemo/vector_index/__init__.py +180 -0
- mnemo/workspace/__init__.py +224 -0
- mnemo_dev-0.1.0.dist-info/METADATA +644 -0
- mnemo_dev-0.1.0.dist-info/RECORD +33 -0
- mnemo_dev-0.1.0.dist-info/WHEEL +5 -0
- mnemo_dev-0.1.0.dist-info/entry_points.txt +3 -0
- mnemo_dev-0.1.0.dist-info/top_level.txt +1 -0
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]
|
mnemo/errors/__init__.py
ADDED
|
@@ -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)
|