devmind-cli 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.
- devmind/__init__.py +2 -0
- devmind/cli.py +285 -0
- devmind/ingestion/comment_extractor.py +91 -0
- devmind/ingestion/file_reader.py +92 -0
- devmind/ingestion/git_parser.py +72 -0
- devmind/integrations/claude_code.py +44 -0
- devmind/memory.py +261 -0
- devmind/web/app.py +86 -0
- devmind_cli-0.1.0.dist-info/METADATA +166 -0
- devmind_cli-0.1.0.dist-info/RECORD +13 -0
- devmind_cli-0.1.0.dist-info/WHEEL +5 -0
- devmind_cli-0.1.0.dist-info/entry_points.txt +2 -0
- devmind_cli-0.1.0.dist-info/top_level.txt +1 -0
devmind/__init__.py
ADDED
devmind/cli.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# pyrefly: ignore [missing-import]
|
|
2
|
+
import typer
|
|
3
|
+
import sys
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import logging
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
# Suppress ResourceWarning and DeprecationWarning from aiohttp/asyncio during garbage collection
|
|
10
|
+
warnings.filterwarnings("ignore", category=ResourceWarning)
|
|
11
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
12
|
+
|
|
13
|
+
# Suppress Windows proactor event loop SSL bugs during shutdown
|
|
14
|
+
if sys.platform == 'win32':
|
|
15
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
16
|
+
|
|
17
|
+
from devmind.memory import initialize_cognee, remember_content, recall_query, improve_memory, forget_memory
|
|
18
|
+
from devmind.ingestion.file_reader import scan_codebase_files
|
|
19
|
+
from devmind.ingestion.git_parser import get_git_history
|
|
20
|
+
from devmind.ingestion.comment_extractor import get_codebase_comments
|
|
21
|
+
|
|
22
|
+
# Setup logging
|
|
23
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
|
24
|
+
logger = logging.getLogger("devmind.cli")
|
|
25
|
+
|
|
26
|
+
def run_async(coro):
|
|
27
|
+
"""
|
|
28
|
+
Custom asyncio runner that sets an exception handler to swallow
|
|
29
|
+
noisy Win32 socket teardown/closed event loop warnings on shutdown.
|
|
30
|
+
"""
|
|
31
|
+
loop = asyncio.new_event_loop()
|
|
32
|
+
asyncio.set_event_loop(loop)
|
|
33
|
+
|
|
34
|
+
def silence_exceptions(loop, context):
|
|
35
|
+
exc = context.get("exception")
|
|
36
|
+
msg = context.get("message", "")
|
|
37
|
+
# Swallows Win32 10038/not-a-socket/Event loop is closed warnings during exit
|
|
38
|
+
if (exc and ("Event loop is closed" in str(exc) or "10038" in str(exc) or "socket" in str(exc))) or "Event loop is closed" in msg or "SSL transport" in msg:
|
|
39
|
+
return
|
|
40
|
+
loop.default_exception_handler(context)
|
|
41
|
+
|
|
42
|
+
loop.set_exception_handler(silence_exceptions)
|
|
43
|
+
try:
|
|
44
|
+
return loop.run_until_complete(coro)
|
|
45
|
+
finally:
|
|
46
|
+
try:
|
|
47
|
+
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
loop.close()
|
|
51
|
+
|
|
52
|
+
app = typer.Typer(
|
|
53
|
+
name="devmind",
|
|
54
|
+
help="DevMind – Codebase Memory for Developers. Powered by Cognee.",
|
|
55
|
+
add_completion=False
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def remember_pipeline(directory: str):
|
|
59
|
+
"""
|
|
60
|
+
Core async pipeline for scanning files, comments, and git logs,
|
|
61
|
+
and loading them into Cognee.
|
|
62
|
+
"""
|
|
63
|
+
# 1. Scan the codebase files
|
|
64
|
+
files = scan_codebase_files(directory)
|
|
65
|
+
if not files:
|
|
66
|
+
typer.echo("No files found to ingest.")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
typer.echo(f"Ingesting {len(files)} files into Cognee memory...")
|
|
70
|
+
|
|
71
|
+
# Ingest file contents
|
|
72
|
+
file_success = 0
|
|
73
|
+
for idx, file_data in enumerate(files, start=1):
|
|
74
|
+
rel_path = file_data["relative_path"]
|
|
75
|
+
content = file_data["content"]
|
|
76
|
+
|
|
77
|
+
tagged_content = f"File Path: {rel_path}\n---\n{content}"
|
|
78
|
+
dataset_name = rel_path.replace("/", "_").replace("\\", "_").replace(".", "_").replace(" ", "_")
|
|
79
|
+
|
|
80
|
+
logger.info(f"[{idx}/{len(files)}] Processing {rel_path}...")
|
|
81
|
+
success = await remember_content(tagged_content, dataset_name=dataset_name)
|
|
82
|
+
if success:
|
|
83
|
+
file_success += 1
|
|
84
|
+
|
|
85
|
+
typer.echo(f"Successfully remembered {file_success}/{len(files)} files.")
|
|
86
|
+
|
|
87
|
+
# 2. Extract and Ingest Git History
|
|
88
|
+
git_logs = get_git_history(directory, max_commits=20)
|
|
89
|
+
if git_logs:
|
|
90
|
+
typer.echo(f"Ingesting git history ({len(git_logs)} commits) into Cognee...")
|
|
91
|
+
git_success = 0
|
|
92
|
+
for idx, commit_log in enumerate(git_logs, start=1):
|
|
93
|
+
dataset_name = f"git_commit_{idx}"
|
|
94
|
+
success = await remember_content(commit_log, dataset_name=dataset_name)
|
|
95
|
+
if success:
|
|
96
|
+
git_success += 1
|
|
97
|
+
typer.echo(f"Successfully remembered {git_success}/{len(git_logs)} commits.")
|
|
98
|
+
|
|
99
|
+
# 3. Extract and Ingest Inline Comments & Docstrings
|
|
100
|
+
relative_paths = [f["relative_path"] for f in files]
|
|
101
|
+
comments = get_codebase_comments(directory, relative_paths)
|
|
102
|
+
if comments:
|
|
103
|
+
typer.echo(f"Ingesting inline comments ({len(comments)} files containing comments)...")
|
|
104
|
+
comment_success = 0
|
|
105
|
+
for idx, comment_block in enumerate(comments, start=1):
|
|
106
|
+
dataset_name = f"code_comments_{idx}"
|
|
107
|
+
success = await remember_content(comment_block, dataset_name=dataset_name)
|
|
108
|
+
if success:
|
|
109
|
+
comment_success += 1
|
|
110
|
+
typer.echo(f"Successfully remembered {comment_success}/{len(comments)} comment segments.")
|
|
111
|
+
|
|
112
|
+
@app.command()
|
|
113
|
+
def remember(
|
|
114
|
+
directory: str = typer.Option(
|
|
115
|
+
".",
|
|
116
|
+
"--dir", "-d",
|
|
117
|
+
help="The directory of the codebase to ingest."
|
|
118
|
+
)
|
|
119
|
+
):
|
|
120
|
+
"""
|
|
121
|
+
Ingest the codebase files into persistent Cognee memory.
|
|
122
|
+
"""
|
|
123
|
+
initialize_cognee()
|
|
124
|
+
run_async(remember_pipeline(directory))
|
|
125
|
+
typer.echo("[Success] Codebase memory ingestion completed.")
|
|
126
|
+
|
|
127
|
+
@app.command()
|
|
128
|
+
def ask(
|
|
129
|
+
query: str = typer.Argument(..., help="Your natural language question about the codebase.")
|
|
130
|
+
):
|
|
131
|
+
"""
|
|
132
|
+
Ask a question about the ingested codebase memory in plain English.
|
|
133
|
+
"""
|
|
134
|
+
initialize_cognee()
|
|
135
|
+
|
|
136
|
+
typer.echo(f"Querying codebase memory for: '{query}'...")
|
|
137
|
+
answer = run_async(recall_query(query))
|
|
138
|
+
|
|
139
|
+
typer.echo("\n--- Response ---")
|
|
140
|
+
typer.echo(answer)
|
|
141
|
+
typer.echo("----------------")
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def chat():
|
|
145
|
+
"""
|
|
146
|
+
Start an interactive DevMind terminal chat session to explore your codebase.
|
|
147
|
+
"""
|
|
148
|
+
initialize_cognee()
|
|
149
|
+
|
|
150
|
+
from rich.console import Console
|
|
151
|
+
from rich.markdown import Markdown
|
|
152
|
+
from rich.panel import Panel
|
|
153
|
+
from rich.prompt import Prompt
|
|
154
|
+
|
|
155
|
+
console = Console()
|
|
156
|
+
console.print(Panel.fit("[bold blue]DevMind Codebase Chat[/bold blue]\n[dim]Type your queries below. Type 'exit' or 'quit' to close.[/dim]", border_style="blue"))
|
|
157
|
+
|
|
158
|
+
while True:
|
|
159
|
+
try:
|
|
160
|
+
query = Prompt.ask("\n[bold green]You[/bold green]")
|
|
161
|
+
if not query.strip():
|
|
162
|
+
continue
|
|
163
|
+
if query.lower().strip() in ['exit', 'quit', 'clear']:
|
|
164
|
+
console.print("[dim]Goodbye![/dim]")
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
with console.status("[bold cyan]DevMind is thinking...[/bold cyan]", spinner="dots"):
|
|
168
|
+
answer = run_async(recall_query(query))
|
|
169
|
+
|
|
170
|
+
console.print("\n[bold magenta]DevMind:[/bold magenta]")
|
|
171
|
+
console.print(Markdown(answer))
|
|
172
|
+
except (KeyboardInterrupt, EOFError):
|
|
173
|
+
console.print("\n[dim]Goodbye![/dim]")
|
|
174
|
+
break
|
|
175
|
+
except Exception as e:
|
|
176
|
+
console.print(f"[bold red]Error:[/bold red] {str(e)}")
|
|
177
|
+
|
|
178
|
+
@app.command()
|
|
179
|
+
def log(
|
|
180
|
+
decision: str = typer.Argument(..., help="The Architectural Decision Record (ADR) text to log.")
|
|
181
|
+
):
|
|
182
|
+
"""
|
|
183
|
+
Log an Architectural Decision Record (ADR) into persistent memory.
|
|
184
|
+
"""
|
|
185
|
+
initialize_cognee()
|
|
186
|
+
typer.echo(f"Logging decision: '{decision}'...")
|
|
187
|
+
|
|
188
|
+
tagged_decision = f"Architectural Decision Record:\n{decision}"
|
|
189
|
+
import time
|
|
190
|
+
dataset_name = f"adr_decision_{int(time.time())}"
|
|
191
|
+
|
|
192
|
+
success = run_async(remember_content(tagged_decision, dataset_name=dataset_name))
|
|
193
|
+
if success:
|
|
194
|
+
typer.echo("[Success] Architectural decision successfully logged.")
|
|
195
|
+
else:
|
|
196
|
+
typer.echo("[Error] Failed to log architectural decision.")
|
|
197
|
+
|
|
198
|
+
@app.command()
|
|
199
|
+
def refresh(
|
|
200
|
+
directory: str = typer.Option(
|
|
201
|
+
".",
|
|
202
|
+
"--dir", "-d",
|
|
203
|
+
help="The directory of the codebase to refresh."
|
|
204
|
+
)
|
|
205
|
+
):
|
|
206
|
+
"""
|
|
207
|
+
Refresh codebase memory by scanning for changed files and refining relationships.
|
|
208
|
+
"""
|
|
209
|
+
initialize_cognee()
|
|
210
|
+
typer.echo("Scanning for codebase changes to refresh memory...")
|
|
211
|
+
run_async(remember_pipeline(directory))
|
|
212
|
+
|
|
213
|
+
typer.echo("Refining the codebase memory graph structure...")
|
|
214
|
+
# Improve memory on all dataset partitions
|
|
215
|
+
success = run_async(improve_memory(dataset_name="codebase_memory"))
|
|
216
|
+
if success:
|
|
217
|
+
typer.echo("[Success] Memory refresh and relationship refinement completed.")
|
|
218
|
+
else:
|
|
219
|
+
typer.echo("[Warning] File changes re-ingested, but relationship refinement had warnings.")
|
|
220
|
+
|
|
221
|
+
@app.command()
|
|
222
|
+
def forget(
|
|
223
|
+
file_path: str = typer.Option(
|
|
224
|
+
None,
|
|
225
|
+
"--file", "-f",
|
|
226
|
+
help="The relative path of the file memory to forget."
|
|
227
|
+
),
|
|
228
|
+
all_memories: bool = typer.Option(
|
|
229
|
+
False,
|
|
230
|
+
"--all", "-a",
|
|
231
|
+
help="Wipe all local memory databases completely."
|
|
232
|
+
)
|
|
233
|
+
):
|
|
234
|
+
"""
|
|
235
|
+
Surgically forget a specific file's memory, or completely wipe the local databases.
|
|
236
|
+
"""
|
|
237
|
+
initialize_cognee()
|
|
238
|
+
|
|
239
|
+
if all_memories:
|
|
240
|
+
typer.echo("Wiping all local memory databases...")
|
|
241
|
+
import shutil
|
|
242
|
+
from devmind.memory import system_path, data_path
|
|
243
|
+
try:
|
|
244
|
+
if os.path.exists(system_path):
|
|
245
|
+
shutil.rmtree(system_path)
|
|
246
|
+
if os.path.exists(data_path):
|
|
247
|
+
shutil.rmtree(data_path)
|
|
248
|
+
typer.echo("[Success] Local memory databases completely wiped.")
|
|
249
|
+
except Exception as e:
|
|
250
|
+
typer.echo(f"[Error] Failed to wipe memory folders: {e}")
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
if file_path:
|
|
254
|
+
dataset_name = file_path.replace("/", "_").replace("\\", "_").replace(".", "_").replace(" ", "_")
|
|
255
|
+
typer.echo(f"Removing memory dataset '{dataset_name}'...")
|
|
256
|
+
success = run_async(forget_memory(dataset_name))
|
|
257
|
+
if success:
|
|
258
|
+
typer.echo(f"[Success] Memory of '{file_path}' successfully forgotten.")
|
|
259
|
+
else:
|
|
260
|
+
typer.echo(f"[Error] Failed to forget memory of '{file_path}'.")
|
|
261
|
+
else:
|
|
262
|
+
typer.echo("[Warning] Please specify either --file <path> to forget a file, or --all to wipe all databases.")
|
|
263
|
+
|
|
264
|
+
@app.command()
|
|
265
|
+
def dashboard(
|
|
266
|
+
port: int = typer.Option(8000, "--port", "-p", help="Port to run the dashboard server on.")
|
|
267
|
+
):
|
|
268
|
+
"""
|
|
269
|
+
Launch the DevMind Web UI dashboard.
|
|
270
|
+
"""
|
|
271
|
+
import uvicorn
|
|
272
|
+
typer.echo(f"Starting DevMind Web UI Dashboard on http://localhost:{port} ...")
|
|
273
|
+
uvicorn.run("devmind.web.app:app", host="127.0.0.1", port=port, reload=False)
|
|
274
|
+
|
|
275
|
+
@app.command()
|
|
276
|
+
def mcp():
|
|
277
|
+
"""
|
|
278
|
+
Start the DevMind MCP server for integration with Claude Code.
|
|
279
|
+
"""
|
|
280
|
+
typer.echo("Starting DevMind MCP Server...")
|
|
281
|
+
from devmind.integrations.claude_code import mcp as mcp_instance
|
|
282
|
+
mcp_instance.run()
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__":
|
|
285
|
+
app()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("devmind.comment_extractor")
|
|
6
|
+
|
|
7
|
+
# Regex to find common developer tags in comments
|
|
8
|
+
TAG_PATTERN = re.compile(r"\b(TODO|FIXME|NOTE|BUG|HACK|WARNING|ADR|DEPRECATED)\b", re.IGNORECASE)
|
|
9
|
+
|
|
10
|
+
def extract_comments_from_file(file_path: str) -> list[str]:
|
|
11
|
+
"""
|
|
12
|
+
Parses a single file, extracting inline comments and docstrings
|
|
13
|
+
that contain key developer tags (TODO, FIXME, NOTE, HACK, etc.).
|
|
14
|
+
"""
|
|
15
|
+
extracted = []
|
|
16
|
+
_, ext = os.path.splitext(file_path.lower())
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
20
|
+
content = f.read()
|
|
21
|
+
|
|
22
|
+
lines = content.splitlines()
|
|
23
|
+
|
|
24
|
+
# 1. Python parsing (extract hash comments and triple-quoted docstrings)
|
|
25
|
+
if ext in (".py", ".sh", ".yaml", ".yml", ".ini"):
|
|
26
|
+
# Inline comment scanner
|
|
27
|
+
for idx, line in enumerate(lines, start=1):
|
|
28
|
+
comment_match = re.search(r"#\s*(.*)", line)
|
|
29
|
+
if comment_match:
|
|
30
|
+
comment_text = comment_match.group(1).strip()
|
|
31
|
+
if TAG_PATTERN.search(comment_text):
|
|
32
|
+
extracted.append(f"Line {idx}: {comment_text}")
|
|
33
|
+
|
|
34
|
+
# Simple regex search for docstrings
|
|
35
|
+
docstrings = re.findall(r'"""(.*?)"""', content, re.DOTALL)
|
|
36
|
+
docstrings.extend(re.findall(r"'''(.*?)'''", content, re.DOTALL))
|
|
37
|
+
for doc in docstrings:
|
|
38
|
+
doc_clean = doc.strip()
|
|
39
|
+
if doc_clean:
|
|
40
|
+
extracted.append(f"Docstring: {doc_clean}")
|
|
41
|
+
|
|
42
|
+
# 2. C-Style languages parsing (JS, TS, C, C++, Go, Java, Rust)
|
|
43
|
+
elif ext in (".js", ".ts", ".jsx", ".tsx", ".c", ".cpp", ".h", ".go", ".java", ".rs", ".css"):
|
|
44
|
+
# Inline comment scanner (// ...)
|
|
45
|
+
for idx, line in enumerate(lines, start=1):
|
|
46
|
+
comment_match = re.search(r"//\s*(.*)", line)
|
|
47
|
+
if comment_match:
|
|
48
|
+
comment_text = comment_match.group(1).strip()
|
|
49
|
+
if TAG_PATTERN.search(comment_text):
|
|
50
|
+
extracted.append(f"Line {idx}: {comment_text}")
|
|
51
|
+
|
|
52
|
+
# Block comment scanner (/* ... */)
|
|
53
|
+
block_comments = re.findall(r"/\*(.*?)\*/", content, re.DOTALL)
|
|
54
|
+
for block in block_comments:
|
|
55
|
+
for idx, block_line in enumerate(block.splitlines(), start=1):
|
|
56
|
+
block_line_clean = block_line.strip().lstrip("*").strip()
|
|
57
|
+
if TAG_PATTERN.search(block_line_clean):
|
|
58
|
+
extracted.append(f"Block Comment: {block_line_clean}")
|
|
59
|
+
|
|
60
|
+
# 3. HTML/XML/Markdown C-style comments (<!-- ... -->)
|
|
61
|
+
elif ext in (".html", ".htm", ".xml", ".md"):
|
|
62
|
+
html_comments = re.findall(r"<!--(.*?)-->", content, re.DOTALL)
|
|
63
|
+
for block in html_comments:
|
|
64
|
+
for block_line in block.splitlines():
|
|
65
|
+
block_line_clean = block_line.strip()
|
|
66
|
+
if TAG_PATTERN.search(block_line_clean):
|
|
67
|
+
extracted.append(f"HTML Comment: {block_line_clean}")
|
|
68
|
+
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.error(f"Error parsing comments from {file_path}: {e}")
|
|
71
|
+
|
|
72
|
+
return extracted
|
|
73
|
+
|
|
74
|
+
def get_codebase_comments(repo_path: str, source_files: list[str]) -> list[str]:
|
|
75
|
+
"""
|
|
76
|
+
Loops through all project files and extracts formatted comments/docstrings.
|
|
77
|
+
Returns a list of structured comment records.
|
|
78
|
+
"""
|
|
79
|
+
all_comments = []
|
|
80
|
+
logger.info("Scanning codebase for inline comments and docstrings...")
|
|
81
|
+
|
|
82
|
+
for file_path in source_files:
|
|
83
|
+
abs_path = os.path.join(repo_path, file_path)
|
|
84
|
+
file_comments = extract_comments_from_file(abs_path)
|
|
85
|
+
|
|
86
|
+
if file_comments:
|
|
87
|
+
comment_log = [f"File Path: {file_path}"]
|
|
88
|
+
comment_log.extend(file_comments)
|
|
89
|
+
all_comments.append("\n".join(comment_log))
|
|
90
|
+
|
|
91
|
+
return all_comments
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("devmind.ingestion.file_reader")
|
|
6
|
+
|
|
7
|
+
# Common directories to skip during scanning
|
|
8
|
+
IGNORED_DIRS = {
|
|
9
|
+
".git",
|
|
10
|
+
".github",
|
|
11
|
+
"__pycache__",
|
|
12
|
+
".venv",
|
|
13
|
+
"venv",
|
|
14
|
+
"env",
|
|
15
|
+
"node_modules",
|
|
16
|
+
".cognee_store",
|
|
17
|
+
".pytest_cache",
|
|
18
|
+
"dist",
|
|
19
|
+
"build",
|
|
20
|
+
"eggs",
|
|
21
|
+
".eggs",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Supported file extensions for codebase reading
|
|
25
|
+
SUPPORTED_EXTENSIONS = {
|
|
26
|
+
".py", ".md", ".txt", ".js", ".ts", ".html", ".css",
|
|
27
|
+
".json", ".yaml", ".yml", ".ini", ".toml", ".sh", ".bat"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Individual configuration files to include even if they don't have standard extensions
|
|
31
|
+
EXPLICIT_FILES = {
|
|
32
|
+
"requirements.txt",
|
|
33
|
+
"setup.py",
|
|
34
|
+
"package.json",
|
|
35
|
+
"Dockerfile",
|
|
36
|
+
"Makefile",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"gitignore",
|
|
39
|
+
".gitignore",
|
|
40
|
+
".env.example",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def is_text_file(file_path: pathlib.Path) -> bool:
|
|
44
|
+
"""
|
|
45
|
+
Check if a file should be read by extension or filename.
|
|
46
|
+
"""
|
|
47
|
+
if file_path.suffix.lower() in SUPPORTED_EXTENSIONS:
|
|
48
|
+
return True
|
|
49
|
+
if file_path.name in EXPLICIT_FILES:
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def scan_codebase_files(root_dir: str) -> list[dict]:
|
|
54
|
+
"""
|
|
55
|
+
Recursively scans the root directory for code and documentation files.
|
|
56
|
+
Returns a list of dicts:
|
|
57
|
+
[
|
|
58
|
+
{
|
|
59
|
+
"relative_path": "path/to/file.py",
|
|
60
|
+
"absolute_path": "/absolute/path/to/file.py",
|
|
61
|
+
"content": "file content..."
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
"""
|
|
65
|
+
root_path = pathlib.Path(root_dir).resolve()
|
|
66
|
+
logger.info(f"Scanning codebase files under: {root_path}")
|
|
67
|
+
|
|
68
|
+
codebase_files = []
|
|
69
|
+
|
|
70
|
+
for dirpath, dirnames, filenames in os.walk(root_path):
|
|
71
|
+
# Modify dirnames in place to skip ignored directories
|
|
72
|
+
dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRS]
|
|
73
|
+
|
|
74
|
+
for filename in filenames:
|
|
75
|
+
file_path = pathlib.Path(dirpath) / filename
|
|
76
|
+
|
|
77
|
+
if is_text_file(file_path):
|
|
78
|
+
relative_path = file_path.relative_to(root_path)
|
|
79
|
+
try:
|
|
80
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
81
|
+
content = f.read()
|
|
82
|
+
|
|
83
|
+
codebase_files.append({
|
|
84
|
+
"relative_path": str(relative_path),
|
|
85
|
+
"absolute_path": str(file_path),
|
|
86
|
+
"content": content
|
|
87
|
+
})
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.warning(f"Skipping file {relative_path} due to read error: {e}")
|
|
90
|
+
|
|
91
|
+
logger.info(f"Scan complete. Found {len(codebase_files)} indexable files.")
|
|
92
|
+
return codebase_files
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from git import Repo
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("devmind.git_parser")
|
|
6
|
+
|
|
7
|
+
def get_git_history(repo_path: str, max_commits: int = 50) -> list[str]:
|
|
8
|
+
"""
|
|
9
|
+
Scans the local git history of the project at repo_path.
|
|
10
|
+
Extracts commit messages, authors, dates, and diffs for each commit,
|
|
11
|
+
returning a list of formatted text logs ready for Cognee ingestion.
|
|
12
|
+
"""
|
|
13
|
+
history_logs = []
|
|
14
|
+
try:
|
|
15
|
+
# Check if directory has a git repository initialized
|
|
16
|
+
git_dir = os.path.join(repo_path, ".git")
|
|
17
|
+
if not os.path.exists(git_dir):
|
|
18
|
+
logger.warning(f"No git repository found at '{repo_path}'. Skipping git log ingestion.")
|
|
19
|
+
return history_logs
|
|
20
|
+
|
|
21
|
+
repo = Repo(repo_path)
|
|
22
|
+
if repo.bare:
|
|
23
|
+
logger.warning(f"Git repository at '{repo_path}' is bare. Skipping git log ingestion.")
|
|
24
|
+
return history_logs
|
|
25
|
+
|
|
26
|
+
# Get commits on active branch
|
|
27
|
+
try:
|
|
28
|
+
commits = list(repo.iter_commits(max_count=max_commits))
|
|
29
|
+
except Exception:
|
|
30
|
+
logger.warning("No commits found in the repository (possibly empty). Skipping git history.")
|
|
31
|
+
return history_logs
|
|
32
|
+
|
|
33
|
+
logger.info(f"Extracting git history for the last {len(commits)} commits...")
|
|
34
|
+
|
|
35
|
+
for commit in commits:
|
|
36
|
+
commit_info = [
|
|
37
|
+
f"Commit Hash: {commit.hexsha}",
|
|
38
|
+
f"Author: {commit.author.name} <{commit.author.email}>",
|
|
39
|
+
f"Date: {commit.authored_datetime.isoformat()}",
|
|
40
|
+
f"Message: {commit.message.strip()}"
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Extract list of files modified and the summary diffs
|
|
44
|
+
changed_files = []
|
|
45
|
+
if len(commit.parents) > 0:
|
|
46
|
+
# Diff against parent commit
|
|
47
|
+
parent = commit.parents[0]
|
|
48
|
+
diffs = parent.diff(commit, create_patch=True)
|
|
49
|
+
for d in diffs:
|
|
50
|
+
file_path = d.b_path if d.b_path else d.a_path
|
|
51
|
+
changed_files.append(file_path)
|
|
52
|
+
|
|
53
|
+
# Truncate patch to avoid overwhelming token limits
|
|
54
|
+
patch_text = d.diff.decode("utf-8", errors="ignore") if d.diff else ""
|
|
55
|
+
if len(patch_text) > 1000:
|
|
56
|
+
patch_text = patch_text[:1000] + "\n... [diff truncated for token optimization]"
|
|
57
|
+
|
|
58
|
+
commit_info.append(f"\nDiff for {file_path}:\n{patch_text}")
|
|
59
|
+
else:
|
|
60
|
+
# Root commit (first commit has no parent)
|
|
61
|
+
for file_path in commit.stats.files.keys():
|
|
62
|
+
changed_files.append(file_path)
|
|
63
|
+
commit_info.append(f"\nFiles added in root commit: {', '.join(changed_files)}")
|
|
64
|
+
|
|
65
|
+
commit_log = "\n".join(commit_info)
|
|
66
|
+
history_logs.append(commit_log)
|
|
67
|
+
|
|
68
|
+
return history_logs
|
|
69
|
+
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.error(f"Error parsing git history: {e}", exc_info=True)
|
|
72
|
+
return history_logs
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import asyncio
|
|
4
|
+
from fastmcp import FastMCP
|
|
5
|
+
from devmind.memory import recall_query, remember_content
|
|
6
|
+
|
|
7
|
+
# Initialize FastMCP Server
|
|
8
|
+
mcp = FastMCP(
|
|
9
|
+
"DevMind",
|
|
10
|
+
dependencies=["cognee", "fastmcp"]
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
@mcp.tool()
|
|
14
|
+
async def query_codebase_memory(query: str) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Queries the DevMind persistent codebase memory for details about the codebase structure,
|
|
17
|
+
design choices, git commit history, comments, and architectural decisions.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
answer = await recall_query(query)
|
|
21
|
+
return answer
|
|
22
|
+
except Exception as e:
|
|
23
|
+
return f"Error querying codebase memory: {e}"
|
|
24
|
+
|
|
25
|
+
@mcp.tool()
|
|
26
|
+
async def log_architectural_decision(decision: str) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Logs an Architectural Decision Record (ADR) into the codebase's persistent memory.
|
|
29
|
+
Use this when introducing major design changes, library switches, or design patterns.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
dataset_name = f"adr_decision_{int(time.time())}"
|
|
33
|
+
tagged_decision = f"Architectural Decision Record:\n{decision}"
|
|
34
|
+
success = await remember_content(tagged_decision, dataset_name=dataset_name)
|
|
35
|
+
if success:
|
|
36
|
+
return "Successfully logged architectural decision to memory."
|
|
37
|
+
else:
|
|
38
|
+
return "Failed to log architectural decision."
|
|
39
|
+
except Exception as e:
|
|
40
|
+
return f"Error logging decision: {e}"
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
# Start the MCP server (running over stdin/stdout transport)
|
|
44
|
+
mcp.run()
|
devmind/memory.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import random
|
|
4
|
+
import asyncio
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
# Set up logging
|
|
8
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
|
9
|
+
logger = logging.getLogger("devmind.memory")
|
|
10
|
+
|
|
11
|
+
# Load dotenv and set project-scoped directories BEFORE importing cognee
|
|
12
|
+
load_dotenv()
|
|
13
|
+
|
|
14
|
+
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
system_path = os.path.join(project_root, ".cognee_system")
|
|
16
|
+
data_path = os.path.join(project_root, ".cognee_data")
|
|
17
|
+
|
|
18
|
+
os.makedirs(system_path, exist_ok=True)
|
|
19
|
+
os.makedirs(os.path.join(system_path, "databases"), exist_ok=True)
|
|
20
|
+
os.makedirs(data_path, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
# Set environment variables for Cognee root paths
|
|
23
|
+
os.environ["SYSTEM_ROOT_DIRECTORY"] = system_path
|
|
24
|
+
os.environ["DATA_ROOT_DIRECTORY"] = data_path
|
|
25
|
+
os.environ["CACHE_ROOT_DIRECTORY"] = os.path.join(project_root, ".cognee_cache")
|
|
26
|
+
os.environ["ENABLE_BACKEND_ACCESS_CONTROL"] = "false"
|
|
27
|
+
os.environ["LOG_LEVEL"] = "WARNING"
|
|
28
|
+
|
|
29
|
+
import cognee
|
|
30
|
+
|
|
31
|
+
# Global list of keys for rotation
|
|
32
|
+
_GROQ_API_KEYS = []
|
|
33
|
+
|
|
34
|
+
def load_api_keys():
|
|
35
|
+
"""
|
|
36
|
+
Loads all available Groq API keys from the environment.
|
|
37
|
+
Supports a comma-separated list via GROQ_API_KEYS, falling back to GROQ_API_KEY.
|
|
38
|
+
"""
|
|
39
|
+
global _GROQ_API_KEYS
|
|
40
|
+
load_dotenv()
|
|
41
|
+
|
|
42
|
+
# Read GROQ_API_KEYS comma-separated list
|
|
43
|
+
keys_str = os.getenv("GROQ_API_KEYS", "")
|
|
44
|
+
if keys_str:
|
|
45
|
+
_GROQ_API_KEYS = [k.strip() for k in keys_str.split(",") if k.strip()]
|
|
46
|
+
|
|
47
|
+
# Fallback to single GROQ_API_KEY if not already in the list
|
|
48
|
+
single_key = os.getenv("GROQ_API_KEY", "")
|
|
49
|
+
if single_key and single_key not in _GROQ_API_KEYS:
|
|
50
|
+
_GROQ_API_KEYS.append(single_key)
|
|
51
|
+
|
|
52
|
+
def get_random_api_key() -> tuple[str, str, str]:
|
|
53
|
+
"""
|
|
54
|
+
Selects a random API key from the list (supports both Groq and OpenRouter keys)
|
|
55
|
+
and returns (key, endpoint, model) appropriate for that provider.
|
|
56
|
+
"""
|
|
57
|
+
if not _GROQ_API_KEYS:
|
|
58
|
+
return "", "", ""
|
|
59
|
+
selected_key = random.choice(_GROQ_API_KEYS)
|
|
60
|
+
|
|
61
|
+
# Auto-detect provider based on key prefix
|
|
62
|
+
if selected_key.startswith("sk-or-v1-"):
|
|
63
|
+
endpoint = "https://openrouter.ai/api/v1"
|
|
64
|
+
model = os.getenv("LLM_MODEL_OPENROUTER", "openrouter/meta-llama/llama-3.3-70b-instruct")
|
|
65
|
+
provider_name = "OpenRouter"
|
|
66
|
+
else:
|
|
67
|
+
endpoint = "https://api.groq.com/openai/v1"
|
|
68
|
+
model = os.getenv("LLM_MODEL_GROQ", "groq/llama-3.3-70b-versatile")
|
|
69
|
+
provider_name = "Groq"
|
|
70
|
+
|
|
71
|
+
if len(selected_key) > 10:
|
|
72
|
+
masked = f"{selected_key[:6]}...{selected_key[-4:]}"
|
|
73
|
+
else:
|
|
74
|
+
masked = "***"
|
|
75
|
+
logger.info(f"Rotating LLM request key -> {masked} ({provider_name} key, model: {model})")
|
|
76
|
+
return selected_key, endpoint, model
|
|
77
|
+
|
|
78
|
+
def initialize_cognee():
|
|
79
|
+
"""
|
|
80
|
+
Loads configuration from .env and verifies LLM & Embedding provider setup.
|
|
81
|
+
"""
|
|
82
|
+
load_dotenv()
|
|
83
|
+
load_api_keys()
|
|
84
|
+
|
|
85
|
+
# Disable backend access control and authentication for local CLI use
|
|
86
|
+
os.environ["ENABLE_BACKEND_ACCESS_CONTROL"] = "false"
|
|
87
|
+
|
|
88
|
+
# Apply storage paths to Cognee configuration
|
|
89
|
+
cognee.config.system_root_directory(system_path)
|
|
90
|
+
cognee.config.data_root_directory(data_path)
|
|
91
|
+
|
|
92
|
+
llm_provider = os.getenv("LLM_PROVIDER", "groq").lower()
|
|
93
|
+
embedding_provider = os.getenv("EMBEDDING_PROVIDER", "fastembed").lower()
|
|
94
|
+
|
|
95
|
+
# Cognee does not natively support "groq" in its LLMProvider enum.
|
|
96
|
+
# We map "groq" to the "custom" provider utilizing Groq's OpenAI-compatible endpoint.
|
|
97
|
+
if llm_provider == "groq":
|
|
98
|
+
groq_key, endpoint, model = get_random_api_key()
|
|
99
|
+
os.environ["LLM_PROVIDER"] = "custom"
|
|
100
|
+
os.environ["LLM_ENDPOINT"] = endpoint
|
|
101
|
+
os.environ["LLM_API_KEY"] = groq_key
|
|
102
|
+
|
|
103
|
+
cognee.config.set_llm_provider("custom")
|
|
104
|
+
cognee.config.set_llm_endpoint(endpoint)
|
|
105
|
+
cognee.config.set_llm_api_key(groq_key)
|
|
106
|
+
cognee.config.set_llm_model(model)
|
|
107
|
+
if not groq_key:
|
|
108
|
+
print("[Error] No LLM API keys found. Please set GROQ_API_KEYS or GROQ_API_KEY in your .env file.")
|
|
109
|
+
import sys
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
else:
|
|
112
|
+
openai_key = os.getenv("OPENAI_API_KEY", "")
|
|
113
|
+
cognee.config.set_llm_provider(llm_provider)
|
|
114
|
+
cognee.config.set_llm_model(os.getenv("LLM_MODEL", "openai/gpt-4o-mini"))
|
|
115
|
+
cognee.config.set_llm_api_key(openai_key)
|
|
116
|
+
if llm_provider == "openai" and not openai_key:
|
|
117
|
+
print("[Error] OPENAI_API_KEY is not set in your environment.")
|
|
118
|
+
import sys
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
|
|
121
|
+
# Configure embedding provider
|
|
122
|
+
cognee.config.set_embedding_provider(embedding_provider)
|
|
123
|
+
cognee.config.set_embedding_model(os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5"))
|
|
124
|
+
cognee.config.set_embedding_dimensions(int(os.getenv("EMBEDDING_DIMENSIONS", "384")))
|
|
125
|
+
|
|
126
|
+
logger.info(f"Initializing DevMind memory layer...")
|
|
127
|
+
logger.info(f"LLM Provider: {llm_provider} (Mapped to custom OpenAI-compatible endpoint if groq)")
|
|
128
|
+
logger.info(f"Embedding Provider: {embedding_provider} (Model: {os.getenv('EMBEDDING_MODEL')})")
|
|
129
|
+
logger.info(f"System Storage Path: {system_path}")
|
|
130
|
+
logger.info(f"Data Storage Path: {data_path}")
|
|
131
|
+
|
|
132
|
+
async def remember_content(content: str, dataset_name: str) -> bool:
|
|
133
|
+
"""
|
|
134
|
+
Ingests text content into Cognee memory under a specified dataset name.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
# Rotate API key if we are on custom/groq rotation
|
|
138
|
+
llm_provider = os.getenv("LLM_PROVIDER", "groq").lower()
|
|
139
|
+
if llm_provider == "groq" or os.environ.get("LLM_PROVIDER") == "custom":
|
|
140
|
+
groq_key, endpoint, model = get_random_api_key()
|
|
141
|
+
if groq_key:
|
|
142
|
+
os.environ["LLM_API_KEY"] = groq_key
|
|
143
|
+
os.environ["LLM_ENDPOINT"] = endpoint
|
|
144
|
+
cognee.config.set_llm_endpoint(endpoint)
|
|
145
|
+
cognee.config.set_llm_api_key(groq_key)
|
|
146
|
+
cognee.config.set_llm_model(model)
|
|
147
|
+
|
|
148
|
+
logger.info(f"Remembering content in dataset '{dataset_name}'...")
|
|
149
|
+
await cognee.remember(content, dataset_name=dataset_name)
|
|
150
|
+
logger.info(f"Successfully remembered dataset '{dataset_name}'.")
|
|
151
|
+
return True
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(f"Error during cognee.remember for '{dataset_name}': {e}", exc_info=True)
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
async def get_all_dataset_names() -> list[str]:
|
|
157
|
+
"""
|
|
158
|
+
Fetches all registered dataset names from Cognee's relational metadata.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
from cognee.infrastructure.databases.relational import get_relational_engine
|
|
162
|
+
from sqlalchemy import select
|
|
163
|
+
from cognee.modules.data.models import Dataset
|
|
164
|
+
|
|
165
|
+
engine = get_relational_engine()
|
|
166
|
+
async with engine.get_async_session() as session:
|
|
167
|
+
stmt = select(Dataset)
|
|
168
|
+
results = (await session.execute(stmt)).scalars().all()
|
|
169
|
+
return [d.name for d in results if d.name]
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.warning(f"Could not fetch dataset names dynamically: {e}")
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
async def recall_query(query: str) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Queries the Cognee memory graph using natural language.
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
# Rotate API key if we are on custom/groq rotation
|
|
180
|
+
llm_provider = os.getenv("LLM_PROVIDER", "groq").lower()
|
|
181
|
+
if llm_provider == "groq" or os.environ.get("LLM_PROVIDER") == "custom":
|
|
182
|
+
groq_key, endpoint, model = get_random_api_key()
|
|
183
|
+
if groq_key:
|
|
184
|
+
os.environ["LLM_API_KEY"] = groq_key
|
|
185
|
+
os.environ["LLM_ENDPOINT"] = endpoint
|
|
186
|
+
cognee.config.set_llm_endpoint(endpoint)
|
|
187
|
+
cognee.config.set_llm_api_key(groq_key)
|
|
188
|
+
cognee.config.set_llm_model(model)
|
|
189
|
+
|
|
190
|
+
logger.info(f"Recalling memory for query: '{query}'...")
|
|
191
|
+
datasets = await get_all_dataset_names()
|
|
192
|
+
from cognee.modules.search.types import SearchType
|
|
193
|
+
query_type = SearchType.RAG_COMPLETION
|
|
194
|
+
|
|
195
|
+
if datasets:
|
|
196
|
+
logger.info(f"Searching across datasets individually in parallel: {datasets}")
|
|
197
|
+
# Query each dataset in parallel to bypass Cognee's single-dataset check
|
|
198
|
+
tasks = [cognee.recall(query_text=query, query_type=query_type, datasets=[d]) for d in datasets]
|
|
199
|
+
results_lists = await asyncio.gather(*tasks, return_exceptions=True)
|
|
200
|
+
|
|
201
|
+
results = []
|
|
202
|
+
for r_list in results_lists:
|
|
203
|
+
if isinstance(r_list, list):
|
|
204
|
+
results.extend(r_list)
|
|
205
|
+
elif isinstance(r_list, Exception):
|
|
206
|
+
logger.warning(f"Error recalling from a dataset partition: {r_list}")
|
|
207
|
+
else:
|
|
208
|
+
results = await cognee.recall(query_text=query, query_type=query_type)
|
|
209
|
+
|
|
210
|
+
if not results:
|
|
211
|
+
return "No relevant memories found."
|
|
212
|
+
|
|
213
|
+
# Cognee returns a list of result objects or dictionaries.
|
|
214
|
+
# Format the output cleanly for console display.
|
|
215
|
+
formatted_results = []
|
|
216
|
+
for index, result in enumerate(results, start=1):
|
|
217
|
+
if hasattr(result, "text"):
|
|
218
|
+
formatted_results.append(result.text)
|
|
219
|
+
elif isinstance(result, dict) and "text" in result:
|
|
220
|
+
formatted_results.append(result["text"])
|
|
221
|
+
else:
|
|
222
|
+
formatted_results.append(str(result))
|
|
223
|
+
|
|
224
|
+
return "\n\n".join(formatted_results)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
logger.error(f"Error during cognee.recall for query '{query}': {e}", exc_info=True)
|
|
227
|
+
return f"Error recalling memory: {e}"
|
|
228
|
+
|
|
229
|
+
async def improve_memory(dataset_name: str) -> bool:
|
|
230
|
+
"""
|
|
231
|
+
Re-enriches and prunes relationships for a given dataset in Cognee.
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
logger.info(f"Improving Cognee memory for dataset '{dataset_name}'...")
|
|
235
|
+
await cognee.improve(dataset=dataset_name)
|
|
236
|
+
logger.info(f"Successfully improved memory for dataset '{dataset_name}'.")
|
|
237
|
+
return True
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.error(f"Error during cognee.improve for '{dataset_name}': {e}", exc_info=True)
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
async def forget_memory(dataset_name: str) -> bool:
|
|
243
|
+
"""
|
|
244
|
+
Surgically deletes memory associated with a given dataset name.
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
logger.info(f"Forgetting dataset '{dataset_name}' from Cognee memory...")
|
|
248
|
+
# Since cognee.forget might take dataset or dataset_name depending on local version,
|
|
249
|
+
# we try dataset first, then fallback to everything or other keywords if required.
|
|
250
|
+
try:
|
|
251
|
+
await cognee.forget(dataset=dataset_name)
|
|
252
|
+
except TypeError:
|
|
253
|
+
await cognee.forget(dataset_name=dataset_name)
|
|
254
|
+
logger.info(f"Successfully forgot dataset '{dataset_name}'.")
|
|
255
|
+
return True
|
|
256
|
+
except Exception as e:
|
|
257
|
+
if isinstance(e, AttributeError) and "'NoneType' object has no attribute 'id'" in str(e):
|
|
258
|
+
logger.info(f"Dataset '{dataset_name}' was not found in memory (it may have already been deleted or never ingested).")
|
|
259
|
+
return True
|
|
260
|
+
logger.error(f"Error during cognee.forget for '{dataset_name}': {e}", exc_info=True)
|
|
261
|
+
return False
|
devmind/web/app.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
# Suppress ResourceWarning and DeprecationWarning from aiohttp/asyncio during garbage collection
|
|
9
|
+
warnings.filterwarnings("ignore", category=ResourceWarning)
|
|
10
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
11
|
+
|
|
12
|
+
# Suppress Windows proactor event loop SSL bugs during shutdown
|
|
13
|
+
if sys.platform == 'win32':
|
|
14
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
15
|
+
from fastapi import FastAPI, Request, BackgroundTasks
|
|
16
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
17
|
+
from fastapi.templating import Jinja2Templates
|
|
18
|
+
from devmind.memory import recall_query, remember_content, initialize_cognee
|
|
19
|
+
from devmind.cli import remember_pipeline
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("devmind.web")
|
|
22
|
+
|
|
23
|
+
app = FastAPI(title="DevMind Dashboard")
|
|
24
|
+
|
|
25
|
+
# Initialize Cognee configurations
|
|
26
|
+
initialize_cognee()
|
|
27
|
+
|
|
28
|
+
# Create templates folder and configuration
|
|
29
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
30
|
+
templates_path = os.path.join(current_dir, "templates")
|
|
31
|
+
os.makedirs(templates_path, exist_ok=True)
|
|
32
|
+
templates = Jinja2Templates(directory=templates_path)
|
|
33
|
+
|
|
34
|
+
@app.get("/", response_class=HTMLResponse)
|
|
35
|
+
async def read_index(request: Request):
|
|
36
|
+
"""
|
|
37
|
+
Renders the DevMind UI dashboard.
|
|
38
|
+
"""
|
|
39
|
+
return templates.TemplateResponse("index.html", {"request": request})
|
|
40
|
+
|
|
41
|
+
@app.post("/api/ask")
|
|
42
|
+
async def api_ask(payload: dict):
|
|
43
|
+
"""
|
|
44
|
+
Handles natural language queries about the codebase.
|
|
45
|
+
"""
|
|
46
|
+
query = payload.get("query", "")
|
|
47
|
+
if not query:
|
|
48
|
+
return JSONResponse({"error": "Query cannot be empty"}, status_code=400)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
answer = await recall_query(query)
|
|
52
|
+
return {"answer": answer}
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"Error querying memory: {e}")
|
|
55
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
56
|
+
|
|
57
|
+
@app.post("/api/log")
|
|
58
|
+
async def api_log(payload: dict):
|
|
59
|
+
"""
|
|
60
|
+
Saves an Architectural Decision Record (ADR) into memory.
|
|
61
|
+
"""
|
|
62
|
+
decision = payload.get("decision", "")
|
|
63
|
+
if not decision:
|
|
64
|
+
return JSONResponse({"error": "Decision cannot be empty"}, status_code=400)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
import time
|
|
68
|
+
dataset_name = f"adr_decision_{int(time.time())}"
|
|
69
|
+
success = await remember_content(f"Architectural Decision Record:\n{decision}", dataset_name=dataset_name)
|
|
70
|
+
return {"success": success}
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.error(f"Error logging decision: {e}")
|
|
73
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
74
|
+
|
|
75
|
+
@app.post("/api/remember")
|
|
76
|
+
async def api_remember(background_tasks: BackgroundTasks):
|
|
77
|
+
"""
|
|
78
|
+
Triggers codebase re-ingestion asynchronously in the background.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
project_dir = os.getcwd()
|
|
82
|
+
background_tasks.add_task(remember_pipeline, project_dir)
|
|
83
|
+
return {"status": "ingested", "message": "Codebase memory re-ingestion started in the background."}
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Error triggering remember: {e}")
|
|
86
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devmind-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DevMind - Semantic Codebase Memory and Agentic Search for Developers
|
|
5
|
+
Author: Anishp-cell
|
|
6
|
+
Project-URL: Homepage, https://github.com/Anishp-cell/devmind-CLI
|
|
7
|
+
Project-URL: Issues, https://github.com/Anishp-cell/devmind-CLI/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: cognee[fastembed]>=0.1.0
|
|
16
|
+
Requires-Dist: typer[all]>=0.9.0
|
|
17
|
+
Requires-Dist: fastapi>=0.100.0
|
|
18
|
+
Requires-Dist: uvicorn>=0.22.0
|
|
19
|
+
Requires-Dist: gitpython>=3.1.30
|
|
20
|
+
Requires-Dist: fastmcp>=0.1.0
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
22
|
+
Requires-Dist: pydantic>=2.0.0
|
|
23
|
+
Requires-Dist: jinja2>=3.1.2
|
|
24
|
+
Requires-Dist: groq>=0.9.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
27
|
+
Requires-Dist: black>=23.0; extra == "dev"
|
|
28
|
+
Requires-Dist: isort>=5.12; extra == "dev"
|
|
29
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# DevMind – Codebase Memory for Developers
|
|
32
|
+
|
|
33
|
+
> "Your codebase finally has a memory."
|
|
34
|
+
|
|
35
|
+
DevMind is a developer CLI tool and local web interface that gives your codebase a persistent, queryable memory powered by **Cognee**. It scans source files, git commit history, comments, and architectural decisions, building a hybrid graph-vector knowledge store. This persistent memory allows developers and AI coding assistants (via MCP) to query the codebase in plain English and carry context across infinite sessions.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
1. **One-Command Ingestion** (`devmind remember`): Scans the codebase, git logs, and code comments to feed `cognee.remember()`.
|
|
42
|
+
2. **Plain-English Q&A** (`devmind ask "..."`): Uses `cognee.recall()` to retrieve grounded, context-aware answers from the memory graph.
|
|
43
|
+
3. **Decision Logging** (`devmind log "..."`): Records Architecture Decision Records (ADRs) to capture design reasoning.
|
|
44
|
+
4. **Memory Refresh** (`devmind refresh`): Automatically detects modified files, updates the graph, and runs `cognee.improve()`.
|
|
45
|
+
5. **Surgical Forget** (`devmind forget --file ...`): Prunes specific file memory from the knowledge graph using `cognee.forget()`.
|
|
46
|
+
6. **Claude Code MCP Server** (`devmind mcp`): Seamlessly integrates with Claude Code or Cursor via standard Model Context Protocol (MCP).
|
|
47
|
+
7. **Local Dashboard UI** (`devmind dashboard`): Provides a clean visual panel showing memory status, search queries, and recent decisions.
|
|
48
|
+
8. **Smart API Key Rotation**: Automatically detects, formats, and rotates between multiple Groq and OpenRouter API keys to balance rate limits on free-tier LLM access.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Installation & Setup
|
|
53
|
+
|
|
54
|
+
### 1. Clone the repository
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/Anishp-cell/devmind-CLI.git
|
|
57
|
+
cd devmind-CLI
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 2. Configure Environment Variables
|
|
61
|
+
Copy `.env.example` to `.env` and fill in your keys:
|
|
62
|
+
```bash
|
|
63
|
+
cp .env.example .env
|
|
64
|
+
```
|
|
65
|
+
To run for **free**, configure your `.env` with a list of rotated API keys:
|
|
66
|
+
```env
|
|
67
|
+
LLM_PROVIDER="groq"
|
|
68
|
+
|
|
69
|
+
# Add a comma-separated list of Groq keys (gsk_...) and/or OpenRouter keys (sk-or-v1-...)
|
|
70
|
+
# The CLI automatically load-balances and routes requests to the correct endpoints!
|
|
71
|
+
GROQ_API_KEYS="gsk_key1,sk-or-v1-key2,gsk_key3"
|
|
72
|
+
|
|
73
|
+
EMBEDDING_PROVIDER="fastembed"
|
|
74
|
+
EMBEDDING_MODEL="BAAI/bge-small-en-v1.5"
|
|
75
|
+
EMBEDDING_DIMENSIONS="384"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 3. Install DevMind
|
|
79
|
+
Install the package in editable mode:
|
|
80
|
+
```bash
|
|
81
|
+
pip install -e .
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## CLI Command Reference
|
|
87
|
+
|
|
88
|
+
* **Ingest Codebase**:
|
|
89
|
+
```bash
|
|
90
|
+
devmind remember
|
|
91
|
+
```
|
|
92
|
+
* **Ask a Question**:
|
|
93
|
+
```bash
|
|
94
|
+
devmind ask "Why did we switch to redis for the queue?"
|
|
95
|
+
```
|
|
96
|
+
* **Log an Architectural Decision (ADR)**:
|
|
97
|
+
```bash
|
|
98
|
+
devmind log "Chose FastAPI for the web UI because it supports async routes natively."
|
|
99
|
+
```
|
|
100
|
+
* **Refresh Changed Memory**:
|
|
101
|
+
```bash
|
|
102
|
+
devmind refresh
|
|
103
|
+
```
|
|
104
|
+
* **Forget a Specific File**:
|
|
105
|
+
```bash
|
|
106
|
+
devmind forget --file devmind/web/app.py
|
|
107
|
+
```
|
|
108
|
+
* **Wipe Local Database Cache**:
|
|
109
|
+
```bash
|
|
110
|
+
devmind forget --all
|
|
111
|
+
```
|
|
112
|
+
* **Launch Web Dashboard**:
|
|
113
|
+
```bash
|
|
114
|
+
devmind dashboard --port 8000
|
|
115
|
+
```
|
|
116
|
+
* **Start MCP Server**:
|
|
117
|
+
```bash
|
|
118
|
+
devmind mcp
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Running the Mock Demo Project
|
|
124
|
+
|
|
125
|
+
To test DevMind on a smaller project without polluting your main repo:
|
|
126
|
+
1. Navigate to the demo directory:
|
|
127
|
+
```bash
|
|
128
|
+
cd examples/demo_project
|
|
129
|
+
```
|
|
130
|
+
2. Build the memory of the demo:
|
|
131
|
+
```bash
|
|
132
|
+
devmind remember --dir .
|
|
133
|
+
```
|
|
134
|
+
3. Query its memory:
|
|
135
|
+
```bash
|
|
136
|
+
devmind ask "What open TODO tasks are left in main.py?"
|
|
137
|
+
devmind ask "Why do we use SQLite according to our architecture decisions?"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Claude Code MCP Integration
|
|
143
|
+
|
|
144
|
+
To connect Claude Code to DevMind's memory, add the server to your Claude MCP config:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
claude mcp add devmind "devmind mcp"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Alternatively, configure your project-level `.mcp.json` file in your project root:
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"mcpServers": {
|
|
154
|
+
"devmind": {
|
|
155
|
+
"command": "devmind",
|
|
156
|
+
"args": ["mcp"]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## AI Assistant Declaration
|
|
165
|
+
|
|
166
|
+
Per the rules of **The Hangover Part AI Hackathon**, this project declares the use of **Claude** (via the Antigravity IDE agent) as an AI pair programmer.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
devmind/__init__.py,sha256=y9-PZTQC2r3AMAK95S8mBrriUKQ8oXl-NpckGK3grDc,64
|
|
2
|
+
devmind/cli.py,sha256=IOmWf42d42rfo0zKVJs02P41mkxt4VYzJP_gN3ppY14,10240
|
|
3
|
+
devmind/memory.py,sha256=wQZroWDg6LK-66mmvR640cBSVBmbL4fYQxQEROe8KwM,11120
|
|
4
|
+
devmind/ingestion/comment_extractor.py,sha256=Q-tMymQ3xPhq8D6tOGyo_jXqxhVkLqOD6blwsLX41Go,3928
|
|
5
|
+
devmind/ingestion/file_reader.py,sha256=9Wetq20moPt5rxGedkxE7zsJajar04pacYOy3glCh3Q,2683
|
|
6
|
+
devmind/ingestion/git_parser.py,sha256=xA0zZeQGTUiCc7S4U1NbF3V7yQOZDgWPqyuhnPSxh3Y,2993
|
|
7
|
+
devmind/integrations/claude_code.py,sha256=w2RKVjz57lyaiu2vhRf1SkcyFlFfJx0WhWTY1V82RNk,1470
|
|
8
|
+
devmind/web/app.py,sha256=btsHTfU4YgZTf1oLc7LH7mAlaEbcxHyPJOQR1U_ugdU,3049
|
|
9
|
+
devmind_cli-0.1.0.dist-info/METADATA,sha256=4NAfTgnBWWRYivqZ31Al4PSJ9Ojx6VPFv_7dmKB34qo,5479
|
|
10
|
+
devmind_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
devmind_cli-0.1.0.dist-info/entry_points.txt,sha256=hVEkT2A4Eon0gRaBlw4KO_gpi6UHq5-qJ99aUO3hRfI,44
|
|
12
|
+
devmind_cli-0.1.0.dist-info/top_level.txt,sha256=_DEg02cn_PSb0audseGNYo2Krcitio-Vuo_7duiIXGA,8
|
|
13
|
+
devmind_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devmind
|