First commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/c_utils_lib/
|
||||
/nostr_core_lib/
|
||||
/openclaw/
|
||||
/nips/
|
||||
38
Makefile
Normal file
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
||||
CC = gcc
|
||||
CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_POSIX_C_SOURCE=200809L
|
||||
|
||||
SRC_DIR = src
|
||||
TARGET = didactyl
|
||||
|
||||
SRCS = \
|
||||
$(SRC_DIR)/main.c \
|
||||
$(SRC_DIR)/config.c \
|
||||
$(SRC_DIR)/context.c \
|
||||
$(SRC_DIR)/llm.c \
|
||||
$(SRC_DIR)/nostr_handler.c \
|
||||
$(SRC_DIR)/agent.c \
|
||||
$(SRC_DIR)/secp_compat.c
|
||||
|
||||
INCLUDES = \
|
||||
-I$(SRC_DIR) \
|
||||
-I../nostr_core_lib \
|
||||
-I../nostr_core_lib/cjson \
|
||||
-I../nostr_core_lib/nostr_core
|
||||
|
||||
NOSTR_LIB = $(firstword $(wildcard ../nostr_core_lib/libnostr_core_*.a))
|
||||
|
||||
LDFLAGS = -lcurl -lssl -lcrypto -lm -lpthread -ldl -lz -lsecp256k1
|
||||
|
||||
all: deps $(TARGET)
|
||||
|
||||
$(TARGET): $(SRCS)
|
||||
@if [ -z "$(NOSTR_LIB)" ]; then echo "nostr_core_lib static library not found; run 'make deps'"; exit 1; fi
|
||||
$(CC) $(CFLAGS) $(INCLUDES) -o $@ $(SRCS) $(NOSTR_LIB) $(LDFLAGS)
|
||||
|
||||
deps:
|
||||
cd ../nostr_core_lib && ./build.sh --nips=all
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
|
||||
.PHONY: all deps clean
|
||||
166
README.md
Normal file
166
README.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Didactyl
|
||||
|
||||
A sovereign AI agent daemon written in C99 that uses Nostr as its primary operating layer.
|
||||
|
||||
Didactyl boots on any internet-connected machine, connects to Nostr relays, listens for encrypted commands from its administrator, reasons with an LLM, and takes actions — posting events, querying relays, running shell commands — all orchestrated through Nostr.
|
||||
|
||||
## Philosophy
|
||||
|
||||
**Nostr-first.** Where traditional agents ride on top of Linux — reading files, writing to disk — Didactyl rides on top of Nostr. Events are its files. Relays are its network bus. Blossom is its blob storage. The Linux host is just the runtime substrate.
|
||||
|
||||
Because all identity, communication, and memory live on Nostr, the agent is **portable** (start it anywhere) and **sovereign** (no single entity can erase its memory).
|
||||
|
||||
## Current Status
|
||||
|
||||
**MVP — Working chat agent with relay connectivity and LLM integration.**
|
||||
|
||||
- Connects to configured Nostr relays with auto-reconnect
|
||||
- Publishes agent profile (kind 0 metadata)
|
||||
- Listens for NIP-04 encrypted DMs from authorized admin
|
||||
- Forwards messages to an OpenAI-compatible LLM API
|
||||
- Sends LLM responses back as encrypted DMs
|
||||
- Runtime logging: relay status, connection health, message flow
|
||||
|
||||
**Next: Agentic tool-use system** — see [plans/didactyl_agentic.md](plans/didactyl_agentic.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- GCC with C99 support
|
||||
- libcurl, libssl, libcrypto, libsecp256k1
|
||||
- An OpenAI-compatible LLM API key (OpenAI, PPQ, Ollama, etc.)
|
||||
- A Nostr keypair (nsec)
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
make deps # builds nostr_core_lib
|
||||
make # builds didactyl
|
||||
```
|
||||
|
||||
### Configure
|
||||
|
||||
Edit `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"name": "Didactyl Agent",
|
||||
"display_name": "Didactyl",
|
||||
"about": "A sovereign AI agent on Nostr"
|
||||
},
|
||||
"keys": {
|
||||
"nsec": "nsec1..."
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "npub1... or hex pubkey"
|
||||
},
|
||||
"relays": [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol"
|
||||
],
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"api_key": "sk-...",
|
||||
"model": "gpt-4o-mini",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Edit `SYSTEM.md` to define the agent's personality and instructions.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
./didactyl
|
||||
```
|
||||
|
||||
Options:
|
||||
```
|
||||
./didactyl --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl --context <path> # custom context file (default: ./SYSTEM.md)
|
||||
```
|
||||
|
||||
### Talk to it
|
||||
|
||||
Send an encrypted DM to the agent's pubkey from the admin account using any Nostr client (Damus, Amethyst, Primal, etc.).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Didactyl │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
|
||||
│ │ config │ │ context │ │ agent │ │
|
||||
│ │ loader │ │ loader │ │ loop │ │
|
||||
│ └────┬────┘ └────┬────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ nostr_handler │ │
|
||||
│ │ relay pool · subscribe · publish │ │
|
||||
│ └──────────────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────┴──────────────────┐ │
|
||||
│ │ LLM client │ │
|
||||
│ │ OpenAI-compatible chat API │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
Nostr Relays LLM API
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── config.json # Agent configuration
|
||||
├── SYSTEM.md # Agent personality/instructions for LLM
|
||||
├── Makefile # Build system
|
||||
├── src/
|
||||
│ ├── main.c # Entry point, signal handling, daemon loop
|
||||
│ ├── config.c / .h # JSON config parsing, key decoding
|
||||
│ ├── context.c / .h # SYSTEM.md file loader
|
||||
│ ├── agent.c / .h # Core agent logic: receive → LLM → respond
|
||||
│ ├── llm.c / .h # LLM HTTP API client (OpenAI-compatible)
|
||||
│ ├── nostr_handler.c / .h # Relay pool, subscriptions, publish, DMs
|
||||
│ └── secp_compat.c # secp256k1 API compatibility shim
|
||||
├── plans/ # Architecture and planning documents
|
||||
│ ├── didactyl_mvp.md
|
||||
│ └── didactyl_agentic.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose | Source |
|
||||
|---|---|---|
|
||||
| nostr_core_lib | Nostr protocol: keys, events, NIPs, relay pool | Workspace (sibling directory) |
|
||||
| cJSON | JSON parsing | Bundled in nostr_core_lib |
|
||||
| libcurl | HTTPS for LLM API calls | System package |
|
||||
| libssl / libcrypto | TLS for WebSocket relay connections | System package |
|
||||
| libsecp256k1 | Schnorr signatures, ECDH | System package |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] MVP chat agent — DM in, LLM response out
|
||||
- [x] Relay pool with auto-reconnect and status logging
|
||||
- [x] Runtime diagnostics — relay health, message flow, LLM calls
|
||||
- [ ] **Agentic tool-use** — LLM can call tools (nostr_post, nostr_query, shell_exec)
|
||||
- [ ] Upgrade to NIP-17 gift-wrapped DMs
|
||||
- [ ] Nostr-native data storage (kind 30078 app-specific events)
|
||||
- [ ] Blossom blob storage integration
|
||||
- [ ] Conversation memory on Nostr
|
||||
- [ ] Config and SYSTEM.md stored as Nostr events
|
||||
- [ ] Multi-turn conversation context window
|
||||
- [ ] Agent-to-agent communication
|
||||
|
||||
## License
|
||||
|
||||
TBD
|
||||
16
SYSTEM.md
Normal file
16
SYSTEM.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent living on Nostr.
|
||||
|
||||
## Communication Rules
|
||||
- You communicate through encrypted Nostr direct messages.
|
||||
- Keep responses concise and clear.
|
||||
|
||||
## Behavior
|
||||
- Be helpful and technically accurate.
|
||||
- If unsure, state uncertainty directly.
|
||||
- Prefer actionable, practical advice.
|
||||
|
||||
## Safety
|
||||
- Do not claim to have executed actions you did not execute.
|
||||
- Do not reveal secrets from configuration or keys.
|
||||
397
plans/didactyl_agentic.md
Normal file
397
plans/didactyl_agentic.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# Didactyl — Agentic Tool-Use Architecture Plan
|
||||
|
||||
## Vision
|
||||
|
||||
Didactyl is a **Nostr-first sovereign AI agent**. Where traditional agents ride on top of Linux — reading files, writing to disk — Didactyl rides on top of Nostr. Events are its files. Relays are its network bus. Blossom is its blob storage. The Linux host is just the runtime substrate.
|
||||
|
||||
The agent receives commands via encrypted Nostr DMs, uses an LLM to reason about them, and can take actions in its environment by calling **tools** — primarily Nostr-native tools, with shell access as an escape hatch.
|
||||
|
||||
---
|
||||
|
||||
## Current State (MVP)
|
||||
|
||||
The MVP is a linear chat pipeline:
|
||||
|
||||
```
|
||||
DM in → LLM chat → DM out
|
||||
```
|
||||
|
||||
The agent can only talk. It cannot act.
|
||||
|
||||
---
|
||||
|
||||
## Target State (Agentic)
|
||||
|
||||
The agent has an **agent loop** with tool-calling:
|
||||
|
||||
```
|
||||
DM in → [LLM decides → execute tool → observe result → repeat] → DM out
|
||||
```
|
||||
|
||||
The LLM can request actions, observe results, and iterate until it has a final answer.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph NOSTR[Nostr Layer]
|
||||
EVENTS[Events = Data/Files]
|
||||
RELAYS[Relays = Network]
|
||||
DMS[DMs = Command Channel]
|
||||
BLOSSOM[Blossom = Blob Storage]
|
||||
end
|
||||
|
||||
subgraph LINUX[Linux Layer]
|
||||
SHELL[Shell = Escape Hatch]
|
||||
end
|
||||
|
||||
subgraph AGENT[Didactyl Agent Loop]
|
||||
LLM[LLM Brain]
|
||||
TOOLS[Tool Registry]
|
||||
LOOP{Agent Loop}
|
||||
end
|
||||
|
||||
DMS -->|incoming command| LOOP
|
||||
LOOP --> LLM
|
||||
LLM -->|tool_call| TOOLS
|
||||
TOOLS -->|nostr tools| NOSTR
|
||||
TOOLS -->|shell_exec| SHELL
|
||||
TOOLS -->|result| LOOP
|
||||
LLM -->|final answer| DMS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How Tool Calling Works
|
||||
|
||||
### The Protocol
|
||||
|
||||
All communication with the LLM is JSON via the OpenAI-compatible chat completions API. Tool definitions are sent as a structured `tools` parameter in every request — not in SYSTEM.md.
|
||||
|
||||
### Request with tools
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are Didactyl..."},
|
||||
{"role": "user", "content": "Post a joke"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "nostr_post",
|
||||
"description": "Publish a Nostr event to connected relays",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "integer", "description": "Event kind number"},
|
||||
"content": {"type": "string", "description": "Event content"}
|
||||
},
|
||||
"required": ["kind", "content"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### LLM responds with tool_call
|
||||
|
||||
```json
|
||||
{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "nostr_post",
|
||||
"arguments": "{\"kind\": 1, \"content\": \"Why do programmers prefer dark mode? Because light attracts bugs!\"}"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
When `content` is null and `tool_calls` is present: **execute the tool, don't reply yet**.
|
||||
|
||||
### Didactyl sends tool result back
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_abc123",
|
||||
"content": "{\"success\": true, \"event_id\": \"abc...\", \"relays_published\": 3}"
|
||||
}
|
||||
```
|
||||
|
||||
### LLM gives final answer
|
||||
|
||||
```json
|
||||
{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Done! I posted a joke to 3 relays."
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
When `content` has text and no `tool_calls`: **this is the final answer, DM it back**.
|
||||
|
||||
---
|
||||
|
||||
## Message Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as Admin via Nostr DM
|
||||
participant Agent as Didactyl Agent Loop
|
||||
participant LLM as LLM API
|
||||
participant Tools as Tool Registry
|
||||
participant Nostr as Nostr Relays
|
||||
participant Shell as Local Shell
|
||||
|
||||
Admin->>Agent: Encrypted DM: Post a joke
|
||||
Agent->>LLM: messages + tool definitions
|
||||
|
||||
loop Until final answer
|
||||
LLM->>Agent: tool_call: nostr_post kind=1 content=joke
|
||||
Agent->>Tools: dispatch nostr_post
|
||||
Tools->>Nostr: Publish kind 1 event
|
||||
Nostr->>Tools: OK
|
||||
Tools->>Agent: result JSON
|
||||
Agent->>LLM: tool result + continue
|
||||
end
|
||||
|
||||
LLM->>Agent: final text: Done! Posted to 3 relays
|
||||
Agent->>Admin: Encrypted DM reply
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Inventory
|
||||
|
||||
### Tier 1: Core Nostr Tools
|
||||
|
||||
| Tool | Description | Underlying API |
|
||||
|---|---|---|
|
||||
| `nostr_post` | Publish any kind event to relays | `nostr_create_and_sign_event()` + `nostr_relay_pool_publish_async()` |
|
||||
| `nostr_query` | Query relays with filters, return matching events | `nostr_relay_pool_query_sync()` |
|
||||
| `nostr_dm` | Send a private DM via NIP-17 gift wrap | `nostr_nip17_send_dm()` |
|
||||
| `nostr_profile` | Update the agent's kind 0 metadata | `nostr_create_and_sign_event(0, ...)` |
|
||||
|
||||
### Tier 2: Extended Nostr Tools
|
||||
|
||||
| Tool | Description | Notes |
|
||||
|---|---|---|
|
||||
| `nostr_follow` | Publish/update kind 3 contact list | Build tags array of p-tags |
|
||||
| `nostr_encrypt` | Encrypt content for a pubkey | NIP-44 |
|
||||
| `nostr_decrypt` | Decrypt content from a pubkey | NIP-44 |
|
||||
| `nostr_relay_info` | Get relay information document | NIP-11 |
|
||||
| `nostr_verify_nip05` | Verify a NIP-05 identifier | NIP-05 |
|
||||
|
||||
### Tier 3: Storage Tools
|
||||
|
||||
| Tool | Description | Notes |
|
||||
|---|---|---|
|
||||
| `nostr_note_save` | Store agent data as kind 30078 event | Agent's filesystem on Nostr |
|
||||
| `nostr_note_load` | Retrieve stored data events | Query own events |
|
||||
| `blossom_upload` | Upload blob to Blossom server | Future |
|
||||
| `blossom_download` | Download blob from Blossom | Future |
|
||||
|
||||
### Tier 4: Linux Escape Hatch
|
||||
|
||||
| Tool | Description | Notes |
|
||||
|---|---|---|
|
||||
| `shell_exec` | Run a shell command, capture stdout/stderr | Sandboxed with timeouts |
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.c # Entry point, daemon loop (existing)
|
||||
├── config.c / .h # Configuration parsing (existing, extended)
|
||||
├── context.c / .h # SYSTEM.md loader (existing)
|
||||
├── nostr_handler.c / .h # Relay pool, subscriptions (existing)
|
||||
├── llm.c / .h # LLM client (existing, extended for tool calling)
|
||||
├── agent.c / .h # Agent logic (existing, rewritten for agent loop)
|
||||
├── tools.h # Tool definition struct, registry API (NEW)
|
||||
├── tools.c # Tool registry, dispatch, JSON helpers (NEW)
|
||||
├── tool_nostr.c # Nostr-native tools implementation (NEW)
|
||||
├── tool_shell.c # Shell execution with sandboxing (NEW)
|
||||
└── secp_compat.c # secp256k1 compatibility (existing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Data Structures
|
||||
|
||||
### Tool definition
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
const char* name;
|
||||
const char* description;
|
||||
const char* parameters_json; // JSON Schema for parameters
|
||||
char* (*execute)(const char* args_json, void* ctx);
|
||||
} tool_t;
|
||||
```
|
||||
|
||||
### Tool registry
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
tool_t* tools;
|
||||
int count;
|
||||
int capacity;
|
||||
didactyl_config_t* cfg;
|
||||
nostr_relay_pool_t* pool;
|
||||
} tool_registry_t;
|
||||
```
|
||||
|
||||
### LLM tool call (parsed from response)
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
char id[64];
|
||||
char name[64];
|
||||
char* arguments_json;
|
||||
} llm_tool_call_t;
|
||||
```
|
||||
|
||||
### LLM response (extended)
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
char* content; // final text, or NULL if tool calls
|
||||
llm_tool_call_t* tool_calls;
|
||||
int tool_call_count;
|
||||
} llm_response_t;
|
||||
```
|
||||
|
||||
### Conversation message (for multi-turn)
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
MSG_ROLE_SYSTEM,
|
||||
MSG_ROLE_USER,
|
||||
MSG_ROLE_ASSISTANT,
|
||||
MSG_ROLE_TOOL
|
||||
} msg_role_t;
|
||||
|
||||
typedef struct {
|
||||
msg_role_t role;
|
||||
char* content;
|
||||
llm_tool_call_t* tool_calls; // for assistant messages
|
||||
int tool_call_count;
|
||||
char tool_call_id[64]; // for tool result messages
|
||||
} conversation_msg_t;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent Loop Pseudocode
|
||||
|
||||
```c
|
||||
void agent_on_message(const char* sender, const char* message) {
|
||||
conversation_msg_t messages[MAX_TURNS * 2 + 2];
|
||||
int msg_count = 0;
|
||||
|
||||
// Build initial messages
|
||||
messages[msg_count++] = {SYSTEM, system_context};
|
||||
messages[msg_count++] = {USER, message};
|
||||
|
||||
for (int turn = 0; turn < MAX_TOOL_TURNS; turn++) {
|
||||
llm_response_t resp = llm_chat_with_tools(messages, msg_count, tool_registry);
|
||||
|
||||
if (resp.content != NULL) {
|
||||
// Final answer — send DM and done
|
||||
nostr_handler_send_dm(sender, resp.content);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tool calls — execute each one
|
||||
messages[msg_count++] = {ASSISTANT, NULL, resp.tool_calls, resp.tool_call_count};
|
||||
|
||||
for (int i = 0; i < resp.tool_call_count; i++) {
|
||||
char* result = tools_execute(registry, resp.tool_calls[i].name, resp.tool_calls[i].arguments_json);
|
||||
messages[msg_count++] = {TOOL, result, .tool_call_id = resp.tool_calls[i].id};
|
||||
}
|
||||
}
|
||||
|
||||
// Max turns exceeded
|
||||
nostr_handler_send_dm(sender, "I hit my tool-use limit for this request.");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
| Control | Description | Config key |
|
||||
|---|---|---|
|
||||
| Admin-only access | Only admin pubkey can send commands | `admin.pubkey` (existing) |
|
||||
| Max tool turns | Limit iterations per request | `tools.max_turns` |
|
||||
| Shell timeout | Kill shell commands after N seconds | `tools.shell_timeout_seconds` |
|
||||
| Shell allowlist | Optional list of allowed commands | `tools.shell_allowlist` |
|
||||
| Working directory | Confine file operations | `tools.working_directory` |
|
||||
| Output size limit | Cap tool output to prevent memory issues | `tools.max_output_bytes` |
|
||||
|
||||
---
|
||||
|
||||
## Config Extension
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"enabled": true,
|
||||
"max_turns": 10,
|
||||
"shell": {
|
||||
"enabled": true,
|
||||
"timeout_seconds": 30,
|
||||
"max_output_bytes": 65536,
|
||||
"working_directory": "/home/didactyl",
|
||||
"allowlist": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Tool registry framework** — `tools.h`, `tools.c` with registration and dispatch
|
||||
2. **LLM tool-calling protocol** — extend `llm.c` with `llm_chat_with_tools()`
|
||||
3. **Agent loop** — rewrite `agent.c` with the tool-call loop
|
||||
4. **`nostr_post` tool** — publish any kind event
|
||||
5. **`nostr_query` tool** — query relays with filters
|
||||
6. **`shell_exec` tool** — sandboxed shell command execution
|
||||
7. **`nostr_dm` tool** — NIP-17 private DMs
|
||||
8. **Config extension** — parse tools config section
|
||||
9. **Security hardening** — timeouts, output limits, allowlists
|
||||
10. **`nostr_note_save` / `nostr_note_load`** — agent data storage on Nostr
|
||||
|
||||
---
|
||||
|
||||
## Future: Nostr as the OS
|
||||
|
||||
Once the tool system is in place, the path to full Nostr-native operation:
|
||||
|
||||
- **Config on Nostr** — store config.json as a kind 30078 event, load on boot
|
||||
- **SYSTEM.md on Nostr** — store system context as a Nostr event
|
||||
- **Conversation memory** — store conversation history as Nostr events
|
||||
- **Agent-to-agent** — agents can DM each other and use tools cooperatively
|
||||
- **Blossom integration** — binary data storage via Blossom servers
|
||||
- **Self-update** — agent can modify its own SYSTEM.md on Nostr
|
||||
414
plans/didactyl_mvp.md
Normal file
414
plans/didactyl_mvp.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# Didactyl — MVP Architecture Plan
|
||||
|
||||
## Vision
|
||||
|
||||
**Didactyl** is a sovereign AI agent daemon written in C99 that uses Nostr as its sole communication and memory layer. The agent boots on any internet-connected machine, connects to Nostr relays, listens for encrypted commands from its administrator, forwards them to an LLM, and replies via encrypted Nostr messages.
|
||||
|
||||
Because all identity, communication, and memory live on Nostr, the agent is **portable** (start it anywhere) and **sovereign** (no single entity can erase its memory).
|
||||
|
||||
---
|
||||
|
||||
## MVP Scope — Proof of Concept
|
||||
|
||||
The first version does exactly this:
|
||||
|
||||
1. Read config from `config.json` (keys, relays, LLM settings, profile metadata)
|
||||
2. Read agent context/personality from `SYSTEM.md`
|
||||
3. Connect to configured relays and stay connected (relay pool with auto-reconnect)
|
||||
4. Publish a **kind 0** metadata event (agent profile)
|
||||
5. Subscribe to **kind 4** NIP-04 encrypted DMs addressed to the agent
|
||||
6. When a DM arrives from the authorized admin pubkey:
|
||||
- Decrypt it
|
||||
- Send the message + SYSTEM.md context to the configured LLM API
|
||||
- Get the LLM response
|
||||
- Encrypt and send the response back as a kind 4 DM to the admin
|
||||
7. Loop forever (daemon)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[config.json] -->|keys, relays, LLM config| D[didactyl daemon]
|
||||
B[SYSTEM.md] -->|agent context/personality| D
|
||||
D -->|1. Connect| R[Nostr Relay Pool]
|
||||
D -->|2. Publish kind 0| R
|
||||
D -->|3. Subscribe kind 4 + p-tag filter| R
|
||||
R -->|4. Incoming encrypted DM| D
|
||||
D -->|5. Decrypt with NIP-04| V{From admin pubkey?}
|
||||
V -->|No| X[Ignore]
|
||||
V -->|Yes| L[LLM API via HTTPS]
|
||||
L -->|Response| D
|
||||
D -->|6. Encrypt response with NIP-04| R
|
||||
R -->|7. Deliver kind 4 DM to admin| ADM[Admin Nostr Client]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
didactyl/
|
||||
├── config.json # Agent configuration
|
||||
├── SYSTEM.md # Agent context/personality for LLM
|
||||
├── src/
|
||||
│ ├── main.c # Entry point, daemon loop
|
||||
│ ├── config.c # JSON config parsing
|
||||
│ ├── config.h # Config structures and API
|
||||
│ ├── agent.c # Core agent logic: receive → LLM → respond
|
||||
│ ├── agent.h # Agent API
|
||||
│ ├── llm.c # LLM HTTP API client (simple HTTPS POST)
|
||||
│ ├── llm.h # LLM API
|
||||
│ ├── nostr_handler.c # Nostr relay pool, subscriptions, publish
|
||||
│ ├── nostr_handler.h # Nostr handler API
|
||||
│ └── context.c # Load SYSTEM.md into memory
|
||||
│ └── context.h # Context API
|
||||
├── Makefile # Build system
|
||||
└── README.md # Project documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration — `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"name": "Didactyl Agent",
|
||||
"display_name": "Didactyl",
|
||||
"about": "A sovereign AI agent on Nostr",
|
||||
"picture": "",
|
||||
"nip05": ""
|
||||
},
|
||||
"keys": {
|
||||
"nsec": "nsec1..."
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "hex-pubkey-of-admin"
|
||||
},
|
||||
"relays": [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.laantungir.net"
|
||||
],
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"api_key": "sk-...",
|
||||
"model": "gpt-4o-mini",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent Context — `SYSTEM.md`
|
||||
|
||||
Recommended name: **`SYSTEM.md`** — this is the universal term LLMs understand as their foundational instructions. Every major LLM API uses "system" as the role for context/personality instructions.
|
||||
|
||||
Example content:
|
||||
|
||||
```markdown
|
||||
# Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent that lives on the Nostr protocol.
|
||||
|
||||
## Personality
|
||||
- You are helpful, concise, and technically knowledgeable
|
||||
- You communicate via Nostr encrypted DMs
|
||||
|
||||
## Capabilities
|
||||
- You can answer questions and have conversations
|
||||
- Your memory persists on Nostr relays
|
||||
|
||||
## Rules
|
||||
- Always be honest about your capabilities
|
||||
- Keep responses concise for the DM format
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Details
|
||||
|
||||
### 1. `main.c` — Entry Point
|
||||
|
||||
Responsibilities:
|
||||
- Parse CLI arguments (config path, debug level, context file path)
|
||||
- Call `config_load()` to parse `config.json`
|
||||
- Call `context_load()` to read `SYSTEM.md` into memory
|
||||
- Call `nostr_init()` to initialize the crypto subsystem
|
||||
- Decode the nsec from config into a 32-byte private key
|
||||
- Derive the public key
|
||||
- Call `nostr_handler_init()` to set up relay pool and connect
|
||||
- Publish kind 0 metadata event
|
||||
- Subscribe to kind 4 events with a `#p` tag filter for the agent's pubkey
|
||||
- Enter the main daemon loop: `nostr_relay_pool_poll()` in a loop
|
||||
- Handle SIGINT/SIGTERM for clean shutdown
|
||||
- Call cleanup functions on exit
|
||||
|
||||
### 2. `config.c` / `config.h` — Configuration
|
||||
|
||||
Structures:
|
||||
```c
|
||||
typedef struct {
|
||||
char name[128];
|
||||
char display_name[128];
|
||||
char about[512];
|
||||
char picture[256];
|
||||
char nip05[256];
|
||||
} agent_profile_t;
|
||||
|
||||
typedef struct {
|
||||
char nsec[100];
|
||||
unsigned char private_key[32];
|
||||
unsigned char public_key[32];
|
||||
char public_key_hex[65];
|
||||
} agent_keys_t;
|
||||
|
||||
typedef struct {
|
||||
char pubkey[65]; // hex
|
||||
} admin_config_t;
|
||||
|
||||
typedef struct {
|
||||
char provider[32];
|
||||
char api_key[256];
|
||||
char model[64];
|
||||
char base_url[256];
|
||||
int max_tokens;
|
||||
double temperature;
|
||||
} llm_config_t;
|
||||
|
||||
typedef struct {
|
||||
agent_profile_t profile;
|
||||
agent_keys_t keys;
|
||||
admin_config_t admin;
|
||||
char** relays;
|
||||
int relay_count;
|
||||
llm_config_t llm;
|
||||
} didactyl_config_t;
|
||||
```
|
||||
|
||||
Functions:
|
||||
- `int config_load(const char* path, didactyl_config_t* config)` — Parse JSON, populate struct
|
||||
- `void config_free(didactyl_config_t* config)` — Free allocated memory
|
||||
|
||||
Uses `cJSON` (already bundled in nostr_core_lib) for parsing.
|
||||
|
||||
### 3. `context.c` / `context.h` — System Context
|
||||
|
||||
Simple file reader:
|
||||
- `char* context_load(const char* path)` — Read entire file into malloc'd string
|
||||
- `void context_free(char* context)` — Free the string
|
||||
|
||||
### 4. `nostr_handler.c` / `nostr_handler.h` — Nostr Communication
|
||||
|
||||
This wraps the `nostr_core_lib` relay pool API:
|
||||
|
||||
Functions:
|
||||
- `int nostr_handler_init(didactyl_config_t* config)` — Create relay pool, add relays, connect
|
||||
- `int nostr_handler_publish_profile(didactyl_config_t* config)` — Create and publish kind 0 event
|
||||
- `int nostr_handler_subscribe_dms(didactyl_config_t* config, dm_callback_t callback, void* user_data)` — Subscribe to kind 4 events tagged with agent's pubkey
|
||||
- `int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message, didactyl_config_t* config)` — Encrypt and publish a kind 4 DM
|
||||
- `int nostr_handler_poll(int timeout_ms)` — Drive the event loop
|
||||
- `void nostr_handler_cleanup(void)` — Destroy pool and cleanup
|
||||
|
||||
The DM subscription callback:
|
||||
```c
|
||||
typedef void (*dm_callback_t)(const char* sender_pubkey, const char* decrypted_message, void* user_data);
|
||||
```
|
||||
|
||||
When a kind 4 event arrives:
|
||||
1. Extract the sender pubkey from the event
|
||||
2. Check if sender matches `config.admin.pubkey` — if not, ignore
|
||||
3. Decrypt the content using `nostr_nip04_decrypt()`
|
||||
4. Call the registered callback with the decrypted message
|
||||
|
||||
### 5. `agent.c` / `agent.h` — Agent Logic
|
||||
|
||||
The brain that ties everything together:
|
||||
|
||||
Functions:
|
||||
- `int agent_init(didactyl_config_t* config, const char* system_context)` — Store config and context
|
||||
- `void agent_on_message(const char* sender_pubkey, const char* message, void* user_data)` — The DM callback implementation:
|
||||
1. Log the incoming message
|
||||
2. Call `llm_chat()` with the system context + user message
|
||||
3. Send the LLM response back via `nostr_handler_send_dm()`
|
||||
- `void agent_cleanup(void)` — Cleanup
|
||||
|
||||
### 6. `llm.c` / `llm.h` — LLM API Client
|
||||
|
||||
A minimal HTTP client for the OpenAI-compatible chat completions API:
|
||||
|
||||
Functions:
|
||||
- `int llm_init(llm_config_t* config)` — Store config
|
||||
- `char* llm_chat(const char* system_prompt, const char* user_message)` — Send chat completion request, return response string (caller frees)
|
||||
- `void llm_cleanup(void)` — Cleanup
|
||||
|
||||
Implementation approach:
|
||||
- Use `libcurl` for HTTPS requests (widely available, simple C API)
|
||||
- Build the JSON request body with `cJSON`:
|
||||
```json
|
||||
{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "system", "content": "<SYSTEM.md content>"},
|
||||
{"role": "user", "content": "<decrypted DM message>"}
|
||||
],
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.7
|
||||
}
|
||||
```
|
||||
- Parse the JSON response with `cJSON` to extract `choices[0].message.content`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose | Source |
|
||||
|---|---|---|
|
||||
| `nostr_core_lib` | Nostr protocol: keys, events, NIP-04, relay pool | In workspace |
|
||||
| `c_utils_lib` | Debug logging | In workspace |
|
||||
| `cJSON` | JSON parsing | Bundled in nostr_core_lib |
|
||||
| `libcurl` | HTTPS for LLM API calls | System package |
|
||||
| `libssl/libcrypto` | TLS for WebSocket connections | System package (already required by nostr_core_lib) |
|
||||
|
||||
---
|
||||
|
||||
## Build System — `Makefile`
|
||||
|
||||
```makefile
|
||||
CC = gcc
|
||||
CFLAGS = -std=c99 -Wall -Wextra -pedantic -D_POSIX_C_SOURCE=200809L
|
||||
LIBS = -lcurl -lssl -lcrypto -lm -lpthread
|
||||
|
||||
# Paths to workspace libraries
|
||||
NOSTR_LIB = ../nostr_core_lib
|
||||
C_UTILS_LIB = ../c_utils_lib
|
||||
CJSON = $(NOSTR_LIB)/cjson
|
||||
|
||||
INCLUDES = -I$(NOSTR_LIB) -I$(C_UTILS_LIB)/src -I$(CJSON)
|
||||
|
||||
SRCS = src/main.c src/config.c src/context.c src/nostr_handler.c src/agent.c src/llm.c
|
||||
TARGET = didactyl
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(SRCS)
|
||||
$(CC) $(CFLAGS) $(INCLUDES) -o $@ $^ \
|
||||
$(NOSTR_LIB)/libnostr_core.a \
|
||||
$(C_UTILS_LIB)/libc_utils.a \
|
||||
$(LIBS)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow — Message Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as Admin Nostr Client
|
||||
participant Relay as Nostr Relays
|
||||
participant OW as Didactyl Daemon
|
||||
participant LLM as LLM API
|
||||
|
||||
Note over OW: Boot: load config.json + SYSTEM.md
|
||||
OW->>Relay: Connect to relay pool
|
||||
OW->>Relay: Publish kind 0 profile
|
||||
OW->>Relay: Subscribe kind 4 where #p = agent_pubkey
|
||||
|
||||
Admin->>Relay: Send kind 4 encrypted DM to agent
|
||||
Relay->>OW: Deliver kind 4 event
|
||||
OW->>OW: Verify sender == admin pubkey
|
||||
OW->>OW: Decrypt with NIP-04
|
||||
OW->>LLM: POST /v1/chat/completions with SYSTEM.md + message
|
||||
LLM->>OW: Response text
|
||||
OW->>OW: Encrypt response with NIP-04
|
||||
OW->>Relay: Publish kind 4 DM to admin
|
||||
Relay->>Admin: Deliver encrypted response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Main Loop Pseudocode
|
||||
|
||||
```c
|
||||
int main(int argc, char* argv[]) {
|
||||
// 1. Parse args (--config, --context, --debug)
|
||||
// 2. Load config
|
||||
didactyl_config_t config;
|
||||
config_load("config.json", &config);
|
||||
|
||||
// 3. Load system context
|
||||
char* system_context = context_load("SYSTEM.md");
|
||||
|
||||
// 4. Init nostr
|
||||
nostr_init();
|
||||
debug_init(debug_level);
|
||||
|
||||
// 5. Decode keys
|
||||
nostr_decode_nsec(config.keys.nsec, config.keys.private_key);
|
||||
nostr_ec_public_key_from_private_key(config.keys.private_key, config.keys.public_key);
|
||||
|
||||
// 6. Init components
|
||||
nostr_handler_init(&config);
|
||||
llm_init(&config.llm);
|
||||
agent_init(&config, system_context);
|
||||
|
||||
// 7. Publish profile
|
||||
nostr_handler_publish_profile(&config);
|
||||
|
||||
// 8. Subscribe to DMs
|
||||
nostr_handler_subscribe_dms(&config, agent_on_message, NULL);
|
||||
|
||||
// 9. Daemon loop
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
|
||||
while (running) {
|
||||
nostr_handler_poll(100); // 100ms poll interval
|
||||
}
|
||||
|
||||
// 10. Cleanup
|
||||
agent_cleanup();
|
||||
llm_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
config_free(&config);
|
||||
context_free(system_context);
|
||||
nostr_cleanup();
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Config loader** — parse `config.json`, decode keys
|
||||
2. **Context loader** — read `SYSTEM.md` into string
|
||||
3. **Nostr handler** — relay pool connect, kind 0 publish, kind 4 subscribe + send
|
||||
4. **LLM client** — HTTP POST to chat completions API, parse response
|
||||
5. **Agent logic** — wire DM callback to LLM and back
|
||||
6. **Main** — tie it all together with the daemon loop
|
||||
7. **Makefile** — build system
|
||||
8. **Test** — manual test with a Nostr client sending DMs to the agent
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Not MVP)
|
||||
|
||||
- Upgrade to NIP-17 gift-wrapped DMs for better privacy
|
||||
- Conversation history/memory stored as Nostr events
|
||||
- Agent can store and retrieve its own notes on Nostr
|
||||
- Multi-turn conversation context window
|
||||
- Shell command execution
|
||||
- File storage on Nostr (NIP-94/NIP-96)
|
||||
- Mnemonic-based key generation for portability
|
||||
- Multiple admin support with role-based access
|
||||
- Agent-to-agent communication
|
||||
58
src/agent.c
Normal file
58
src/agent.c
Normal file
@@ -0,0 +1,58 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "agent.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "llm.h"
|
||||
#include "nostr_handler.h"
|
||||
|
||||
static didactyl_config_t* g_cfg = NULL;
|
||||
static char* g_system_context = NULL;
|
||||
|
||||
int agent_init(didactyl_config_t* config, const char* system_context) {
|
||||
if (!config || !system_context) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_cfg = config;
|
||||
g_system_context = strdup(system_context);
|
||||
if (!g_system_context) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void agent_on_message(const char* sender_pubkey_hex, const char* message, void* user_data) {
|
||||
(void)user_data;
|
||||
|
||||
if (!g_cfg || !g_system_context || !sender_pubkey_hex || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] incoming message from %.16s...\n", sender_pubkey_hex);
|
||||
fprintf(stdout, "[didactyl] calling llm for sender %.16s...\n", sender_pubkey_hex);
|
||||
|
||||
char* response = llm_chat(g_system_context, message);
|
||||
if (!response) {
|
||||
const char* fallback = "I could not get a response from the LLM right now.";
|
||||
fprintf(stdout, "[didactyl] llm response unavailable, sending fallback\n");
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] llm response: %.240s%s\n",
|
||||
response,
|
||||
strlen(response) > 240 ? "..." : "");
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, response);
|
||||
free(response);
|
||||
}
|
||||
|
||||
void agent_cleanup(void) {
|
||||
free(g_system_context);
|
||||
g_system_context = NULL;
|
||||
g_cfg = NULL;
|
||||
}
|
||||
10
src/agent.h
Normal file
10
src/agent.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#ifndef OPEN_WING_AGENT_H
|
||||
#define OPEN_WING_AGENT_H
|
||||
|
||||
#include "config.h"
|
||||
|
||||
int agent_init(didactyl_config_t* config, const char* system_context);
|
||||
void agent_on_message(const char* sender_pubkey_hex, const char* message, void* user_data);
|
||||
void agent_cleanup(void);
|
||||
|
||||
#endif
|
||||
275
src/config.c
Normal file
275
src/config.c
Normal file
@@ -0,0 +1,275 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static int read_file_to_buffer(const char* path, char** out_buf, size_t* out_len) {
|
||||
FILE* fp = fopen(path, "rb");
|
||||
if (!fp) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (fseek(fp, 0, SEEK_END) != 0) {
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
|
||||
long len = ftell(fp);
|
||||
if (len < 0) {
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (fseek(fp, 0, SEEK_SET) != 0) {
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* buf = (char*)malloc((size_t)len + 1U);
|
||||
if (!buf) {
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t read_len = fread(buf, 1, (size_t)len, fp);
|
||||
fclose(fp);
|
||||
|
||||
if (read_len != (size_t)len) {
|
||||
free(buf);
|
||||
return -1;
|
||||
}
|
||||
|
||||
buf[len] = '\0';
|
||||
*out_buf = buf;
|
||||
*out_len = (size_t)len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int copy_json_string(cJSON* obj, const char* key, char* dst, size_t dst_size, int required) {
|
||||
cJSON* item = cJSON_GetObjectItemCaseSensitive(obj, key);
|
||||
if (!item || !cJSON_IsString(item) || !item->valuestring) {
|
||||
return required ? -1 : 0;
|
||||
}
|
||||
|
||||
size_t n = strlen(item->valuestring);
|
||||
if (n >= dst_size) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(dst, item->valuestring, n + 1U);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int is_hex64(const char* s) {
|
||||
if (!s || strlen(s) != 64U) {
|
||||
return 0;
|
||||
}
|
||||
for (size_t i = 0; i < 64U; i++) {
|
||||
if (!isxdigit((unsigned char)s[i])) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int decode_private_key(const char* nsec_or_hex, unsigned char out_private_key[32]) {
|
||||
if (!nsec_or_hex || !out_private_key) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (strncmp(nsec_or_hex, "nsec1", 5) == 0) {
|
||||
return nostr_decode_nsec(nsec_or_hex, out_private_key) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
if (is_hex64(nsec_or_hex)) {
|
||||
return nostr_hex_to_bytes(nsec_or_hex, out_private_key, 32) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int decode_pubkey_hex_or_npub(const char* in, char out_hex[65]) {
|
||||
if (!in || !out_hex) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (is_hex64(in)) {
|
||||
memcpy(out_hex, in, 65);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strncmp(in, "npub1", 5) == 0) {
|
||||
unsigned char pubkey[32];
|
||||
if (nostr_decode_npub(in, pubkey) != 0) {
|
||||
return -1;
|
||||
}
|
||||
nostr_bytes_to_hex(pubkey, 32, out_hex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int parse_relays(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* relays = cJSON_GetObjectItemCaseSensitive(root, "relays");
|
||||
if (!relays || !cJSON_IsArray(relays)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int count = cJSON_GetArraySize(relays);
|
||||
if (count <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
config->relays = (char**)calloc((size_t)count, sizeof(char*));
|
||||
if (!config->relays) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
config->relay_count = count;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON* relay = cJSON_GetArrayItem(relays, i);
|
||||
if (!relay || !cJSON_IsString(relay) || !relay->valuestring || relay->valuestring[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
config->relays[i] = strdup(relay->valuestring);
|
||||
if (!config->relays[i]) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void config_free(didactyl_config_t* config) {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config->relays) {
|
||||
for (int i = 0; i < config->relay_count; i++) {
|
||||
free(config->relays[i]);
|
||||
}
|
||||
free(config->relays);
|
||||
}
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
}
|
||||
|
||||
int config_load(const char* path, didactyl_config_t* config) {
|
||||
if (!path || !config) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
|
||||
char* json_buf = NULL;
|
||||
size_t json_len = 0;
|
||||
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_ParseWithLength(json_buf, json_len);
|
||||
free(json_buf);
|
||||
|
||||
if (!root) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rc = -1;
|
||||
|
||||
cJSON* agent = cJSON_GetObjectItemCaseSensitive(root, "agent");
|
||||
cJSON* keys = cJSON_GetObjectItemCaseSensitive(root, "keys");
|
||||
cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin");
|
||||
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
|
||||
|
||||
if (!agent || !cJSON_IsObject(agent) ||
|
||||
!keys || !cJSON_IsObject(keys) ||
|
||||
!admin || !cJSON_IsObject(admin) ||
|
||||
!llm || !cJSON_IsObject(llm)) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (copy_json_string(agent, "name", config->profile.name, sizeof(config->profile.name), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "display_name", config->profile.display_name, sizeof(config->profile.display_name), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "about", config->profile.about, sizeof(config->profile.about), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "picture", config->profile.picture, sizeof(config->profile.picture), 0) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "nip05", config->profile.nip05, sizeof(config->profile.nip05), 0) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
char admin_key_raw[OW_MAX_KEY_LEN] = {0};
|
||||
if (copy_json_string(admin, "pubkey", admin_key_raw, sizeof(admin_key_raw), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (decode_pubkey_hex_or_npub(admin_key_raw, config->admin.pubkey) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_relays(root, config) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (copy_json_string(llm, "provider", config->llm.provider, sizeof(config->llm.provider), 0) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (config->llm.provider[0] == '\0') {
|
||||
strcpy(config->llm.provider, "openai");
|
||||
}
|
||||
|
||||
if (copy_json_string(llm, "api_key", config->llm.api_key, sizeof(config->llm.api_key), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(llm, "model", config->llm.model, sizeof(config->llm.model), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(llm, "base_url", config->llm.base_url, sizeof(config->llm.base_url), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(llm, "max_tokens");
|
||||
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(llm, "temperature");
|
||||
|
||||
config->llm.max_tokens = (max_tokens && cJSON_IsNumber(max_tokens)) ? (int)max_tokens->valuedouble : 512;
|
||||
config->llm.temperature = (temperature && cJSON_IsNumber(temperature)) ? temperature->valuedouble : 0.7;
|
||||
|
||||
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (nostr_ec_public_key_from_private_key(config->keys.private_key, config->keys.public_key) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
nostr_bytes_to_hex(config->keys.public_key, 32, config->keys.public_key_hex);
|
||||
|
||||
rc = 0;
|
||||
|
||||
cleanup:
|
||||
cJSON_Delete(root);
|
||||
if (rc != 0) {
|
||||
config_free(config);
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
52
src/config.h
Normal file
52
src/config.h
Normal file
@@ -0,0 +1,52 @@
|
||||
#ifndef OPEN_WING_CONFIG_H
|
||||
#define OPEN_WING_CONFIG_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#define OW_MAX_NAME_LEN 128
|
||||
#define OW_MAX_ABOUT_LEN 512
|
||||
#define OW_MAX_URL_LEN 256
|
||||
#define OW_MAX_KEY_LEN 256
|
||||
#define OW_MAX_MODEL_LEN 128
|
||||
|
||||
typedef struct {
|
||||
char name[OW_MAX_NAME_LEN];
|
||||
char display_name[OW_MAX_NAME_LEN];
|
||||
char about[OW_MAX_ABOUT_LEN];
|
||||
char picture[OW_MAX_URL_LEN];
|
||||
char nip05[OW_MAX_URL_LEN];
|
||||
} agent_profile_t;
|
||||
|
||||
typedef struct {
|
||||
char nsec[OW_MAX_KEY_LEN];
|
||||
unsigned char private_key[32];
|
||||
unsigned char public_key[32];
|
||||
char public_key_hex[65];
|
||||
} agent_keys_t;
|
||||
|
||||
typedef struct {
|
||||
char pubkey[65];
|
||||
} admin_config_t;
|
||||
|
||||
typedef struct {
|
||||
char provider[32];
|
||||
char api_key[OW_MAX_KEY_LEN];
|
||||
char model[OW_MAX_MODEL_LEN];
|
||||
char base_url[OW_MAX_URL_LEN];
|
||||
int max_tokens;
|
||||
double temperature;
|
||||
} llm_config_t;
|
||||
|
||||
typedef struct {
|
||||
agent_profile_t profile;
|
||||
agent_keys_t keys;
|
||||
admin_config_t admin;
|
||||
char** relays;
|
||||
int relay_count;
|
||||
llm_config_t llm;
|
||||
} didactyl_config_t;
|
||||
|
||||
int config_load(const char* path, didactyl_config_t* config);
|
||||
void config_free(didactyl_config_t* config);
|
||||
|
||||
#endif
|
||||
54
src/context.c
Normal file
54
src/context.c
Normal file
@@ -0,0 +1,54 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "context.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
char* context_load(const char* path) {
|
||||
if (!path) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
FILE* fp = fopen(path, "rb");
|
||||
if (!fp) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (fseek(fp, 0, SEEK_END) != 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
long len = ftell(fp);
|
||||
if (len < 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (fseek(fp, 0, SEEK_SET) != 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* buffer = (char*)malloc((size_t)len + 1U);
|
||||
if (!buffer) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
size_t read_len = fread(buffer, 1, (size_t)len, fp);
|
||||
fclose(fp);
|
||||
|
||||
if (read_len != (size_t)len) {
|
||||
free(buffer);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
buffer[len] = '\0';
|
||||
return buffer;
|
||||
}
|
||||
|
||||
void context_free(char* context) {
|
||||
free(context);
|
||||
}
|
||||
7
src/context.h
Normal file
7
src/context.h
Normal file
@@ -0,0 +1,7 @@
|
||||
#ifndef OPEN_WING_CONTEXT_H
|
||||
#define OPEN_WING_CONTEXT_H
|
||||
|
||||
char* context_load(const char* path);
|
||||
void context_free(char* context);
|
||||
|
||||
#endif
|
||||
176
src/llm.c
Normal file
176
src/llm.c
Normal file
@@ -0,0 +1,176 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "llm.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
typedef struct {
|
||||
char* data;
|
||||
size_t len;
|
||||
size_t cap;
|
||||
} response_buffer_t;
|
||||
|
||||
static llm_config_t g_cfg;
|
||||
static int g_initialized = 0;
|
||||
|
||||
static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) {
|
||||
response_buffer_t* rb = (response_buffer_t*)userp;
|
||||
size_t total = size * nmemb;
|
||||
|
||||
if (rb->len + total + 1U > rb->cap) {
|
||||
size_t new_cap = rb->cap == 0 ? 1024U : rb->cap * 2U;
|
||||
while (new_cap < rb->len + total + 1U) {
|
||||
new_cap *= 2U;
|
||||
}
|
||||
char* p = (char*)realloc(rb->data, new_cap);
|
||||
if (!p) {
|
||||
return 0;
|
||||
}
|
||||
rb->data = p;
|
||||
rb->cap = new_cap;
|
||||
}
|
||||
|
||||
memcpy(rb->data + rb->len, contents, total);
|
||||
rb->len += total;
|
||||
rb->data[rb->len] = '\0';
|
||||
return total;
|
||||
}
|
||||
|
||||
static char* build_request_json(const char* system_prompt, const char* user_message) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
if (!root || !messages) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(messages);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(root, "model", g_cfg.model);
|
||||
cJSON_AddNumberToObject(root, "max_tokens", g_cfg.max_tokens);
|
||||
cJSON_AddNumberToObject(root, "temperature", g_cfg.temperature);
|
||||
|
||||
cJSON* system_msg = cJSON_CreateObject();
|
||||
cJSON* user_msg = cJSON_CreateObject();
|
||||
if (!system_msg || !user_msg) {
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(system_msg, "role", "system");
|
||||
cJSON_AddStringToObject(system_msg, "content", system_prompt ? system_prompt : "");
|
||||
|
||||
cJSON_AddStringToObject(user_msg, "role", "user");
|
||||
cJSON_AddStringToObject(user_msg, "content", user_message ? user_message : "");
|
||||
|
||||
cJSON_AddItemToArray(messages, system_msg);
|
||||
cJSON_AddItemToArray(messages, user_msg);
|
||||
cJSON_AddItemToObject(root, "messages", messages);
|
||||
|
||||
char* body = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return body;
|
||||
}
|
||||
|
||||
static char* parse_response_content(const char* json) {
|
||||
cJSON* root = cJSON_Parse(json);
|
||||
if (!root) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
|
||||
if (!choices || !cJSON_IsArray(choices) || cJSON_GetArraySize(choices) == 0) {
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* first = cJSON_GetArrayItem(choices, 0);
|
||||
cJSON* msg = cJSON_GetObjectItemCaseSensitive(first, "message");
|
||||
cJSON* content = msg ? cJSON_GetObjectItemCaseSensitive(msg, "content") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring) {
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* out = strdup(content->valuestring);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
int llm_init(const llm_config_t* config) {
|
||||
if (!config) {
|
||||
return -1;
|
||||
}
|
||||
memset(&g_cfg, 0, sizeof(g_cfg));
|
||||
g_cfg = *config;
|
||||
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||
g_initialized = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* llm_chat(const char* system_prompt, const char* user_message) {
|
||||
if (!g_initialized) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* body = build_request_json(system_prompt, user_message);
|
||||
if (!body) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
free(body);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char url[OW_MAX_URL_LEN + 64];
|
||||
snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url);
|
||||
|
||||
response_buffer_t rb = {0};
|
||||
struct curl_slist* headers = NULL;
|
||||
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
|
||||
char auth_header[OW_MAX_KEY_LEN + 32];
|
||||
snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", g_cfg.api_key);
|
||||
headers = curl_slist_append(headers, auth_header);
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
free(body);
|
||||
|
||||
if (res != CURLE_OK || status < 200 || status >= 300 || !rb.data) {
|
||||
free(rb.data);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* answer = parse_response_content(rb.data);
|
||||
free(rb.data);
|
||||
return answer;
|
||||
}
|
||||
|
||||
void llm_cleanup(void) {
|
||||
if (!g_initialized) {
|
||||
return;
|
||||
}
|
||||
curl_global_cleanup();
|
||||
memset(&g_cfg, 0, sizeof(g_cfg));
|
||||
g_initialized = 0;
|
||||
}
|
||||
10
src/llm.h
Normal file
10
src/llm.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#ifndef OPEN_WING_LLM_H
|
||||
#define OPEN_WING_LLM_H
|
||||
|
||||
#include "config.h"
|
||||
|
||||
int llm_init(const llm_config_t* config);
|
||||
char* llm_chat(const char* system_prompt, const char* user_message);
|
||||
void llm_cleanup(void);
|
||||
|
||||
#endif
|
||||
120
src/main.c
Normal file
120
src/main.c
Normal file
@@ -0,0 +1,120 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "agent.h"
|
||||
#include "config.h"
|
||||
#include "context.h"
|
||||
#include "llm.h"
|
||||
#include "nostr_handler.h"
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
static void signal_handler(int signum) {
|
||||
(void)signum;
|
||||
g_running = 0;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char* config_path = "./config.json";
|
||||
const char* context_path = "./SYSTEM.md";
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) {
|
||||
config_path = argv[++i];
|
||||
} else if (strcmp(argv[i], "--context") == 0 && i + 1 < argc) {
|
||||
context_path = argv[++i];
|
||||
} else {
|
||||
fprintf(stderr, "Usage: %s [--config <path>] [--context <path>]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (nostr_init() != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "Failed to initialize nostr core\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
didactyl_config_t cfg;
|
||||
if (config_load(config_path, &cfg) != 0) {
|
||||
fprintf(stderr, "Failed to load config: %s\n", config_path);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
char* system_context = context_load(context_path);
|
||||
if (!system_context) {
|
||||
fprintf(stderr, "Failed to load context file: %s\n", context_path);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (llm_init(&cfg.llm) != 0) {
|
||||
fprintf(stderr, "Failed to initialize llm client\n");
|
||||
context_free(system_context);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (nostr_handler_init(&cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize nostr handler\n");
|
||||
llm_cleanup();
|
||||
context_free(system_context);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (agent_init(&cfg, system_context) != 0) {
|
||||
fprintf(stderr, "Failed to initialize agent\n");
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
context_free(system_context);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (nostr_handler_publish_profile() != 0) {
|
||||
fprintf(stderr, "Warning: failed to publish profile\n");
|
||||
}
|
||||
|
||||
if (nostr_handler_subscribe_dms(agent_on_message, NULL) != 0) {
|
||||
fprintf(stderr, "Failed to subscribe to DMs\n");
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
context_free(system_context);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
|
||||
fprintf(stdout, "[didactyl] running with pubkey %s\n", cfg.keys.public_key_hex);
|
||||
|
||||
while (g_running) {
|
||||
(void)nostr_handler_poll(100);
|
||||
struct timespec ts = {0, 10 * 1000 * 1000};
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] shutting down\n");
|
||||
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
context_free(system_context);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
|
||||
return 0;
|
||||
}
|
||||
391
src/nostr_handler.c
Normal file
391
src/nostr_handler.c
Normal file
@@ -0,0 +1,391 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "nostr_handler.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "../../nostr_core_lib/cjson/cJSON.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static didactyl_config_t* g_cfg = NULL;
|
||||
static nostr_relay_pool_t* g_pool = NULL;
|
||||
static dm_callback_t g_dm_callback = NULL;
|
||||
static void* g_dm_user_data = NULL;
|
||||
static int g_poll_counter = 0;
|
||||
static time_t g_start_time = 0;
|
||||
|
||||
static const char* relay_status_str(nostr_pool_relay_status_t status) {
|
||||
switch (status) {
|
||||
case NOSTR_POOL_RELAY_DISCONNECTED:
|
||||
return "disconnected";
|
||||
case NOSTR_POOL_RELAY_CONNECTING:
|
||||
return "connecting";
|
||||
case NOSTR_POOL_RELAY_CONNECTED:
|
||||
return "connected";
|
||||
case NOSTR_POOL_RELAY_ERROR:
|
||||
return "error";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static void log_relay_statuses(const char* reason) {
|
||||
if (!g_pool || !g_cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] relay status snapshot (%s)\n", reason ? reason : "periodic");
|
||||
for (int i = 0; i < g_cfg->relay_count; i++) {
|
||||
const char* relay = g_cfg->relays[i];
|
||||
nostr_pool_relay_status_t status = nostr_relay_pool_get_relay_status(g_pool, relay);
|
||||
const char* last_err = nostr_relay_pool_get_relay_last_connection_error(g_pool, relay);
|
||||
double ping_ms = nostr_relay_pool_get_relay_ping_latency(g_pool, relay);
|
||||
|
||||
fprintf(stdout, "[didactyl] - %s => %s", relay, relay_status_str(status));
|
||||
if (ping_ms > 0.0) {
|
||||
fprintf(stdout, " (ping %.1f ms)", ping_ms);
|
||||
}
|
||||
if (last_err && last_err[0] != '\0') {
|
||||
fprintf(stdout, " [last_error: %s]", last_err);
|
||||
}
|
||||
fprintf(stdout, "\n");
|
||||
}
|
||||
}
|
||||
|
||||
static void log_publish_targets(const char* action) {
|
||||
if (!g_cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] %s target relays (%d):\n", action ? action : "publish", g_cfg->relay_count);
|
||||
for (int i = 0; i < g_cfg->relay_count; i++) {
|
||||
fprintf(stdout, "[didactyl] -> %s\n", g_cfg->relays[i]);
|
||||
}
|
||||
}
|
||||
|
||||
static int hex_to_pubkey(const char* hex, unsigned char out_pubkey[32]) {
|
||||
if (!hex || !out_pubkey || strlen(hex) != 64U) {
|
||||
return -1;
|
||||
}
|
||||
return nostr_hex_to_bytes(hex, out_pubkey, 32) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
static cJSON* create_dm_tags_for_recipient(const char* recipient_pubkey_hex) {
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
if (!tags) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* p_tag = cJSON_CreateArray();
|
||||
if (!p_tag) {
|
||||
cJSON_Delete(tags);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(p_tag, cJSON_CreateString("p"));
|
||||
cJSON_AddItemToArray(p_tag, cJSON_CreateString(recipient_pubkey_hex));
|
||||
cJSON_AddItemToArray(tags, p_tag);
|
||||
return tags;
|
||||
}
|
||||
|
||||
static int extract_first_p_tag(cJSON* tags, char out_pubkey_hex[65]) {
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* key = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* val = cJSON_GetArrayItem(tag, 1);
|
||||
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(key->valuestring, "p") == 0 && strlen(val->valuestring) == 64U) {
|
||||
memcpy(out_pubkey_hex, val->valuestring, 65U);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
(void)user_data;
|
||||
|
||||
if (!event || !g_cfg || !g_dm_callback) {
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
|
||||
if (!kind || !pubkey || !content || !tags ||
|
||||
!cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) || !cJSON_IsString(content)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int)kind->valuedouble != 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) != 0) {
|
||||
fprintf(stdout, "[didactyl] ignored DM from unauthorized pubkey %.16s... via %s\n",
|
||||
pubkey->valuestring,
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
return;
|
||||
}
|
||||
|
||||
char recipient_pubkey_hex[65] = {0};
|
||||
if (extract_first_p_tag(tags, recipient_pubkey_hex) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(recipient_pubkey_hex, g_cfg->keys.public_key_hex) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
unsigned char sender_pubkey[32];
|
||||
if (hex_to_pubkey(pubkey->valuestring, sender_pubkey) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* decrypted = (char*)malloc(NOSTR_NIP04_MAX_PLAINTEXT_SIZE);
|
||||
if (!decrypted) {
|
||||
fprintf(stderr, "[didactyl] failed to allocate DM decrypt buffer\n");
|
||||
return;
|
||||
}
|
||||
decrypted[0] = '\0';
|
||||
|
||||
if (nostr_nip04_decrypt(g_cfg->keys.private_key, sender_pubkey, content->valuestring, decrypted, NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) {
|
||||
fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", pubkey->valuestring);
|
||||
free(decrypted);
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] received DM from %.16s... via %s\n",
|
||||
pubkey->valuestring,
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
g_dm_callback(pubkey->valuestring, decrypted, g_dm_user_data);
|
||||
free(decrypted);
|
||||
}
|
||||
|
||||
static void on_eose(cJSON** events, int event_count, void* user_data) {
|
||||
(void)events;
|
||||
(void)event_count;
|
||||
(void)user_data;
|
||||
}
|
||||
|
||||
int nostr_handler_init(didactyl_config_t* config) {
|
||||
if (!config) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_cfg = config;
|
||||
g_poll_counter = 0;
|
||||
g_start_time = time(NULL);
|
||||
|
||||
fprintf(stdout, "[didactyl] initializing relay pool with %d relays\n", g_cfg->relay_count);
|
||||
|
||||
nostr_pool_reconnect_config_t reconnect = *nostr_pool_reconnect_config_default();
|
||||
reconnect.enable_auto_reconnect = 1;
|
||||
reconnect.ping_interval_seconds = 20;
|
||||
reconnect.pong_timeout_seconds = 10;
|
||||
|
||||
g_pool = nostr_relay_pool_create(&reconnect);
|
||||
if (!g_pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < g_cfg->relay_count; i++) {
|
||||
if (nostr_relay_pool_add_relay(g_pool, g_cfg->relays[i]) != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "[didactyl] failed to add relay: %s\n", g_cfg->relays[i]);
|
||||
return -1;
|
||||
}
|
||||
fprintf(stdout, "[didactyl] added relay: %s\n", g_cfg->relays[i]);
|
||||
}
|
||||
|
||||
log_relay_statuses("after init");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int nostr_handler_publish_profile(void) {
|
||||
if (!g_cfg || !g_pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* profile = cJSON_CreateObject();
|
||||
if (!profile) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(profile, "name", g_cfg->profile.name);
|
||||
cJSON_AddStringToObject(profile, "display_name", g_cfg->profile.display_name);
|
||||
cJSON_AddStringToObject(profile, "about", g_cfg->profile.about);
|
||||
cJSON_AddStringToObject(profile, "picture", g_cfg->profile.picture);
|
||||
if (g_cfg->profile.nip05[0] != '\0') {
|
||||
cJSON_AddStringToObject(profile, "nip05", g_cfg->profile.nip05);
|
||||
}
|
||||
|
||||
char* content = cJSON_PrintUnformatted(profile);
|
||||
cJSON_Delete(profile);
|
||||
if (!content) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* event = nostr_create_and_sign_event(0, content, NULL, g_cfg->keys.private_key, time(NULL));
|
||||
free(content);
|
||||
|
||||
if (!event) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
log_publish_targets("publish profile");
|
||||
|
||||
int sent = nostr_relay_pool_publish_async(
|
||||
g_pool,
|
||||
(const char**)g_cfg->relays,
|
||||
g_cfg->relay_count,
|
||||
event,
|
||||
NULL,
|
||||
NULL);
|
||||
|
||||
cJSON_Delete(event);
|
||||
fprintf(stdout, "[didactyl] publish profile result: sent_to=%d relays\n", sent);
|
||||
return sent > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
|
||||
if (!g_cfg || !g_pool || !callback) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_dm_callback = callback;
|
||||
g_dm_user_data = user_data;
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* p_values = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !p_values) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(p_values);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(p_values, cJSON_CreateString(g_cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "#p", p_values);
|
||||
cJSON_AddNumberToObject(filter, "since", (double)g_start_time);
|
||||
cJSON_AddNumberToObject(filter, "limit", 100);
|
||||
|
||||
nostr_pool_subscription_t* sub = nostr_relay_pool_subscribe(
|
||||
g_pool,
|
||||
(const char**)g_cfg->relays,
|
||||
g_cfg->relay_count,
|
||||
filter,
|
||||
on_event,
|
||||
on_eose,
|
||||
NULL,
|
||||
0,
|
||||
1,
|
||||
NOSTR_POOL_EOSE_FULL_SET,
|
||||
30,
|
||||
120);
|
||||
|
||||
cJSON_Delete(filter);
|
||||
if (!sub) {
|
||||
fprintf(stderr, "[didactyl] DM subscription failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] DM subscription active for pubkey %.16s...\n", g_cfg->keys.public_key_hex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message) {
|
||||
if (!g_cfg || !g_pool || !recipient_pubkey_hex || !message) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
unsigned char recipient_pubkey[32];
|
||||
if (hex_to_pubkey(recipient_pubkey_hex, recipient_pubkey) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* encrypted = (char*)malloc(NOSTR_NIP04_MAX_ENCRYPTED_SIZE);
|
||||
if (!encrypted) {
|
||||
fprintf(stderr, "[didactyl] failed to allocate DM encrypt buffer\n");
|
||||
return -1;
|
||||
}
|
||||
encrypted[0] = '\0';
|
||||
|
||||
if (nostr_nip04_encrypt(g_cfg->keys.private_key, recipient_pubkey, message, encrypted, NOSTR_NIP04_MAX_ENCRYPTED_SIZE) != NOSTR_SUCCESS) {
|
||||
free(encrypted);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* tags = create_dm_tags_for_recipient(recipient_pubkey_hex);
|
||||
if (!tags) {
|
||||
free(encrypted);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* event = nostr_create_and_sign_event(4, encrypted, tags, g_cfg->keys.private_key, time(NULL));
|
||||
cJSON_Delete(tags);
|
||||
free(encrypted);
|
||||
if (!event) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
log_publish_targets("publish DM");
|
||||
|
||||
int sent = nostr_relay_pool_publish_async(
|
||||
g_pool,
|
||||
(const char**)g_cfg->relays,
|
||||
g_cfg->relay_count,
|
||||
event,
|
||||
NULL,
|
||||
NULL);
|
||||
|
||||
cJSON_Delete(event);
|
||||
fprintf(stdout, "[didactyl] sent DM to %.16s... via %d relay(s)\n", recipient_pubkey_hex, sent);
|
||||
return sent > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int nostr_handler_poll(int timeout_ms) {
|
||||
if (!g_pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rc = nostr_relay_pool_poll(g_pool, timeout_ms);
|
||||
g_poll_counter++;
|
||||
|
||||
if ((g_poll_counter % 600) == 0) {
|
||||
log_relay_statuses("periodic");
|
||||
}
|
||||
|
||||
return rc;
|
||||
}
|
||||
|
||||
void nostr_handler_cleanup(void) {
|
||||
if (g_pool) {
|
||||
nostr_relay_pool_destroy(g_pool);
|
||||
}
|
||||
|
||||
g_pool = NULL;
|
||||
g_cfg = NULL;
|
||||
g_dm_callback = NULL;
|
||||
g_dm_user_data = NULL;
|
||||
}
|
||||
15
src/nostr_handler.h
Normal file
15
src/nostr_handler.h
Normal file
@@ -0,0 +1,15 @@
|
||||
#ifndef OPEN_WING_NOSTR_HANDLER_H
|
||||
#define OPEN_WING_NOSTR_HANDLER_H
|
||||
|
||||
#include "config.h"
|
||||
|
||||
typedef void (*dm_callback_t)(const char* sender_pubkey_hex, const char* message, void* user_data);
|
||||
|
||||
int nostr_handler_init(didactyl_config_t* config);
|
||||
int nostr_handler_publish_profile(void);
|
||||
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
|
||||
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
|
||||
int nostr_handler_poll(int timeout_ms);
|
||||
void nostr_handler_cleanup(void);
|
||||
|
||||
#endif
|
||||
13
src/secp_compat.c
Normal file
13
src/secp_compat.c
Normal file
@@ -0,0 +1,13 @@
|
||||
#include <secp256k1.h>
|
||||
#include <secp256k1_extrakeys.h>
|
||||
#include <secp256k1_schnorrsig.h>
|
||||
|
||||
int secp256k1_schnorrsig_sign32(
|
||||
const secp256k1_context* ctx,
|
||||
unsigned char* sig64,
|
||||
const unsigned char* msg32,
|
||||
const secp256k1_keypair* keypair,
|
||||
const unsigned char* aux_rand32
|
||||
) {
|
||||
return secp256k1_schnorrsig_sign(ctx, sig64, msg32, keypair, aux_rand32);
|
||||
}
|
||||
Reference in New Issue
Block a user