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.
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/METADATA +338 -26
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/RECORD +32 -16
- memvcs/__init__.py +1 -1
- memvcs/cli.py +1 -1
- memvcs/coordinator/server.py +18 -2
- memvcs/core/agents.py +411 -0
- memvcs/core/archaeology.py +410 -0
- memvcs/core/collaboration.py +435 -0
- memvcs/core/compliance.py +427 -0
- memvcs/core/compression_metrics.py +248 -0
- memvcs/core/confidence.py +379 -0
- memvcs/core/daemon.py +735 -0
- memvcs/core/delta.py +45 -23
- memvcs/core/distiller.py +3 -12
- memvcs/core/fast_similarity.py +404 -0
- memvcs/core/federated.py +13 -2
- memvcs/core/gardener.py +8 -68
- memvcs/core/pack.py +1 -1
- memvcs/core/privacy_validator.py +187 -0
- memvcs/core/private_search.py +327 -0
- memvcs/core/protocol_builder.py +198 -0
- memvcs/core/search_index.py +538 -0
- memvcs/core/semantic_graph.py +388 -0
- memvcs/core/session.py +520 -0
- memvcs/core/timetravel.py +430 -0
- memvcs/integrations/mcp_server.py +775 -4
- memvcs/integrations/web_ui/server.py +424 -0
- memvcs/integrations/web_ui/websocket.py +223 -0
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/WHEEL +0 -0
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/entry_points.txt +0 -0
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {agmem-0.2.0.dist-info → agmem-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|