First commit

This commit is contained in:
Your Name
2026-02-27 09:32:51 -04:00
commit 36a2e63c6e
18 changed files with 2216 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/c_utils_lib/
/nostr_core_lib/
/openclaw/
/nips/

38
Makefile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}