agmem 0.2.0__py3-none-any.whl → 0.3.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.
@@ -180,6 +180,430 @@ def create_app(repo_path: Path) -> FastAPI:
180
180
  # Return embedded graph viewer
181
181
  return HTMLResponse(GRAPH_HTML_TEMPLATE)
182
182
 
183
+ # --- Additional API Endpoints ---
184
+
185
+ @app.get("/api/commit/{commit_hash}")
186
+ async def api_commit(commit_hash: str):
187
+ """Get detailed information about a single commit."""
188
+ from memvcs.core.repository import Repository
189
+ from memvcs.core.objects import Commit, Tree
190
+ from memvcs.core.refs import _valid_commit_hash
191
+
192
+ repo = Repository(_repo_path)
193
+ if not repo.is_valid_repo():
194
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
195
+
196
+ resolved = repo.resolve_ref(commit_hash) or (
197
+ commit_hash if _valid_commit_hash(commit_hash) else None
198
+ )
199
+ if not resolved:
200
+ raise HTTPException(status_code=400, detail="Invalid revision or hash")
201
+
202
+ commit = Commit.load(repo.object_store, resolved)
203
+ if not commit:
204
+ raise HTTPException(status_code=404, detail="Commit not found")
205
+
206
+ # Get commit data via to_dict()
207
+ commit_data = commit.to_dict()
208
+
209
+ # Get file list from tree
210
+ tree = Tree.load(repo.object_store, commit_data["tree"])
211
+ files = []
212
+ if tree:
213
+ for e in tree.entries:
214
+ path = f"{e.path}/{e.name}" if e.path else e.name
215
+ files.append({"path": path, "hash": e.hash, "type": e.obj_type})
216
+
217
+ return {
218
+ "hash": resolved,
219
+ "short_hash": resolved[:8],
220
+ "tree": commit_data["tree"],
221
+ "parents": commit_data.get("parents", []),
222
+ "message": commit_data["message"],
223
+ "author": commit_data["author"],
224
+ "timestamp": commit_data["timestamp"],
225
+ "metadata": commit_data.get("metadata", {}),
226
+ "files": files,
227
+ }
228
+
229
+ @app.get("/api/trust")
230
+ async def api_trust():
231
+ """Get trust graph data for visualization."""
232
+ from memvcs.core.repository import Repository
233
+
234
+ repo = Repository(_repo_path)
235
+ if not repo.is_valid_repo():
236
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
237
+
238
+ try:
239
+ from memvcs.core.trust import TrustManager
240
+
241
+ trust_mgr = TrustManager(repo.mem_dir)
242
+ agents = trust_mgr.list_agents()
243
+
244
+ nodes = []
245
+ links = []
246
+
247
+ for agent in agents:
248
+ info = trust_mgr.get_agent_info(agent)
249
+ nodes.append(
250
+ {
251
+ "id": agent,
252
+ "name": info.get("name", agent[:8]),
253
+ "trust_level": info.get("trust_level", "unknown"),
254
+ "public_key": info.get("public_key", "")[:16] + "...",
255
+ }
256
+ )
257
+
258
+ # Build trust relationships
259
+ for agent in agents:
260
+ trusted = trust_mgr.get_trusted_agents(agent)
261
+ for trusted_agent in trusted:
262
+ links.append(
263
+ {
264
+ "source": agent,
265
+ "target": trusted_agent,
266
+ "trust_level": trust_mgr.get_trust_level(agent, trusted_agent),
267
+ }
268
+ )
269
+
270
+ return {"nodes": nodes, "links": links}
271
+ except ImportError:
272
+ return {"nodes": [], "links": [], "error": "Trust module not available"}
273
+ except Exception as e:
274
+ return {"nodes": [], "links": [], "error": str(e)}
275
+
276
+ @app.get("/api/privacy")
277
+ async def api_privacy():
278
+ """Get privacy budget status."""
279
+ from memvcs.core.repository import Repository
280
+
281
+ repo = Repository(_repo_path)
282
+ if not repo.is_valid_repo():
283
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
284
+
285
+ try:
286
+ from memvcs.core.privacy_budget import PrivacyBudget
287
+
288
+ budget = PrivacyBudget(repo.mem_dir)
289
+ status = budget.get_status()
290
+
291
+ return {
292
+ "epsilon_used": status.get("epsilon_used", 0),
293
+ "epsilon_limit": status.get("epsilon_limit", 10),
294
+ "delta_used": status.get("delta_used", 0),
295
+ "delta_limit": status.get("delta_limit", 1e-5),
296
+ "operations_count": status.get("operations_count", 0),
297
+ "percentage_used": min(
298
+ 100, (status.get("epsilon_used", 0) / status.get("epsilon_limit", 10)) * 100
299
+ ),
300
+ }
301
+ except ImportError:
302
+ return {"epsilon_used": 0, "epsilon_limit": 10, "error": "Privacy module not available"}
303
+ except Exception as e:
304
+ return {"epsilon_used": 0, "epsilon_limit": 10, "error": str(e)}
305
+
306
+ @app.get("/api/search")
307
+ async def api_search(q: str, memory_type: Optional[str] = None, max_results: int = 20):
308
+ """Search memory files."""
309
+ from memvcs.core.repository import Repository
310
+ from memvcs.core.constants import MEMORY_TYPES
311
+
312
+ repo = Repository(_repo_path)
313
+ if not repo.is_valid_repo():
314
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
315
+
316
+ if not q or len(q) < 2:
317
+ raise HTTPException(status_code=400, detail="Query must be at least 2 characters")
318
+
319
+ query_lower = q.lower()
320
+ results = []
321
+
322
+ subdirs = list(MEMORY_TYPES)
323
+ if memory_type and memory_type.lower() in MEMORY_TYPES:
324
+ subdirs = [memory_type.lower()]
325
+
326
+ for subdir in subdirs:
327
+ dir_path = repo.current_dir / subdir
328
+ if not dir_path.exists():
329
+ continue
330
+
331
+ for f in dir_path.rglob("*"):
332
+ if f.is_file() and len(results) < max_results:
333
+ try:
334
+ content = f.read_text(encoding="utf-8", errors="replace")
335
+ if query_lower in content.lower():
336
+ rel = str(f.relative_to(repo.current_dir))
337
+ # Extract matching snippet
338
+ idx = content.lower().find(query_lower)
339
+ start = max(0, idx - 50)
340
+ end = min(len(content), idx + len(q) + 50)
341
+ snippet = content[start:end]
342
+
343
+ results.append(
344
+ {
345
+ "path": rel,
346
+ "memory_type": subdir,
347
+ "snippet": snippet,
348
+ "filename": f.name,
349
+ }
350
+ )
351
+ except Exception:
352
+ pass
353
+
354
+ return {"query": q, "results": results, "count": len(results)}
355
+
356
+ @app.get("/api/status")
357
+ async def api_status():
358
+ """Get repository status."""
359
+ from memvcs.core.repository import Repository
360
+
361
+ repo = Repository(_repo_path)
362
+ if not repo.is_valid_repo():
363
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
364
+
365
+ status = repo.get_status()
366
+ head = repo.refs.get_head()
367
+ branch = repo.refs.get_current_branch()
368
+
369
+ return {
370
+ "branch": branch or "detached",
371
+ "head": head.get("value", "")[:8] if head else None,
372
+ "staged": status.get("staged", []),
373
+ "modified": status.get("modified", []),
374
+ "untracked": status.get("untracked", []),
375
+ "is_clean": not status.get("staged") and not status.get("modified"),
376
+ }
377
+
378
+ @app.get("/api/audit")
379
+ async def api_audit(max_entries: int = 50):
380
+ """Get audit log entries."""
381
+ from memvcs.core.repository import Repository
382
+
383
+ repo = Repository(_repo_path)
384
+ if not repo.is_valid_repo():
385
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
386
+
387
+ try:
388
+ from memvcs.core.audit import read_audit, verify_audit
389
+
390
+ entries = read_audit(repo.mem_dir, max_entries=max_entries)
391
+ valid, first_bad = verify_audit(repo.mem_dir)
392
+
393
+ return {
394
+ "entries": entries,
395
+ "count": len(entries),
396
+ "valid": valid,
397
+ "first_bad_index": first_bad,
398
+ }
399
+ except ImportError:
400
+ return {"entries": [], "error": "Audit module not available"}
401
+ except Exception as e:
402
+ return {"entries": [], "error": str(e)}
403
+
404
+ # --- Collaboration API ---
405
+
406
+ @app.get("/api/collaboration")
407
+ async def api_collaboration():
408
+ """Get collaboration dashboard data."""
409
+ from memvcs.core.repository import Repository
410
+
411
+ repo = Repository(_repo_path)
412
+ if not repo.is_valid_repo():
413
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
414
+
415
+ try:
416
+ from memvcs.core.collaboration import get_collaboration_dashboard
417
+
418
+ return get_collaboration_dashboard(repo.mem_dir)
419
+ except Exception as e:
420
+ return {"error": str(e)}
421
+
422
+ @app.get("/api/agents")
423
+ async def api_agents():
424
+ """Get all registered agents."""
425
+ from memvcs.core.repository import Repository
426
+
427
+ repo = Repository(_repo_path)
428
+ if not repo.is_valid_repo():
429
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
430
+
431
+ try:
432
+ from memvcs.core.collaboration import AgentRegistry
433
+
434
+ registry = AgentRegistry(repo.mem_dir)
435
+ return {"agents": [a.to_dict() for a in registry.list_agents()]}
436
+ except Exception as e:
437
+ return {"error": str(e)}
438
+
439
+ @app.get("/api/trust")
440
+ async def api_trust():
441
+ """Get trust network graph."""
442
+ from memvcs.core.repository import Repository
443
+
444
+ repo = Repository(_repo_path)
445
+ if not repo.is_valid_repo():
446
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
447
+
448
+ try:
449
+ from memvcs.core.collaboration import TrustManager
450
+
451
+ trust_mgr = TrustManager(repo.mem_dir)
452
+ return trust_mgr.get_trust_graph()
453
+ except Exception as e:
454
+ return {"error": str(e), "nodes": [], "links": []}
455
+
456
+ # --- Compliance API ---
457
+
458
+ @app.get("/api/compliance")
459
+ async def api_compliance():
460
+ """Get compliance dashboard data."""
461
+ from memvcs.core.repository import Repository
462
+
463
+ repo = Repository(_repo_path)
464
+ if not repo.is_valid_repo():
465
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
466
+
467
+ try:
468
+ from memvcs.core.compliance import get_compliance_dashboard
469
+
470
+ return get_compliance_dashboard(repo.mem_dir, repo.current_dir)
471
+ except Exception as e:
472
+ return {"error": str(e)}
473
+
474
+ @app.get("/api/privacy")
475
+ async def api_privacy():
476
+ """Get privacy budget status."""
477
+ from memvcs.core.repository import Repository
478
+
479
+ repo = Repository(_repo_path)
480
+ if not repo.is_valid_repo():
481
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
482
+
483
+ try:
484
+ from memvcs.core.compliance import PrivacyManager
485
+
486
+ mgr = PrivacyManager(repo.mem_dir)
487
+ return mgr.get_dashboard_data()
488
+ except Exception as e:
489
+ return {"error": str(e), "budgets": []}
490
+
491
+ @app.get("/api/integrity")
492
+ async def api_integrity():
493
+ """Get integrity verification status."""
494
+ from memvcs.core.repository import Repository
495
+
496
+ repo = Repository(_repo_path)
497
+ if not repo.is_valid_repo():
498
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
499
+
500
+ try:
501
+ from memvcs.core.compliance import TamperDetector
502
+
503
+ detector = TamperDetector(repo.mem_dir)
504
+ return detector.verify_integrity(repo.current_dir)
505
+ except Exception as e:
506
+ return {"error": str(e), "verified": False}
507
+
508
+ # --- Archaeology API ---
509
+
510
+ @app.get("/api/archaeology")
511
+ async def api_archaeology():
512
+ """Get archaeology dashboard data."""
513
+ from memvcs.core.repository import Repository
514
+
515
+ repo = Repository(_repo_path)
516
+ if not repo.is_valid_repo():
517
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
518
+
519
+ try:
520
+ from memvcs.core.archaeology import get_archaeology_dashboard
521
+
522
+ return get_archaeology_dashboard(repo.root)
523
+ except Exception as e:
524
+ return {"error": str(e)}
525
+
526
+ @app.get("/api/forgotten")
527
+ async def api_forgotten(days: int = 30, limit: int = 20):
528
+ """Get forgotten memories."""
529
+ from memvcs.core.repository import Repository
530
+
531
+ repo = Repository(_repo_path)
532
+ if not repo.is_valid_repo():
533
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
534
+
535
+ try:
536
+ from memvcs.core.archaeology import ForgottenKnowledgeFinder
537
+
538
+ finder = ForgottenKnowledgeFinder(repo.root)
539
+ forgotten = finder.find_forgotten(days_threshold=days, limit=limit)
540
+ return {"forgotten": [f.to_dict() for f in forgotten], "count": len(forgotten)}
541
+ except Exception as e:
542
+ return {"error": str(e), "forgotten": []}
543
+
544
+ # --- Confidence API ---
545
+
546
+ @app.get("/api/confidence")
547
+ async def api_confidence():
548
+ """Get confidence dashboard data."""
549
+ from memvcs.core.repository import Repository
550
+
551
+ repo = Repository(_repo_path)
552
+ if not repo.is_valid_repo():
553
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
554
+
555
+ try:
556
+ from memvcs.core.confidence import get_confidence_dashboard
557
+
558
+ return get_confidence_dashboard(repo.mem_dir)
559
+ except Exception as e:
560
+ return {"error": str(e)}
561
+
562
+ @app.get("/api/confidence/{path:path}")
563
+ async def api_confidence_score(path: str):
564
+ """Get confidence score for a specific memory."""
565
+ from memvcs.core.repository import Repository
566
+
567
+ repo = Repository(_repo_path)
568
+ if not repo.is_valid_repo():
569
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
570
+
571
+ try:
572
+ from memvcs.core.confidence import ConfidenceCalculator
573
+ from datetime import datetime, timezone
574
+
575
+ calculator = ConfidenceCalculator(repo.mem_dir)
576
+ full_path = repo.current_dir / path
577
+
578
+ created_at = None
579
+ if full_path.exists():
580
+ mtime = full_path.stat().st_mtime
581
+ created_at = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
582
+
583
+ score = calculator.calculate_score(path, created_at=created_at)
584
+ return score.to_dict()
585
+ except Exception as e:
586
+ return {"error": str(e)}
587
+
588
+ # --- Session API ---
589
+
590
+ @app.get("/api/sessions")
591
+ async def api_sessions():
592
+ """Get current session status."""
593
+ from memvcs.core.repository import Repository
594
+
595
+ repo = Repository(_repo_path)
596
+ if not repo.is_valid_repo():
597
+ raise HTTPException(status_code=400, detail="Not an agmem repository")
598
+
599
+ try:
600
+ from memvcs.core.session import SessionManager
601
+
602
+ manager = SessionManager(repo.root)
603
+ return manager.get_status()
604
+ except Exception as e:
605
+ return {"error": str(e), "active": False}
606
+
183
607
  return app
184
608
 
185
609
 
@@ -0,0 +1,223 @@
1
+ """
2
+ WebSocket support for real-time memory updates.
3
+
4
+ Provides live notifications for:
5
+ - File changes
6
+ - Commits
7
+ - Session events
8
+ - Agent activity
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Dict, List, Optional, Set
16
+
17
+ try:
18
+ from fastapi import WebSocket, WebSocketDisconnect
19
+
20
+ HAS_FASTAPI = True
21
+ except ImportError:
22
+ HAS_FASTAPI = False
23
+
24
+
25
+ class ConnectionManager:
26
+ """Manages WebSocket connections and broadcasts."""
27
+
28
+ def __init__(self):
29
+ self.active_connections: List[WebSocket] = []
30
+ self.subscriptions: Dict[str, Set[WebSocket]] = {}
31
+
32
+ async def connect(self, websocket: WebSocket):
33
+ """Accept a new WebSocket connection."""
34
+ await websocket.accept()
35
+ self.active_connections.append(websocket)
36
+
37
+ def disconnect(self, websocket: WebSocket):
38
+ """Remove a WebSocket connection."""
39
+ if websocket in self.active_connections:
40
+ self.active_connections.remove(websocket)
41
+ # Remove from all subscriptions
42
+ for topic in self.subscriptions:
43
+ self.subscriptions[topic].discard(websocket)
44
+
45
+ def subscribe(self, websocket: WebSocket, topic: str):
46
+ """Subscribe a connection to a topic."""
47
+ if topic not in self.subscriptions:
48
+ self.subscriptions[topic] = set()
49
+ self.subscriptions[topic].add(websocket)
50
+
51
+ def unsubscribe(self, websocket: WebSocket, topic: str):
52
+ """Unsubscribe a connection from a topic."""
53
+ if topic in self.subscriptions:
54
+ self.subscriptions[topic].discard(websocket)
55
+
56
+ async def send_personal(self, message: Dict[str, Any], websocket: WebSocket):
57
+ """Send a message to a specific connection."""
58
+ await websocket.send_json(message)
59
+
60
+ async def broadcast(self, message: Dict[str, Any]):
61
+ """Broadcast a message to all connections."""
62
+ disconnected = []
63
+ for connection in self.active_connections:
64
+ try:
65
+ await connection.send_json(message)
66
+ except Exception:
67
+ disconnected.append(connection)
68
+
69
+ for conn in disconnected:
70
+ self.disconnect(conn)
71
+
72
+ async def broadcast_to_topic(self, topic: str, message: Dict[str, Any]):
73
+ """Broadcast a message to subscribers of a topic."""
74
+ if topic not in self.subscriptions:
75
+ return
76
+
77
+ disconnected = []
78
+ for connection in self.subscriptions[topic]:
79
+ try:
80
+ await connection.send_json(message)
81
+ except Exception:
82
+ disconnected.append(connection)
83
+
84
+ for conn in disconnected:
85
+ self.disconnect(conn)
86
+
87
+
88
+ # Global connection manager
89
+ manager = ConnectionManager()
90
+
91
+
92
+ class EventType:
93
+ """Event type constants."""
94
+
95
+ FILE_CHANGED = "file_changed"
96
+ FILE_CREATED = "file_created"
97
+ FILE_DELETED = "file_deleted"
98
+ COMMIT = "commit"
99
+ SESSION_START = "session_start"
100
+ SESSION_END = "session_end"
101
+ AGENT_ACTIVITY = "agent_activity"
102
+ ALERT = "alert"
103
+ HEALTH_CHECK = "health_check"
104
+
105
+
106
+ def create_event(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
107
+ """Create a standardized event message."""
108
+ return {
109
+ "type": event_type,
110
+ "timestamp": datetime.now(timezone.utc).isoformat(),
111
+ "data": data,
112
+ }
113
+
114
+
115
+ async def emit_file_change(path: str, change_type: str):
116
+ """Emit a file change event."""
117
+ event = create_event(EventType.FILE_CHANGED, {"path": path, "change_type": change_type})
118
+ await manager.broadcast_to_topic("files", event)
119
+ await manager.broadcast_to_topic("all", event)
120
+
121
+
122
+ async def emit_commit(commit_hash: str, message: str, files: List[str]):
123
+ """Emit a commit event."""
124
+ event = create_event(
125
+ EventType.COMMIT, {"hash": commit_hash, "message": message, "files": files}
126
+ )
127
+ await manager.broadcast_to_topic("commits", event)
128
+ await manager.broadcast_to_topic("all", event)
129
+
130
+
131
+ async def emit_session_event(event_type: str, session_id: str, details: Dict[str, Any]):
132
+ """Emit a session event."""
133
+ event = create_event(event_type, {"session_id": session_id, **details})
134
+ await manager.broadcast_to_topic("sessions", event)
135
+ await manager.broadcast_to_topic("all", event)
136
+
137
+
138
+ async def emit_alert(alert_type: str, message: str, severity: str = "info"):
139
+ """Emit an alert event."""
140
+ event = create_event(
141
+ EventType.ALERT, {"alert_type": alert_type, "message": message, "severity": severity}
142
+ )
143
+ await manager.broadcast_to_topic("alerts", event)
144
+ await manager.broadcast_to_topic("all", event)
145
+
146
+
147
+ def add_websocket_routes(app):
148
+ """Add WebSocket routes to a FastAPI app."""
149
+ if not HAS_FASTAPI:
150
+ return
151
+
152
+ @app.websocket("/ws")
153
+ async def websocket_endpoint(websocket: WebSocket):
154
+ """Main WebSocket endpoint."""
155
+ await manager.connect(websocket)
156
+
157
+ # Subscribe to 'all' by default
158
+ manager.subscribe(websocket, "all")
159
+
160
+ try:
161
+ while True:
162
+ data = await websocket.receive_json()
163
+
164
+ # Handle subscription messages
165
+ if data.get("action") == "subscribe":
166
+ topic = data.get("topic", "all")
167
+ manager.subscribe(websocket, topic)
168
+ await manager.send_personal({"type": "subscribed", "topic": topic}, websocket)
169
+
170
+ elif data.get("action") == "unsubscribe":
171
+ topic = data.get("topic")
172
+ if topic:
173
+ manager.unsubscribe(websocket, topic)
174
+ await manager.send_personal(
175
+ {"type": "unsubscribed", "topic": topic}, websocket
176
+ )
177
+
178
+ elif data.get("action") == "ping":
179
+ await manager.send_personal(
180
+ {"type": "pong", "timestamp": datetime.now(timezone.utc).isoformat()},
181
+ websocket,
182
+ )
183
+
184
+ except WebSocketDisconnect:
185
+ manager.disconnect(websocket)
186
+
187
+ @app.websocket("/ws/files")
188
+ async def websocket_files(websocket: WebSocket):
189
+ """WebSocket for file change events only."""
190
+ await manager.connect(websocket)
191
+ manager.subscribe(websocket, "files")
192
+
193
+ try:
194
+ while True:
195
+ await websocket.receive_text() # Keep connection alive
196
+ except WebSocketDisconnect:
197
+ manager.disconnect(websocket)
198
+
199
+ @app.websocket("/ws/commits")
200
+ async def websocket_commits(websocket: WebSocket):
201
+ """WebSocket for commit events only."""
202
+ await manager.connect(websocket)
203
+ manager.subscribe(websocket, "commits")
204
+
205
+ try:
206
+ while True:
207
+ await websocket.receive_text()
208
+ except WebSocketDisconnect:
209
+ manager.disconnect(websocket)
210
+
211
+
212
+ # Async helper for synchronous callers
213
+ def sync_emit_file_change(path: str, change_type: str):
214
+ """Synchronous wrapper for file change emission."""
215
+ try:
216
+ loop = asyncio.get_event_loop()
217
+ if loop.is_running():
218
+ asyncio.create_task(emit_file_change(path, change_type))
219
+ else:
220
+ loop.run_until_complete(emit_file_change(path, change_type))
221
+ except RuntimeError:
222
+ # No event loop - ignore
223
+ pass
File without changes