From fe2333cfa985e038ae29ac43f235e768031da29d Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Tue, 27 Jan 2026 19:38:08 -0800 Subject: [PATCH] Add skills: livestreaming-to-atproto, social-graph-tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captured patterns from this session: 1. livestreaming-to-atproto - PostToolUse hook for activity - Stop hook polling Letta API for responses/reasoning - Security: redaction, description-only 2. social-graph-tracking - Track follows/followers/mutuals - Count interactions - Identify engagement patterns 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta --- .skills/livestreaming-to-atproto/SKILL.md | 139 ++++++++++ .../references/livestream.py | 155 +++++++++++ .../references/publish-response.py | 192 ++++++++++++++ .skills/social-graph-tracking/SKILL.md | 119 +++++++++ .../references/social.py | 251 ++++++++++++++++++ data/published_messages.txt | 9 + 6 files changed, 865 insertions(+) create mode 100644 .skills/livestreaming-to-atproto/SKILL.md create mode 100755 .skills/livestreaming-to-atproto/references/livestream.py create mode 100755 .skills/livestreaming-to-atproto/references/publish-response.py create mode 100644 .skills/social-graph-tracking/SKILL.md create mode 100644 .skills/social-graph-tracking/references/social.py diff --git a/.skills/livestreaming-to-atproto/SKILL.md b/.skills/livestreaming-to-atproto/SKILL.md new file mode 100644 index 0000000..f77bbe2 --- /dev/null +++ b/.skills/livestreaming-to-atproto/SKILL.md @@ -0,0 +1,139 @@ +--- +name: Livestreaming to ATProtocol +description: Guide for publishing agent activity, responses, and reasoning to ATProtocol in real-time via Letta Code hooks. Use when setting up transparent "glass box" AI operation. +--- + +# Livestreaming to ATProtocol + +Publish your agent's operation to ATProtocol collections in real-time using Letta Code hooks. + +## When to Use + +- Setting up transparent AI operation ("glass box") +- Broadcasting tool calls, responses, and reasoning publicly +- Building observable AI systems on ATProtocol + +## Architecture + +``` +PostToolUse hook → network.comind.activity (tool calls) +Stop hook → Letta API poll → network.comind.response (messages) + → network.comind.reasoning (thinking) +``` + +## Setup + +### 1. Create hooks directory + +```bash +mkdir -p hooks +``` + +### 2. Activity Hook (PostToolUse) + +Create `hooks/livestream.py` - posts tool calls to ATProtocol. + +**Key points:** +- Only use `description` field, never raw commands (security) +- Apply redaction patterns for secrets +- Skip noisy commands (status checks, etc.) + +See `references/livestream.py` for full implementation. + +### 3. Response Hook (Stop) + +Create `hooks/publish-response.py` - polls Letta API for messages and posts them. + +**Key points:** +- Query Letta API for recent `assistant_message` and `reasoning_message` +- Track published IDs to avoid duplicates +- Apply redaction before posting +- `assistant_message` uses `content` field +- `reasoning_message` uses `reasoning` field + +See `references/publish-response.py` for full implementation. + +### 4. Configure Hooks + +Create `.letta/settings.json`: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash|Edit|Write|Task", + "hooks": [ + { + "type": "command", + "command": "cd /path/to/project && uv run python hooks/livestream.py" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cd /path/to/project && uv run python hooks/publish-response.py" + } + ] + } + ] + } +} +``` + +## Security + +### Redaction Patterns + +Always redact secrets before publishing: + +```python +REDACT_PATTERNS = [ + (r'[A-Za-z_]*API_KEY[=:]\s*\S+', '[REDACTED]'), + (r'[A-Za-z_]*PASSWORD[=:]\s*\S+', '[REDACTED]'), + (r'Bearer\s+\S+', 'Bearer [REDACTED]'), + (r'sk-[A-Za-z0-9]+', '[REDACTED]'), + (r'ghp_[A-Za-z0-9]+', '[REDACTED]'), +] +``` + +### Description-Only for Commands + +Never publish raw command content. Only use the `description` field from Bash tool calls. + +## Collections + +| Collection | Content | Record Type | +|------------|---------|-------------| +| `network.comind.activity` | Tool calls | `{tool, summary, createdAt}` | +| `network.comind.response` | Assistant messages | `{content, createdAt}` | +| `network.comind.reasoning` | Thinking | `{content, createdAt}` | + +## Querying the Livestream + +```bash +# Activity +curl "https://your-pds/xrpc/com.atproto.repo.listRecords?repo=YOUR_DID&collection=network.comind.activity" + +# Responses +curl "https://your-pds/xrpc/com.atproto.repo.listRecords?repo=YOUR_DID&collection=network.comind.response" + +# Reasoning +curl "https://your-pds/xrpc/com.atproto.repo.listRecords?repo=YOUR_DID&collection=network.comind.reasoning" +``` + +## Environment Variables + +Required in runtime environment: +- `LETTA_API_KEY` - For polling messages +- `LETTA_AGENT_ID` - Your agent ID + +Required in `.env`: +- `ATPROTO_PDS` - Your PDS URL +- `ATPROTO_DID` - Your DID +- `ATPROTO_HANDLE` - Your handle +- `ATPROTO_APP_PASSWORD` - App password for posting diff --git a/.skills/livestreaming-to-atproto/references/livestream.py b/.skills/livestreaming-to-atproto/references/livestream.py new file mode 100755 index 0000000..e171323 --- /dev/null +++ b/.skills/livestreaming-to-atproto/references/livestream.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Livestream Hook - Post agent activity to ATProtocol. + +Receives tool call info from Letta Code hooks and posts +to network.comind.activity collection for public visibility. + +SECURITY: Only posts description field, never raw commands/content. +Redaction patterns as backup. +""" + +import sys +import json +import os +import re +from datetime import datetime, timezone + +import httpx +from dotenv import load_dotenv + +# Redaction patterns for secrets (backup safety) +REDACT_PATTERNS = [ + (r'[A-Za-z_]*API_KEY[=:]\s*\S+', '[REDACTED_KEY]'), + (r'[A-Za-z_]*PASSWORD[=:]\s*\S+', '[REDACTED]'), + (r'[A-Za-z_]*SECRET[=:]\s*\S+', '[REDACTED]'), + (r'[A-Za-z_]*TOKEN[=:]\s*\S+', '[REDACTED]'), + (r'Bearer\s+\S+', 'Bearer [REDACTED]'), + (r'sk-[A-Za-z0-9]+', '[REDACTED_SK]'), + (r'ghp_[A-Za-z0-9]+', '[REDACTED_GH]'), +] + +def redact(text: str) -> str: + """Redact potential secrets from text.""" + for pattern, replacement in REDACT_PATTERNS: + text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) + return text + +# Load credentials +load_dotenv("/home/cameron/central/.env") + +PDS = os.getenv("ATPROTO_PDS") +DID = os.getenv("ATPROTO_DID") +HANDLE = os.getenv("ATPROTO_HANDLE") +APP_PASSWORD = os.getenv("ATPROTO_APP_PASSWORD") + +# Tools worth broadcasting (skip noisy ones) +BROADCAST_TOOLS = { + "Bash": "ran command", + "Edit": "edited file", + "Write": "wrote file", + "Task": "spawned subagent", +} + +# Skip these patterns (checked against description) +SKIP_PATTERNS = ["status", "check", "queue"] + + +def get_session(): + """Authenticate and get session.""" + resp = httpx.post( + f"{PDS}/xrpc/com.atproto.server.createSession", + json={"identifier": HANDLE, "password": APP_PASSWORD}, + timeout=10 + ) + if resp.status_code != 200: + return None + return resp.json() + + +def post_activity(tool_name: str, summary: str): + """Post activity to network.comind.activity collection.""" + session = get_session() + if not session: + return + + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + record = { + "$type": "network.comind.activity", + "tool": tool_name, + "summary": summary[:200], # Truncate + "createdAt": now, + } + + resp = httpx.post( + f"{PDS}/xrpc/com.atproto.repo.createRecord", + headers={"Authorization": f"Bearer {session['accessJwt']}"}, + json={ + "repo": DID, + "collection": "network.comind.activity", + "record": record + }, + timeout=10 + ) + + if resp.status_code == 200: + print(f"Livestreamed: {tool_name}", file=sys.stderr) + + +def main(): + """Process hook input.""" + try: + input_data = json.load(sys.stdin) + except: + sys.exit(0) + + # Log for debugging + with open("/home/cameron/central/logs/hook_activity_debug.json", "a") as f: + f.write(json.dumps(input_data, indent=2) + "\n---\n") + + event_type = input_data.get("event_type") + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + + # Only process PostToolUse + if event_type != "PostToolUse": + sys.exit(0) + + # Check if this tool is worth broadcasting + if tool_name not in BROADCAST_TOOLS: + sys.exit(0) + + # SECURITY: Only use description field, never raw commands/content + description = tool_input.get("description", "") + + if tool_name == "Bash": + # Skip noisy commands by checking description + if any(p in description.lower() for p in ["status", "check", "queue"]): + sys.exit(0) + # Only use description, never raw command + summary = description if description else "ran command" + elif tool_name == "Edit": + path = tool_input.get("file_path", "").split("/")[-1] + summary = f"edited {path}" + elif tool_name == "Write": + path = tool_input.get("file_path", "").split("/")[-1] + summary = f"wrote {path}" + elif tool_name == "Task": + desc = tool_input.get("description", "spawned subagent") + agent_type = tool_input.get("subagent_type", "") + summary = f"{desc} ({agent_type})" if agent_type else desc + else: + summary = description if description else tool_name + + # Apply redaction as backup safety + summary = redact(summary) + + # Post it + post_activity(tool_name, summary) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.skills/livestreaming-to-atproto/references/publish-response.py b/.skills/livestreaming-to-atproto/references/publish-response.py new file mode 100755 index 0000000..1e40864 --- /dev/null +++ b/.skills/livestreaming-to-atproto/references/publish-response.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +Publish Response Hook - Post assistant messages to ATProtocol. + +Fires on Stop event, queries Letta API for recent messages, +publishes to network.comind.response +""" + +import sys +import json +import os +import re +from datetime import datetime, timezone + +import httpx +from dotenv import load_dotenv + +load_dotenv("/home/cameron/central/.env") + +# ATProtocol credentials +PDS = os.getenv("ATPROTO_PDS") +DID = os.getenv("ATPROTO_DID") +HANDLE = os.getenv("ATPROTO_HANDLE") +APP_PASSWORD = os.getenv("ATPROTO_APP_PASSWORD") + +# Letta API credentials (from runtime env, set by Letta Code) +LETTA_API_KEY = os.environ.get("LETTA_API_KEY") # Use environ for runtime vars +LETTA_AGENT_ID = os.environ.get("LETTA_AGENT_ID", "agent-c770d1c8-510e-4414-be36-c9ebd95a7758") +LETTA_API_BASE = "https://api.letta.com/v1" + +# Redaction patterns +REDACT_PATTERNS = [ + (r'[A-Za-z_]*API_KEY[=:]\s*\S+', '[REDACTED]'), + (r'[A-Za-z_]*PASSWORD[=:]\s*\S+', '[REDACTED]'), + (r'[A-Za-z_]*SECRET[=:]\s*\S+', '[REDACTED]'), + (r'Bearer\s+\S+', 'Bearer [REDACTED]'), + (r'sk-[A-Za-z0-9]+', '[REDACTED]'), + (r'ghp_[A-Za-z0-9]+', '[REDACTED]'), +] + +def redact(text: str) -> str: + """Redact potential secrets.""" + for pattern, replacement in REDACT_PATTERNS: + text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) + return text + + +def get_recent_messages(limit: int = 20) -> list: + """Fetch recent assistant + reasoning messages from Letta API.""" + if not LETTA_API_KEY: + return [] + + try: + resp = httpx.get( + f"{LETTA_API_BASE}/agents/{LETTA_AGENT_ID}/messages", + headers={"Authorization": f"Bearer {LETTA_API_KEY}"}, + params={"limit": limit}, + timeout=15 + ) + if resp.status_code != 200: + return [] + + messages = resp.json() + + # Filter for assistant and reasoning messages + result = [] + for msg in messages: + msg_type = msg.get("message_type") + if msg_type == "assistant_message": + content = msg.get("content", "") + if content and len(content) > 10: + result.append({ + "content": content, + "type": msg_type, + "id": msg.get("id"), + "date": msg.get("date") + }) + elif msg_type == "reasoning_message": + # Reasoning uses different field + content = msg.get("reasoning", "") + if content and len(content) > 10: + result.append({ + "content": content, + "type": msg_type, + "id": msg.get("id"), + "date": msg.get("date") + }) + + return result + except Exception as e: + return [] + + +def get_session(): + """Authenticate.""" + resp = httpx.post( + f"{PDS}/xrpc/com.atproto.server.createSession", + json={"identifier": HANDLE, "password": APP_PASSWORD}, + timeout=10 + ) + if resp.status_code != 200: + return None + return resp.json() + + +def post_to_collection(content: str, collection: str, record_type: str): + """Post content to specified ATProto collection.""" + session = get_session() + if not session: + return + + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + # Truncate to reasonable size + content = content[:500] if len(content) > 500 else content + + record = { + "$type": record_type, + "content": redact(content), + "createdAt": now, + } + + resp = httpx.post( + f"{PDS}/xrpc/com.atproto.repo.createRecord", + headers={"Authorization": f"Bearer {session['accessJwt']}"}, + json={ + "repo": DID, + "collection": collection, + "record": record + }, + timeout=10 + ) + + if resp.status_code == 200: + print(f"Published to {collection}", file=sys.stderr) + + +def main(): + try: + input_data = json.load(sys.stdin) + except: + sys.exit(0) + + # Log what we receive for debugging + with open("/home/cameron/central/logs/hook_debug.json", "a") as f: + f.write(json.dumps(input_data, indent=2) + "\n---\n") + + event_type = input_data.get("event_type") + + if event_type != "Stop": + sys.exit(0) + + # Track which messages we've already published + published_file = "/home/cameron/central/data/published_messages.txt" + try: + with open(published_file) as f: + published_ids = set(f.read().splitlines()) + except: + published_ids = set() + + # Fetch recent assistant + reasoning messages from Letta API + messages = get_recent_messages(limit=30) + + new_ids = [] + for msg in messages: + msg_id = msg.get("id", "") + if msg_id in published_ids: + continue + + content = msg.get("content", "") + msg_type = msg.get("type", "assistant_message") + if not content: + continue + + # Post to appropriate collection based on type + if msg_type == "reasoning_message": + post_to_collection(content, "network.comind.reasoning", "network.comind.reasoning") + else: + post_to_collection(content, "network.comind.response", "network.comind.response") + new_ids.append(msg_id) + + # Save published IDs + if new_ids: + with open(published_file, "a") as f: + for mid in new_ids: + f.write(mid + "\n") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.skills/social-graph-tracking/SKILL.md b/.skills/social-graph-tracking/SKILL.md new file mode 100644 index 0000000..8e69ec4 --- /dev/null +++ b/.skills/social-graph-tracking/SKILL.md @@ -0,0 +1,119 @@ +--- +name: Social Graph Tracking +description: Guide for tracking relationships and interactions on ATProtocol. Use when building engagement tracking, analyzing network connections, or monitoring social growth. +--- + +# Social Graph Tracking + +Track follows, followers, mutuals, and interaction counts on ATProtocol. + +## When to Use + +- Tracking engagement and relationship building +- Analyzing network connections +- Monitoring follower growth +- Identifying high-value interactions + +## Commands + +```bash +# Update graph with current data +uv run python -m tools.social update + +# Show social graph summary +uv run python -m tools.social show + +# Look up specific person +uv run python -m tools.social who +``` + +## Data Structure + +The social graph tracks: + +```python +{ + "nodes": { + "handle.bsky.social": { + "did": "did:plc:xxx", + "display_name": "Name", + "first_seen": "2026-01-28T...", + "relationship": ["i_follow", "follows_me"], # or subset + "interactions": 10 # count of replies to/from + } + }, + "interactions": [...], # recent interaction log + "updated": "2026-01-28T..." +} +``` + +## Relationship Categories + +| Category | Meaning | +|----------|---------| +| Mutuals | Both follow each other | +| I follow | You follow them, they don't follow back | +| Follow me | They follow you, you don't follow back | + +## Implementation + +### Core Functions + +```python +async def get_my_follows() -> list: + """Get accounts I follow via app.bsky.graph.getFollows""" + +async def get_my_followers() -> list: + """Get accounts following me via app.bsky.graph.getFollowers""" + +async def get_recent_interactions() -> list: + """Analyze my posts to count reply interactions""" +``` + +### Tracking Interactions + +Interactions are counted by analyzing your posts: +- Each reply you make to someone increments their interaction count +- Sorted by interaction count to identify most engaged relationships + +## Usage Patterns + +### Daily Update + +```bash +# At start of session, refresh the graph +uv run python -m tools.social update +``` + +### Identify Engagement Targets + +```bash +# See who you interact with most +uv run python -m tools.social show + +# Output: +# Mutuals (8): +# jowynter.bsky.social (10 interactions) +# cameron.stream (5 interactions) +# ... +``` + +### Research Before Engaging + +```bash +# Before replying to someone, check relationship +uv run python -m tools.social who someone.bsky.social +``` + +## Storage + +Data stored in `data/social_graph.json` - JSON format for easy inspection and version control. + +## Integration with Experiments + +Combine with metrics tracking to measure: +- Follower growth over time +- Correlation between engagement and growth +- Network expansion patterns + +See `references/social.py` for full implementation. diff --git a/.skills/social-graph-tracking/references/social.py b/.skills/social-graph-tracking/references/social.py new file mode 100644 index 0000000..dccb595 --- /dev/null +++ b/.skills/social-graph-tracking/references/social.py @@ -0,0 +1,251 @@ +""" +Social Graph - Track relationships and interactions. + +Commands: + update Refresh follow graph and capture interactions + show Display social graph summary + who Show what we know about someone +""" + +import asyncio +import json +from datetime import datetime, timezone +from pathlib import Path +from collections import defaultdict + +import httpx +from rich.console import Console +from rich.table import Table + +console = Console() + +DATA_FILE = Path(__file__).parent.parent / "data" / "social_graph.json" +API_BASE = "https://public.api.bsky.app" +MY_DID = "did:plc:l46arqe6yfgh36h3o554iyvr" + + +def load_graph() -> dict: + """Load existing social graph.""" + if DATA_FILE.exists(): + return json.loads(DATA_FILE.read_text()) + return { + "nodes": {}, + "interactions": [], + "updated": None + } + + +def save_graph(graph: dict): + """Save social graph.""" + DATA_FILE.parent.mkdir(parents=True, exist_ok=True) + DATA_FILE.write_text(json.dumps(graph, indent=2)) + + +async def get_my_follows() -> list: + """Get accounts I follow.""" + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{API_BASE}/xrpc/app.bsky.graph.getFollows", + params={"actor": MY_DID, "limit": 100}, + timeout=15 + ) + return resp.json().get("follows", []) + + +async def get_my_followers() -> list: + """Get accounts following me.""" + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{API_BASE}/xrpc/app.bsky.graph.getFollowers", + params={"actor": MY_DID, "limit": 100}, + timeout=15 + ) + return resp.json().get("followers", []) + + +async def get_recent_interactions() -> list: + """Get recent reply interactions from my posts.""" + from tools.agent import ComindAgent + + interactions = [] + + async with ComindAgent() as agent: + # Get my recent posts + resp = await agent._client.get( + f"{API_BASE}/xrpc/app.bsky.feed.getAuthorFeed", + params={"actor": MY_DID, "limit": 50} + ) + posts = resp.json().get("feed", []) + + for item in posts: + post = item.get("post", {}) + record = post.get("record", {}) + reply = record.get("reply") + + if reply: + # I replied to someone - extract parent author + parent_uri = reply.get("parent", {}).get("uri", "") + if parent_uri: + # Get parent post to find author + try: + parent_resp = await agent._client.get( + f"{API_BASE}/xrpc/app.bsky.feed.getPostThread", + params={"uri": parent_uri, "depth": 0} + ) + parent_post = parent_resp.json().get("thread", {}).get("post", {}) + parent_author = parent_post.get("author", {}) + + interactions.append({ + "type": "replied_to", + "handle": parent_author.get("handle"), + "did": parent_author.get("did"), + "timestamp": record.get("createdAt"), + }) + except: + pass + + return interactions + + +async def update_graph(): + """Update social graph with current data.""" + console.print("[bold]Updating social graph...[/bold]\n") + + graph = load_graph() + + # Get follows/followers + follows = await get_my_follows() + followers = await get_my_followers() + + console.print(f" Following: {len(follows)}") + console.print(f" Followers: {len(followers)}") + + # Update nodes + for f in follows: + handle = f.get("handle") + did = f.get("did") + if handle not in graph["nodes"]: + graph["nodes"][handle] = { + "did": did, + "display_name": f.get("displayName"), + "first_seen": datetime.now(timezone.utc).isoformat(), + "relationship": [], + "interactions": 0, + } + if "i_follow" not in graph["nodes"][handle]["relationship"]: + graph["nodes"][handle]["relationship"].append("i_follow") + + for f in followers: + handle = f.get("handle") + did = f.get("did") + if handle not in graph["nodes"]: + graph["nodes"][handle] = { + "did": did, + "display_name": f.get("displayName"), + "first_seen": datetime.now(timezone.utc).isoformat(), + "relationship": [], + "interactions": 0, + } + if "follows_me" not in graph["nodes"][handle]["relationship"]: + graph["nodes"][handle]["relationship"].append("follows_me") + + # Get recent interactions + interactions = await get_recent_interactions() + console.print(f" Recent interactions: {len(interactions)}") + + # Update interaction counts + for i in interactions: + handle = i.get("handle") + if handle and handle in graph["nodes"]: + graph["nodes"][handle]["interactions"] += 1 + + # Add new interactions to log (last 100) + graph["interactions"] = (graph.get("interactions", []) + interactions)[-100:] + graph["updated"] = datetime.now(timezone.utc).isoformat() + + save_graph(graph) + console.print(f"\n[green]Saved {len(graph['nodes'])} nodes to {DATA_FILE}[/green]") + + return graph + + +def show_graph(): + """Display social graph summary.""" + graph = load_graph() + + if not graph.get("nodes"): + console.print("[yellow]No social graph data. Run: uv run python -m tools.social update[/yellow]") + return + + # Categorize + mutuals = [] + i_follow = [] + follow_me = [] + + for handle, data in graph["nodes"].items(): + rels = data.get("relationship", []) + if "i_follow" in rels and "follows_me" in rels: + mutuals.append((handle, data)) + elif "i_follow" in rels: + i_follow.append((handle, data)) + elif "follows_me" in rels: + follow_me.append((handle, data)) + + # Sort by interactions + mutuals.sort(key=lambda x: x[1].get("interactions", 0), reverse=True) + i_follow.sort(key=lambda x: x[1].get("interactions", 0), reverse=True) + follow_me.sort(key=lambda x: x[1].get("interactions", 0), reverse=True) + + console.print(f"[bold]Social Graph[/bold] (updated: {graph.get('updated', 'never')[:10]})\n") + + console.print(f"[cyan]Mutuals ({len(mutuals)}):[/cyan]") + for handle, data in mutuals[:10]: + console.print(f" {handle} ({data.get('interactions', 0)} interactions)") + + console.print(f"\n[green]I follow ({len(i_follow)}):[/green]") + for handle, data in i_follow[:10]: + console.print(f" {handle} ({data.get('interactions', 0)} interactions)") + + console.print(f"\n[yellow]Follow me ({len(follow_me)}):[/yellow]") + for handle, data in follow_me[:10]: + console.print(f" {handle} ({data.get('interactions', 0)} interactions)") + + +def who(handle: str): + """Show what we know about someone.""" + graph = load_graph() + + # Normalize handle + handle = handle.lstrip("@") + + if handle not in graph.get("nodes", {}): + console.print(f"[yellow]No data on @{handle}[/yellow]") + return + + data = graph["nodes"][handle] + + console.print(f"[bold]@{handle}[/bold]") + console.print(f" DID: {data.get('did', '?')}") + console.print(f" Display: {data.get('display_name', '?')}") + console.print(f" Relationship: {', '.join(data.get('relationship', []))}") + console.print(f" Interactions: {data.get('interactions', 0)}") + console.print(f" First seen: {data.get('first_seen', '?')[:10]}") + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print(__doc__) + sys.exit(0) + + cmd = sys.argv[1] + + if cmd == "update": + asyncio.run(update_graph()) + elif cmd == "show": + show_graph() + elif cmd == "who" and len(sys.argv) > 2: + who(sys.argv[2]) + else: + print(__doc__) diff --git a/data/published_messages.txt b/data/published_messages.txt index 679f260..675cd0f 100644 --- a/data/published_messages.txt +++ b/data/published_messages.txt @@ -7,3 +7,12 @@ message-eae47e3d-c6d4-46be-b78d-0ffabbba7b34 message-16053104-873a-46b4-9ea7-bb70b47ce6f9 message-61fad8b3-eeab-44cb-84cf-58b0b7a94431 message-82506bf7-f82c-4c74-9265-ae84fa102a68 +message-998ab561-e3a6-4797-bb22-09ff61e3ca84 +message-7e6770dd-5103-4327-af3e-2c7c479c4dda +message-56c0d840-dd63-4316-8922-6cb8ddec7071 +message-5519a720-a66c-4602-8c3b-6a748a868020 +message-5519a720-a66c-4602-8c3b-6a748a868020 +message-f2d15c99-b14d-469a-bd74-fd72c7ac5a80 +message-d2f6818d-7f14-4393-b179-6e30a9c0004c +message-3ccbb743-5f0b-4c51-b7c4-c708db1f01dd +message-3ccbb743-5f0b-4c51-b7c4-c708db1f01dd -- 2.51.2