amfs-http-server 0.1.2__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amfs-http-server
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: AMFS HTTP/REST API server with SSE support
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "amfs-http-server"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "AMFS HTTP/REST API server with SSE support"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.0"
@@ -88,11 +88,24 @@ class UpdateTeamMemberRequest(BaseModel):
88
88
 
89
89
 
90
90
  class RunPatternDetectionRequest(BaseModel):
91
- incident_threshold: int = 2
92
- stale_days: int = 30
93
- hot_entity_stddev: float = 2.0
94
- drift_stddev: float = 2.0
95
91
  entity_path: str | None = None
92
+ agent_id: str | None = None
93
+ stale_days: int = 14
94
+ orphan_days: int = 7
95
+ pr_stale_days: int = 3
96
+ similarity_threshold: float = 0.75
97
+ incident_threshold: int = 2
98
+
99
+
100
+ # ──────────────────────────────────────────────────────────────────────
101
+ # Agent Snapshots
102
+ # ──────────────────────────────────────────────────────────────────────
103
+
104
+
105
+ class CreateSnapshotRequest(BaseModel):
106
+ name: str
107
+ description: str = ""
108
+ snapshot_data: dict[str, Any] = Field(default_factory=dict)
96
109
 
97
110
 
98
111
  # ──────────────────────────────────────────────────────────────────────
@@ -19,23 +19,35 @@ import json
19
19
  import logging
20
20
  import os
21
21
  import secrets
22
- from datetime import datetime
22
+ from datetime import datetime, timezone
23
23
  from typing import Any
24
24
 
25
25
  import uvicorn
26
26
  from fastapi import Depends, FastAPI, HTTPException, Query, Request
27
27
  from fastapi.middleware.cors import CORSMiddleware
28
+ from fastapi.responses import JSONResponse
28
29
  from sse_starlette.sse import EventSourceResponse
29
30
 
30
31
  from amfs import AgentMemory, MemoryType, OutcomeType
31
32
  from amfs.config import load_config_or_default
32
- from amfs_core.models import AMFSConfig, LayerConfig, MemoryEntry, SearchQuery
33
+ from pydantic import BaseModel, Field
34
+ from amfs_core.models import (
35
+ AMFSConfig,
36
+ Event,
37
+ EventType,
38
+ GraphEdge,
39
+ LayerConfig,
40
+ MemoryEntry,
41
+ SearchQuery,
42
+ )
43
+ from amfs_core.quality import HeuristicQualityEvaluator
33
44
 
34
45
  from amfs_http.auth import verify_api_key
35
46
  from amfs_http.models import (
36
47
  AddTeamMemberRequest,
37
48
  ContextRequest,
38
49
  CreateAPIKeyRequest,
50
+ CreateSnapshotRequest,
39
51
  CreateTeamRequest,
40
52
  EventRequest,
41
53
  OutcomeRequest,
@@ -224,6 +236,11 @@ async def health() -> dict[str, str]:
224
236
  return {"status": "ok"}
225
237
 
226
238
 
239
+ @app.get("/api/v1/health")
240
+ async def health_v1() -> dict[str, str]:
241
+ return {"status": "ok"}
242
+
243
+
227
244
  @app.get("/api/v1/auth/whoami")
228
245
  async def whoami(
229
246
  request: Request,
@@ -277,6 +294,43 @@ async def read_entry(
277
294
  return _entry_to_response(entry)
278
295
 
279
296
 
297
+ @app.get("/api/v1/quality/{entity_path:path}/{key}")
298
+ async def entry_quality(
299
+ entity_path: str,
300
+ key: str,
301
+ branch: str = Query("main"),
302
+ _auth: str | None = Depends(verify_api_key),
303
+ ) -> dict[str, Any]:
304
+ """Compute a quality report for a stored entry on demand."""
305
+ mem = _get_memory()
306
+ entry = mem.read(entity_path, key, branch=branch)
307
+ if entry is None:
308
+ raise HTTPException(status_code=404, detail="Entry not found")
309
+
310
+ try:
311
+ existing_entries = mem.list(entity_path)
312
+ existing_keys = [e.key for e in existing_entries if e.key != key]
313
+ except Exception:
314
+ existing_keys = []
315
+
316
+ evaluator = HeuristicQualityEvaluator()
317
+ mt = entry.memory_type.value if hasattr(entry.memory_type, "value") else str(entry.memory_type)
318
+ report = evaluator.evaluate(
319
+ entry.value,
320
+ entity_path=entity_path,
321
+ key=key,
322
+ confidence=entry.confidence,
323
+ memory_type=mt,
324
+ pattern_refs=list(entry.provenance.pattern_refs),
325
+ existing_keys=existing_keys,
326
+ )
327
+ return {
328
+ "entity_path": entity_path,
329
+ "key": key,
330
+ "quality": report.model_dump(mode="json"),
331
+ }
332
+
333
+
280
334
  @app.post("/api/v1/entries")
281
335
  async def write_entry(
282
336
  req: WriteRequest,
@@ -319,6 +373,26 @@ async def write_entry(
319
373
  resource=f"{req.entity_path}/{req.key}",
320
374
  ip_address=request.client.host if request.client else None,
321
375
  )
376
+
377
+ try:
378
+ agent = entry.provenance.agent_id
379
+ ek = f"{entry.entity_path}/{entry.key}"
380
+ mem._adapter.upsert_graph_edge(
381
+ GraphEdge(
382
+ source_entity=agent,
383
+ source_type="agent",
384
+ relation="wrote",
385
+ target_entity=ek,
386
+ target_type="entry",
387
+ confidence=entry.confidence,
388
+ provenance={"agent_id": agent, "trigger": "write"},
389
+ ),
390
+ namespace=mem.namespace,
391
+ branch=entry.branch or "main",
392
+ )
393
+ except Exception:
394
+ logger.debug("Failed to materialize wrote edge", exc_info=True)
395
+
322
396
  return _entry_to_response(entry)
323
397
 
324
398
 
@@ -644,7 +718,6 @@ async def get_trace(
644
718
  mem = _get_memory()
645
719
  trace = mem._adapter.get_trace(trace_id)
646
720
  if trace is None:
647
- from fastapi.responses import JSONResponse
648
721
  return JSONResponse({"error": "Trace not found"}, status_code=404)
649
722
  return trace.model_dump(mode="json")
650
723
 
@@ -654,8 +727,6 @@ async def explain_trace(
654
727
  trace_id: str,
655
728
  _auth: str | None = Depends(verify_api_key),
656
729
  ) -> dict[str, Any]:
657
- from fastapi.responses import JSONResponse
658
-
659
730
  api_key = os.environ.get("AMFS_LLM_API_KEY", "")
660
731
  if not api_key:
661
732
  return JSONResponse(
@@ -848,6 +919,8 @@ async def list_agents(
848
919
  entries = mem.list()
849
920
  agent_data: dict[str, dict[str, Any]] = {}
850
921
  for e in entries:
922
+ if e.entity_path.startswith("_system/"):
923
+ continue
851
924
  aid = e.provenance.agent_id
852
925
  if aid not in agent_data:
853
926
  agent_data[aid] = {
@@ -861,13 +934,28 @@ async def list_agents(
861
934
  if e.provenance.written_at > agent_data[aid]["last_active"]:
862
935
  agent_data[aid]["last_active"] = e.provenance.written_at
863
936
 
937
+ agent_descriptions: dict[str, dict[str, Any]] = {}
938
+ try:
939
+ desc_entries = mem.list("_system/agents")
940
+ for de in desc_entries:
941
+ val = de.value if isinstance(de.value, dict) else {}
942
+ agent_descriptions[de.key] = {
943
+ "description": val.get("description", ""),
944
+ "platform": val.get("platform", ""),
945
+ }
946
+ except Exception:
947
+ pass
948
+
864
949
  agents = []
865
950
  for ad in sorted(agent_data.values(), key=lambda x: x["entries_written"], reverse=True):
951
+ desc_info = agent_descriptions.get(ad["agent_id"], {})
866
952
  agents.append({
867
953
  "agentId": ad["agent_id"],
868
954
  "entriesWritten": ad["entries_written"],
869
955
  "entitiesTouched": len(ad["entities_touched"]),
870
956
  "lastActive": ad["last_active"].isoformat() if ad["last_active"] else None,
957
+ "description": desc_info.get("description", ""),
958
+ "platform": desc_info.get("platform", ""),
871
959
  })
872
960
  return {"agents": agents}
873
961
 
@@ -882,7 +970,10 @@ async def agent_memory_graph(
882
970
  entries = mem.list()
883
971
  traces = mem._adapter.list_traces(agent_id=agent_id, limit=10000)
884
972
 
885
- written_by_agent = [e for e in entries if e.provenance.agent_id == agent_id]
973
+ written_by_agent = [
974
+ e for e in entries
975
+ if e.provenance.agent_id == agent_id and not e.entity_path.startswith("_system/")
976
+ ]
886
977
  entities_written: dict[str, list[dict]] = {}
887
978
  for e in written_by_agent:
888
979
  ep = e.entity_path
@@ -1050,9 +1141,12 @@ async def agent_activity(
1050
1141
  limit: int = Query(100),
1051
1142
  _auth: str | None = Depends(verify_api_key),
1052
1143
  ) -> dict[str, Any]:
1053
- """Timeline of writes and outcomes for this agent."""
1144
+ """Timeline of writes, outcomes, reads, and other events for this agent."""
1054
1145
  mem = _get_memory()
1055
- entries = [e for e in mem.list() if e.provenance.agent_id == agent_id]
1146
+ entries = [
1147
+ e for e in mem.list()
1148
+ if e.provenance.agent_id == agent_id and not e.entity_path.startswith("_system/")
1149
+ ]
1056
1150
  entries.sort(key=lambda e: e.provenance.written_at, reverse=True)
1057
1151
 
1058
1152
  writes = [
@@ -1079,7 +1173,23 @@ async def agent_activity(
1079
1173
  for t in traces if t.outcome_ref
1080
1174
  ]
1081
1175
 
1082
- timeline = sorted(writes + outcomes, key=lambda x: x["timestamp"], reverse=True)[:limit]
1176
+ events = mem._adapter.list_events(agent_id, mem.namespace, limit=limit)
1177
+ event_items = [
1178
+ {
1179
+ "type": evt.event_type.value if hasattr(evt.event_type, "value") else str(evt.event_type),
1180
+ "summary": evt.summary,
1181
+ "details": evt.details,
1182
+ "actorAgentId": evt.actor_agent_id,
1183
+ "timestamp": evt.created_at.isoformat(),
1184
+ }
1185
+ for evt in events
1186
+ ]
1187
+
1188
+ timeline = sorted(
1189
+ writes + outcomes + event_items,
1190
+ key=lambda x: x["timestamp"],
1191
+ reverse=True,
1192
+ )[:limit]
1083
1193
  return {"agentId": agent_id, "timeline": timeline}
1084
1194
 
1085
1195
 
@@ -1107,6 +1217,293 @@ async def agent_timeline(
1107
1217
  }
1108
1218
 
1109
1219
 
1220
+ class LogEventRequest(BaseModel):
1221
+ agent_id: str
1222
+ event_type: str
1223
+ summary: str | None = None
1224
+ details: dict[str, Any] = Field(default_factory=dict)
1225
+ actor_agent_id: str | None = None
1226
+ branch: str = "main"
1227
+
1228
+
1229
+ @app.post("/api/v1/timeline/events")
1230
+ async def log_timeline_event(
1231
+ body: LogEventRequest,
1232
+ _auth: str | None = Depends(verify_api_key),
1233
+ ) -> dict[str, Any]:
1234
+ """Log a timeline event for an agent."""
1235
+ mem = _get_memory()
1236
+ try:
1237
+ event_type_enum = EventType(body.event_type)
1238
+ except ValueError:
1239
+ return JSONResponse(
1240
+ status_code=400,
1241
+ content={"error": f"Unknown event_type: {body.event_type}"},
1242
+ )
1243
+ event = Event(
1244
+ namespace=mem.namespace,
1245
+ agent_id=body.agent_id,
1246
+ branch=body.branch,
1247
+ event_type=event_type_enum,
1248
+ summary=body.summary,
1249
+ details=body.details,
1250
+ actor_agent_id=body.actor_agent_id,
1251
+ )
1252
+ saved = mem._adapter.log_event(event)
1253
+ return saved.model_dump(mode="json")
1254
+
1255
+
1256
+ class UpsertGraphEdgeRequest(BaseModel):
1257
+ source_entity: str
1258
+ source_type: str = "agent"
1259
+ relation: str
1260
+ target_entity: str
1261
+ target_type: str = "agent"
1262
+ provenance: dict[str, Any] = Field(default_factory=dict)
1263
+ branch: str = "main"
1264
+
1265
+
1266
+ @app.post("/api/v1/graph/edges")
1267
+ async def upsert_graph_edge_endpoint(
1268
+ body: UpsertGraphEdgeRequest,
1269
+ _auth: str | None = Depends(verify_api_key),
1270
+ ) -> dict[str, Any]:
1271
+ """Create or update a knowledge graph edge."""
1272
+ mem = _get_memory()
1273
+ edge = GraphEdge(
1274
+ source_entity=body.source_entity,
1275
+ source_type=body.source_type,
1276
+ relation=body.relation,
1277
+ target_entity=body.target_entity,
1278
+ target_type=body.target_type,
1279
+ provenance=body.provenance,
1280
+ )
1281
+ mem._adapter.upsert_graph_edge(edge, namespace=mem.namespace, branch=body.branch)
1282
+ return {"status": "ok"}
1283
+
1284
+
1285
+ # ──────────────────────────────────────────────────────────────────────
1286
+ # Agent Snapshots
1287
+ # ──────────────────────────────────────────────────────────────────────
1288
+
1289
+
1290
+ SNAPSHOT_ENTITY = "_system/agent-snapshots"
1291
+
1292
+
1293
+ @app.post("/api/v1/agents/{agent_id}/snapshots")
1294
+ async def create_snapshot(
1295
+ agent_id: str,
1296
+ req: CreateSnapshotRequest,
1297
+ _auth: str | None = Depends(verify_api_key),
1298
+ ) -> dict[str, Any]:
1299
+ """Create a named snapshot of an agent's brain state."""
1300
+ mem = _get_memory()
1301
+ snapshot_id = f"snap-{int(datetime.now(timezone.utc).timestamp() * 1000)}"
1302
+ snapshot_value = {
1303
+ "id": snapshot_id,
1304
+ "agent_id": agent_id,
1305
+ "name": req.name,
1306
+ "description": req.description,
1307
+ "created_at": datetime.now(timezone.utc).isoformat(),
1308
+ "stats": req.snapshot_data.get("stats", {}),
1309
+ "data": req.snapshot_data,
1310
+ }
1311
+
1312
+ original_agent = mem._tagger.agent_id
1313
+ mem._tagger.agent_id = agent_id
1314
+ try:
1315
+ mem.write(
1316
+ SNAPSHOT_ENTITY,
1317
+ f"{agent_id}/{snapshot_id}",
1318
+ snapshot_value,
1319
+ confidence=1.0,
1320
+ )
1321
+ finally:
1322
+ mem._tagger.agent_id = original_agent
1323
+
1324
+ try:
1325
+ mem._adapter.log_event(Event(
1326
+ namespace=mem.namespace,
1327
+ agent_id=agent_id,
1328
+ branch="main",
1329
+ event_type=EventType.SNAPSHOT_TAKEN,
1330
+ summary=f"Snapshot '{req.name}' taken",
1331
+ details={
1332
+ "snapshot_id": snapshot_id,
1333
+ "name": req.name,
1334
+ "description": req.description,
1335
+ **snapshot_value.get("stats", {}),
1336
+ },
1337
+ ))
1338
+ except Exception:
1339
+ logger.debug("Failed to log snapshot event", exc_info=True)
1340
+
1341
+ return {
1342
+ "id": snapshot_id,
1343
+ "name": req.name,
1344
+ "agent_id": agent_id,
1345
+ "created_at": snapshot_value["created_at"],
1346
+ }
1347
+
1348
+
1349
+ @app.get("/api/v1/agents/{agent_id}/snapshots")
1350
+ async def list_snapshots(
1351
+ agent_id: str,
1352
+ _auth: str | None = Depends(verify_api_key),
1353
+ ) -> dict[str, Any]:
1354
+ """List all snapshots for an agent."""
1355
+ mem = _get_memory()
1356
+ entries = mem.list(entity_path=SNAPSHOT_ENTITY)
1357
+ prefix = f"{agent_id}/"
1358
+ snapshots = []
1359
+ for e in entries:
1360
+ if not e.key.startswith(prefix):
1361
+ continue
1362
+ val = e.value if isinstance(e.value, dict) else {}
1363
+ snapshots.append({
1364
+ "id": val.get("id", e.key.split("/")[-1]),
1365
+ "name": val.get("name", e.key),
1366
+ "description": val.get("description", ""),
1367
+ "agent_id": agent_id,
1368
+ "created_at": val.get("created_at", e.provenance.written_at.isoformat()),
1369
+ "stats": val.get("stats", {}),
1370
+ })
1371
+ snapshots.sort(key=lambda s: s["created_at"], reverse=True)
1372
+ return {"snapshots": snapshots, "count": len(snapshots)}
1373
+
1374
+
1375
+ @app.get("/api/v1/agents/{agent_id}/snapshots/{snapshot_id}")
1376
+ async def get_snapshot(
1377
+ agent_id: str,
1378
+ snapshot_id: str,
1379
+ _auth: str | None = Depends(verify_api_key),
1380
+ ) -> dict[str, Any]:
1381
+ """Get the full data for a specific snapshot."""
1382
+ mem = _get_memory()
1383
+ key = f"{agent_id}/{snapshot_id}"
1384
+ entry = mem.recall(SNAPSHOT_ENTITY, key)
1385
+ if entry is None:
1386
+ raise HTTPException(status_code=404, detail="Snapshot not found")
1387
+ val = entry.value if isinstance(entry.value, dict) else {}
1388
+ return val
1389
+
1390
+
1391
+ @app.delete("/api/v1/agents/{agent_id}/snapshots/{snapshot_id}")
1392
+ async def delete_snapshot(
1393
+ agent_id: str,
1394
+ snapshot_id: str,
1395
+ _auth: str | None = Depends(verify_api_key),
1396
+ ) -> dict[str, Any]:
1397
+ """Delete a snapshot."""
1398
+ mem = _get_memory()
1399
+ key = f"{agent_id}/{snapshot_id}"
1400
+ entry = mem.recall(SNAPSHOT_ENTITY, key)
1401
+ if entry is None:
1402
+ raise HTTPException(status_code=404, detail="Snapshot not found")
1403
+ mem.write(SNAPSHOT_ENTITY, key, None, confidence=0.0)
1404
+ return {"deleted": True, "id": snapshot_id}
1405
+
1406
+
1407
+ @app.post("/api/v1/agents/{agent_id}/snapshots/{snapshot_id}/recover")
1408
+ async def recover_snapshot(
1409
+ agent_id: str,
1410
+ snapshot_id: str,
1411
+ _auth: str | None = Depends(verify_api_key),
1412
+ ) -> dict[str, Any]:
1413
+ """Recover an agent's memory to the state captured in a snapshot."""
1414
+ mem = _get_memory()
1415
+ key = f"{agent_id}/{snapshot_id}"
1416
+ entry = mem.recall(SNAPSHOT_ENTITY, key)
1417
+ if entry is None:
1418
+ raise HTTPException(status_code=404, detail="Snapshot not found")
1419
+
1420
+ val = entry.value if isinstance(entry.value, dict) else {}
1421
+ created_at = val.get("created_at")
1422
+ if not created_at:
1423
+ raise HTTPException(status_code=400, detail="Snapshot missing timestamp")
1424
+
1425
+ timestamp = datetime.fromisoformat(created_at)
1426
+ count = mem._adapter.rollback_to_timestamp(
1427
+ agent_id, "main", timestamp, mem.namespace,
1428
+ )
1429
+
1430
+ try:
1431
+ mem._adapter.log_event(Event(
1432
+ namespace=mem.namespace,
1433
+ agent_id=agent_id,
1434
+ branch="main",
1435
+ event_type=EventType.SNAPSHOT_RECOVERED,
1436
+ summary=f"Recovered from snapshot '{val.get('name', snapshot_id)}'",
1437
+ details={
1438
+ "snapshot_id": snapshot_id,
1439
+ "name": val.get("name", ""),
1440
+ "recovered_to": created_at,
1441
+ "entries_restored": count,
1442
+ },
1443
+ ))
1444
+ except Exception:
1445
+ logger.debug("Failed to log recovery event", exc_info=True)
1446
+
1447
+ return {
1448
+ "recovered": True,
1449
+ "snapshot_id": snapshot_id,
1450
+ "entries_restored": count,
1451
+ "recovered_to": created_at,
1452
+ }
1453
+
1454
+
1455
+ # ──────────────────────────────────────────────────────────────────────
1456
+ # Agent-scoped branches & pull requests
1457
+ # ──────────────────────────────────────────────────────────────────────
1458
+
1459
+
1460
+ @app.get("/api/v1/agents/{agent_id}/branches")
1461
+ async def agent_branches(
1462
+ agent_id: str,
1463
+ _auth: str | None = Depends(verify_api_key),
1464
+ ) -> dict[str, Any]:
1465
+ """Branches created or merged by this agent."""
1466
+ mem = _get_memory()
1467
+ try:
1468
+ all_branches = mem.list_branches() # type: ignore[attr-defined]
1469
+ except (AttributeError, Exception):
1470
+ return {"agentId": agent_id, "branches": [], "count": 0}
1471
+ scoped = [
1472
+ b for b in all_branches
1473
+ if getattr(b, "created_by", None) == agent_id
1474
+ or getattr(b, "merged_by", None) == agent_id
1475
+ ]
1476
+ return {
1477
+ "agentId": agent_id,
1478
+ "branches": [b.model_dump(mode="json") if hasattr(b, "model_dump") else b for b in scoped],
1479
+ "count": len(scoped),
1480
+ }
1481
+
1482
+
1483
+ @app.get("/api/v1/agents/{agent_id}/pull-requests")
1484
+ async def agent_pull_requests(
1485
+ agent_id: str,
1486
+ _auth: str | None = Depends(verify_api_key),
1487
+ ) -> dict[str, Any]:
1488
+ """Pull requests created, merged, or closed by this agent."""
1489
+ mem = _get_memory()
1490
+ try:
1491
+ all_prs = mem.list_pull_requests() # type: ignore[attr-defined]
1492
+ except (AttributeError, Exception):
1493
+ return {"agentId": agent_id, "pullRequests": [], "count": 0}
1494
+ scoped = [
1495
+ pr for pr in all_prs
1496
+ if getattr(pr, "created_by", None) == agent_id
1497
+ or getattr(pr, "merged_by", None) == agent_id
1498
+ or getattr(pr, "closed_by", None) == agent_id
1499
+ ]
1500
+ return {
1501
+ "agentId": agent_id,
1502
+ "pullRequests": [pr.model_dump(mode="json") if hasattr(pr, "model_dump") else pr for pr in scoped],
1503
+ "count": len(scoped),
1504
+ }
1505
+
1506
+
1110
1507
  # ──────────────────────────────────────────────────────────────────────
1111
1508
  # Pro Branching Plugin (amfs_branching — proprietary)
1112
1509
  # ──────────────────────────────────────────────────────────────────────
@@ -1119,6 +1516,18 @@ except ImportError:
1119
1516
  pass
1120
1517
 
1121
1518
 
1519
+ # ──────────────────────────────────────────────────────────────────────
1520
+ # Control Plane Plugin (amfs_control_plane — proprietary SaaS billing)
1521
+ # ──────────────────────────────────────────────────────────────────────
1522
+
1523
+ try:
1524
+ from amfs_control_plane import mount_control_plane # type: ignore[import-not-found]
1525
+ mount_control_plane(app)
1526
+ logger.info("Control-plane routes mounted (auth, billing, Stripe webhook)")
1527
+ except ImportError:
1528
+ pass
1529
+
1530
+
1122
1531
  # ──────────────────────────────────────────────────────────────────────
1123
1532
  # Admin — API Keys
1124
1533
  # ──────────────────────────────────────────────────────────────────────
@@ -1133,6 +1542,18 @@ def _get_db_pool():
1133
1542
  return None
1134
1543
 
1135
1544
 
1545
+ def _get_namespace() -> str:
1546
+ """Return the namespace for the current adapter.
1547
+
1548
+ All admin queries MUST use this to scope data to the correct tenant.
1549
+ """
1550
+ mem = _get_memory()
1551
+ adapter = mem._adapter
1552
+ if hasattr(adapter, "_namespace"):
1553
+ return adapter._namespace
1554
+ return "default"
1555
+
1556
+
1136
1557
  def _audit_log(
1137
1558
  action: str,
1138
1559
  *,
@@ -1145,14 +1566,15 @@ def _audit_log(
1145
1566
  pool = _get_db_pool()
1146
1567
  if pool is None:
1147
1568
  return
1569
+ ns = _get_namespace()
1148
1570
  try:
1149
1571
  with pool.connection() as conn:
1150
1572
  with conn.cursor() as cur:
1151
1573
  cur.execute(
1152
1574
  """INSERT INTO amfs_audit_log
1153
- (actor_type, actor_name, action, resource, ip_address)
1154
- VALUES (%s, %s, %s, %s, %s)""",
1155
- (actor_type, actor_name, action, resource, ip_address),
1575
+ (namespace, actor_type, actor_name, action, resource, ip_address)
1576
+ VALUES (%s, %s, %s, %s, %s, %s)""",
1577
+ (ns, actor_type, actor_name, action, resource, ip_address),
1156
1578
  )
1157
1579
  except Exception:
1158
1580
  logger.debug("Failed to write audit log", exc_info=True)
@@ -1165,12 +1587,16 @@ async def list_api_keys(
1165
1587
  pool = _get_db_pool()
1166
1588
  if pool is None:
1167
1589
  return {"keys": []}
1590
+ ns = _get_namespace()
1168
1591
  with pool.connection() as conn:
1169
1592
  with conn.cursor() as cur:
1170
1593
  cur.execute(
1171
1594
  """SELECT id, name, prefix, key_type, active, scopes,
1172
1595
  rate_limit_rpm, last_used, created_at, expires_at
1173
- FROM amfs_api_keys ORDER BY created_at DESC"""
1596
+ FROM amfs_api_keys
1597
+ WHERE namespace = %s
1598
+ ORDER BY created_at DESC""",
1599
+ (ns,),
1174
1600
  )
1175
1601
  rows = cur.fetchall()
1176
1602
  keys = []
@@ -1206,15 +1632,17 @@ async def create_api_key(
1206
1632
  raw_key = f"amfs_{secrets.token_urlsafe(32)}"
1207
1633
  prefix = raw_key[:12]
1208
1634
  key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
1635
+ ns = _get_namespace()
1209
1636
 
1210
1637
  with pool.connection() as conn:
1211
1638
  with conn.cursor() as cur:
1212
1639
  cur.execute(
1213
1640
  """INSERT INTO amfs_api_keys
1214
- (name, key_hash, prefix, key_type, scopes, rate_limit_rpm, expires_at)
1215
- VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
1641
+ (namespace, name, key_hash, prefix, key_type, scopes, rate_limit_rpm, expires_at)
1642
+ VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s)
1216
1643
  RETURNING id, created_at""",
1217
1644
  (
1645
+ ns,
1218
1646
  req.name,
1219
1647
  key_hash,
1220
1648
  prefix,
@@ -1251,11 +1679,14 @@ async def revoke_api_key(
1251
1679
  pool = _get_db_pool()
1252
1680
  if pool is None:
1253
1681
  return {"error": "API key management requires a Postgres backend"}
1682
+ ns = _get_namespace()
1254
1683
  with pool.connection() as conn:
1255
1684
  with conn.cursor() as cur:
1256
1685
  cur.execute(
1257
- "UPDATE amfs_api_keys SET active = FALSE WHERE id = %s::uuid RETURNING id, name",
1258
- (key_id,),
1686
+ """UPDATE amfs_api_keys SET active = FALSE
1687
+ WHERE id = %s::uuid AND namespace = %s
1688
+ RETURNING id, name""",
1689
+ (key_id, ns),
1259
1690
  )
1260
1691
  row = cur.fetchone()
1261
1692
  if row is None:
@@ -1283,8 +1714,9 @@ async def list_audit_log(
1283
1714
  if pool is None:
1284
1715
  return {"entries": []}
1285
1716
 
1286
- conditions = ["1=1"]
1287
- params: list[Any] = []
1717
+ ns = _get_namespace()
1718
+ conditions = ["namespace = %s"]
1719
+ params: list[Any] = [ns]
1288
1720
 
1289
1721
  if action is not None and action != "all":
1290
1722
  conditions.append("action = %s")
@@ -1370,6 +1802,7 @@ async def list_teams(
1370
1802
  pool = _get_db_pool()
1371
1803
  if pool is None:
1372
1804
  return {"teams": []}
1805
+ ns = _get_namespace()
1373
1806
  with pool.connection() as conn:
1374
1807
  with conn.cursor() as cur:
1375
1808
  cur.execute(
@@ -1377,9 +1810,12 @@ async def list_teams(
1377
1810
  t.created_at, t.updated_at,
1378
1811
  COUNT(m.id) AS member_count
1379
1812
  FROM amfs_teams t
1380
- LEFT JOIN amfs_team_members m ON m.team_id = t.id
1813
+ LEFT JOIN amfs_team_members m
1814
+ ON m.team_id = t.id AND m.removed_at IS NULL
1815
+ WHERE t.namespace = %s
1381
1816
  GROUP BY t.id
1382
- ORDER BY t.created_at DESC"""
1817
+ ORDER BY t.created_at DESC""",
1818
+ (ns,),
1383
1819
  )
1384
1820
  rows = cur.fetchall()
1385
1821
  return {
@@ -1407,13 +1843,14 @@ async def create_team(
1407
1843
  pool = _get_db_pool()
1408
1844
  if pool is None:
1409
1845
  return {"error": "Team management requires a Postgres backend"}
1846
+ ns = _get_namespace()
1410
1847
  with pool.connection() as conn:
1411
1848
  with conn.cursor() as cur:
1412
1849
  cur.execute(
1413
- """INSERT INTO amfs_teams (name, slug, description)
1414
- VALUES (%s, %s, %s)
1850
+ """INSERT INTO amfs_teams (namespace, name, slug, description)
1851
+ VALUES (%s, %s, %s, %s)
1415
1852
  RETURNING id, created_at, updated_at""",
1416
- (req.name, req.slug, req.description),
1853
+ (ns, req.name, req.slug, req.description),
1417
1854
  )
1418
1855
  row = cur.fetchone()
1419
1856
  _audit_log(
@@ -1455,13 +1892,14 @@ async def update_team(
1455
1892
  return {"error": "No fields to update"}
1456
1893
 
1457
1894
  updates.append("updated_at = NOW()")
1458
- params.append(team_id)
1895
+ ns = _get_namespace()
1896
+ params.extend([team_id, ns])
1459
1897
 
1460
1898
  with pool.connection() as conn:
1461
1899
  with conn.cursor() as cur:
1462
1900
  cur.execute(
1463
1901
  f"""UPDATE amfs_teams SET {', '.join(updates)}
1464
- WHERE id = %s::uuid
1902
+ WHERE id = %s::uuid AND namespace = %s
1465
1903
  RETURNING id, name, slug, description, created_at, updated_at""",
1466
1904
  params,
1467
1905
  )
@@ -1493,11 +1931,14 @@ async def delete_team(
1493
1931
  pool = _get_db_pool()
1494
1932
  if pool is None:
1495
1933
  return {"error": "Team management requires a Postgres backend"}
1934
+ ns = _get_namespace()
1496
1935
  with pool.connection() as conn:
1497
1936
  with conn.cursor() as cur:
1498
1937
  cur.execute(
1499
- "DELETE FROM amfs_teams WHERE id = %s::uuid RETURNING id, slug",
1500
- (team_id,),
1938
+ """DELETE FROM amfs_teams
1939
+ WHERE id = %s::uuid AND namespace = %s
1940
+ RETURNING id, slug""",
1941
+ (team_id, ns),
1501
1942
  )
1502
1943
  row = cur.fetchone()
1503
1944
  if row is None:
@@ -1518,21 +1959,36 @@ async def delete_team(
1518
1959
  @app.get("/api/v1/admin/teams/{team_id}/members")
1519
1960
  async def list_team_members(
1520
1961
  team_id: str,
1962
+ include_removed: bool = Query(False),
1521
1963
  _auth: str | None = Depends(verify_api_key),
1522
1964
  ) -> dict[str, Any]:
1523
1965
  pool = _get_db_pool()
1524
1966
  if pool is None:
1525
1967
  return {"members": []}
1968
+ ns = _get_namespace()
1526
1969
  with pool.connection() as conn:
1527
1970
  with conn.cursor() as cur:
1528
- cur.execute(
1529
- """SELECT id, email, display_name, role,
1530
- invited_at, accepted_at, created_at
1531
- FROM amfs_team_members
1532
- WHERE team_id = %s::uuid
1533
- ORDER BY created_at""",
1534
- (team_id,),
1535
- )
1971
+ if include_removed:
1972
+ cur.execute(
1973
+ """SELECT id, email, display_name, role,
1974
+ invited_at, accepted_at, created_at,
1975
+ removed_at, removed_by
1976
+ FROM amfs_team_members
1977
+ WHERE team_id = %s::uuid AND namespace = %s
1978
+ ORDER BY created_at""",
1979
+ (team_id, ns),
1980
+ )
1981
+ else:
1982
+ cur.execute(
1983
+ """SELECT id, email, display_name, role,
1984
+ invited_at, accepted_at, created_at,
1985
+ removed_at, removed_by
1986
+ FROM amfs_team_members
1987
+ WHERE team_id = %s::uuid AND namespace = %s
1988
+ AND removed_at IS NULL
1989
+ ORDER BY created_at""",
1990
+ (team_id, ns),
1991
+ )
1536
1992
  rows = cur.fetchall()
1537
1993
  return {
1538
1994
  "members": [
@@ -1544,6 +2000,8 @@ async def list_team_members(
1544
2000
  "invitedAt": row["invited_at"].isoformat(),
1545
2001
  "acceptedAt": row["accepted_at"].isoformat() if row["accepted_at"] else None,
1546
2002
  "createdAt": row["created_at"].isoformat(),
2003
+ "removedAt": row["removed_at"].isoformat() if row.get("removed_at") else None,
2004
+ "removedBy": row.get("removed_by"),
1547
2005
  }
1548
2006
  for row in rows
1549
2007
  ]
@@ -1560,15 +2018,36 @@ async def add_team_member(
1560
2018
  pool = _get_db_pool()
1561
2019
  if pool is None:
1562
2020
  return {"error": "Team management requires a Postgres backend"}
2021
+ ns = _get_namespace()
1563
2022
  with pool.connection() as conn:
1564
2023
  with conn.cursor() as cur:
2024
+ # If this email was previously removed from this team, reinstate instead of duplicating
1565
2025
  cur.execute(
1566
- """INSERT INTO amfs_team_members (team_id, email, display_name, role)
1567
- VALUES (%s::uuid, %s, %s, %s)
1568
- RETURNING id, invited_at, created_at""",
1569
- (team_id, req.email, req.display_name, req.role),
2026
+ """SELECT id FROM amfs_team_members
2027
+ WHERE team_id = %s::uuid AND email = %s AND namespace = %s
2028
+ AND removed_at IS NOT NULL""",
2029
+ (team_id, req.email, ns),
1570
2030
  )
1571
- row = cur.fetchone()
2031
+ existing_removed = cur.fetchone()
2032
+ if existing_removed:
2033
+ cur.execute(
2034
+ """UPDATE amfs_team_members
2035
+ SET removed_at = NULL, removed_by = NULL,
2036
+ role = %s, display_name = %s
2037
+ WHERE id = %s::uuid
2038
+ RETURNING id, invited_at, created_at""",
2039
+ (req.role, req.display_name, existing_removed["id"]),
2040
+ )
2041
+ row = cur.fetchone()
2042
+ else:
2043
+ cur.execute(
2044
+ """INSERT INTO amfs_team_members
2045
+ (namespace, team_id, email, display_name, role)
2046
+ VALUES (%s, %s::uuid, %s, %s, %s)
2047
+ RETURNING id, invited_at, created_at""",
2048
+ (ns, team_id, req.email, req.display_name, req.role),
2049
+ )
2050
+ row = cur.fetchone()
1572
2051
  _audit_log(
1573
2052
  "team.member.add",
1574
2053
  resource=f"{team_id}/{req.email}",
@@ -1609,13 +2088,14 @@ async def update_team_member(
1609
2088
  if not updates:
1610
2089
  return {"error": "No fields to update"}
1611
2090
 
1612
- params.extend([member_id, team_id])
2091
+ ns = _get_namespace()
2092
+ params.extend([member_id, team_id, ns])
1613
2093
 
1614
2094
  with pool.connection() as conn:
1615
2095
  with conn.cursor() as cur:
1616
2096
  cur.execute(
1617
2097
  f"""UPDATE amfs_team_members SET {', '.join(updates)}
1618
- WHERE id = %s::uuid AND team_id = %s::uuid
2098
+ WHERE id = %s::uuid AND team_id = %s::uuid AND namespace = %s
1619
2099
  RETURNING id, email, display_name, role, invited_at, accepted_at, created_at""",
1620
2100
  params,
1621
2101
  )
@@ -1649,13 +2129,17 @@ async def remove_team_member(
1649
2129
  pool = _get_db_pool()
1650
2130
  if pool is None:
1651
2131
  return {"error": "Team management requires a Postgres backend"}
2132
+ removed_by = request.headers.get("X-AMFS-Dashboard-Actor", "api")
2133
+ ns = _get_namespace()
1652
2134
  with pool.connection() as conn:
1653
2135
  with conn.cursor() as cur:
1654
2136
  cur.execute(
1655
- """DELETE FROM amfs_team_members
2137
+ """UPDATE amfs_team_members
2138
+ SET removed_at = NOW(), removed_by = %s
1656
2139
  WHERE id = %s::uuid AND team_id = %s::uuid
2140
+ AND namespace = %s AND removed_at IS NULL
1657
2141
  RETURNING id, email""",
1658
- (member_id, team_id),
2142
+ (removed_by, member_id, team_id, ns),
1659
2143
  )
1660
2144
  row = cur.fetchone()
1661
2145
  if row is None:
@@ -1668,6 +2152,107 @@ async def remove_team_member(
1668
2152
  return {"deleted": str(row["id"])}
1669
2153
 
1670
2154
 
2155
+ @app.post("/api/v1/admin/teams/{team_id}/members/{member_id}/reinstate")
2156
+ async def reinstate_team_member(
2157
+ team_id: str,
2158
+ member_id: str,
2159
+ request: Request,
2160
+ _auth: str | None = Depends(verify_api_key),
2161
+ ) -> dict[str, Any]:
2162
+ """Re-activate a previously removed team member."""
2163
+ pool = _get_db_pool()
2164
+ if pool is None:
2165
+ return {"error": "Team management requires a Postgres backend"}
2166
+ ns = _get_namespace()
2167
+ with pool.connection() as conn:
2168
+ with conn.cursor() as cur:
2169
+ cur.execute(
2170
+ """UPDATE amfs_team_members
2171
+ SET removed_at = NULL, removed_by = NULL
2172
+ WHERE id = %s::uuid AND team_id = %s::uuid
2173
+ AND namespace = %s AND removed_at IS NOT NULL
2174
+ RETURNING id, email, display_name, role,
2175
+ invited_at, accepted_at, created_at""",
2176
+ (member_id, team_id, ns),
2177
+ )
2178
+ row = cur.fetchone()
2179
+ if row is None:
2180
+ return {"error": "Removed member not found"}
2181
+ _audit_log(
2182
+ "team.member.reinstate",
2183
+ resource=f"{team_id}/{row['email']}",
2184
+ ip_address=request.client.host if request.client else None,
2185
+ )
2186
+ return {
2187
+ "id": str(row["id"]),
2188
+ "email": row["email"],
2189
+ "displayName": row["display_name"],
2190
+ "role": row["role"],
2191
+ "invitedAt": row["invited_at"].isoformat(),
2192
+ "acceptedAt": row["accepted_at"].isoformat() if row["accepted_at"] else None,
2193
+ "createdAt": row["created_at"].isoformat(),
2194
+ "reinstated": True,
2195
+ }
2196
+
2197
+
2198
+ @app.get("/api/v1/admin/members/check-email")
2199
+ async def check_member_email(
2200
+ email: str = Query(...),
2201
+ _auth: str | None = Depends(verify_api_key),
2202
+ ) -> dict[str, Any]:
2203
+ """Check if an email is associated with any active or removed team memberships.
2204
+
2205
+ Called by the dashboard OAuth flow to determine if a returning user
2206
+ should be blocked (removed) or allowed through.
2207
+
2208
+ Returns status: "active", "removed", or "not_found".
2209
+ """
2210
+ pool = _get_db_pool()
2211
+ if pool is None:
2212
+ return {"email": email, "status": "unknown", "memberships": []}
2213
+ ns = _get_namespace()
2214
+ with pool.connection() as conn:
2215
+ with conn.cursor() as cur:
2216
+ cur.execute(
2217
+ """SELECT m.id, m.team_id, t.name AS team_name, m.role,
2218
+ m.removed_at, m.removed_by,
2219
+ m.created_at, m.accepted_at
2220
+ FROM amfs_team_members m
2221
+ JOIN amfs_teams t ON t.id = m.team_id
2222
+ WHERE m.email = %s AND m.namespace = %s
2223
+ ORDER BY m.removed_at NULLS FIRST""",
2224
+ (email, ns),
2225
+ )
2226
+ rows = cur.fetchall()
2227
+ if not rows:
2228
+ return {"email": email, "status": "not_found", "memberships": []}
2229
+ active = [r for r in rows if r["removed_at"] is None]
2230
+ removed = [r for r in rows if r["removed_at"] is not None]
2231
+ if active:
2232
+ status = "active"
2233
+ elif removed:
2234
+ status = "removed"
2235
+ else:
2236
+ status = "not_found"
2237
+ return {
2238
+ "email": email,
2239
+ "status": status,
2240
+ "memberships": [
2241
+ {
2242
+ "id": str(r["id"]),
2243
+ "teamId": str(r["team_id"]),
2244
+ "teamName": r["team_name"],
2245
+ "role": r["role"],
2246
+ "removedAt": r["removed_at"].isoformat() if r["removed_at"] else None,
2247
+ "removedBy": r["removed_by"],
2248
+ "createdAt": r["created_at"].isoformat(),
2249
+ "acceptedAt": r["accepted_at"].isoformat() if r["accepted_at"] else None,
2250
+ }
2251
+ for r in rows
2252
+ ],
2253
+ }
2254
+
2255
+
1671
2256
  # ──────────────────────────────────────────────────────────────────────
1672
2257
  # Admin — Pattern Detection (Pro)
1673
2258
  # ──────────────────────────────────────────────────────────────────────
@@ -1676,36 +2261,50 @@ async def remove_team_member(
1676
2261
  @app.get("/api/v1/admin/patterns")
1677
2262
  async def list_detected_patterns(
1678
2263
  pattern_type: str | None = Query(None),
2264
+ category: str | None = Query(None),
1679
2265
  severity: str | None = Query(None),
1680
2266
  resolved: bool | None = Query(None),
2267
+ agent_id: str | None = Query(None),
1681
2268
  limit: int = Query(100),
1682
2269
  _auth: str | None = Depends(verify_api_key),
1683
2270
  ) -> dict[str, Any]:
1684
2271
  """List previously detected patterns from the database."""
2272
+ from amfs_patterns.detector import PATTERN_CATEGORIES, PATTERN_METADATA
2273
+
1685
2274
  pool = _get_db_pool()
1686
2275
  if pool is None:
1687
- return {"patterns": []}
2276
+ return {"patterns": [], "categories": PATTERN_CATEGORIES, "metadata": PATTERN_METADATA}
1688
2277
 
1689
- conditions = ["1=1"]
1690
- params: list[Any] = []
2278
+ ns = _get_namespace()
2279
+ conditions = ["namespace = %s"]
2280
+ params: list[Any] = [ns]
1691
2281
 
1692
2282
  if pattern_type is not None:
1693
2283
  conditions.append("pattern_type = %s")
1694
2284
  params.append(pattern_type)
2285
+ if category is not None:
2286
+ conditions.append("category = %s")
2287
+ params.append(category)
1695
2288
  if severity is not None:
1696
2289
  conditions.append("severity = %s")
1697
2290
  params.append(severity)
1698
2291
  if resolved is not None:
1699
2292
  conditions.append("resolved = %s")
1700
2293
  params.append(resolved)
2294
+ if agent_id is not None:
2295
+ conditions.append("(details->>'agent' = %s OR details->>'agent_a' = %s OR details->>'agent_b' = %s)")
2296
+ params.extend([agent_id, agent_id, agent_id])
1701
2297
 
1702
2298
  where = " AND ".join(conditions)
1703
2299
  sql = f"""
1704
2300
  SELECT id, pattern_type, severity, entity_path, description,
1705
- details, resolved, detected_at, resolved_at
2301
+ details, resolved, detected_at, resolved_at,
2302
+ COALESCE(category, 'collaboration') as category
1706
2303
  FROM amfs_detected_patterns
1707
2304
  WHERE {where}
1708
- ORDER BY detected_at DESC
2305
+ ORDER BY
2306
+ CASE severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
2307
+ detected_at DESC
1709
2308
  LIMIT %s
1710
2309
  """
1711
2310
  params.append(limit)
@@ -1721,6 +2320,7 @@ async def list_detected_patterns(
1721
2320
  "id": str(row["id"]),
1722
2321
  "patternType": row["pattern_type"],
1723
2322
  "severity": row["severity"],
2323
+ "category": row["category"],
1724
2324
  "entityPath": row["entity_path"],
1725
2325
  "description": row["description"],
1726
2326
  "details": row["details"] or {},
@@ -1729,7 +2329,9 @@ async def list_detected_patterns(
1729
2329
  "resolvedAt": row["resolved_at"].isoformat() if row["resolved_at"] else None,
1730
2330
  }
1731
2331
  for row in rows
1732
- ]
2332
+ ],
2333
+ "categories": PATTERN_CATEGORIES,
2334
+ "metadata": PATTERN_METADATA,
1733
2335
  }
1734
2336
 
1735
2337
 
@@ -1739,9 +2341,10 @@ async def run_pattern_scan(
1739
2341
  request: Request,
1740
2342
  _auth: str | None = Depends(verify_api_key),
1741
2343
  ) -> dict[str, Any]:
1742
- """Run the pattern detector and persist results."""
2344
+ """Run the collaboration-aware pattern detector and persist results."""
1743
2345
  try:
1744
2346
  from amfs_patterns import PatternDetector
2347
+ from amfs_patterns.detector import PATTERN_CATEGORIES, PATTERN_METADATA
1745
2348
  except ImportError:
1746
2349
  return {"error": "amfs-patterns package not installed"}
1747
2350
 
@@ -1749,28 +2352,50 @@ async def run_pattern_scan(
1749
2352
  entries = mem.list(req.entity_path)
1750
2353
  outcomes = mem._adapter.list_outcomes(entity_path=req.entity_path, limit=10000)
1751
2354
 
2355
+ if req.agent_id:
2356
+ entries = [e for e in entries if e.provenance.agent_id == req.agent_id]
2357
+
2358
+ branches: list[Any] = []
2359
+ pull_requests: list[Any] = []
2360
+ try:
2361
+ branches = mem.list_branches() # type: ignore[attr-defined]
2362
+ except (AttributeError, Exception):
2363
+ pass
2364
+ try:
2365
+ pull_requests = mem.list_pull_requests() # type: ignore[attr-defined]
2366
+ except (AttributeError, Exception):
2367
+ pass
2368
+
1752
2369
  detector = PatternDetector(
1753
- incident_threshold=req.incident_threshold,
1754
2370
  stale_days=req.stale_days,
1755
- hot_entity_stddev=req.hot_entity_stddev,
1756
- drift_stddev=req.drift_stddev,
2371
+ orphan_days=req.orphan_days,
2372
+ pr_stale_days=req.pr_stale_days,
2373
+ similarity_threshold=req.similarity_threshold,
2374
+ incident_threshold=req.incident_threshold,
2375
+ )
2376
+ report = detector.analyze(
2377
+ entries,
2378
+ outcome_data=outcomes,
2379
+ branches=branches,
2380
+ pull_requests=pull_requests,
1757
2381
  )
1758
- report = detector.analyze(entries, outcome_data=outcomes)
1759
2382
 
1760
2383
  pool = _get_db_pool()
1761
2384
  persisted = 0
1762
2385
  if pool is not None:
1763
2386
  with pool.connection() as conn:
1764
2387
  with conn.cursor() as cur:
2388
+ cur.execute("DELETE FROM amfs_detected_patterns WHERE resolved = FALSE")
1765
2389
  for p in report.patterns:
1766
2390
  cur.execute(
1767
2391
  """INSERT INTO amfs_detected_patterns
1768
- (pattern_type, severity, entity_path, description,
1769
- details, detected_at)
1770
- VALUES (%s, %s, %s, %s, %s::jsonb, %s)""",
2392
+ (pattern_type, severity, category, entity_path,
2393
+ description, details, detected_at)
2394
+ VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s)""",
1771
2395
  (
1772
2396
  p.pattern_type,
1773
2397
  p.severity,
2398
+ p.category,
1774
2399
  p.entity_path,
1775
2400
  p.description,
1776
2401
  json.dumps(p.details, default=str),
@@ -1795,6 +2420,7 @@ async def run_pattern_scan(
1795
2420
  {
1796
2421
  "patternType": p.pattern_type,
1797
2422
  "severity": p.severity,
2423
+ "category": p.category,
1798
2424
  "entityPath": p.entity_path,
1799
2425
  "description": p.description,
1800
2426
  "details": p.details,
@@ -1802,6 +2428,8 @@ async def run_pattern_scan(
1802
2428
  }
1803
2429
  for p in report.patterns
1804
2430
  ],
2431
+ "categories": PATTERN_CATEGORIES,
2432
+ "metadata": PATTERN_METADATA,
1805
2433
  }
1806
2434
 
1807
2435
 
@@ -1851,8 +2479,6 @@ async def graph_neighbors(
1851
2479
  _auth: str | None = Depends(verify_api_key),
1852
2480
  ) -> dict[str, Any]:
1853
2481
  """Traverse the knowledge graph from an entity."""
1854
- from fastapi.responses import JSONResponse
1855
-
1856
2482
  mem = _get_memory()
1857
2483
  try:
1858
2484
  edges = mem.graph_neighbors(
@@ -1878,6 +2504,7 @@ async def graph_neighbors(
1878
2504
 
1879
2505
  @app.get("/api/v1/pro/graph/expertise")
1880
2506
  async def expertise_graph(
2507
+ agent_id: str | None = Query(None),
1881
2508
  limit_agents: int = Query(30, ge=1, le=200),
1882
2509
  limit_entities: int = Query(30, ge=1, le=200),
1883
2510
  _auth: str | None = Depends(verify_api_key),
@@ -1885,7 +2512,8 @@ async def expertise_graph(
1885
2512
  """Build an agent×entity expertise heatmap.
1886
2513
 
1887
2514
  Returns a list of agents, entities, and cells with scores derived
1888
- from write counts. The dashboard renders this as a heatmap table.
2515
+ from write counts. When ``agent_id`` is provided, results are scoped
2516
+ to that single agent.
1889
2517
  """
1890
2518
  mem = _get_memory()
1891
2519
  entries = mem.list()
@@ -1896,6 +2524,8 @@ async def expertise_graph(
1896
2524
 
1897
2525
  for e in entries:
1898
2526
  aid = e.provenance.agent_id
2527
+ if agent_id and aid != agent_id:
2528
+ continue
1899
2529
  ep = e.entity_path
1900
2530
  agent_totals[aid] = agent_totals.get(aid, 0) + 1
1901
2531
  entity_totals[ep] = entity_totals.get(ep, 0) + 1
@@ -1949,11 +2579,12 @@ async def graph_backfill(
1949
2579
  outcome causal chains → 'informed' + 'read' edges,
1950
2580
  agent writes → 'wrote' edges.
1951
2581
  """
1952
- from amfs_core.models import GraphEdge
1953
-
1954
2582
  mem = _get_memory()
1955
2583
  entries = mem.list()
1956
- outcomes = mem._adapter.list_outcomes(namespace=mem.namespace) if hasattr(mem._adapter, "list_outcomes") else []
2584
+ try:
2585
+ outcomes = mem._adapter.list_outcomes() if hasattr(mem._adapter, "list_outcomes") else []
2586
+ except Exception:
2587
+ outcomes = []
1957
2588
 
1958
2589
  created = 0
1959
2590
  errors = 0