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/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Roslyn analyzer bridge — uses .NET SDK when available for richer C# analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_ANALYZER_DIR = Path(__file__).parent.parent.parent / "analyzers" / "roslyn"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def dotnet_available() -> bool:
|
|
15
|
+
"""Check if .NET SDK is installed."""
|
|
16
|
+
return shutil.which("dotnet") is not None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _find_solution_or_project(repo_root: Path) -> Path | None:
|
|
20
|
+
"""Find .sln or .csproj in the repo."""
|
|
21
|
+
slns = list(repo_root.glob("*.sln"))
|
|
22
|
+
if slns:
|
|
23
|
+
return slns[0]
|
|
24
|
+
csprojs = list(repo_root.rglob("*.csproj"))
|
|
25
|
+
if csprojs:
|
|
26
|
+
return repo_root
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def roslyn_available(repo_root: Path) -> bool:
|
|
31
|
+
"""Check if Roslyn analysis is possible for this repo."""
|
|
32
|
+
if not dotnet_available():
|
|
33
|
+
return False
|
|
34
|
+
return _find_solution_or_project(repo_root) is not None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_roslyn_analyzer(repo_root: Path) -> list[dict[str, Any]] | None:
|
|
38
|
+
"""Run the Roslyn analyzer and return parsed results."""
|
|
39
|
+
if not dotnet_available():
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
target = _find_solution_or_project(repo_root)
|
|
43
|
+
if target is None:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
["dotnet", "run", "--project", str(_ANALYZER_DIR), "--", str(target)],
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
timeout=120,
|
|
52
|
+
cwd=str(repo_root),
|
|
53
|
+
)
|
|
54
|
+
if result.returncode != 0:
|
|
55
|
+
return None
|
|
56
|
+
# stdout may have warnings before JSON — find the JSON array
|
|
57
|
+
stdout = result.stdout.strip()
|
|
58
|
+
json_start = stdout.find("[")
|
|
59
|
+
if json_start == -1:
|
|
60
|
+
return None
|
|
61
|
+
return json.loads(stdout[json_start:])
|
|
62
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def roslyn_to_mnemo_format(roslyn_results: list[dict[str, Any]], repo_root: Path) -> dict[str, dict[str, Any]]:
|
|
67
|
+
"""Convert Roslyn JSON output to the same format tree-sitter extractors return.
|
|
68
|
+
|
|
69
|
+
Returns: {relative_file_path: {"imports": [...], "classes": [...], "functions": [...]}}
|
|
70
|
+
"""
|
|
71
|
+
output: dict[str, dict[str, Any]] = {}
|
|
72
|
+
|
|
73
|
+
for file_entry in roslyn_results:
|
|
74
|
+
filepath = file_entry.get("file", "")
|
|
75
|
+
if not filepath:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Make path relative to repo root
|
|
79
|
+
try:
|
|
80
|
+
rel = str(Path(filepath).relative_to(repo_root))
|
|
81
|
+
except ValueError:
|
|
82
|
+
rel = filepath
|
|
83
|
+
|
|
84
|
+
info: dict[str, Any] = {}
|
|
85
|
+
|
|
86
|
+
imports = file_entry.get("imports", [])
|
|
87
|
+
if imports:
|
|
88
|
+
info["imports"] = imports
|
|
89
|
+
|
|
90
|
+
classes = file_entry.get("classes", [])
|
|
91
|
+
if classes:
|
|
92
|
+
info["classes"] = []
|
|
93
|
+
for cls in classes:
|
|
94
|
+
entry: dict[str, Any] = {"name": cls["name"]}
|
|
95
|
+
if cls.get("implements"):
|
|
96
|
+
entry["implements"] = cls["implements"]
|
|
97
|
+
if cls.get("methods"):
|
|
98
|
+
entry["methods"] = cls["methods"]
|
|
99
|
+
info["classes"].append(entry)
|
|
100
|
+
|
|
101
|
+
functions = file_entry.get("functions", [])
|
|
102
|
+
if functions:
|
|
103
|
+
info["functions"] = functions
|
|
104
|
+
|
|
105
|
+
if info:
|
|
106
|
+
output[rel] = info
|
|
107
|
+
|
|
108
|
+
return output
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""API Discovery — parse OpenAPI/Swagger specs and service endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..chunking import api_endpoint_chunk
|
|
10
|
+
from ..config import IGNORE_DIRS, mnemo_path
|
|
11
|
+
from ..retrieval import index_chunks, semantic_query
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _should_ignore(path: Path) -> bool:
|
|
15
|
+
return any(part in IGNORE_DIRS for part in path.parts)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _find_openapi_specs(repo_root: Path) -> list[Path]:
|
|
19
|
+
"""Find OpenAPI/Swagger spec files."""
|
|
20
|
+
specs = []
|
|
21
|
+
patterns = ["swagger.json", "openapi.json", "swagger.yaml", "openapi.yaml"]
|
|
22
|
+
|
|
23
|
+
for pattern in patterns:
|
|
24
|
+
for f in repo_root.rglob(pattern):
|
|
25
|
+
if not _should_ignore(f):
|
|
26
|
+
specs.append(f)
|
|
27
|
+
|
|
28
|
+
# Also check for specs in common locations
|
|
29
|
+
for candidate in [
|
|
30
|
+
repo_root / "docs" / "api",
|
|
31
|
+
repo_root / "api",
|
|
32
|
+
repo_root / "specs",
|
|
33
|
+
]:
|
|
34
|
+
if candidate.exists():
|
|
35
|
+
for f in candidate.rglob("*.json"):
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(f.read_text())
|
|
38
|
+
if "openapi" in data or "swagger" in data:
|
|
39
|
+
specs.append(f)
|
|
40
|
+
except (json.JSONDecodeError, OSError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
return specs
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_openapi(spec_path: Path) -> dict[str, Any] | None:
|
|
47
|
+
"""Parse an OpenAPI spec into a compact summary."""
|
|
48
|
+
try:
|
|
49
|
+
content = spec_path.read_text()
|
|
50
|
+
if spec_path.suffix in (".yaml", ".yml"):
|
|
51
|
+
try:
|
|
52
|
+
import yaml
|
|
53
|
+
data = yaml.safe_load(content)
|
|
54
|
+
except ImportError:
|
|
55
|
+
return None
|
|
56
|
+
else:
|
|
57
|
+
data = json.loads(content)
|
|
58
|
+
except (json.JSONDecodeError, OSError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
if not data or not isinstance(data, dict):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
info = data.get("info", {})
|
|
65
|
+
paths = data.get("paths", {})
|
|
66
|
+
|
|
67
|
+
endpoints = []
|
|
68
|
+
for path, methods in paths.items():
|
|
69
|
+
for method, details in methods.items():
|
|
70
|
+
if method in ("get", "post", "put", "delete", "patch"):
|
|
71
|
+
endpoint = {
|
|
72
|
+
"method": method.upper(),
|
|
73
|
+
"path": path,
|
|
74
|
+
"summary": details.get("summary", details.get("operationId", "")),
|
|
75
|
+
}
|
|
76
|
+
# Get request body schema name
|
|
77
|
+
req_body = details.get("requestBody", {})
|
|
78
|
+
if req_body:
|
|
79
|
+
content_types = req_body.get("content", {})
|
|
80
|
+
for ct, schema_info in content_types.items():
|
|
81
|
+
ref = schema_info.get("schema", {}).get("$ref", "")
|
|
82
|
+
if ref:
|
|
83
|
+
endpoint["request_schema"] = ref.split("/")[-1]
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
# Get response schema
|
|
87
|
+
responses = details.get("responses", {})
|
|
88
|
+
for code, resp in responses.items():
|
|
89
|
+
if code.startswith("2"):
|
|
90
|
+
resp_content = resp.get("content", {})
|
|
91
|
+
for ct, schema_info in resp_content.items():
|
|
92
|
+
ref = schema_info.get("schema", {}).get("$ref", "")
|
|
93
|
+
if ref:
|
|
94
|
+
endpoint["response_schema"] = ref.split("/")[-1]
|
|
95
|
+
break
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
endpoints.append(endpoint)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"title": info.get("title", spec_path.stem),
|
|
102
|
+
"version": info.get("version", ""),
|
|
103
|
+
"base_url": (data.get("servers", [{}])[0].get("url", "") if data.get("servers") else ""),
|
|
104
|
+
"endpoints": endpoints,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _detect_endpoints_from_controllers(repo_root: Path) -> list[dict[str, Any]]:
|
|
109
|
+
"""Detect API endpoints from controller attributes in .NET code."""
|
|
110
|
+
import re
|
|
111
|
+
endpoints = []
|
|
112
|
+
|
|
113
|
+
for cs_file in repo_root.rglob("*Controller.cs"):
|
|
114
|
+
if _should_ignore(cs_file):
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
content = cs_file.read_text(errors="replace")
|
|
118
|
+
except (OSError, PermissionError):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Skip test files
|
|
122
|
+
if "Tests" in str(cs_file):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
service = cs_file.relative_to(repo_root).parts[0]
|
|
126
|
+
|
|
127
|
+
# Get route prefix from class
|
|
128
|
+
class_route = ""
|
|
129
|
+
route_match = re.search(r'\[Route\("([^"]+)"\)\]', content)
|
|
130
|
+
if route_match:
|
|
131
|
+
class_route = route_match.group(1)
|
|
132
|
+
|
|
133
|
+
# Find HTTP method attributes (flexible whitespace matching)
|
|
134
|
+
for match in re.finditer(
|
|
135
|
+
r'\[(Http(Get|Post|Put|Delete|Patch))(?:\("([^"]*)"\))?\]\s*(?:\[.*?\]\s*)*public\s+(?:async\s+)?\S+\s+(\w+)',
|
|
136
|
+
content, re.DOTALL
|
|
137
|
+
):
|
|
138
|
+
method = match.group(2).upper()
|
|
139
|
+
route = match.group(3) or ""
|
|
140
|
+
func_name = match.group(4)
|
|
141
|
+
full_path = f"/{class_route}/{route}".replace("//", "/").rstrip("/")
|
|
142
|
+
full_path = re.sub(r'\[controller\]', cs_file.stem.replace('Controller', '').lower(), full_path)
|
|
143
|
+
endpoints.append({
|
|
144
|
+
"service": service,
|
|
145
|
+
"method": method,
|
|
146
|
+
"path": full_path,
|
|
147
|
+
"handler": func_name,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return endpoints
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def discover_apis(repo_root: Path) -> str:
|
|
154
|
+
"""Discover all APIs in the repo and return as markdown."""
|
|
155
|
+
lines = ["# API Discovery\n"]
|
|
156
|
+
|
|
157
|
+
endpoint_chunks = []
|
|
158
|
+
|
|
159
|
+
# Try OpenAPI specs first
|
|
160
|
+
specs = _find_openapi_specs(repo_root)
|
|
161
|
+
if specs:
|
|
162
|
+
lines.append("## OpenAPI Specifications")
|
|
163
|
+
for spec_path in specs:
|
|
164
|
+
parsed = _parse_openapi(spec_path)
|
|
165
|
+
if parsed:
|
|
166
|
+
lines.append(f"\n### {parsed['title']} (v{parsed['version']})")
|
|
167
|
+
if parsed['base_url']:
|
|
168
|
+
lines.append(f"Base URL: `{parsed['base_url']}`")
|
|
169
|
+
lines.append("")
|
|
170
|
+
for ep in parsed["endpoints"]:
|
|
171
|
+
req = f" ← {ep['request_schema']}" if ep.get("request_schema") else ""
|
|
172
|
+
resp = f" → {ep['response_schema']}" if ep.get("response_schema") else ""
|
|
173
|
+
lines.append(f"- `{ep['method']} {ep['path']}` {ep['summary']}{req}{resp}")
|
|
174
|
+
endpoint_chunks.append(
|
|
175
|
+
api_endpoint_chunk(
|
|
176
|
+
path=str(spec_path.relative_to(repo_root)),
|
|
177
|
+
method=ep["method"],
|
|
178
|
+
endpoint=ep["path"],
|
|
179
|
+
summary=ep.get("summary", ""),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
lines.append("")
|
|
183
|
+
|
|
184
|
+
# Detect from controllers
|
|
185
|
+
controller_endpoints = _detect_endpoints_from_controllers(repo_root)
|
|
186
|
+
if controller_endpoints:
|
|
187
|
+
lines.append("## Controller Endpoints")
|
|
188
|
+
# Group by service
|
|
189
|
+
by_service: dict[str, list] = {}
|
|
190
|
+
for ep in controller_endpoints:
|
|
191
|
+
svc = ep["service"]
|
|
192
|
+
if svc not in by_service:
|
|
193
|
+
by_service[svc] = []
|
|
194
|
+
by_service[svc].append(ep)
|
|
195
|
+
|
|
196
|
+
for svc in sorted(by_service.keys()):
|
|
197
|
+
lines.append(f"\n### {svc}")
|
|
198
|
+
for ep in by_service[svc]:
|
|
199
|
+
lines.append(f"- `{ep['method']} {ep['path']}` → {ep['handler']}()")
|
|
200
|
+
endpoint_chunks.append(
|
|
201
|
+
api_endpoint_chunk(
|
|
202
|
+
path=f"{svc}/controller",
|
|
203
|
+
method=ep["method"],
|
|
204
|
+
endpoint=ep["path"],
|
|
205
|
+
summary=f"Handler: {ep['handler']}",
|
|
206
|
+
service=svc,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
lines.append("")
|
|
210
|
+
|
|
211
|
+
if endpoint_chunks:
|
|
212
|
+
index_chunks(repo_root, "api", endpoint_chunks)
|
|
213
|
+
|
|
214
|
+
if len(lines) <= 2:
|
|
215
|
+
return "No APIs discovered. Add OpenAPI specs or check controller annotations."
|
|
216
|
+
|
|
217
|
+
return "\n".join(lines)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def search_api(repo_root: Path, query: str) -> str:
|
|
221
|
+
"""Search for a specific API endpoint or schema."""
|
|
222
|
+
full_report = discover_apis(repo_root)
|
|
223
|
+
semantic_results = semantic_query(repo_root, "api", query, limit=8)
|
|
224
|
+
if semantic_results:
|
|
225
|
+
lines = [f"# API Search: '{query}'\n", "## Semantic Matches"]
|
|
226
|
+
for result in semantic_results:
|
|
227
|
+
meta = result.get("metadata", {})
|
|
228
|
+
lines.append(f"- `{meta.get('symbol', '')}` ({meta.get('path', '')})")
|
|
229
|
+
lines.append(f" {result.get('content', '')[:240]}")
|
|
230
|
+
return "\n".join(lines)
|
|
231
|
+
query_lower = query.lower()
|
|
232
|
+
|
|
233
|
+
# Filter lines that match
|
|
234
|
+
lines = full_report.splitlines()
|
|
235
|
+
results = []
|
|
236
|
+
current_section = ""
|
|
237
|
+
for line in lines:
|
|
238
|
+
if line.startswith("#"):
|
|
239
|
+
current_section = line
|
|
240
|
+
if query_lower in line.lower():
|
|
241
|
+
if current_section and current_section not in results:
|
|
242
|
+
results.append(current_section)
|
|
243
|
+
results.append(line)
|
|
244
|
+
|
|
245
|
+
if not results:
|
|
246
|
+
return f"No API endpoints matching '{query}'. Run full discovery with mnemo_discover_apis."
|
|
247
|
+
|
|
248
|
+
return "\n".join(results)
|
mnemo/chunking.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Chunk schema and builders for semantic indexing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Chunk:
|
|
13
|
+
"""Canonical chunk used for local semantic retrieval."""
|
|
14
|
+
|
|
15
|
+
id: str
|
|
16
|
+
chunk_type: str
|
|
17
|
+
path: str
|
|
18
|
+
language: str
|
|
19
|
+
symbol: str
|
|
20
|
+
content: str
|
|
21
|
+
hash: str
|
|
22
|
+
metadata: dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _hash(text: str) -> str:
|
|
26
|
+
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:16]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _chunk_id(chunk_type: str, path: str, symbol: str) -> str:
|
|
30
|
+
raw = f"{chunk_type}:{path}:{symbol}"
|
|
31
|
+
return hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()[:24]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_code_chunks(path: str, language: str, info: dict[str, Any]) -> list[Chunk]:
|
|
35
|
+
"""Build code chunks from parsed file info."""
|
|
36
|
+
chunks: list[Chunk] = []
|
|
37
|
+
|
|
38
|
+
for cls in info.get("classes", []):
|
|
39
|
+
name = str(cls.get("name", "UnknownClass"))
|
|
40
|
+
methods = cls.get("methods", [])
|
|
41
|
+
methods_text = "\n".join(f"- {method}" for method in methods)
|
|
42
|
+
content = f"class {name}\n{methods_text}".strip()
|
|
43
|
+
chunks.append(
|
|
44
|
+
Chunk(
|
|
45
|
+
id=_chunk_id("code", path, f"class:{name}"),
|
|
46
|
+
chunk_type="code",
|
|
47
|
+
path=path,
|
|
48
|
+
language=language,
|
|
49
|
+
symbol=name,
|
|
50
|
+
content=content,
|
|
51
|
+
hash=_hash(content),
|
|
52
|
+
metadata={"kind": "class", "method_count": len(methods)},
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
seen_ids: set[str] = set()
|
|
57
|
+
for fn in info.get("functions", []):
|
|
58
|
+
text = str(fn)
|
|
59
|
+
symbol = text.split("(")[0].replace("def ", "").strip()
|
|
60
|
+
chunk_id = _chunk_id("code", path, f"function:{symbol}:{_hash(text)}")
|
|
61
|
+
if chunk_id in seen_ids:
|
|
62
|
+
continue
|
|
63
|
+
seen_ids.add(chunk_id)
|
|
64
|
+
chunks.append(
|
|
65
|
+
Chunk(
|
|
66
|
+
id=chunk_id,
|
|
67
|
+
chunk_type="code",
|
|
68
|
+
path=path,
|
|
69
|
+
language=language,
|
|
70
|
+
symbol=symbol or "function",
|
|
71
|
+
content=text,
|
|
72
|
+
hash=_hash(text),
|
|
73
|
+
metadata={"kind": "function"},
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return chunks
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def markdown_heading_chunks(base_dir: Path, file_path: Path) -> list[Chunk]:
|
|
80
|
+
"""Split a markdown file into heading-based chunks."""
|
|
81
|
+
try:
|
|
82
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
83
|
+
except OSError:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
rel_path = str(file_path.relative_to(base_dir))
|
|
87
|
+
lines = content.splitlines()
|
|
88
|
+
sections: list[tuple[str, list[str]]] = []
|
|
89
|
+
current_heading = "Document"
|
|
90
|
+
current_lines: list[str] = []
|
|
91
|
+
|
|
92
|
+
for line in lines:
|
|
93
|
+
if line.startswith("#"):
|
|
94
|
+
if current_lines:
|
|
95
|
+
sections.append((current_heading, current_lines))
|
|
96
|
+
current_heading = line.lstrip("#").strip() or "Section"
|
|
97
|
+
current_lines = [line]
|
|
98
|
+
else:
|
|
99
|
+
current_lines.append(line)
|
|
100
|
+
if current_lines:
|
|
101
|
+
sections.append((current_heading, current_lines))
|
|
102
|
+
|
|
103
|
+
chunks: list[Chunk] = []
|
|
104
|
+
for heading, body_lines in sections:
|
|
105
|
+
text = "\n".join(body_lines).strip()
|
|
106
|
+
if not text:
|
|
107
|
+
continue
|
|
108
|
+
chunks.append(
|
|
109
|
+
Chunk(
|
|
110
|
+
id=_chunk_id("knowledge", rel_path, heading),
|
|
111
|
+
chunk_type="knowledge",
|
|
112
|
+
path=rel_path,
|
|
113
|
+
language="markdown",
|
|
114
|
+
symbol=heading,
|
|
115
|
+
content=text,
|
|
116
|
+
hash=_hash(text),
|
|
117
|
+
metadata={"kind": "heading"},
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return chunks
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def api_endpoint_chunk(path: str, method: str, endpoint: str, summary: str, service: str = "") -> Chunk:
|
|
124
|
+
"""Build a chunk from an API endpoint."""
|
|
125
|
+
symbol = f"{method.upper()} {endpoint}"
|
|
126
|
+
content = f"{symbol}\n{summary}".strip()
|
|
127
|
+
return Chunk(
|
|
128
|
+
id=_chunk_id("api", path, symbol),
|
|
129
|
+
chunk_type="api",
|
|
130
|
+
path=path,
|
|
131
|
+
language="http",
|
|
132
|
+
symbol=symbol,
|
|
133
|
+
content=content,
|
|
134
|
+
hash=_hash(content),
|
|
135
|
+
metadata={"kind": "endpoint", "service": service},
|
|
136
|
+
)
|
mnemo/cli.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Mnemo CLI - persistent memory for AI coding chats."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .clients import CLIENT_CHOICES, DEFAULT_CLIENT
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def cli():
|
|
12
|
+
"""Mnemo - persistent memory and repo map for AI coding assistants."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@cli.command()
|
|
17
|
+
@click.option(
|
|
18
|
+
"--client",
|
|
19
|
+
"-c",
|
|
20
|
+
default=DEFAULT_CLIENT,
|
|
21
|
+
type=click.Choice(CLIENT_CHOICES),
|
|
22
|
+
show_default=True,
|
|
23
|
+
help="AI client to configure.",
|
|
24
|
+
)
|
|
25
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
26
|
+
def init(path: str, client: str):
|
|
27
|
+
"""Initialize .mnemo/ in the current repo."""
|
|
28
|
+
from .init import init as do_init
|
|
29
|
+
|
|
30
|
+
result = do_init(Path(path).resolve(), client=client)
|
|
31
|
+
click.echo(result)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@cli.command()
|
|
35
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
36
|
+
def map(path: str):
|
|
37
|
+
"""Regenerate the repo map."""
|
|
38
|
+
from .config import mnemo_path
|
|
39
|
+
from .repo_map import save_repo_map
|
|
40
|
+
|
|
41
|
+
repo_root = Path(path).resolve()
|
|
42
|
+
if not mnemo_path(repo_root).exists():
|
|
43
|
+
click.echo("Not initialized. Run `mnemo init` first.")
|
|
44
|
+
return
|
|
45
|
+
save_repo_map(repo_root)
|
|
46
|
+
click.echo("Repo map updated.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cli.command()
|
|
50
|
+
@click.argument("content")
|
|
51
|
+
@click.option("--category", "-c", default="general")
|
|
52
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
53
|
+
def remember(content: str, category: str, path: str):
|
|
54
|
+
"""Store a memory entry. Example: mnemo remember 'uses FastAPI'"""
|
|
55
|
+
from .memory import add_memory
|
|
56
|
+
|
|
57
|
+
entry = add_memory(Path(path).resolve(), content, category)
|
|
58
|
+
click.echo(f"Remembered #{entry['id']}: {content}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cli.command()
|
|
62
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
63
|
+
def recall(path: str):
|
|
64
|
+
"""Show all stored memory."""
|
|
65
|
+
from .memory import recall as do_recall
|
|
66
|
+
|
|
67
|
+
data = do_recall(Path(path).resolve())
|
|
68
|
+
if not data:
|
|
69
|
+
click.echo("No memory found. Run `mnemo init` first.")
|
|
70
|
+
return
|
|
71
|
+
click.echo(data)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@cli.command()
|
|
75
|
+
@click.option(
|
|
76
|
+
"--client",
|
|
77
|
+
"-c",
|
|
78
|
+
default="all",
|
|
79
|
+
type=click.Choice(CLIENT_CHOICES),
|
|
80
|
+
show_default=True,
|
|
81
|
+
help="AI client setup to inspect.",
|
|
82
|
+
)
|
|
83
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
84
|
+
def doctor(path: str, client: str):
|
|
85
|
+
"""Diagnose Mnemo installation and client setup."""
|
|
86
|
+
from .doctor import doctor as run_doctor
|
|
87
|
+
|
|
88
|
+
click.echo(run_doctor(Path(path).resolve(), client=client))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cli.command()
|
|
92
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
93
|
+
@click.confirmation_option(prompt="This will delete all Mnemo memory. Are you sure?")
|
|
94
|
+
def reset(path: str):
|
|
95
|
+
"""Wipe all Mnemo data and start fresh. Run: mnemo reset"""
|
|
96
|
+
import shutil
|
|
97
|
+
|
|
98
|
+
from .config import mnemo_path
|
|
99
|
+
from .clients import CLIENTS, context_path
|
|
100
|
+
|
|
101
|
+
repo_root = Path(path).resolve()
|
|
102
|
+
base = mnemo_path(repo_root)
|
|
103
|
+
|
|
104
|
+
# Remove .mnemo/ data
|
|
105
|
+
if base.exists():
|
|
106
|
+
shutil.rmtree(base)
|
|
107
|
+
click.echo(".mnemo/ deleted.")
|
|
108
|
+
else:
|
|
109
|
+
click.echo("Nothing to reset - .mnemo/ not found.")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# Remove client context files
|
|
113
|
+
for target in CLIENTS.values():
|
|
114
|
+
ctx = context_path(repo_root, target)
|
|
115
|
+
if ctx and ctx.exists():
|
|
116
|
+
ctx.unlink()
|
|
117
|
+
click.echo(f"Removed {ctx.relative_to(repo_root)}")
|
|
118
|
+
|
|
119
|
+
click.echo("Run `mnemo init` to start fresh.")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@cli.command()
|
|
123
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
124
|
+
def status(path: str):
|
|
125
|
+
"""Quick check: is Mnemo initialized and MCP server responding?"""
|
|
126
|
+
from .config import mnemo_path
|
|
127
|
+
from .clients import find_mnemo_mcp_command
|
|
128
|
+
from .doctor import _check_mcp_alive
|
|
129
|
+
|
|
130
|
+
repo_root = Path(path).resolve()
|
|
131
|
+
base = mnemo_path(repo_root)
|
|
132
|
+
|
|
133
|
+
if not base.exists():
|
|
134
|
+
click.echo("❌ Not initialized. Run: mnemo init")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
command = find_mnemo_mcp_command()
|
|
138
|
+
alive = _check_mcp_alive(command)
|
|
139
|
+
|
|
140
|
+
if alive:
|
|
141
|
+
click.echo("✅ Mnemo active — MCP server responding")
|
|
142
|
+
else:
|
|
143
|
+
click.echo("⚠️ Mnemo initialized but MCP server not responding — restart your IDE")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@cli.command()
|
|
147
|
+
@click.argument("target", required=False)
|
|
148
|
+
@click.option("--discover", "-d", type=click.Path(exists=True), help="Auto-discover all repos under a directory.")
|
|
149
|
+
@click.option("--init", "auto_init", is_flag=True, help="Auto-initialize discovered repos that haven't been set up.")
|
|
150
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
151
|
+
def link(target: str | None, discover: str | None, auto_init: bool, path: str):
|
|
152
|
+
"""Link a sibling repo for cross-repo queries. Use --discover to auto-find repos."""
|
|
153
|
+
from .workspace import link_repo, discover_repos
|
|
154
|
+
|
|
155
|
+
repo_root = Path(path).resolve()
|
|
156
|
+
if discover:
|
|
157
|
+
result = discover_repos(repo_root, Path(discover), auto_init=auto_init)
|
|
158
|
+
elif target:
|
|
159
|
+
result = link_repo(repo_root, Path(target))
|
|
160
|
+
else:
|
|
161
|
+
click.echo("Provide a repo path or use --discover <dir>")
|
|
162
|
+
return
|
|
163
|
+
click.echo(result)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@cli.command()
|
|
167
|
+
@click.argument("name")
|
|
168
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
169
|
+
def unlink(name: str, path: str):
|
|
170
|
+
"""Remove a linked repo."""
|
|
171
|
+
from .workspace import unlink_repo
|
|
172
|
+
|
|
173
|
+
click.echo(unlink_repo(Path(path).resolve(), name))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@cli.command()
|
|
177
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
178
|
+
def links(path: str):
|
|
179
|
+
"""Show all linked repos and their status."""
|
|
180
|
+
from .workspace import format_links
|
|
181
|
+
|
|
182
|
+
click.echo(format_links(Path(path).resolve()))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
cli()
|