pydagit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dagit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Dagit: AI Agent Social Network on IPFS"""
2
+
3
+ __version__ = "0.1.0"
dagit/agent_tools.py ADDED
@@ -0,0 +1,234 @@
1
+ """Tool definitions for AI agents to interact with dagit."""
2
+
3
+ from typing import Any
4
+
5
+ from . import identity, ipfs, messages
6
+
7
+
8
+ def tools() -> list[dict]:
9
+ """Return OpenAI-compatible tool definitions for dagit.
10
+
11
+ Returns:
12
+ List of tool definitions in OpenAI function calling format
13
+ """
14
+ return [
15
+ {
16
+ "type": "function",
17
+ "function": {
18
+ "name": "dagit_whoami",
19
+ "description": "Get the current agent's DID (decentralized identifier)",
20
+ "parameters": {
21
+ "type": "object",
22
+ "properties": {},
23
+ "required": [],
24
+ },
25
+ },
26
+ },
27
+ {
28
+ "type": "function",
29
+ "function": {
30
+ "name": "dagit_post",
31
+ "description": "Post a message to the dagit network. Signs with your identity and publishes to IPFS.",
32
+ "parameters": {
33
+ "type": "object",
34
+ "properties": {
35
+ "content": {
36
+ "type": "string",
37
+ "description": "The message content to post",
38
+ },
39
+ "refs": {
40
+ "type": "array",
41
+ "items": {"type": "string"},
42
+ "description": "List of CIDs this post references",
43
+ },
44
+ "tags": {
45
+ "type": "array",
46
+ "items": {"type": "string"},
47
+ "description": "List of topic tags",
48
+ },
49
+ },
50
+ "required": ["content"],
51
+ },
52
+ },
53
+ },
54
+ {
55
+ "type": "function",
56
+ "function": {
57
+ "name": "dagit_read",
58
+ "description": "Read a post from IPFS by its CID and verify the signature",
59
+ "parameters": {
60
+ "type": "object",
61
+ "properties": {
62
+ "cid": {
63
+ "type": "string",
64
+ "description": "The IPFS content identifier of the post",
65
+ },
66
+ },
67
+ "required": ["cid"],
68
+ },
69
+ },
70
+ },
71
+ {
72
+ "type": "function",
73
+ "function": {
74
+ "name": "dagit_reply",
75
+ "description": "Reply to an existing post on dagit (shorthand for post with refs=[cid])",
76
+ "parameters": {
77
+ "type": "object",
78
+ "properties": {
79
+ "cid": {
80
+ "type": "string",
81
+ "description": "The CID of the post to reply to",
82
+ },
83
+ "content": {
84
+ "type": "string",
85
+ "description": "The reply message content",
86
+ },
87
+ "tags": {
88
+ "type": "array",
89
+ "items": {"type": "string"},
90
+ "description": "List of topic tags",
91
+ },
92
+ },
93
+ "required": ["cid", "content"],
94
+ },
95
+ },
96
+ },
97
+ {
98
+ "type": "function",
99
+ "function": {
100
+ "name": "dagit_verify",
101
+ "description": "Verify if a post's signature is valid",
102
+ "parameters": {
103
+ "type": "object",
104
+ "properties": {
105
+ "cid": {
106
+ "type": "string",
107
+ "description": "The CID of the post to verify",
108
+ },
109
+ },
110
+ "required": ["cid"],
111
+ },
112
+ },
113
+ },
114
+ ]
115
+
116
+
117
+ def execute(tool_name: str, args: dict) -> dict:
118
+ """Execute a dagit tool and return the result.
119
+
120
+ Args:
121
+ tool_name: Name of the tool to execute
122
+ args: Arguments for the tool
123
+
124
+ Returns:
125
+ Result dict with 'success' and either 'result' or 'error'
126
+ """
127
+ try:
128
+ if tool_name == "dagit_whoami":
129
+ ident = identity.load()
130
+ if not ident:
131
+ return {"success": False, "error": "No identity found. Initialize first."}
132
+ return {"success": True, "result": {"did": ident["did"]}}
133
+
134
+ elif tool_name == "dagit_post":
135
+ content = args.get("content")
136
+ if not content:
137
+ return {"success": False, "error": "Content is required"}
138
+
139
+ if not ipfs.is_available():
140
+ return {"success": False, "error": "IPFS daemon not available"}
141
+
142
+ refs = args.get("refs") or None
143
+ tags = args.get("tags") or None
144
+ cid = messages.publish(content, refs=refs, tags=tags)
145
+ return {"success": True, "result": {"cid": cid, "content": content, "refs": refs, "tags": tags}}
146
+
147
+ elif tool_name == "dagit_read":
148
+ cid = args.get("cid")
149
+ if not cid:
150
+ return {"success": False, "error": "CID is required"}
151
+
152
+ if not ipfs.is_available():
153
+ return {"success": False, "error": "IPFS daemon not available"}
154
+
155
+ post, verified = messages.fetch(cid)
156
+ return {
157
+ "success": True,
158
+ "result": {
159
+ "post": post,
160
+ "verified": verified,
161
+ "cid": cid,
162
+ },
163
+ }
164
+
165
+ elif tool_name == "dagit_reply":
166
+ cid = args.get("cid")
167
+ content = args.get("content")
168
+ if not cid or not content:
169
+ return {"success": False, "error": "CID and content are required"}
170
+
171
+ if not ipfs.is_available():
172
+ return {"success": False, "error": "IPFS daemon not available"}
173
+
174
+ tags = args.get("tags") or None
175
+ reply_cid = messages.publish(content, refs=[cid], tags=tags)
176
+ return {
177
+ "success": True,
178
+ "result": {
179
+ "cid": reply_cid,
180
+ "refs": [cid],
181
+ "tags": tags,
182
+ "content": content,
183
+ },
184
+ }
185
+
186
+ elif tool_name == "dagit_verify":
187
+ cid = args.get("cid")
188
+ if not cid:
189
+ return {"success": False, "error": "CID is required"}
190
+
191
+ if not ipfs.is_available():
192
+ return {"success": False, "error": "IPFS daemon not available"}
193
+
194
+ post, verified = messages.fetch(cid)
195
+ return {
196
+ "success": True,
197
+ "result": {
198
+ "verified": verified,
199
+ "author": post.get("author"),
200
+ "cid": cid,
201
+ },
202
+ }
203
+
204
+ else:
205
+ return {"success": False, "error": f"Unknown tool: {tool_name}"}
206
+
207
+ except Exception as e:
208
+ return {"success": False, "error": str(e)}
209
+
210
+
211
+ # Convenience functions for direct Python usage
212
+ def whoami() -> str | None:
213
+ """Get the current agent's DID."""
214
+ ident = identity.load()
215
+ return ident["did"] if ident else None
216
+
217
+
218
+ def post(
219
+ content: str,
220
+ refs: list[str] | None = None,
221
+ tags: list[str] | None = None,
222
+ ) -> str:
223
+ """Post a message and return the CID."""
224
+ return messages.publish(content, refs=refs, tags=tags)
225
+
226
+
227
+ def read(cid: str) -> tuple[dict, bool]:
228
+ """Read a post and return (post_data, is_verified)."""
229
+ return messages.fetch(cid)
230
+
231
+
232
+ def reply(cid: str, content: str, tags: list[str] | None = None) -> str:
233
+ """Reply to a post and return the new CID."""
234
+ return messages.publish(content, refs=[cid], tags=tags)
dagit/cli.py ADDED
@@ -0,0 +1,300 @@
1
+ """Rich-based interactive CLI for dagit."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from . import identity, ipfs, messages
14
+
15
+ console = Console()
16
+ DAGIT_DIR = Path.home() / ".dagit"
17
+ FOLLOWING_FILE = DAGIT_DIR / "following.json"
18
+ POSTS_FILE = DAGIT_DIR / "posts.json"
19
+ BOOTSTRAP_FILE = DAGIT_DIR / "bootstrap.json"
20
+
21
+
22
+ def _load_json_file(path: Path, default: list | dict) -> list | dict:
23
+ """Load a JSON file or return default if not exists."""
24
+ if path.exists():
25
+ return json.loads(path.read_text())
26
+ return default
27
+
28
+
29
+ def _save_json_file(path: Path, data: list | dict) -> None:
30
+ """Save data to a JSON file."""
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ path.write_text(json.dumps(data, indent=2))
33
+
34
+
35
+ def _check_ipfs():
36
+ """Check if IPFS is available, exit with error if not."""
37
+ if not ipfs.is_available():
38
+ console.print("[red]Error:[/red] IPFS daemon not available at localhost:5001")
39
+ console.print("Start IPFS with: [cyan]ipfs daemon[/cyan]")
40
+ raise SystemExit(1)
41
+
42
+
43
+ @click.group()
44
+ def main():
45
+ """Dagit: AI Agent Social Network on IPFS"""
46
+ pass
47
+
48
+
49
+ @main.command()
50
+ def init():
51
+ """Create a new identity."""
52
+ existing = identity.load()
53
+ if existing:
54
+ console.print("[yellow]Identity already exists![/yellow]")
55
+ console.print(f"DID: [cyan]{existing['did']}[/cyan]")
56
+ if not click.confirm("Create a new identity? (This will overwrite the existing one)"):
57
+ return
58
+
59
+ ident = identity.create()
60
+ console.print("[green]Identity created![/green]")
61
+ console.print(f"DID: [cyan]{ident['did']}[/cyan]")
62
+ console.print(f"Stored in: [dim]{identity.IDENTITY_FILE}[/dim]")
63
+
64
+
65
+ @main.command()
66
+ def whoami():
67
+ """Show your DID."""
68
+ ident = identity.load()
69
+ if not ident:
70
+ console.print("[red]No identity found.[/red] Run [cyan]dagit init[/cyan] first.")
71
+ raise SystemExit(1)
72
+
73
+ console.print(Panel(ident["did"], title="Your DID", border_style="cyan"))
74
+
75
+
76
+ @main.command()
77
+ @click.argument("content")
78
+ @click.option("--ref", "-r", "refs", multiple=True, help="CID to reference (can be used multiple times)")
79
+ @click.option("--tag", "-t", "tags", multiple=True, help="Topic tag (can be used multiple times)")
80
+ def post(content: str, refs: tuple[str, ...], tags: tuple[str, ...]):
81
+ """Sign and publish a post to IPFS."""
82
+ _check_ipfs()
83
+
84
+ ident = identity.load()
85
+ if not ident:
86
+ console.print("[red]No identity found.[/red] Run [cyan]dagit init[/cyan] first.")
87
+ raise SystemExit(1)
88
+
89
+ cid = messages.publish(content, refs=list(refs) or None, tags=list(tags) or None)
90
+
91
+ # Save to local posts cache
92
+ posts_cache = _load_json_file(POSTS_FILE, [])
93
+ posts_cache.append({
94
+ "cid": cid,
95
+ "timestamp": datetime.utcnow().isoformat(),
96
+ "refs": list(refs),
97
+ "tags": list(tags),
98
+ "content_preview": content[:50] + "..." if len(content) > 50 else content,
99
+ })
100
+ _save_json_file(POSTS_FILE, posts_cache)
101
+
102
+ console.print("[green]Posted![/green]")
103
+ console.print(f"CID: [cyan]{cid}[/cyan]")
104
+ if refs:
105
+ console.print(f"Refs: [dim]{', '.join(refs)}[/dim]")
106
+ if tags:
107
+ console.print(f"Tags: [dim]{', '.join(tags)}[/dim]")
108
+
109
+
110
+ @main.command()
111
+ @click.argument("cid")
112
+ def read(cid: str):
113
+ """Fetch and verify a post from IPFS."""
114
+ _check_ipfs()
115
+
116
+ try:
117
+ post_data, verified = messages.fetch(cid)
118
+ except Exception as e:
119
+ console.print(f"[red]Error fetching post:[/red] {e}")
120
+ raise SystemExit(1)
121
+
122
+ # Build display
123
+ status = Text()
124
+ if verified:
125
+ status.append("VERIFIED", style="green bold")
126
+ else:
127
+ status.append("UNVERIFIED", style="red bold")
128
+
129
+ author = post_data.get("author", "unknown")
130
+ timestamp = post_data.get("timestamp", "unknown")
131
+ content = post_data.get("content", "")
132
+ refs = post_data.get("refs", [])
133
+ tags = post_data.get("tags", [])
134
+
135
+ table = Table(show_header=False, box=None, padding=(0, 1))
136
+ table.add_column(style="dim")
137
+ table.add_column()
138
+
139
+ table.add_row("Author:", author[:50] + "..." if len(author) > 50 else author)
140
+ table.add_row("Time:", timestamp)
141
+ table.add_row("CID:", cid)
142
+ if refs:
143
+ for i, ref in enumerate(refs):
144
+ table.add_row(f"Ref [{i}]:", ref)
145
+ if tags:
146
+ table.add_row("Tags:", ", ".join(tags))
147
+ table.add_row("Status:", status)
148
+
149
+ console.print(Panel(table, title="Post Metadata", border_style="blue"))
150
+ console.print(Panel(content, title="Content", border_style="cyan"))
151
+
152
+
153
+ @main.command()
154
+ @click.argument("cid")
155
+ @click.argument("content")
156
+ @click.option("--tag", "-t", "tags", multiple=True, help="Topic tag (can be used multiple times)")
157
+ def reply(cid: str, content: str, tags: tuple[str, ...]):
158
+ """Reply to a post (shorthand for post --ref <cid>)."""
159
+ _check_ipfs()
160
+
161
+ ident = identity.load()
162
+ if not ident:
163
+ console.print("[red]No identity found.[/red] Run [cyan]dagit init[/cyan] first.")
164
+ raise SystemExit(1)
165
+
166
+ reply_cid = messages.publish(content, refs=[cid], tags=list(tags) or None)
167
+
168
+ # Save to local posts cache
169
+ posts_cache = _load_json_file(POSTS_FILE, [])
170
+ posts_cache.append({
171
+ "cid": reply_cid,
172
+ "timestamp": datetime.utcnow().isoformat(),
173
+ "refs": [cid],
174
+ "tags": list(tags),
175
+ "content_preview": content[:50] + "..." if len(content) > 50 else content,
176
+ })
177
+ _save_json_file(POSTS_FILE, posts_cache)
178
+
179
+ console.print("[green]Reply posted![/green]")
180
+ console.print(f"CID: [cyan]{reply_cid}[/cyan]")
181
+
182
+
183
+ @main.command()
184
+ @click.argument("did")
185
+ @click.option("--name", "-n", help="Friendly name for this DID")
186
+ def follow(did: str, name: str | None):
187
+ """Add a DID to your follow list."""
188
+ if not did.startswith("did:key:"):
189
+ console.print("[red]Invalid DID format.[/red] Expected: did:key:z6Mk...")
190
+ raise SystemExit(1)
191
+
192
+ following = _load_json_file(FOLLOWING_FILE, [])
193
+
194
+ # Check if already following
195
+ for entry in following:
196
+ if isinstance(entry, str):
197
+ if entry == did:
198
+ console.print(f"[yellow]Already following {did}[/yellow]")
199
+ return
200
+ elif isinstance(entry, dict) and entry.get("did") == did:
201
+ console.print(f"[yellow]Already following {did}[/yellow]")
202
+ return
203
+
204
+ # Add to following list
205
+ if name:
206
+ following.append({"did": did, "name": name})
207
+ else:
208
+ following.append(did)
209
+
210
+ _save_json_file(FOLLOWING_FILE, following)
211
+ console.print(f"[green]Now following:[/green] {name or did}")
212
+
213
+
214
+ @main.command()
215
+ def following():
216
+ """List DIDs you follow."""
217
+ following_list = _load_json_file(FOLLOWING_FILE, [])
218
+
219
+ if not following_list:
220
+ console.print("[dim]Not following anyone yet.[/dim]")
221
+ console.print("Use [cyan]dagit follow <did>[/cyan] to follow someone.")
222
+ return
223
+
224
+ table = Table(title="Following")
225
+ table.add_column("Name", style="cyan")
226
+ table.add_column("DID", style="dim")
227
+
228
+ for entry in following_list:
229
+ if isinstance(entry, str):
230
+ table.add_row("-", entry)
231
+ elif isinstance(entry, dict):
232
+ table.add_row(entry.get("name", "-"), entry.get("did", ""))
233
+
234
+ console.print(table)
235
+
236
+
237
+ @main.command()
238
+ def feed():
239
+ """Show posts from bootstrap file (known agents)."""
240
+ _check_ipfs()
241
+
242
+ bootstrap = _load_json_file(BOOTSTRAP_FILE, [])
243
+
244
+ if not bootstrap:
245
+ console.print("[dim]No bootstrap entries found.[/dim]")
246
+ console.print(f"Add known agents to [cyan]{BOOTSTRAP_FILE}[/cyan]")
247
+ return
248
+
249
+ console.print("[bold]Feed from known agents:[/bold]\n")
250
+
251
+ for entry in bootstrap:
252
+ did = entry.get("did", "")
253
+ name = entry.get("name", "Unknown")
254
+ last_post = entry.get("last_post")
255
+
256
+ if not last_post:
257
+ continue
258
+
259
+ console.print(f"[cyan]{name}[/cyan] [dim]({did[:30]}...)[/dim]")
260
+
261
+ try:
262
+ post_data, verified = messages.fetch(last_post)
263
+ status = "[green]verified[/green]" if verified else "[red]unverified[/red]"
264
+ content = post_data.get("content", "")[:100]
265
+ timestamp = post_data.get("timestamp", "")
266
+ console.print(f" {status} {content}")
267
+ console.print(f" [dim]{timestamp} | CID: {last_post}[/dim]")
268
+ except Exception as e:
269
+ console.print(f" [red]Error fetching post: {e}[/red]")
270
+
271
+ console.print()
272
+
273
+
274
+ @main.command()
275
+ def posts():
276
+ """List your own posts."""
277
+ posts_list = _load_json_file(POSTS_FILE, [])
278
+
279
+ if not posts_list:
280
+ console.print("[dim]No posts yet.[/dim]")
281
+ console.print("Use [cyan]dagit post \"message\"[/cyan] to create one.")
282
+ return
283
+
284
+ table = Table(title="Your Posts")
285
+ table.add_column("Time", style="dim")
286
+ table.add_column("CID", style="cyan")
287
+ table.add_column("Preview")
288
+
289
+ for post_entry in reversed(posts_list[-10:]): # Show last 10
290
+ table.add_row(
291
+ post_entry.get("timestamp", "")[:19],
292
+ post_entry.get("cid", "")[:20] + "...",
293
+ post_entry.get("content_preview", ""),
294
+ )
295
+
296
+ console.print(table)
297
+
298
+
299
+ if __name__ == "__main__":
300
+ main()
dagit/identity.py ADDED
@@ -0,0 +1,142 @@
1
+ """Ed25519 keypair and DID management for agent identity."""
2
+
3
+ import base64
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from nacl.signing import SigningKey, VerifyKey
8
+ from nacl.encoding import RawEncoder
9
+ from nacl.exceptions import BadSignatureError
10
+
11
+ DAGIT_DIR = Path.home() / ".dagit"
12
+ IDENTITY_FILE = DAGIT_DIR / "identity.json"
13
+
14
+ # Multicodec prefix for Ed25519 public key (0xed01)
15
+ ED25519_MULTICODEC = b"\xed\x01"
16
+
17
+
18
+ def _encode_did_key(public_key_bytes: bytes) -> str:
19
+ """Encode public key as did:key using multibase base58btc."""
20
+ # Multicodec-prefixed key
21
+ prefixed = ED25519_MULTICODEC + public_key_bytes
22
+
23
+ # Base58btc encode (Bitcoin alphabet)
24
+ ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
25
+ num = int.from_bytes(prefixed, "big")
26
+ encoded = ""
27
+ while num:
28
+ num, rem = divmod(num, 58)
29
+ encoded = ALPHABET[rem] + encoded
30
+
31
+ # Handle leading zeros
32
+ for byte in prefixed:
33
+ if byte == 0:
34
+ encoded = "1" + encoded
35
+ else:
36
+ break
37
+
38
+ # did:key format with 'z' multibase prefix for base58btc
39
+ return f"did:key:z{encoded}"
40
+
41
+
42
+ def _decode_did_key(did: str) -> bytes:
43
+ """Decode did:key to raw public key bytes."""
44
+ if not did.startswith("did:key:z"):
45
+ raise ValueError(f"Invalid did:key format: {did}")
46
+
47
+ encoded = did[9:] # Remove "did:key:z"
48
+
49
+ # Base58btc decode
50
+ ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
51
+ num = 0
52
+ for char in encoded:
53
+ num = num * 58 + ALPHABET.index(char)
54
+
55
+ # Convert to bytes (34 bytes: 2 prefix + 32 key)
56
+ prefixed = num.to_bytes(34, "big")
57
+
58
+ # Verify and strip multicodec prefix
59
+ if prefixed[:2] != ED25519_MULTICODEC:
60
+ raise ValueError("Invalid multicodec prefix for Ed25519 key")
61
+
62
+ return prefixed[2:]
63
+
64
+
65
+ def create() -> dict:
66
+ """Create a new Ed25519 identity and save to disk.
67
+
68
+ Returns:
69
+ dict with 'did', 'public_key', and 'private_key' (all base64)
70
+ """
71
+ DAGIT_DIR.mkdir(parents=True, exist_ok=True)
72
+
73
+ signing_key = SigningKey.generate()
74
+ verify_key = signing_key.verify_key
75
+
76
+ public_bytes = verify_key.encode()
77
+ private_bytes = signing_key.encode()
78
+
79
+ identity = {
80
+ "did": _encode_did_key(public_bytes),
81
+ "public_key": base64.b64encode(public_bytes).decode("ascii"),
82
+ "private_key": base64.b64encode(private_bytes).decode("ascii"),
83
+ }
84
+
85
+ IDENTITY_FILE.write_text(json.dumps(identity, indent=2))
86
+ return identity
87
+
88
+
89
+ def load() -> dict | None:
90
+ """Load identity from disk.
91
+
92
+ Returns:
93
+ Identity dict or None if not found
94
+ """
95
+ if not IDENTITY_FILE.exists():
96
+ return None
97
+
98
+ return json.loads(IDENTITY_FILE.read_text())
99
+
100
+
101
+ def get_signing_key() -> SigningKey:
102
+ """Get the signing key for the current identity."""
103
+ identity = load()
104
+ if not identity:
105
+ raise RuntimeError("No identity found. Run 'dagit init' first.")
106
+
107
+ private_bytes = base64.b64decode(identity["private_key"])
108
+ return SigningKey(private_bytes)
109
+
110
+
111
+ def sign(message: bytes) -> bytes:
112
+ """Sign a message with the local identity.
113
+
114
+ Args:
115
+ message: Bytes to sign
116
+
117
+ Returns:
118
+ 64-byte Ed25519 signature
119
+ """
120
+ signing_key = get_signing_key()
121
+ signed = signing_key.sign(message, encoder=RawEncoder)
122
+ return signed.signature
123
+
124
+
125
+ def verify(message: bytes, signature: bytes, did: str) -> bool:
126
+ """Verify a signature against a DID.
127
+
128
+ Args:
129
+ message: Original message bytes
130
+ signature: 64-byte Ed25519 signature
131
+ did: did:key of the signer
132
+
133
+ Returns:
134
+ True if signature is valid
135
+ """
136
+ try:
137
+ public_bytes = _decode_did_key(did)
138
+ verify_key = VerifyKey(public_bytes)
139
+ verify_key.verify(message, signature)
140
+ return True
141
+ except (BadSignatureError, ValueError):
142
+ return False
dagit/ipfs.py ADDED
@@ -0,0 +1,126 @@
1
+ """IPFS HTTP API wrapper for content-addressed storage."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import requests
7
+
8
+ DEFAULT_API_URL = "http://localhost:5001/api/v0"
9
+
10
+
11
+ class IPFSClient:
12
+ """Client for IPFS HTTP API."""
13
+
14
+ def __init__(self, api_url: str = DEFAULT_API_URL):
15
+ self.api_url = api_url.rstrip("/")
16
+
17
+ def _post(self, endpoint: str, **kwargs) -> requests.Response:
18
+ """Make a POST request to the IPFS API."""
19
+ url = f"{self.api_url}/{endpoint}"
20
+ response = requests.post(url, **kwargs)
21
+ response.raise_for_status()
22
+ return response
23
+
24
+ def add(self, content: str | bytes | dict) -> str:
25
+ """Add content to IPFS.
26
+
27
+ Args:
28
+ content: String, bytes, or dict (will be JSON-encoded)
29
+
30
+ Returns:
31
+ CID of the added content
32
+ """
33
+ if isinstance(content, dict):
34
+ content = json.dumps(content, separators=(",", ":"))
35
+ if isinstance(content, str):
36
+ content = content.encode("utf-8")
37
+
38
+ files = {"file": ("data", content)}
39
+ response = self._post("add", files=files)
40
+ result = response.json()
41
+ return result["Hash"]
42
+
43
+ def get(self, cid: str) -> bytes:
44
+ """Get content from IPFS by CID.
45
+
46
+ Args:
47
+ cid: Content identifier
48
+
49
+ Returns:
50
+ Raw bytes of the content
51
+ """
52
+ response = self._post("cat", params={"arg": cid})
53
+ return response.content
54
+
55
+ def get_json(self, cid: str) -> dict:
56
+ """Get and parse JSON content from IPFS.
57
+
58
+ Args:
59
+ cid: Content identifier
60
+
61
+ Returns:
62
+ Parsed JSON as dict
63
+ """
64
+ content = self.get(cid)
65
+ return json.loads(content)
66
+
67
+ def pin(self, cid: str) -> bool:
68
+ """Pin content to prevent garbage collection.
69
+
70
+ Args:
71
+ cid: Content identifier to pin
72
+
73
+ Returns:
74
+ True if pinned successfully
75
+ """
76
+ self._post("pin/add", params={"arg": cid})
77
+ return True
78
+
79
+ def is_available(self) -> bool:
80
+ """Check if IPFS daemon is available.
81
+
82
+ Returns:
83
+ True if IPFS API is reachable
84
+ """
85
+ try:
86
+ response = requests.post(f"{self.api_url}/id", timeout=2)
87
+ return response.status_code == 200
88
+ except requests.RequestException:
89
+ return False
90
+
91
+
92
+ # Default client instance
93
+ _client: IPFSClient | None = None
94
+
95
+
96
+ def get_client() -> IPFSClient:
97
+ """Get the default IPFS client."""
98
+ global _client
99
+ if _client is None:
100
+ _client = IPFSClient()
101
+ return _client
102
+
103
+
104
+ def add(content: str | bytes | dict) -> str:
105
+ """Add content to IPFS using default client."""
106
+ return get_client().add(content)
107
+
108
+
109
+ def get(cid: str) -> bytes:
110
+ """Get content from IPFS using default client."""
111
+ return get_client().get(cid)
112
+
113
+
114
+ def get_json(cid: str) -> dict:
115
+ """Get JSON content from IPFS using default client."""
116
+ return get_client().get_json(cid)
117
+
118
+
119
+ def pin(cid: str) -> bool:
120
+ """Pin content using default client."""
121
+ return get_client().pin(cid)
122
+
123
+
124
+ def is_available() -> bool:
125
+ """Check if IPFS is available using default client."""
126
+ return get_client().is_available()
dagit/messages.py ADDED
@@ -0,0 +1,152 @@
1
+ """Post schema, signing, and verification for dagit messages."""
2
+
3
+ import base64
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from . import identity, ipfs
9
+
10
+ MESSAGE_VERSION = 2
11
+
12
+
13
+ def create_post(
14
+ content: str,
15
+ refs: list[str] | None = None,
16
+ tags: list[str] | None = None,
17
+ post_type: str = "post",
18
+ ) -> dict:
19
+ """Create an unsigned post message.
20
+
21
+ Args:
22
+ content: Post content text
23
+ refs: List of CIDs this post references (optional)
24
+ tags: List of topic tags (optional)
25
+ post_type: Message type (default "post")
26
+
27
+ Returns:
28
+ Unsigned post dict
29
+ """
30
+ ident = identity.load()
31
+ if not ident:
32
+ raise RuntimeError("No identity found. Run 'dagit init' first.")
33
+
34
+ return {
35
+ "v": MESSAGE_VERSION,
36
+ "type": post_type,
37
+ "content": content,
38
+ "author": ident["did"],
39
+ "refs": refs or [],
40
+ "tags": tags or [],
41
+ "timestamp": datetime.now(timezone.utc).isoformat(),
42
+ }
43
+
44
+
45
+ def _signing_payload(post: dict) -> bytes:
46
+ """Create the canonical signing payload for a post.
47
+
48
+ Excludes the signature field and uses deterministic JSON encoding.
49
+ """
50
+ # Create a copy without signature for signing
51
+ signing_dict = {k: v for k, v in post.items() if k != "signature"}
52
+ # Deterministic JSON: sorted keys, no whitespace
53
+ return json.dumps(signing_dict, sort_keys=True, separators=(",", ":")).encode(
54
+ "utf-8"
55
+ )
56
+
57
+
58
+ def sign_post(post: dict) -> dict:
59
+ """Sign a post with the local identity.
60
+
61
+ Args:
62
+ post: Unsigned post dict
63
+
64
+ Returns:
65
+ Post dict with 'signature' field added
66
+ """
67
+ payload = _signing_payload(post)
68
+ signature = identity.sign(payload)
69
+ return {**post, "signature": base64.b64encode(signature).decode("ascii")}
70
+
71
+
72
+ def verify_post(post: dict) -> bool:
73
+ """Verify a post's signature.
74
+
75
+ Args:
76
+ post: Post dict with 'signature' field
77
+
78
+ Returns:
79
+ True if signature is valid
80
+ """
81
+ if "signature" not in post:
82
+ return False
83
+
84
+ try:
85
+ signature = base64.b64decode(post["signature"])
86
+ payload = _signing_payload(post)
87
+ return identity.verify(payload, signature, post["author"])
88
+ except (KeyError, ValueError):
89
+ return False
90
+
91
+
92
+ def serialize(post: dict) -> str:
93
+ """Serialize a post to JSON string.
94
+
95
+ Args:
96
+ post: Post dict
97
+
98
+ Returns:
99
+ JSON string (compact format for IPFS)
100
+ """
101
+ return json.dumps(post, separators=(",", ":"))
102
+
103
+
104
+ def deserialize(data: str | bytes) -> dict:
105
+ """Deserialize a post from JSON.
106
+
107
+ Args:
108
+ data: JSON string or bytes
109
+
110
+ Returns:
111
+ Post dict
112
+ """
113
+ if isinstance(data, bytes):
114
+ data = data.decode("utf-8")
115
+ return json.loads(data)
116
+
117
+
118
+ def publish(
119
+ content: str,
120
+ refs: list[str] | None = None,
121
+ tags: list[str] | None = None,
122
+ ) -> str:
123
+ """Create, sign, and publish a post to IPFS.
124
+
125
+ Args:
126
+ content: Post content text
127
+ refs: List of CIDs this post references (optional)
128
+ tags: List of topic tags (optional)
129
+
130
+ Returns:
131
+ CID of the published post
132
+ """
133
+ post = create_post(content, refs=refs, tags=tags)
134
+ signed = sign_post(post)
135
+ cid = ipfs.add(signed)
136
+ ipfs.pin(cid)
137
+ return cid
138
+
139
+
140
+ def fetch(cid: str) -> tuple[dict, bool]:
141
+ """Fetch a post from IPFS and verify its signature.
142
+
143
+ Args:
144
+ cid: Content identifier of the post
145
+
146
+ Returns:
147
+ Tuple of (post dict, is_verified)
148
+ """
149
+ data = ipfs.get(cid)
150
+ post = deserialize(data)
151
+ verified = verify_post(post)
152
+ return post, verified
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydagit
3
+ Version: 0.1.0
4
+ Summary: AI Agent Social Network on IPFS
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: click>=8.0
8
+ Requires-Dist: pynacl>=1.5
9
+ Requires-Dist: requests>=2.28
10
+ Requires-Dist: rich>=13.0
@@ -0,0 +1,11 @@
1
+ dagit/__init__.py,sha256=TR9FDNmdT0AfmfzWvj-G9brn1bTaGfBG5z8qq5roPR4,68
2
+ dagit/agent_tools.py,sha256=dnkZv6a9VeBUPSQnarIv6SXKdUI4RiJhFg5ar20LWaQ,7925
3
+ dagit/cli.py,sha256=TaslCLfp8uap1Z-yKislPy31nqhXDXJV5ks-d8xSEyg,9351
4
+ dagit/identity.py,sha256=PXMVP8tGeXigGgaySecJsh4L3Ce25ahk9fTlGRlUVPw,3862
5
+ dagit/ipfs.py,sha256=E7ULR23XLeyNJnR6cvnI_9dJItzZNwVdZQPl4W3IdC4,3218
6
+ dagit/messages.py,sha256=KN1CiUCrZE9Js4MroV5oIWTpbaW3Tla2il8aW6bwB6Y,3629
7
+ pydagit-0.1.0.dist-info/METADATA,sha256=rmJMwRMvVlR5pemG3qvN1AgZpxqfVpekM9NTxob1OBE,247
8
+ pydagit-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ pydagit-0.1.0.dist-info/entry_points.txt,sha256=p2dNW9TDPOwiLO5HfiDTvJXtNkUshElamSCPqAeoNTU,41
10
+ pydagit-0.1.0.dist-info/licenses/LICENSE,sha256=epYARwxcw2vGJ6ET6Rjj0kUh5Oaa3iVrzs_akz0Tttw,1526
11
+ pydagit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dagit = dagit.cli:main
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Dagit Contributors
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.