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.
Files changed (67) hide show
  1. agmem-0.1.1.dist-info/METADATA +656 -0
  2. agmem-0.1.1.dist-info/RECORD +67 -0
  3. agmem-0.1.1.dist-info/WHEEL +5 -0
  4. agmem-0.1.1.dist-info/entry_points.txt +2 -0
  5. agmem-0.1.1.dist-info/licenses/LICENSE +21 -0
  6. agmem-0.1.1.dist-info/top_level.txt +1 -0
  7. memvcs/__init__.py +9 -0
  8. memvcs/cli.py +178 -0
  9. memvcs/commands/__init__.py +23 -0
  10. memvcs/commands/add.py +258 -0
  11. memvcs/commands/base.py +23 -0
  12. memvcs/commands/blame.py +169 -0
  13. memvcs/commands/branch.py +110 -0
  14. memvcs/commands/checkout.py +101 -0
  15. memvcs/commands/clean.py +76 -0
  16. memvcs/commands/clone.py +91 -0
  17. memvcs/commands/commit.py +174 -0
  18. memvcs/commands/daemon.py +267 -0
  19. memvcs/commands/diff.py +157 -0
  20. memvcs/commands/fsck.py +203 -0
  21. memvcs/commands/garden.py +107 -0
  22. memvcs/commands/graph.py +151 -0
  23. memvcs/commands/init.py +61 -0
  24. memvcs/commands/log.py +103 -0
  25. memvcs/commands/mcp.py +59 -0
  26. memvcs/commands/merge.py +88 -0
  27. memvcs/commands/pull.py +65 -0
  28. memvcs/commands/push.py +143 -0
  29. memvcs/commands/reflog.py +52 -0
  30. memvcs/commands/remote.py +51 -0
  31. memvcs/commands/reset.py +98 -0
  32. memvcs/commands/search.py +163 -0
  33. memvcs/commands/serve.py +54 -0
  34. memvcs/commands/show.py +125 -0
  35. memvcs/commands/stash.py +97 -0
  36. memvcs/commands/status.py +112 -0
  37. memvcs/commands/tag.py +117 -0
  38. memvcs/commands/test.py +132 -0
  39. memvcs/commands/tree.py +156 -0
  40. memvcs/core/__init__.py +21 -0
  41. memvcs/core/config_loader.py +245 -0
  42. memvcs/core/constants.py +12 -0
  43. memvcs/core/diff.py +380 -0
  44. memvcs/core/gardener.py +466 -0
  45. memvcs/core/hooks.py +151 -0
  46. memvcs/core/knowledge_graph.py +381 -0
  47. memvcs/core/merge.py +474 -0
  48. memvcs/core/objects.py +323 -0
  49. memvcs/core/pii_scanner.py +343 -0
  50. memvcs/core/refs.py +447 -0
  51. memvcs/core/remote.py +278 -0
  52. memvcs/core/repository.py +522 -0
  53. memvcs/core/schema.py +414 -0
  54. memvcs/core/staging.py +227 -0
  55. memvcs/core/storage/__init__.py +72 -0
  56. memvcs/core/storage/base.py +359 -0
  57. memvcs/core/storage/gcs.py +308 -0
  58. memvcs/core/storage/local.py +182 -0
  59. memvcs/core/storage/s3.py +369 -0
  60. memvcs/core/test_runner.py +371 -0
  61. memvcs/core/vector_store.py +313 -0
  62. memvcs/integrations/__init__.py +5 -0
  63. memvcs/integrations/mcp_server.py +267 -0
  64. memvcs/integrations/web_ui/__init__.py +1 -0
  65. memvcs/integrations/web_ui/server.py +352 -0
  66. memvcs/utils/__init__.py +9 -0
  67. 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
+ '''
@@ -0,0 +1,9 @@
1
+ """Utility functions for agmem."""
2
+
3
+ from .helpers import find_repo_root, format_timestamp, shorten_hash
4
+
5
+ __all__ = [
6
+ 'find_repo_root',
7
+ 'format_timestamp',
8
+ 'shorten_hash',
9
+ ]