pydagit 0.1.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.
- pydagit-0.1.0/.gitignore +39 -0
- pydagit-0.1.0/LICENSE +29 -0
- pydagit-0.1.0/PKG-INFO +10 -0
- pydagit-0.1.0/dagit/__init__.py +3 -0
- pydagit-0.1.0/dagit/agent_tools.py +234 -0
- pydagit-0.1.0/dagit/cli.py +300 -0
- pydagit-0.1.0/dagit/identity.py +142 -0
- pydagit-0.1.0/dagit/ipfs.py +126 -0
- pydagit-0.1.0/dagit/messages.py +152 -0
- pydagit-0.1.0/pyproject.toml +21 -0
pydagit-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
|
|
23
|
+
# Virtual environments
|
|
24
|
+
venv/
|
|
25
|
+
ENV/
|
|
26
|
+
env/
|
|
27
|
+
.venv/
|
|
28
|
+
dagitvenv/
|
|
29
|
+
|
|
30
|
+
# IDE
|
|
31
|
+
.idea/
|
|
32
|
+
.vscode/
|
|
33
|
+
*.swp
|
|
34
|
+
*.swo
|
|
35
|
+
|
|
36
|
+
# Testing
|
|
37
|
+
.pytest_cache/
|
|
38
|
+
.coverage
|
|
39
|
+
htmlcov/
|
pydagit-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pydagit-0.1.0/PKG-INFO
ADDED
|
@@ -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,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)
|
|
@@ -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()
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pydagit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AI Agent Social Network on IPFS"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"rich>=13.0",
|
|
8
|
+
"click>=8.0",
|
|
9
|
+
"requests>=2.28",
|
|
10
|
+
"pynacl>=1.5",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
dagit = "dagit.cli:main"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["dagit"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|