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/retrieval.py ADDED
@@ -0,0 +1,31 @@
1
+ """Semantic retrieval orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .chunking import Chunk
8
+ from .vector_index import LocalVectorIndex
9
+
10
+ _INDICES: dict[str, LocalVectorIndex] = {}
11
+
12
+
13
+ def get_index(repo_root: Path) -> LocalVectorIndex:
14
+ key = str(repo_root.resolve())
15
+ if key not in _INDICES:
16
+ _INDICES[key] = LocalVectorIndex(repo_root)
17
+ return _INDICES[key]
18
+
19
+
20
+ def index_chunks(repo_root: Path, namespace: str, chunks: list[Chunk]) -> None:
21
+ get_index(repo_root).upsert(namespace, chunks)
22
+
23
+
24
+ def semantic_query(
25
+ repo_root: Path,
26
+ namespace: str,
27
+ query: str,
28
+ limit: int = 10,
29
+ filters: dict[str, str] | None = None,
30
+ ) -> list[dict]:
31
+ return get_index(repo_root).query(namespace, query, limit=limit, filters=filters)
@@ -0,0 +1,102 @@
1
+ """Sprint/task context - store and recall task-specific context."""
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_tasks(repo_root: Path) -> list[dict]:
12
+ data = get_storage(repo_root).read_collection(Collections.TASKS)
13
+ return data if isinstance(data, list) else []
14
+
15
+
16
+ def _save_tasks(repo_root: Path, tasks: list[dict]) -> None:
17
+ get_storage(repo_root).write_collection(Collections.TASKS, tasks)
18
+
19
+
20
+ def set_current_task(
21
+ repo_root: Path,
22
+ task_id: str,
23
+ description: str = "",
24
+ files: list[str] | None = None,
25
+ notes: str = "",
26
+ ) -> dict:
27
+ """Set the current task/ticket being worked on."""
28
+ tasks = _load_tasks(repo_root)
29
+
30
+ for task in tasks:
31
+ if task["task_id"] == task_id:
32
+ task["last_active"] = time.time()
33
+ if description:
34
+ task["description"] = description
35
+ if files:
36
+ task["files"] = sorted(set(task.get("files", []) + files))
37
+ if notes:
38
+ task["notes"] = task.get("notes", "") + "\n" + notes
39
+ task["status"] = "active"
40
+ _save_tasks(repo_root, tasks)
41
+ return task
42
+
43
+ entry = {
44
+ "task_id": task_id,
45
+ "description": description,
46
+ "files": files or [],
47
+ "notes": notes,
48
+ "status": "active",
49
+ "created": time.time(),
50
+ "last_active": time.time(),
51
+ }
52
+ tasks.append(entry)
53
+ _save_tasks(repo_root, tasks)
54
+ return entry
55
+
56
+
57
+ def complete_task(repo_root: Path, task_id: str, summary: str = "") -> str:
58
+ """Mark a task as complete with an optional summary."""
59
+ tasks = _load_tasks(repo_root)
60
+ for task in tasks:
61
+ if task["task_id"] == task_id:
62
+ task["status"] = "completed"
63
+ task["completed_at"] = time.time()
64
+ if summary:
65
+ task["summary"] = summary
66
+ _save_tasks(repo_root, tasks)
67
+ return f"Task {task_id} marked complete."
68
+ return f"Task {task_id} not found."
69
+
70
+
71
+ def get_current_task(repo_root: Path) -> str:
72
+ """Get the currently active task context."""
73
+ tasks = _load_tasks(repo_root)
74
+ active = [task for task in tasks if task.get("status") == "active"]
75
+
76
+ if not active:
77
+ return "No active task. Use mnemo_task to set one."
78
+
79
+ lines = ["# Active Tasks\n"]
80
+ for task in active:
81
+ lines.append(f"## {task['task_id']}")
82
+ if task.get("description"):
83
+ lines.append(task["description"])
84
+ if task.get("files"):
85
+ lines.append(f"**Files:** {', '.join(task['files'])}")
86
+ if task.get("notes"):
87
+ lines.append(f"**Notes:** {task['notes']}")
88
+ lines.append("")
89
+ return "\n".join(lines)
90
+
91
+
92
+ def format_tasks(repo_root: Path) -> str:
93
+ """Format all tasks as markdown."""
94
+ tasks = _load_tasks(repo_root)
95
+ if not tasks:
96
+ return "No tasks stored."
97
+
98
+ lines = ["# Task History\n"]
99
+ for task in tasks[-20:]:
100
+ status = "done" if task.get("status") == "completed" else "active"
101
+ lines.append(f"- {status}: **{task['task_id']}** - {task.get('description', '')[:80]}")
102
+ return "\n".join(lines)
mnemo/storage.py ADDED
@@ -0,0 +1,215 @@
1
+ """Storage abstraction for Mnemo collections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Protocol
8
+
9
+ from .config import CONTEXT_FILE, DECISIONS_FILE, MEMORY_FILE, mnemo_path
10
+
11
+
12
+ class Collections:
13
+ """Logical storage collection names."""
14
+
15
+ MEMORY = "memory"
16
+ DECISIONS = "decisions"
17
+ CONTEXT = "context"
18
+ HASHES = "hashes"
19
+ ERRORS = "errors"
20
+ INCIDENTS = "incidents"
21
+ REVIEWS = "reviews"
22
+ TASKS = "tasks"
23
+ REPO_MAP = "repo_map"
24
+ KNOWLEDGE = "knowledge"
25
+
26
+
27
+ class StorageAdapter(Protocol):
28
+ """Synchronous storage contract used by local Mnemo tools."""
29
+
30
+ def get(self, collection: str, key: str) -> dict[str, Any] | None:
31
+ """Return a single item by key."""
32
+
33
+ def put(self, collection: str, key: str, value: dict[str, Any]) -> None:
34
+ """Create or replace a single item."""
35
+
36
+ def list(self, collection: str) -> list[dict[str, Any]]:
37
+ """Return all items in a collection."""
38
+
39
+ def query(self, collection: str, filters: dict[str, Any]) -> list[dict[str, Any]]:
40
+ """Return items matching exact filter values."""
41
+
42
+ def delete(self, collection: str, key: str) -> None:
43
+ """Delete a single item by key."""
44
+
45
+ def search(self, collection: str, query: str, k: int = 10) -> list[dict[str, Any]]:
46
+ """Return keyword matches for a collection."""
47
+
48
+ def read_collection(self, collection: str) -> list[dict[str, Any]] | dict[str, Any]:
49
+ """Read a collection in its native persisted shape."""
50
+
51
+ def write_collection(self, collection: str, data: list[dict[str, Any]] | dict[str, Any]) -> None:
52
+ """Write a collection in its native persisted shape."""
53
+
54
+
55
+ COLLECTION_FILES = {
56
+ Collections.MEMORY: MEMORY_FILE,
57
+ Collections.DECISIONS: DECISIONS_FILE,
58
+ Collections.CONTEXT: CONTEXT_FILE,
59
+ Collections.HASHES: "hashes.json",
60
+ Collections.ERRORS: "errors.json",
61
+ Collections.INCIDENTS: "incidents.json",
62
+ Collections.REVIEWS: "reviews.json",
63
+ Collections.TASKS: "tasks.json",
64
+ Collections.REPO_MAP: "repo_map.json",
65
+ }
66
+
67
+ LIST_COLLECTIONS = {
68
+ Collections.MEMORY,
69
+ Collections.DECISIONS,
70
+ Collections.ERRORS,
71
+ Collections.INCIDENTS,
72
+ Collections.REVIEWS,
73
+ Collections.TASKS,
74
+ }
75
+
76
+
77
+ class JSONFileAdapter:
78
+ """Local JSON-file storage backend preserving the current `.mnemo` layout."""
79
+
80
+ def __init__(self, repo_root: Path):
81
+ self.repo_root = repo_root
82
+ self.base_path = mnemo_path(repo_root)
83
+ self.base_path.mkdir(parents=True, exist_ok=True)
84
+
85
+ def collection_path(self, collection: str) -> Path:
86
+ filename = COLLECTION_FILES.get(collection, f"{collection}.json")
87
+ return self.base_path / filename
88
+
89
+ def get(self, collection: str, key: str) -> dict[str, Any] | None:
90
+ data = self._load(collection)
91
+ if isinstance(data, dict):
92
+ value = data.get(key)
93
+ if isinstance(value, dict):
94
+ return value
95
+ if value is not None:
96
+ return {"key": key, "value": value}
97
+ return None
98
+
99
+ for item in data:
100
+ if self._item_key(item) == key:
101
+ return item
102
+ return None
103
+
104
+ def put(self, collection: str, key: str, value: dict[str, Any]) -> None:
105
+ if collection in LIST_COLLECTIONS:
106
+ items = self._load_list(collection)
107
+ replacement = dict(value)
108
+ if "id" not in replacement and key and "task_id" not in replacement:
109
+ replacement["id"] = key
110
+
111
+ for index, item in enumerate(items):
112
+ if self._item_key(item) == key:
113
+ items[index] = replacement
114
+ self.replace_all(collection, items)
115
+ return
116
+
117
+ items.append(replacement)
118
+ self.replace_all(collection, items)
119
+ return
120
+
121
+ data = self._load_dict(collection)
122
+ data[key] = value
123
+ self._save(collection, data)
124
+
125
+ def list(self, collection: str) -> list[dict[str, Any]]:
126
+ data = self._load(collection)
127
+ if isinstance(data, list):
128
+ return [item for item in data if isinstance(item, dict)]
129
+ return [
130
+ {"key": key, **value} if isinstance(value, dict) else {"key": key, "value": value}
131
+ for key, value in data.items()
132
+ ]
133
+
134
+ def query(self, collection: str, filters: dict[str, Any]) -> list[dict[str, Any]]:
135
+ if not filters:
136
+ return self.list(collection)
137
+ return [
138
+ item
139
+ for item in self.list(collection)
140
+ if all(item.get(field) == expected for field, expected in filters.items())
141
+ ]
142
+
143
+ def delete(self, collection: str, key: str) -> None:
144
+ if collection in LIST_COLLECTIONS:
145
+ items = [item for item in self._load_list(collection) if self._item_key(item) != key]
146
+ self.replace_all(collection, items)
147
+ return
148
+
149
+ data = self._load_dict(collection)
150
+ data.pop(key, None)
151
+ self._save(collection, data)
152
+
153
+ def search(self, collection: str, query: str, k: int = 10) -> list[dict[str, Any]]:
154
+ terms = [term for term in query.lower().split() if term]
155
+ if not terms:
156
+ return self.list(collection)[:k]
157
+
158
+ scored: list[tuple[int, dict[str, Any]]] = []
159
+ for item in self.list(collection):
160
+ text = json.dumps(item, sort_keys=True).lower()
161
+ score = sum(1 for term in terms if term in text)
162
+ if score:
163
+ scored.append((score, item))
164
+
165
+ scored.sort(key=lambda match: match[0], reverse=True)
166
+ return [item for _, item in scored[:k]]
167
+
168
+ def replace_all(self, collection: str, items: list[dict[str, Any]]) -> None:
169
+ """Replace a list collection in one write."""
170
+ self._save(collection, items)
171
+
172
+ def read_collection(self, collection: str) -> list[dict[str, Any]] | dict[str, Any]:
173
+ """Read a collection in its native persisted shape."""
174
+ return self._load(collection)
175
+
176
+ def write_collection(self, collection: str, data: list[dict[str, Any]] | dict[str, Any]) -> None:
177
+ """Write a collection in its native persisted shape."""
178
+ self._save(collection, data)
179
+
180
+ def _load(self, collection: str) -> list[dict[str, Any]] | dict[str, Any]:
181
+ path = self.collection_path(collection)
182
+ if not path.exists():
183
+ return [] if collection in LIST_COLLECTIONS else {}
184
+ try:
185
+ data = json.loads(path.read_text(encoding="utf-8"))
186
+ except (json.JSONDecodeError, OSError):
187
+ return [] if collection in LIST_COLLECTIONS else {}
188
+ if collection in LIST_COLLECTIONS:
189
+ return data if isinstance(data, list) else []
190
+ return data if isinstance(data, dict) else {}
191
+
192
+ def _load_list(self, collection: str) -> list[dict[str, Any]]:
193
+ data = self._load(collection)
194
+ return data if isinstance(data, list) else []
195
+
196
+ def _load_dict(self, collection: str) -> dict[str, Any]:
197
+ data = self._load(collection)
198
+ return data if isinstance(data, dict) else {}
199
+
200
+ def _save(self, collection: str, data: Any) -> None:
201
+ path = self.collection_path(collection)
202
+ path.parent.mkdir(parents=True, exist_ok=True)
203
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
204
+
205
+ @staticmethod
206
+ def _item_key(item: dict[str, Any]) -> str:
207
+ for field in ("id", "task_id", "key"):
208
+ if field in item:
209
+ return str(item[field])
210
+ return ""
211
+
212
+
213
+ def get_storage(repo_root: Path) -> StorageAdapter:
214
+ """Return the default local storage adapter."""
215
+ return JSONFileAdapter(repo_root)
@@ -0,0 +1,96 @@
1
+ """Team Knowledge Graph — who knows what, based on git history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from collections import defaultdict
7
+
8
+ from ..config import IGNORE_DIRS
9
+
10
+
11
+ def _should_ignore(path: str) -> bool:
12
+ return any(part in IGNORE_DIRS for part in path.split("/"))
13
+
14
+
15
+ def build_team_graph(repo_root: Path) -> dict[str, dict]:
16
+ """Build expertise map from git history."""
17
+ try:
18
+ from git import Repo
19
+ repo = Repo(repo_root)
20
+ except Exception:
21
+ return {}
22
+
23
+ # author → {service: commit_count}
24
+ expertise: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
25
+ # file → last author
26
+ last_touch: dict[str, str] = {}
27
+
28
+ try:
29
+ for commit in repo.iter_commits(max_count=500, no_merges=True):
30
+ author = commit.author.name
31
+ for file_path in commit.stats.files:
32
+ if _should_ignore(file_path):
33
+ continue
34
+ parts = file_path.split("/")
35
+ service = parts[0] if len(parts) > 1 else "root"
36
+ expertise[author][service] += 1
37
+ if file_path not in last_touch:
38
+ last_touch[file_path] = author
39
+ except Exception:
40
+ pass
41
+
42
+ return {
43
+ "expertise": dict(expertise),
44
+ "last_touch": last_touch,
45
+ }
46
+
47
+
48
+ def get_experts(repo_root: Path, query: str = "") -> str:
49
+ """Find who has expertise in a specific area."""
50
+ graph = build_team_graph(repo_root)
51
+ if not graph:
52
+ return "No git history available for team analysis."
53
+
54
+ expertise = graph.get("expertise", {})
55
+ query_lower = query.lower()
56
+
57
+ lines = []
58
+ if query:
59
+ lines.append(f"# Experts for '{query}'\n")
60
+ # Find authors with most commits in matching services
61
+ scores: list[tuple[str, int]] = []
62
+ for author, services in expertise.items():
63
+ score = sum(count for svc, count in services.items() if query_lower in svc.lower())
64
+ if score > 0:
65
+ scores.append((author, score))
66
+ scores.sort(key=lambda x: -x[1])
67
+ if scores:
68
+ for author, count in scores[:10]:
69
+ lines.append(f"- **{author}** — {count} commits")
70
+ else:
71
+ lines.append(f"No experts found for '{query}'.")
72
+ else:
73
+ lines.append("# Team Knowledge Map\n")
74
+ for author in sorted(expertise.keys()):
75
+ services = expertise[author]
76
+ top = sorted(services.items(), key=lambda x: -x[1])[:3]
77
+ areas = ", ".join(f"{svc} ({count})" for svc, count in top)
78
+ lines.append(f"- **{author}** — {areas}")
79
+
80
+ return "\n".join(lines)
81
+
82
+
83
+ def who_last_touched(repo_root: Path, filepath: str) -> str:
84
+ """Find who last modified a file."""
85
+ graph = build_team_graph(repo_root)
86
+ last_touch = graph.get("last_touch", {})
87
+
88
+ matches = [(f, author) for f, author in last_touch.items() if filepath.lower() in f.lower()]
89
+
90
+ if not matches:
91
+ return f"No git history for '{filepath}'."
92
+
93
+ lines = [f"# Last touched: '{filepath}'\n"]
94
+ for f, author in matches[:10]:
95
+ lines.append(f"- **{f}** → {author}")
96
+ return "\n".join(lines)
@@ -0,0 +1,111 @@
1
+ """Test Intelligence — map tests to source code, know what to run/update."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from ..config import IGNORE_DIRS
9
+
10
+
11
+ def _should_ignore(path: Path) -> bool:
12
+ return any(part in IGNORE_DIRS for part in path.parts)
13
+
14
+
15
+ def build_test_map(repo_root: Path) -> dict[str, list[str]]:
16
+ """Map source files to their test files based on naming conventions and imports."""
17
+ test_files: list[Path] = []
18
+ source_files: list[Path] = []
19
+
20
+ for cs_file in repo_root.rglob("*.cs"):
21
+ if _should_ignore(cs_file):
22
+ continue
23
+ rel = str(cs_file.relative_to(repo_root))
24
+ if "Test" in rel:
25
+ test_files.append(cs_file)
26
+ else:
27
+ source_files.append(cs_file)
28
+
29
+ # Map: source_file → [test_files]
30
+ test_map: dict[str, list[str]] = {}
31
+
32
+ for source in source_files:
33
+ source_name = source.stem # e.g. "AuthorizationService"
34
+ source_rel = str(source.relative_to(repo_root))
35
+ matching_tests = []
36
+
37
+ for test in test_files:
38
+ test_name = test.stem
39
+ # Convention: FooTests.cs tests Foo.cs
40
+ if source_name in test_name:
41
+ matching_tests.append(str(test.relative_to(repo_root)))
42
+ continue
43
+
44
+ # Check if test imports/uses the source class
45
+ try:
46
+ content = test.read_text(errors="replace")
47
+ if source_name in content:
48
+ matching_tests.append(str(test.relative_to(repo_root)))
49
+ except (OSError, PermissionError):
50
+ continue
51
+
52
+ if matching_tests:
53
+ test_map[source_rel] = matching_tests
54
+
55
+ return test_map
56
+
57
+
58
+ def get_tests_for_file(repo_root: Path, filepath: str) -> str:
59
+ """Get all tests that cover a specific file."""
60
+ test_map = build_test_map(repo_root)
61
+ query = filepath.lower()
62
+
63
+ matches = []
64
+ for source, tests in test_map.items():
65
+ if query in source.lower():
66
+ matches.append((source, tests))
67
+
68
+ if not matches:
69
+ return f"No tests found for '{filepath}'."
70
+
71
+ lines = [f"# Tests for '{filepath}'\n"]
72
+ for source, tests in matches:
73
+ lines.append(f"## {source}")
74
+ for t in tests:
75
+ lines.append(f"- {t}")
76
+ lines.append("")
77
+ return "\n".join(lines)
78
+
79
+
80
+ def get_coverage_summary(repo_root: Path) -> str:
81
+ """Get a summary of test coverage by service."""
82
+ test_map = build_test_map(repo_root)
83
+
84
+ # Group by service
85
+ by_service: dict[str, dict] = {}
86
+ for source, tests in test_map.items():
87
+ parts = source.split("/")
88
+ svc = parts[0] if len(parts) > 1 else "root"
89
+ if svc not in by_service:
90
+ by_service[svc] = {"covered": 0, "tests": 0}
91
+ by_service[svc]["covered"] += 1
92
+ by_service[svc]["tests"] += len(tests)
93
+
94
+ # Count total source files per service
95
+ total_by_service: dict[str, int] = {}
96
+ for cs_file in repo_root.rglob("*.cs"):
97
+ if _should_ignore(cs_file) or "Test" in str(cs_file):
98
+ continue
99
+ parts = cs_file.relative_to(repo_root).parts
100
+ svc = parts[0] if len(parts) > 1 else "root"
101
+ total_by_service[svc] = total_by_service.get(svc, 0) + 1
102
+
103
+ lines = ["# Test Coverage Summary\n"]
104
+ for svc in sorted(set(list(by_service.keys()) + list(total_by_service.keys()))):
105
+ total = total_by_service.get(svc, 0)
106
+ covered = by_service.get(svc, {}).get("covered", 0)
107
+ tests = by_service.get(svc, {}).get("tests", 0)
108
+ pct = f"{covered/total*100:.0f}%" if total > 0 else "0%"
109
+ lines.append(f"- **{svc}**: {covered}/{total} files covered ({pct}), {tests} test files")
110
+
111
+ return "\n".join(lines)