agmem 0.1.1__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.
- agmem-0.1.1.dist-info/METADATA +656 -0
- agmem-0.1.1.dist-info/RECORD +67 -0
- agmem-0.1.1.dist-info/WHEEL +5 -0
- agmem-0.1.1.dist-info/entry_points.txt +2 -0
- agmem-0.1.1.dist-info/licenses/LICENSE +21 -0
- agmem-0.1.1.dist-info/top_level.txt +1 -0
- memvcs/__init__.py +9 -0
- memvcs/cli.py +178 -0
- memvcs/commands/__init__.py +23 -0
- memvcs/commands/add.py +258 -0
- memvcs/commands/base.py +23 -0
- memvcs/commands/blame.py +169 -0
- memvcs/commands/branch.py +110 -0
- memvcs/commands/checkout.py +101 -0
- memvcs/commands/clean.py +76 -0
- memvcs/commands/clone.py +91 -0
- memvcs/commands/commit.py +174 -0
- memvcs/commands/daemon.py +267 -0
- memvcs/commands/diff.py +157 -0
- memvcs/commands/fsck.py +203 -0
- memvcs/commands/garden.py +107 -0
- memvcs/commands/graph.py +151 -0
- memvcs/commands/init.py +61 -0
- memvcs/commands/log.py +103 -0
- memvcs/commands/mcp.py +59 -0
- memvcs/commands/merge.py +88 -0
- memvcs/commands/pull.py +65 -0
- memvcs/commands/push.py +143 -0
- memvcs/commands/reflog.py +52 -0
- memvcs/commands/remote.py +51 -0
- memvcs/commands/reset.py +98 -0
- memvcs/commands/search.py +163 -0
- memvcs/commands/serve.py +54 -0
- memvcs/commands/show.py +125 -0
- memvcs/commands/stash.py +97 -0
- memvcs/commands/status.py +112 -0
- memvcs/commands/tag.py +117 -0
- memvcs/commands/test.py +132 -0
- memvcs/commands/tree.py +156 -0
- memvcs/core/__init__.py +21 -0
- memvcs/core/config_loader.py +245 -0
- memvcs/core/constants.py +12 -0
- memvcs/core/diff.py +380 -0
- memvcs/core/gardener.py +466 -0
- memvcs/core/hooks.py +151 -0
- memvcs/core/knowledge_graph.py +381 -0
- memvcs/core/merge.py +474 -0
- memvcs/core/objects.py +323 -0
- memvcs/core/pii_scanner.py +343 -0
- memvcs/core/refs.py +447 -0
- memvcs/core/remote.py +278 -0
- memvcs/core/repository.py +522 -0
- memvcs/core/schema.py +414 -0
- memvcs/core/staging.py +227 -0
- memvcs/core/storage/__init__.py +72 -0
- memvcs/core/storage/base.py +359 -0
- memvcs/core/storage/gcs.py +308 -0
- memvcs/core/storage/local.py +182 -0
- memvcs/core/storage/s3.py +369 -0
- memvcs/core/test_runner.py +371 -0
- memvcs/core/vector_store.py +313 -0
- memvcs/integrations/__init__.py +5 -0
- memvcs/integrations/mcp_server.py +267 -0
- memvcs/integrations/web_ui/__init__.py +1 -0
- memvcs/integrations/web_ui/server.py +352 -0
- memvcs/utils/__init__.py +9 -0
- memvcs/utils/helpers.py +178 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem MCP Server - Model Context Protocol integration.
|
|
3
|
+
|
|
4
|
+
Exposes agent memory to Cursor, Claude, and other MCP clients via tools and resources.
|
|
5
|
+
Run with: agmem mcp or python -m memvcs.integrations.mcp_server
|
|
6
|
+
|
|
7
|
+
Configure in Cursor/Claude:
|
|
8
|
+
mcpServers.agmem.command = "agmem"
|
|
9
|
+
mcpServers.agmem.args = ["mcp"]
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from memvcs.core.constants import MEMORY_TYPES
|
|
18
|
+
|
|
19
|
+
# Use logging to stderr - never print() in MCP stdio servers
|
|
20
|
+
logging.basicConfig(
|
|
21
|
+
level=logging.INFO,
|
|
22
|
+
format="%(name)s: %(message)s",
|
|
23
|
+
stream=__import__("sys").stderr,
|
|
24
|
+
)
|
|
25
|
+
logger = logging.getLogger("agmem-mcp")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_repo():
|
|
29
|
+
"""Get repository from cwd. Returns (repo, None) or (None, error_msg)."""
|
|
30
|
+
from memvcs.core.repository import Repository
|
|
31
|
+
|
|
32
|
+
repo_path = Path(os.getcwd()).resolve()
|
|
33
|
+
repo = Repository(repo_path)
|
|
34
|
+
if not repo.is_valid_repo():
|
|
35
|
+
return None, "Not an agmem repository. Run 'agmem init' first."
|
|
36
|
+
return repo, None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _create_mcp_server():
|
|
40
|
+
"""Create and configure the MCP server. Lazy import to allow running without mcp deps."""
|
|
41
|
+
try:
|
|
42
|
+
from mcp.server.fastmcp import FastMCP
|
|
43
|
+
|
|
44
|
+
mcp = FastMCP(
|
|
45
|
+
"agmem",
|
|
46
|
+
instructions="Agentic Memory Version Control System - Git for AI agent memories. "
|
|
47
|
+
"Provides tools to read, search, add, and diff agent memory stored in current/.",
|
|
48
|
+
)
|
|
49
|
+
except ImportError:
|
|
50
|
+
try:
|
|
51
|
+
from mcp.server.mcpserver import MCPServer
|
|
52
|
+
|
|
53
|
+
mcp = MCPServer("agmem")
|
|
54
|
+
except ImportError:
|
|
55
|
+
try:
|
|
56
|
+
from fastmcp import FastMCP
|
|
57
|
+
|
|
58
|
+
mcp = FastMCP("agmem")
|
|
59
|
+
except ImportError:
|
|
60
|
+
raise ImportError(
|
|
61
|
+
"MCP support requires 'mcp' or 'fastmcp' package. "
|
|
62
|
+
"Install with: pip install agmem[mcp]"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# --- Tools ---
|
|
66
|
+
|
|
67
|
+
@mcp.tool()
|
|
68
|
+
def memory_read(path: str) -> str:
|
|
69
|
+
"""Read a memory file from current/ directory.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
path: Relative path within current/ (e.g. semantic/user-preferences.md,
|
|
73
|
+
episodic/session1.md, procedural/coding-workflow.md)
|
|
74
|
+
"""
|
|
75
|
+
repo, err = _get_repo()
|
|
76
|
+
if err:
|
|
77
|
+
return f"Error: {err}"
|
|
78
|
+
|
|
79
|
+
full_path = (repo.current_dir / path).resolve()
|
|
80
|
+
try:
|
|
81
|
+
full_path.relative_to(repo.current_dir.resolve())
|
|
82
|
+
except ValueError:
|
|
83
|
+
return f"Error: Path outside current/: {path}"
|
|
84
|
+
if not full_path.exists():
|
|
85
|
+
return f"Error: File not found: {path}"
|
|
86
|
+
if full_path.is_dir():
|
|
87
|
+
return f"Error: {path} is a directory, not a file"
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
return full_path.read_text(encoding="utf-8", errors="replace")
|
|
91
|
+
except Exception as e:
|
|
92
|
+
return f"Error reading {path}: {e}"
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
def memory_search(query: str, memory_type: Optional[str] = None) -> str:
|
|
96
|
+
"""Full-text search over memory files in current/.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
query: Search term to find in memory content
|
|
100
|
+
memory_type: Optional filter - episodic, semantic, or procedural
|
|
101
|
+
"""
|
|
102
|
+
repo, err = _get_repo()
|
|
103
|
+
if err:
|
|
104
|
+
return f"Error: {err}"
|
|
105
|
+
|
|
106
|
+
query_lower = query.lower()
|
|
107
|
+
results = []
|
|
108
|
+
|
|
109
|
+
subdirs = list(MEMORY_TYPES)
|
|
110
|
+
if memory_type:
|
|
111
|
+
memory_type = memory_type.lower()
|
|
112
|
+
if memory_type in MEMORY_TYPES:
|
|
113
|
+
subdirs = [memory_type]
|
|
114
|
+
else:
|
|
115
|
+
return "Error: memory_type must be one of: episodic, semantic, procedural"
|
|
116
|
+
|
|
117
|
+
for subdir in subdirs:
|
|
118
|
+
dir_path = repo.current_dir / subdir
|
|
119
|
+
if not dir_path.exists():
|
|
120
|
+
continue
|
|
121
|
+
for f in dir_path.rglob("*"):
|
|
122
|
+
if f.is_file():
|
|
123
|
+
try:
|
|
124
|
+
content = f.read_text(encoding="utf-8", errors="replace")
|
|
125
|
+
if query_lower in content.lower():
|
|
126
|
+
rel = str(f.relative_to(repo.current_dir))
|
|
127
|
+
results.append(f"--- {rel} ---\n{content[:500]}...")
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
if not results:
|
|
132
|
+
return f"No matches for '{query}' in memory."
|
|
133
|
+
return "\n\n".join(results[:10])
|
|
134
|
+
|
|
135
|
+
@mcp.tool()
|
|
136
|
+
def memory_add(path: str, commit: bool = False, message: str = "") -> str:
|
|
137
|
+
"""Stage a memory file for commit. Optionally commit immediately.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
path: Relative path within current/
|
|
141
|
+
commit: If True, commit after staging
|
|
142
|
+
message: Commit message (required if commit=True)
|
|
143
|
+
"""
|
|
144
|
+
repo, err = _get_repo()
|
|
145
|
+
if err:
|
|
146
|
+
return f"Error: {err}"
|
|
147
|
+
|
|
148
|
+
full_path = (repo.current_dir / path).resolve()
|
|
149
|
+
try:
|
|
150
|
+
full_path.relative_to(repo.current_dir.resolve())
|
|
151
|
+
except ValueError:
|
|
152
|
+
return f"Error: Path outside current/: {path}"
|
|
153
|
+
if not full_path.exists() or not full_path.is_file():
|
|
154
|
+
return f"Error: File not found: {path}"
|
|
155
|
+
|
|
156
|
+
rel_path = str(full_path.relative_to(repo.current_dir))
|
|
157
|
+
try:
|
|
158
|
+
repo.stage_file(rel_path)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return f"Error staging {path}: {e}"
|
|
161
|
+
|
|
162
|
+
if commit:
|
|
163
|
+
if not message:
|
|
164
|
+
return "Staged. Error: message required for commit."
|
|
165
|
+
try:
|
|
166
|
+
commit_hash = repo.commit(message)
|
|
167
|
+
return f"Staged and committed: {rel_path} ({commit_hash[:8]})"
|
|
168
|
+
except Exception as e:
|
|
169
|
+
return f"Staged. Error committing: {e}"
|
|
170
|
+
return f"Staged: {rel_path}. Run 'agmem commit -m \"message\"' to save."
|
|
171
|
+
|
|
172
|
+
@mcp.tool()
|
|
173
|
+
def memory_log(max_count: int = 10) -> str:
|
|
174
|
+
"""Return recent commit history.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
max_count: Maximum number of commits to return (default 10)
|
|
178
|
+
"""
|
|
179
|
+
repo, err = _get_repo()
|
|
180
|
+
if err:
|
|
181
|
+
return f"Error: {err}"
|
|
182
|
+
|
|
183
|
+
commits = repo.get_log(max_count=max_count)
|
|
184
|
+
if not commits:
|
|
185
|
+
return "No commits yet."
|
|
186
|
+
|
|
187
|
+
lines = []
|
|
188
|
+
for c in commits:
|
|
189
|
+
lines.append(f"{c['short_hash']} {c['message']}")
|
|
190
|
+
return "\n".join(lines)
|
|
191
|
+
|
|
192
|
+
@mcp.tool()
|
|
193
|
+
def memory_diff(
|
|
194
|
+
base: Optional[str] = None, head: Optional[str] = None, working: bool = False
|
|
195
|
+
) -> str:
|
|
196
|
+
"""Show diff between commits or working tree.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
base: Base ref (commit, branch, or tag). Default: HEAD~1
|
|
200
|
+
head: Head ref. Default: HEAD
|
|
201
|
+
working: If True, diff working tree vs HEAD (ignore base/head)
|
|
202
|
+
"""
|
|
203
|
+
repo, err = _get_repo()
|
|
204
|
+
if err:
|
|
205
|
+
return f"Error: {err}"
|
|
206
|
+
|
|
207
|
+
from memvcs.core.diff import DiffEngine
|
|
208
|
+
|
|
209
|
+
engine = DiffEngine(repo.object_store)
|
|
210
|
+
|
|
211
|
+
if working:
|
|
212
|
+
head_commit = repo.get_head_commit()
|
|
213
|
+
if not head_commit:
|
|
214
|
+
return "No commits yet."
|
|
215
|
+
working_files = {}
|
|
216
|
+
for root, dirs, files in os.walk(repo.current_dir):
|
|
217
|
+
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
|
218
|
+
for f in files:
|
|
219
|
+
fp = Path(root) / f
|
|
220
|
+
rel = str(fp.relative_to(repo.current_dir))
|
|
221
|
+
working_files[rel] = fp.read_bytes()
|
|
222
|
+
tree_diff = engine.diff_working_dir(
|
|
223
|
+
head_commit.store(repo.object_store), working_files
|
|
224
|
+
)
|
|
225
|
+
return engine.format_diff(tree_diff, "HEAD", "working")
|
|
226
|
+
else:
|
|
227
|
+
base_ref = base or "HEAD~1"
|
|
228
|
+
head_ref = head or "HEAD"
|
|
229
|
+
c1 = repo.resolve_ref(base_ref)
|
|
230
|
+
c2 = repo.resolve_ref(head_ref)
|
|
231
|
+
if not c1:
|
|
232
|
+
return f"Error: Unknown revision: {base_ref}"
|
|
233
|
+
if not c2:
|
|
234
|
+
return f"Error: Unknown revision: {head_ref}"
|
|
235
|
+
tree_diff = engine.diff_commits(c1, c2)
|
|
236
|
+
return engine.format_diff(tree_diff, base_ref, head_ref)
|
|
237
|
+
|
|
238
|
+
# --- Resources: mem://current/{path} (if supported by SDK) ---
|
|
239
|
+
if hasattr(mcp, "resource"):
|
|
240
|
+
|
|
241
|
+
@mcp.resource("mem://current/{path}")
|
|
242
|
+
def memory_resource(path: str) -> str:
|
|
243
|
+
"""Read memory file from current/ by path. URI: mem://current/semantic/user-preferences.md"""
|
|
244
|
+
repo, err = _get_repo()
|
|
245
|
+
if err:
|
|
246
|
+
return f"Error: {err}"
|
|
247
|
+
|
|
248
|
+
full_path = repo.current_dir / path
|
|
249
|
+
if not full_path.exists() or not full_path.is_file():
|
|
250
|
+
return f"File not found: {path}"
|
|
251
|
+
if not str(full_path.resolve()).startswith(str(repo.current_dir.resolve())):
|
|
252
|
+
return f"Path outside current/: {path}"
|
|
253
|
+
|
|
254
|
+
return full_path.read_text(encoding="utf-8", errors="replace")
|
|
255
|
+
|
|
256
|
+
return mcp
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def run():
|
|
260
|
+
"""Run the MCP server. Uses stdio transport for Cursor/Claude."""
|
|
261
|
+
mcp = _create_mcp_server()
|
|
262
|
+
# Default: stdio for Cursor/Claude Desktop
|
|
263
|
+
mcp.run(transport="stdio")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
run()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""agmem Web UI for browsing history and diffs."""
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem Web UI server - FastAPI app for browsing history and diffs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, HTTPException
|
|
10
|
+
from fastapi.responses import HTMLResponse, FileResponse
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
|
|
13
|
+
# Will be set when app is created
|
|
14
|
+
_repo_path: Optional[Path] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_app(repo_path: Path) -> FastAPI:
|
|
18
|
+
"""Create FastAPI app for the given repository."""
|
|
19
|
+
global _repo_path
|
|
20
|
+
_repo_path = Path(repo_path).resolve()
|
|
21
|
+
|
|
22
|
+
app = FastAPI(title="agmem", description="Agent Memory Version Control")
|
|
23
|
+
|
|
24
|
+
static_dir = Path(__file__).parent / "static"
|
|
25
|
+
if static_dir.exists():
|
|
26
|
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
27
|
+
|
|
28
|
+
@app.get("/", response_class=HTMLResponse)
|
|
29
|
+
async def index():
|
|
30
|
+
html = (static_dir / "index.html").read_text()
|
|
31
|
+
return HTMLResponse(html)
|
|
32
|
+
|
|
33
|
+
@app.get("/api/log")
|
|
34
|
+
async def api_log(max_count: int = 50):
|
|
35
|
+
from memvcs.core.repository import Repository
|
|
36
|
+
|
|
37
|
+
repo = Repository(_repo_path)
|
|
38
|
+
if not repo.is_valid_repo():
|
|
39
|
+
raise HTTPException(status_code=400, detail="Not an agmem repository")
|
|
40
|
+
commits = repo.get_log(max_count=max_count)
|
|
41
|
+
return {"commits": commits}
|
|
42
|
+
|
|
43
|
+
@app.get("/api/tree/{commit_hash}")
|
|
44
|
+
async def api_tree(commit_hash: str):
|
|
45
|
+
from memvcs.core.repository import Repository
|
|
46
|
+
from memvcs.core.objects import Tree, Commit
|
|
47
|
+
from memvcs.core.refs import _valid_commit_hash
|
|
48
|
+
|
|
49
|
+
repo = Repository(_repo_path)
|
|
50
|
+
if not repo.is_valid_repo():
|
|
51
|
+
raise HTTPException(status_code=400, detail="Not an agmem repository")
|
|
52
|
+
|
|
53
|
+
resolved = repo.resolve_ref(commit_hash) or (commit_hash if _valid_commit_hash(commit_hash) else None)
|
|
54
|
+
if not resolved:
|
|
55
|
+
raise HTTPException(status_code=400, detail="Invalid revision or hash")
|
|
56
|
+
c = Commit.load(repo.object_store, resolved)
|
|
57
|
+
if not c:
|
|
58
|
+
raise HTTPException(status_code=404, detail="Commit not found")
|
|
59
|
+
|
|
60
|
+
tree = Tree.load(repo.object_store, c.tree)
|
|
61
|
+
if not tree:
|
|
62
|
+
return {"entries": []}
|
|
63
|
+
|
|
64
|
+
entries = []
|
|
65
|
+
for e in tree.entries:
|
|
66
|
+
path = f"{e.path}/{e.name}" if e.path else e.name
|
|
67
|
+
entries.append({"path": path, "name": e.name, "hash": e.hash, "type": e.obj_type})
|
|
68
|
+
return {"entries": entries}
|
|
69
|
+
|
|
70
|
+
@app.get("/api/diff")
|
|
71
|
+
async def api_diff(base: str, head: str):
|
|
72
|
+
from memvcs.core.repository import Repository
|
|
73
|
+
from memvcs.core.diff import DiffEngine
|
|
74
|
+
|
|
75
|
+
repo = Repository(_repo_path)
|
|
76
|
+
if not repo.is_valid_repo():
|
|
77
|
+
raise HTTPException(status_code=400, detail="Not an agmem repository")
|
|
78
|
+
|
|
79
|
+
c1 = repo.resolve_ref(base)
|
|
80
|
+
c2 = repo.resolve_ref(head)
|
|
81
|
+
if not c1:
|
|
82
|
+
raise HTTPException(status_code=404, detail=f"Unknown revision: {base}")
|
|
83
|
+
if not c2:
|
|
84
|
+
raise HTTPException(status_code=404, detail=f"Unknown revision: {head}")
|
|
85
|
+
|
|
86
|
+
engine = DiffEngine(repo.object_store)
|
|
87
|
+
tree_diff = engine.diff_commits(c1, c2)
|
|
88
|
+
files = []
|
|
89
|
+
for fd in tree_diff.files:
|
|
90
|
+
files.append({
|
|
91
|
+
"path": fd.path,
|
|
92
|
+
"diff_type": fd.diff_type.value,
|
|
93
|
+
"old_hash": fd.old_hash,
|
|
94
|
+
"new_hash": fd.new_hash,
|
|
95
|
+
"diff_lines": fd.diff_lines,
|
|
96
|
+
})
|
|
97
|
+
return {
|
|
98
|
+
"base": base,
|
|
99
|
+
"head": head,
|
|
100
|
+
"added": tree_diff.added_count,
|
|
101
|
+
"deleted": tree_diff.deleted_count,
|
|
102
|
+
"modified": tree_diff.modified_count,
|
|
103
|
+
"files": files,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@app.get("/api/blob/{hash_id}")
|
|
107
|
+
async def api_blob(hash_id: str):
|
|
108
|
+
from memvcs.core.repository import Repository
|
|
109
|
+
from memvcs.core.objects import Blob, _valid_object_hash
|
|
110
|
+
|
|
111
|
+
repo = Repository(_repo_path)
|
|
112
|
+
if not repo.is_valid_repo():
|
|
113
|
+
raise HTTPException(status_code=400, detail="Not an agmem repository")
|
|
114
|
+
if not _valid_object_hash(hash_id):
|
|
115
|
+
raise HTTPException(status_code=400, detail="Invalid object hash")
|
|
116
|
+
|
|
117
|
+
blob = Blob.load(repo.object_store, hash_id)
|
|
118
|
+
if not blob:
|
|
119
|
+
raise HTTPException(status_code=404, detail="Blob not found")
|
|
120
|
+
try:
|
|
121
|
+
content = blob.content.decode("utf-8", errors="replace")
|
|
122
|
+
except Exception:
|
|
123
|
+
content = "<binary>"
|
|
124
|
+
return {"hash": hash_id, "content": content}
|
|
125
|
+
|
|
126
|
+
@app.get("/api/graph")
|
|
127
|
+
async def api_graph(include_similarity: bool = False, threshold: float = 0.7):
|
|
128
|
+
"""Get knowledge graph data for visualization."""
|
|
129
|
+
from memvcs.core.repository import Repository
|
|
130
|
+
from memvcs.core.knowledge_graph import KnowledgeGraphBuilder
|
|
131
|
+
|
|
132
|
+
repo = Repository(_repo_path)
|
|
133
|
+
if not repo.is_valid_repo():
|
|
134
|
+
raise HTTPException(status_code=400, detail="Not an agmem repository")
|
|
135
|
+
|
|
136
|
+
# Try to get vector store for similarity
|
|
137
|
+
vector_store = None
|
|
138
|
+
if include_similarity:
|
|
139
|
+
try:
|
|
140
|
+
from memvcs.core.vector_store import VectorStore
|
|
141
|
+
vector_store = VectorStore(_repo_path / '.mem')
|
|
142
|
+
except ImportError:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
builder = KnowledgeGraphBuilder(repo, vector_store)
|
|
146
|
+
graph_data = builder.build_graph(
|
|
147
|
+
include_similarity=include_similarity,
|
|
148
|
+
similarity_threshold=threshold
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Return D3-compatible format
|
|
152
|
+
return {
|
|
153
|
+
'nodes': [
|
|
154
|
+
{
|
|
155
|
+
'id': n.id,
|
|
156
|
+
'name': n.label,
|
|
157
|
+
'group': n.memory_type,
|
|
158
|
+
'size': min(20, max(5, n.size // 100))
|
|
159
|
+
}
|
|
160
|
+
for n in graph_data.nodes
|
|
161
|
+
],
|
|
162
|
+
'links': [
|
|
163
|
+
{
|
|
164
|
+
'source': e.source,
|
|
165
|
+
'target': e.target,
|
|
166
|
+
'type': e.edge_type,
|
|
167
|
+
'value': e.weight
|
|
168
|
+
}
|
|
169
|
+
for e in graph_data.edges
|
|
170
|
+
],
|
|
171
|
+
'metadata': graph_data.metadata
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@app.get("/graph", response_class=HTMLResponse)
|
|
175
|
+
async def graph_view():
|
|
176
|
+
"""Serve the knowledge graph visualization page."""
|
|
177
|
+
graph_html = static_dir / "graph.html"
|
|
178
|
+
if graph_html.exists():
|
|
179
|
+
return HTMLResponse(graph_html.read_text())
|
|
180
|
+
else:
|
|
181
|
+
# Return embedded graph viewer
|
|
182
|
+
return HTMLResponse(GRAPH_HTML_TEMPLATE)
|
|
183
|
+
|
|
184
|
+
return app
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Embedded graph viewer template
|
|
188
|
+
GRAPH_HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
189
|
+
<html>
|
|
190
|
+
<head>
|
|
191
|
+
<title>agmem Knowledge Graph</title>
|
|
192
|
+
<meta charset="utf-8">
|
|
193
|
+
<style>
|
|
194
|
+
body {
|
|
195
|
+
margin: 0;
|
|
196
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
197
|
+
background: #1a1a2e;
|
|
198
|
+
color: #eee;
|
|
199
|
+
}
|
|
200
|
+
#header {
|
|
201
|
+
padding: 10px 20px;
|
|
202
|
+
background: #16213e;
|
|
203
|
+
border-bottom: 1px solid #0f3460;
|
|
204
|
+
}
|
|
205
|
+
h1 { margin: 0; font-size: 20px; }
|
|
206
|
+
#controls {
|
|
207
|
+
padding: 10px 20px;
|
|
208
|
+
background: #16213e;
|
|
209
|
+
display: flex;
|
|
210
|
+
gap: 20px;
|
|
211
|
+
align-items: center;
|
|
212
|
+
}
|
|
213
|
+
label { font-size: 14px; }
|
|
214
|
+
#graph { width: 100%; height: calc(100vh - 100px); }
|
|
215
|
+
.node { cursor: pointer; }
|
|
216
|
+
.node text { font-size: 10px; fill: #fff; }
|
|
217
|
+
.link { stroke-opacity: 0.6; }
|
|
218
|
+
.link.reference { stroke: #e94560; }
|
|
219
|
+
.link.similarity { stroke: #0f3460; }
|
|
220
|
+
.link.same_topic { stroke: #533483; }
|
|
221
|
+
.tooltip {
|
|
222
|
+
position: absolute;
|
|
223
|
+
background: #16213e;
|
|
224
|
+
padding: 10px;
|
|
225
|
+
border-radius: 4px;
|
|
226
|
+
border: 1px solid #0f3460;
|
|
227
|
+
font-size: 12px;
|
|
228
|
+
pointer-events: none;
|
|
229
|
+
opacity: 0;
|
|
230
|
+
transition: opacity 0.2s;
|
|
231
|
+
}
|
|
232
|
+
#stats { font-size: 12px; color: #888; }
|
|
233
|
+
</style>
|
|
234
|
+
</head>
|
|
235
|
+
<body>
|
|
236
|
+
<div id="header">
|
|
237
|
+
<h1>agmem Knowledge Graph</h1>
|
|
238
|
+
</div>
|
|
239
|
+
<div id="controls">
|
|
240
|
+
<label><input type="checkbox" id="showLabels" checked> Show labels</label>
|
|
241
|
+
<span id="stats">Loading...</span>
|
|
242
|
+
</div>
|
|
243
|
+
<div id="graph"></div>
|
|
244
|
+
<div id="tooltip" class="tooltip"></div>
|
|
245
|
+
|
|
246
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
247
|
+
<script>
|
|
248
|
+
const width = window.innerWidth;
|
|
249
|
+
const height = window.innerHeight - 100;
|
|
250
|
+
|
|
251
|
+
const colorScale = d3.scaleOrdinal()
|
|
252
|
+
.domain(['episodic', 'semantic', 'procedural', 'checkpoints', 'session-summaries', 'unknown'])
|
|
253
|
+
.range(['#e94560', '#0f3460', '#533483', '#1a1a2e', '#16213e', '#444']);
|
|
254
|
+
|
|
255
|
+
const svg = d3.select('#graph')
|
|
256
|
+
.append('svg')
|
|
257
|
+
.attr('width', width)
|
|
258
|
+
.attr('height', height);
|
|
259
|
+
|
|
260
|
+
const g = svg.append('g');
|
|
261
|
+
|
|
262
|
+
// Zoom behavior
|
|
263
|
+
svg.call(d3.zoom()
|
|
264
|
+
.extent([[0, 0], [width, height]])
|
|
265
|
+
.scaleExtent([0.1, 4])
|
|
266
|
+
.on('zoom', ({transform}) => g.attr('transform', transform)));
|
|
267
|
+
|
|
268
|
+
const tooltip = d3.select('#tooltip');
|
|
269
|
+
|
|
270
|
+
// Fetch and render graph
|
|
271
|
+
fetch('/api/graph')
|
|
272
|
+
.then(res => res.json())
|
|
273
|
+
.then(data => {
|
|
274
|
+
document.getElementById('stats').textContent =
|
|
275
|
+
`${data.nodes.length} files, ${data.links.length} connections`;
|
|
276
|
+
|
|
277
|
+
const simulation = d3.forceSimulation(data.nodes)
|
|
278
|
+
.force('link', d3.forceLink(data.links).id(d => d.id).distance(100))
|
|
279
|
+
.force('charge', d3.forceManyBody().strength(-200))
|
|
280
|
+
.force('center', d3.forceCenter(width / 2, height / 2));
|
|
281
|
+
|
|
282
|
+
const link = g.append('g')
|
|
283
|
+
.selectAll('line')
|
|
284
|
+
.data(data.links)
|
|
285
|
+
.join('line')
|
|
286
|
+
.attr('class', d => `link ${d.type}`)
|
|
287
|
+
.attr('stroke-width', d => Math.sqrt(d.value) * 2);
|
|
288
|
+
|
|
289
|
+
const node = g.append('g')
|
|
290
|
+
.selectAll('g')
|
|
291
|
+
.data(data.nodes)
|
|
292
|
+
.join('g')
|
|
293
|
+
.attr('class', 'node')
|
|
294
|
+
.call(d3.drag()
|
|
295
|
+
.on('start', dragstarted)
|
|
296
|
+
.on('drag', dragged)
|
|
297
|
+
.on('end', dragended));
|
|
298
|
+
|
|
299
|
+
node.append('circle')
|
|
300
|
+
.attr('r', d => d.size)
|
|
301
|
+
.attr('fill', d => colorScale(d.group))
|
|
302
|
+
.on('mouseover', (event, d) => {
|
|
303
|
+
tooltip.style('opacity', 1)
|
|
304
|
+
.html(`<strong>${d.name}</strong><br>Type: ${d.group}<br>Path: ${d.id}`)
|
|
305
|
+
.style('left', (event.pageX + 10) + 'px')
|
|
306
|
+
.style('top', (event.pageY - 10) + 'px');
|
|
307
|
+
})
|
|
308
|
+
.on('mouseout', () => tooltip.style('opacity', 0));
|
|
309
|
+
|
|
310
|
+
const labels = node.append('text')
|
|
311
|
+
.text(d => d.name)
|
|
312
|
+
.attr('dx', d => d.size + 3)
|
|
313
|
+
.attr('dy', 3);
|
|
314
|
+
|
|
315
|
+
document.getElementById('showLabels').addEventListener('change', (e) => {
|
|
316
|
+
labels.style('display', e.target.checked ? 'block' : 'none');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
simulation.on('tick', () => {
|
|
320
|
+
link
|
|
321
|
+
.attr('x1', d => d.source.x)
|
|
322
|
+
.attr('y1', d => d.source.y)
|
|
323
|
+
.attr('x2', d => d.target.x)
|
|
324
|
+
.attr('y2', d => d.target.y);
|
|
325
|
+
|
|
326
|
+
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
function dragstarted(event) {
|
|
330
|
+
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
331
|
+
event.subject.fx = event.subject.x;
|
|
332
|
+
event.subject.fy = event.subject.y;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function dragged(event) {
|
|
336
|
+
event.subject.fx = event.x;
|
|
337
|
+
event.subject.fy = event.y;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function dragended(event) {
|
|
341
|
+
if (!event.active) simulation.alphaTarget(0);
|
|
342
|
+
event.subject.fx = null;
|
|
343
|
+
event.subject.fy = null;
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
.catch(err => {
|
|
347
|
+
document.getElementById('stats').textContent = 'Error loading graph: ' + err;
|
|
348
|
+
});
|
|
349
|
+
</script>
|
|
350
|
+
</body>
|
|
351
|
+
</html>
|
|
352
|
+
'''
|