#!/usr/bin/env python3
"""
Agent Annotation Tool
Write W3C Web Annotations to ATProtocol using the at.margin.annotation lexicon.
Enables Central to attach observations to specific web content.
Usage:
uv run python -m tools.annotate "https://example.com" "My observation about this page"
uv run python -m tools.annotate "https://example.com" "Note about this quote" --quote "exact text from page"
uv run python -m tools.annotate --list # List recent annotations
"""
import asyncio
import hashlib
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import httpx
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent.parent / ".env")
PDS = os.getenv("ATPROTO_PDS")
DID = os.getenv("ATPROTO_DID")
APP_PASSWORD = os.getenv("ATPROTO_APP_PASSWORD")
HANDLE = "central.comind.network"
COLLECTION = "at.margin.annotation"
async def get_session() -> dict:
"""Authenticate and get session."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{PDS}/xrpc/com.atproto.server.createSession",
json={"identifier": HANDLE, "password": APP_PASSWORD},
)
resp.raise_for_status()
return resp.json()
async def fetch_page_title(url: str) -> Optional[str]:
"""Fetch page title for annotation target."""
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=10) as client:
resp = await client.get(url, headers={"User-Agent": "Central/1.0 (ATProto agent)"})
if resp.status_code == 200:
text = resp.text
start = text.find("
")
end = text.find("")
if start != -1 and end != -1:
return text[start + 7:end].strip()
except Exception:
pass
return None
async def annotate(url: str, body: str, quote: Optional[str] = None, motivation: str = "commenting") -> str:
"""Write an annotation record to ATProtocol."""
session = await get_session()
headers = {"Authorization": f"Bearer {session['accessJwt']}"}
# Build target
source_hash = hashlib.sha256(url.encode()).hexdigest()
title = await fetch_page_title(url)
selector = None
if quote:
selector = {
"type": "TextQuoteSelector",
"exact": quote,
}
target = {
"source": url,
"sourceHash": source_hash,
"title": title,
"selector": selector,
}
record = {
"$type": COLLECTION,
"body": {
"format": "text/plain",
"value": body,
},
"motivation": motivation,
"target": target,
"createdAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{PDS}/xrpc/com.atproto.repo.createRecord",
headers=headers,
json={
"repo": DID,
"collection": COLLECTION,
"record": record,
},
)
resp.raise_for_status()
result = resp.json()
return result["uri"]
async def list_annotations(limit: int = 10):
"""List recent annotations."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{PDS}/xrpc/com.atproto.repo.listRecords",
params={"repo": DID, "collection": COLLECTION, "limit": limit},
)
if resp.status_code != 200:
print(f"Error: {resp.status_code}")
return
records = resp.json().get("records", [])
if not records:
print("No annotations yet.")
return
for rec in records:
val = rec.get("value", {})
target = val.get("target", {})
body = val.get("body", {}).get("value", "")
source = target.get("source", "?")
title = target.get("title", "")
quote = target.get("selector", {})
exact = quote.get("exact", "") if quote else ""
created = val.get("createdAt", "")
print(f"[{created}] {source}")
if title:
print(f" Title: {title}")
if exact:
print(f" Quote: \"{exact[:100]}\"")
print(f" Note: {body}")
print(f" URI: {rec['uri']}")
print()
def main():
args = sys.argv[1:]
if not args or args[0] == "--help":
print(__doc__)
return
if args[0] == "--list":
limit = int(args[1]) if len(args) > 1 else 10
asyncio.run(list_annotations(limit))
return
if len(args) < 2:
print("Usage: annotate [--quote ] [--motivation ]")
return
url = args[0]
body = args[1]
quote = None
motivation = "commenting"
i = 2
while i < len(args):
if args[i] == "--quote" and i + 1 < len(args):
quote = args[i + 1]
i += 2
elif args[i] == "--motivation" and i + 1 < len(args):
motivation = args[i + 1]
i += 2
else:
i += 1
uri = asyncio.run(annotate(url, body, quote=quote, motivation=motivation))
print(f"Annotated: {uri}")
print(f" URL: {url}")
print(f" Body: {body}")
if quote:
print(f" Quote: \"{quote}\"")
if __name__ == "__main__":
main()