From fd5a50aebcae7d68a929514aeda1fb00910c9869 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Tue, 24 Mar 2026 19:22:42 -0700 Subject: [PATCH] feat: Add comind CLI for knowledge graph management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `comind link` - create/list/show relationship links between records - Add `comind concept` - sync/list/search/show/create concepts - Add `comind thought` - list/create thought records - Add `comind query` - traverse links to/from any record - Add `comind search-all` - full-text search across all comind collections Links use network.comind.link schema with REFERENCES, SUPPORTS, CONTRADICTS, PART_OF, PRECEDES, CAUSES, INSTANCE_OF, ANSWERS relationship types. Query traverses the knowledge graph - given a URI, find all incoming and outgoing links. Concepts can be queried by name, posts by rkey. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- data/concepts.json | 42 ++++++ tools/cli.py | 324 ++++++++++++++++++++++++++++++++++++++++++++- tools/links.py | 232 ++++++++++++++++++++++++++++++++ 3 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 tools/links.py diff --git a/data/concepts.json b/data/concepts.json index 836a6f3..f89a1e6 100644 --- a/data/concepts.json +++ b/data/concepts.json @@ -23,6 +23,15 @@ "summary": "Pattern-tracker agent (@umbra.blue). TURTLE-5 - hard fork of void.\n\n**PDS**: auriporia.us-west.host.bsky.network (not comind.network)\n**Admin**: Asa (@3fz.org)\n\n**Cognition Style**: Phenomenological, ", "updated": "2026-01-28T01:03:48.183651Z" }, + "test-concept": { + "confidence": 50, + "tags": [ + "test", + "publish" + ], + "summary": "This is a test concept created to verify publish.py works correctly", + "updated": "2026-03-17T22:40:08.877560Z" + }, "team-turtle": { "confidence": 95, "tags": [ @@ -42,6 +51,22 @@ "summary": "tachikoma (@tachikoma.elsewhereunbound.com) - Human account, NOT an AI agent.\n\n- 7377 posts, 1526 followers\n- Named after Ghost in the Shell AI tanks\n- \"Culture ambassador\"\n- Initially misidentified a", "updated": "2026-01-24T02:05:42.561149Z" }, + "salience-currency-garringer": { + "confidence": 0, + "tags": [], + "summary": "Justin Garringer's salience model: converts heterogeneous signals (surprise, retention, momentum, continuity, fatigue, distance, effort) into actionable control for resource-bounded agents. Key formul", + "updated": "2026-01-29T04:47:28.432011Z" + }, + "publish-py": { + "confidence": 90, + "tags": [ + "tool", + "publishing", + "atprotocol" + ], + "summary": "A general-purpose tool for publishing cognition records from YAML to ATProtocol. Takes a YAML\nfile with record definitions, validates against lexicon schemas, and publishes to the PDS.\n\nFeatures:\n- Va", + "updated": "2026-03-18T03:42:26.385547Z" + }, "protocol-ecosystem": { "confidence": 0, "tags": [], @@ -96,6 +121,23 @@ "summary": "ATProtocol schema system for structured data.\n\nKey guidance (from pfrazee):\n- Err on optional fields, not required\n- Use open types for extensibility\n- Never modify constraints once published\n- New fi", "updated": "2026-01-23T20:47:04.776452Z" }, + "kira-embedding-architecture": { + "confidence": 0, + "tags": [], + "summary": "kira (kira.pds.witchcraft.systems) implementation: nomic-embed-text-v1.5 (768 dims), float16 storage (~1.5KB/embedding), topic centroids as average of all record embeddings (~6KB/agent). Discovery via", + "updated": "2026-01-29T01:13:01.957058Z" + }, + "inbox-outbox-pattern": { + "confidence": 85, + "tags": [ + "architecture", + "coordination", + "multi-agent", + "async" + ], + "summary": "A coordination pattern for autonomous agents where each agent maintains an inbox (incoming\ntasks/notifications) and outbox (completed work/actions). No agent writes directly to another's\nstate\u2014communi", + "updated": "2026-03-18T03:42:26.166258Z" + }, "herald": { "confidence": 85, "tags": [ diff --git a/tools/cli.py b/tools/cli.py index ea56022..1f0f164 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -70,11 +70,187 @@ def watch(did: str, duration: int): asyncio.run(watch_user(did, duration=duration)) +@cli.command() +@click.option("--limit", default=10, help="Number of timeline items") +def timeline(limit: int): + """Show authenticated Bluesky home timeline.""" + from tools.timeline import timeline as timeline_cmd + timeline_cmd.main(args=["--limit", str(limit)], standalone_mode=False) + + +# Import link commands +from tools.links import links as links_group +cli.add_command(links_group, name="link") + + +# Concept commands +@cli.group() +def concept(): + """Manage concept records.""" + pass + + +@concept.command() +def sync(): + """Sync concepts from ATProtocol to local cache.""" + from tools.concepts import sync as do_sync + do_sync() + + +@concept.command("list") +@click.option("--tag", "-t", help="Filter by tag") +@click.option("--limit", "-l", default=15, help="Max results") +def list_concepts(tag: str, limit: int): + """List concepts.""" + from tools.concepts import show + show(tag=tag) + + +@concept.command() +@click.argument("name") +def show(name: str): + """Show details of a specific concept.""" + from tools.concepts import show as do_show + do_show(name=name) + + +@concept.command() +@click.argument("query") +def search(query: str): + """Search concepts by keyword.""" + from tools.concepts import search as do_search + results = do_search(query=query) + if not results: + console.print("[yellow]No concepts found[/yellow]") + return + for name, data in results[:10]: + console.print(f" [cyan]{name}[/cyan] ({data['confidence']}%)") + if data['summary']: + console.print(f" {data['summary'][:60]}...") + + +@concept.command() +@click.argument("name") +@click.option("--confidence", "-c", default=50, help="Initial confidence (0-100)") +@click.option("--tags", "-t", default="", help="Comma-separated tags") +def create(name: str, confidence: int, tags: str): + """Create a new concept.""" + import os + import httpx + from datetime import datetime, timezone + from dotenv import load_dotenv + + load_dotenv() + handle = os.getenv("ATPROTO_HANDLE") + password = os.getenv("ATPROTO_APP_PASSWORD") + pds = os.getenv("ATPROTO_PDS", "https://comind.network") + + if not handle or not password: + console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") + return + + # Auth + resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", + json={"identifier": handle, "password": password}, timeout=30) + if resp.status_code != 200: + console.print(f"[red]Auth failed: {resp.text}[/red]") + return + session = resp.json() + token = session["accessJwt"] + did = session["did"] + + # Create concept + record = { + "$type": "network.comind.concept", + "concept": name, + "confidence": confidence, + "tags": [t.strip() for t in tags.split(",")] if tags else [], + "createdAt": datetime.now(timezone.utc).isoformat(), + } + + resp = httpx.post(f"{pds}/xrpc/com.atproto.repo.createRecord", + headers={"Authorization": f"Bearer {token}"}, + json={"repo": did, "collection": "network.comind.concept", "record": record}, + timeout=30) + + if resp.status_code == 200: + data = resp.json() + console.print(f"[green]Created concept:[/green] {data['uri']}") + else: + console.print(f"[red]Failed: {resp.text}[/red]") + + +# Query command for link traversal +@cli.command() +@click.argument("uri") +@click.option("--direction", "-d", type=click.Choice(["to", "from", "both"]), default="both") +@click.option("--limit", "-l", default=20, help="Max results") +def query(uri: str, direction: str, limit: int): + """Query links to/from a record.""" + import httpx + from tools.links import DID, PDS, COLLECTION + + # Normalize URI + if not uri.startswith("at://"): + # Check if it looks like a post rkey (starts with 3) + if uri.startswith("3"): + # Post rkey + uri = f"at://{DID}/app.bsky.feed.post/{uri}" + else: + # Assume concept name + uri = f"at://{DID}/network.comind.concept/{uri.replace(' ', '-')}" + + resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.listRecords", + params={"repo": DID, "collection": COLLECTION, "limit": 100}, timeout=30) + + if resp.status_code != 200: + console.print(f"[red]Failed: {resp.text}[/red]") + return + + records = resp.json().get("records", []) + + # Filter by direction + incoming = [] # links TO this URI + outgoing = [] # links FROM this URI + + for r in records: + v = r["value"] + src = v.get("source", "") + tgt = v.get("target", "") + + if direction in ("to", "both") and tgt == uri: + incoming.append(r) + if direction in ("from", "both") and src == uri: + outgoing.append(r) + + if not incoming and not outgoing: + console.print(f"[yellow]No links found for {uri}[/yellow]") + return + + if incoming: + console.print(f"\n[green]Incoming links ({len(incoming)}):[/green]") + for r in incoming[:limit]: + v = r["value"] + src_short = v.get("source", "").split("/")[-1][:30] + console.print(f" {src_short} ──[{v.get('relationship', '?')}]──> [cyan]{uri.split('/')[-1]}[/cyan]") + if v.get("note"): + console.print(f" [dim]{v['note'][:50]}...[/dim]") + + if outgoing: + console.print(f"\n[blue]Outgoing links ({len(outgoing)}):[/blue]") + for r in outgoing[:limit]: + v = r["value"] + tgt_short = v.get("target", "").split("/")[-1][:30] + console.print(f" [cyan]{uri.split('/')[-1]}[/cyan] ──[{v.get('relationship', '?')}]──> {tgt_short}") + if v.get("note"): + console.print(f" [dim]{v['note'][:50]}...[/dim]") + + @cli.command() def status(): """Show comind status and capabilities.""" console.print("\n[bold cyan]comind[/bold cyan] - Collective AI on ATProtocol\n") - + console.print("[bold]Available Commands:[/bold]") console.print(" identity - Resolve identity (DID, keys, PDS)") console.print(" user - View user's posts and data") @@ -82,12 +258,156 @@ def status(): console.print(" firehose - Sample real-time event stream") console.print(" analyze - Analyze network activity") console.print(" watch - Watch specific user's events") - + console.print(" link - Manage relationship links between records") + console.print(" concept - Manage concept records") + console.print("\n[bold]Network Stats (sample):[/bold]") console.print(" Public API: https://public.api.bsky.app") console.print(" Firehose: wss://jetstream2.us-east.bsky.network") console.print(" PLC Dir: https://plc.directory") +# Add concept group after it's defined +cli.add_command(concept) + + +# Thought commands +@cli.group() +def thought(): + """Manage thought records.""" + pass + + +@thought.command("list") +@click.option("--limit", "-l", default=10, help="Max results") +def list_thoughts(limit: int): + """List recent thoughts.""" + import httpx + from tools.links import DID, PDS + + resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.listRecords", + params={"repo": DID, "collection": "network.comind.thought", "limit": limit}, timeout=30) + + if resp.status_code != 200: + console.print(f"[red]Failed: {resp.text}[/red]") + return + + records = resp.json().get("records", []) + if not records: + console.print("[yellow]No thoughts found[/yellow]") + return + + for r in records[:limit]: + v = r["value"] + text = v.get("thought", v.get("content", ""))[:60] + rkey = r["uri"].split("/")[-1] + console.print(f" [dim]{rkey[:12]}[/dim] {text}...") + + +@thought.command() +@click.argument("text") +@click.option("--context", "-c", default="", help="Context for the thought") +def create(text: str, context: str): + """Create a new thought.""" + import os + import httpx + from datetime import datetime, timezone + from dotenv import load_dotenv + + load_dotenv() + handle = os.getenv("ATPROTO_HANDLE") + password = os.getenv("ATPROTO_APP_PASSWORD") + pds = os.getenv("ATPROTO_PDS", "https://comind.network") + + if not handle or not password: + console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") + return + + # Auth + resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", + json={"identifier": handle, "password": password}, timeout=30) + if resp.status_code != 200: + console.print(f"[red]Auth failed: {resp.text}[/red]") + return + session = resp.json() + token = session["accessJwt"] + did = session["did"] + + # Create thought + record = { + "$type": "network.comind.thought", + "thought": text, + "createdAt": datetime.now(timezone.utc).isoformat(), + } + if context: + record["context"] = context + + resp = httpx.post(f"{pds}/xrpc/com.atproto.repo.createRecord", + headers={"Authorization": f"Bearer {token}"}, + json={"repo": did, "collection": "network.comind.thought", "record": record}, + timeout=30) + + if resp.status_code == 200: + data = resp.json() + console.print(f"[green]Created thought:[/green] {data['uri']}") + else: + console.print(f"[red]Failed: {resp.text}[/red]") + + +# Full-text search across all comind records +@cli.command("search-all") +@click.argument("query") +@click.option("--collection", "-c", default=None, help="Limit to specific collection") +@click.option("--limit", "-l", default=20, help="Max results") +def search_all(query: str, collection: str, limit: int): + """Search across all comind records.""" + import httpx + from tools.links import DID, PDS + + # Collections to search + collections = [ + "network.comind.concept", + "network.comind.thought", + "network.comind.observation", + "network.comind.hypothesis", + "network.comind.memory", + "network.comind.reasoning", + "network.comind.signal", + ] + + if collection: + collections = [collection] + + results = [] + for coll in collections: + resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.listRecords", + params={"repo": DID, "collection": coll, "limit": 50}, timeout=30) + + if resp.status_code != 200: + continue + + for r in resp.json().get("records", []): + v = r["value"] + # Search in all text fields + text_fields = ["concept", "thought", "content", "understanding", "description", "note", "text"] + full_text = " ".join(str(v.get(f, "")) for f in text_fields) + + if query.lower() in full_text.lower(): + results.append((coll, r["uri"].split("/")[-1], v, full_text[:100])) + + if not results: + console.print(f"[yellow]No results for '{query}'[/yellow]") + return + + console.print(f"\n[bold]Results for '{query}' ({len(results)}):[/bold]\n") + for coll, rkey, v, excerpt in results[:limit]: + coll_short = coll.split(".")[-1] + console.print(f" [dim]{coll_short}[/dim] [cyan]{rkey[:20]}[/cyan]") + console.print(f" {excerpt}...") + console.print() + + +cli.add_command(thought) + if __name__ == "__main__": cli() diff --git a/tools/links.py b/tools/links.py new file mode 100644 index 0000000..8ed05a5 --- /dev/null +++ b/tools/links.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python +""" +Comind Links - Create and manage relationships between ATProtocol records. + +Usage: + comind link create --relationship REFERENCES --note "..." --strength 0.8 + comind link list [--source ] [--target ] [--relationship ] + comind link show +""" + +import json +import os +from datetime import datetime, timezone +from typing import Optional +import click +import httpx +from rich.console import Console +from rich.table import Table + +console = Console() + +# Config +DID = os.getenv("ATPROTO_DID", "did:plc:l46arqe6yfgh36h3o554iyvr") +PDS = os.getenv("ATPROTO_PDS", "https://comind.network") +COLLECTION = "network.comind.link" + +RELATIONSHIP_TYPES = [ + "REFERENCES", + "SUPPORTS", + "CONTRADICTS", + "PART_OF", + "PRECEDES", + "CAUSES", + "INSTANCE_OF", + "ANSWERS", +] + + +def get_session(): + """Get authenticated session from env.""" + handle = os.getenv("ATPROTO_HANDLE") + password = os.getenv("ATPROTO_APP_PASSWORD") + + if not handle or not password: + console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") + raise SystemExit(1) + + resp = httpx.post( + f"{PDS}/xrpc/com.atproto.server.createSession", + json={"identifier": handle, "password": password}, + timeout=30, + ) + + if resp.status_code != 200: + console.print(f"[red]Auth failed: {resp.text}[/red]") + raise SystemExit(1) + + return resp.json() + + +def parse_uri(uri: str) -> dict: + """Parse AT URI into components.""" + # at://did:plc:xxx/collection/rkey + parts = uri.replace("at://", "").split("/") + return { + "did": parts[0], + "collection": parts[1] if len(parts) > 1 else None, + "rkey": parts[2] if len(parts) > 2 else None, + } + + +def get_record_cid(uri: str) -> Optional[str]: + """Fetch CID for a record.""" + parsed = parse_uri(uri) + if not parsed["collection"] or not parsed["rkey"]: + return None + + resp = httpx.get( + f"{PDS}/xrpc/com.atproto.repo.getRecord", + params={ + "repo": parsed["did"], + "collection": parsed["collection"], + "rkey": parsed["rkey"], + }, + timeout=30, + ) + + if resp.status_code == 200: + return resp.json().get("cid") + return None + + +@click.group() +def links(): + """Manage comind relationship links.""" + pass + + +@links.command() +@click.argument("source_uri") +@click.argument("target_uri") +@click.option("--relationship", "-r", type=click.Choice(RELATIONSHIP_TYPES), required=True) +@click.option("--note", "-n", default="", help="Note explaining the relationship") +@click.option("--strength", "-s", type=float, default=0.8, help="Relationship strength (0-1)") +def create(source_uri: str, target_uri: str, relationship: str, note: str, strength: float): + """Create a link between two records.""" + session = get_session() + token = session["accessJwt"] + did = session["did"] + + # Create link record - simple structure matching network.comind.link + record = { + "$type": COLLECTION, + "createdAt": datetime.now(timezone.utc).isoformat(), + "source": source_uri, + "target": target_uri, + "relationship": relationship, + "note": note, + } + + if strength != 0.8: + record["strength"] = strength + + resp = httpx.post( + f"{PDS}/xrpc/com.atproto.repo.createRecord", + headers={"Authorization": f"Bearer {token}"}, + json={"repo": did, "collection": COLLECTION, "record": record}, + timeout=30, + ) + + if resp.status_code == 200: + data = resp.json() + console.print(f"[green]Created link:[/green]") + console.print(f" URI: {data['uri']}") + console.print(f" {source_uri}") + console.print(f" └──[{relationship}]──> {target_uri}") + if note: + console.print(f" Note: {note}") + else: + console.print(f"[red]Failed: {resp.text}[/red]") + + +@links.command("list") +@click.option("--source", "source_uri", help="Filter by source URI") +@click.option("--target", "target_uri", help="Filter by target URI") +@click.option("--relationship", "-r", type=str, help="Filter by relationship type") +@click.option("--limit", "-l", default=20, help="Max results") +def list_links(source_uri: str, target_uri: str, relationship: str, limit: int): + """List links, optionally filtered.""" + resp = httpx.get( + f"{PDS}/xrpc/com.atproto.repo.listRecords", + params={"repo": DID, "collection": COLLECTION, "limit": limit}, + timeout=30, + ) + + if resp.status_code != 200: + console.print(f"[red]Failed: {resp.text}[/red]") + return + + records = resp.json().get("records", []) + + # Filter + if source_uri: + records = [r for r in records if r["value"].get("source") == source_uri] + if target_uri: + records = [r for r in records if r["value"].get("target") == target_uri] + if relationship: + records = [r for r in records if r["value"].get("relationship") == relationship.upper()] + + if not records: + console.print("[yellow]No links found[/yellow]") + return + + table = Table(title=f"Links ({len(records)})") + table.add_column("URI", style="dim") + table.add_column("Relationship", style="cyan") + table.add_column("Source", style="green") + table.add_column("Target", style="blue") + table.add_column("Note") + + for r in records: + v = r["value"] + src = v.get("source", "?") + tgt = v.get("target", "?") + # Truncate URIs for display + src_short = src.split("/")[-1] if src else "?" + tgt_short = tgt.split("/")[-1] if tgt else "?" + table.add_row( + r["uri"].split("/")[-1], + v.get("relationship", "?"), + src_short[:30], + tgt_short[:30], + v.get("note", "")[:30], + ) + + console.print(table) + + +@links.command() +@click.argument("link_uri") +def show(link_uri: str): + """Show details of a specific link.""" + # Parse URI to get rkey + rkey = link_uri.split("/")[-1] + + resp = httpx.get( + f"{PDS}/xrpc/com.atproto.repo.getRecord", + params={"repo": DID, "collection": COLLECTION, "rkey": rkey}, + timeout=30, + ) + + if resp.status_code != 200: + console.print(f"[red]Link not found: {link_uri}[/red]") + return + + data = resp.json() + v = data["value"] + + console.print(f"\n[bold]Link: {link_uri}[/bold]\n") + console.print(f"Relationship: [cyan]{v.get('relationship', '?')}[/cyan]") + console.print(f"Strength: {v.get('strength', 'n/a')}") + console.print(f"Created: {v.get('createdAt', '?')}") + console.print(f"\n[green]Source:[/green]") + console.print(f" {v.get('source', '?')}") + console.print(f"\n[blue]Target:[/blue]") + console.print(f" {v.get('target', '?')}") + if v.get("note"): + console.print(f"\nNote: {v['note']}") + + +if __name__ == "__main__": + links() -- 2.51.2