From 36a2e63c6ecd6b270f9a189b7805eb6257f33297 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 27 Feb 2026 09:32:51 -0400 Subject: [PATCH] First commit --- .gitignore | 4 + Makefile | 38 ++++ README.md | 166 +++++++++++++++ SYSTEM.md | 16 ++ plans/didactyl_agentic.md | 397 ++++++++++++++++++++++++++++++++++++ plans/didactyl_mvp.md | 414 ++++++++++++++++++++++++++++++++++++++ src/agent.c | 58 ++++++ src/agent.h | 10 + src/config.c | 275 +++++++++++++++++++++++++ src/config.h | 52 +++++ src/context.c | 54 +++++ src/context.h | 7 + src/llm.c | 176 ++++++++++++++++ src/llm.h | 10 + src/main.c | 120 +++++++++++ src/nostr_handler.c | 391 +++++++++++++++++++++++++++++++++++ src/nostr_handler.h | 15 ++ src/secp_compat.c | 13 ++ 18 files changed, 2216 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 SYSTEM.md create mode 100644 plans/didactyl_agentic.md create mode 100644 plans/didactyl_mvp.md create mode 100644 src/agent.c create mode 100644 src/agent.h create mode 100644 src/config.c create mode 100644 src/config.h create mode 100644 src/context.c create mode 100644 src/context.h create mode 100644 src/llm.c create mode 100644 src/llm.h create mode 100644 src/main.c create mode 100644 src/nostr_handler.c create mode 100644 src/nostr_handler.h create mode 100644 src/secp_compat.c diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f9ee2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/c_utils_lib/ +/nostr_core_lib/ +/openclaw/ +/nips/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..72cd2ee --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f843a68 --- /dev/null +++ b/README.md @@ -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 # custom config file (default: ./config.json) +./didactyl --context # 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 diff --git a/SYSTEM.md b/SYSTEM.md new file mode 100644 index 0000000..3476b46 --- /dev/null +++ b/SYSTEM.md @@ -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. diff --git a/plans/didactyl_agentic.md b/plans/didactyl_agentic.md new file mode 100644 index 0000000..acb0364 --- /dev/null +++ b/plans/didactyl_agentic.md @@ -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 diff --git a/plans/didactyl_mvp.md b/plans/didactyl_mvp.md new file mode 100644 index 0000000..25ac149 --- /dev/null +++ b/plans/didactyl_mvp.md @@ -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": ""}, + {"role": "user", "content": ""} + ], + "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 diff --git a/src/agent.c b/src/agent.c new file mode 100644 index 0000000..cfa7b71 --- /dev/null +++ b/src/agent.c @@ -0,0 +1,58 @@ +#define _POSIX_C_SOURCE 200809L + +#include "agent.h" + +#include +#include +#include + +#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; +} diff --git a/src/agent.h b/src/agent.h new file mode 100644 index 0000000..fcccaec --- /dev/null +++ b/src/agent.h @@ -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 \ No newline at end of file diff --git a/src/config.c b/src/config.c new file mode 100644 index 0000000..7055778 --- /dev/null +++ b/src/config.c @@ -0,0 +1,275 @@ +#define _POSIX_C_SOURCE 200809L + +#include "config.h" + +#include +#include +#include +#include + +#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; +} diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..dbb6438 --- /dev/null +++ b/src/config.h @@ -0,0 +1,52 @@ +#ifndef OPEN_WING_CONFIG_H +#define OPEN_WING_CONFIG_H + +#include + +#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 \ No newline at end of file diff --git a/src/context.c b/src/context.c new file mode 100644 index 0000000..c3d8a9a --- /dev/null +++ b/src/context.c @@ -0,0 +1,54 @@ +#define _POSIX_C_SOURCE 200809L + +#include "context.h" + +#include +#include + +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); +} diff --git a/src/context.h b/src/context.h new file mode 100644 index 0000000..af7717b --- /dev/null +++ b/src/context.h @@ -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 \ No newline at end of file diff --git a/src/llm.c b/src/llm.c new file mode 100644 index 0000000..9ebf7e0 --- /dev/null +++ b/src/llm.c @@ -0,0 +1,176 @@ +#define _POSIX_C_SOURCE 200809L + +#include "llm.h" + +#include +#include +#include +#include + +#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; +} diff --git a/src/llm.h b/src/llm.h new file mode 100644 index 0000000..544062a --- /dev/null +++ b/src/llm.h @@ -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 \ No newline at end of file diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..31a7b49 --- /dev/null +++ b/src/main.c @@ -0,0 +1,120 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#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 ] [--context ]\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; +} diff --git a/src/nostr_handler.c b/src/nostr_handler.c new file mode 100644 index 0000000..b0c7bd9 --- /dev/null +++ b/src/nostr_handler.c @@ -0,0 +1,391 @@ +#define _POSIX_C_SOURCE 200809L + +#include "nostr_handler.h" + +#include +#include +#include +#include + +#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; +} diff --git a/src/nostr_handler.h b/src/nostr_handler.h new file mode 100644 index 0000000..541b552 --- /dev/null +++ b/src/nostr_handler.h @@ -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 \ No newline at end of file diff --git a/src/secp_compat.c b/src/secp_compat.c new file mode 100644 index 0000000..be98a01 --- /dev/null +++ b/src/secp_compat.c @@ -0,0 +1,13 @@ +#include +#include +#include + +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); +}