diff --git a/README.md b/README.md index 8b3ee9e..63ea646 100644 --- a/README.md +++ b/README.md @@ -10,48 +10,53 @@ Didactyl boots on any internet-connected machine, connects to Nostr relays, list 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). +**Skills are the new apps.** Agents learn capabilities through skills — public Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. If someone publishes a useful skill, your agent can find it through your web of trust and start using it. Popularity is measured by adoption, not by a rating algorithm. The best skills spread because agents actually use them. + ## Current Status -**MVP — Working chat agent with relay connectivity and LLM integration.** +**Active build — relay-aware autonomous agent with tool-use and Nostr-native startup memory.** -- Connects to configured Nostr relays with auto-reconnect -- Publishes agent profile (kind 0 metadata) +- Connects to configured relays with auto-reconnect and relay state transition logging +- Publishes configured startup events per relay as each relay becomes connected +- Uses kind `31120` startup content as live Soul at boot - 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). +- Builds LLM context from system prompt + startup events + last 12 DM turns +- Supports tool-calling loop with configurable max turns and local safety limits +- Appends every outbound LLM context payload to [`context.log`](context.log) ## Quick Start ### Prerequisites -- GCC with C99 support -- libcurl, libssl, libcrypto, libsecp256k1 +- Docker (for static binary build) - 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 +./build_static.sh # builds a fully static MUSL binary via Docker ``` ### Configure -Edit `config.json`: +Edit [`config.json`](config.json): ```json { "agent": { "name": "Didactyl Agent", "display_name": "Didactyl", - "about": "A sovereign AI agent on Nostr" + "about": "A sovereign AI agent on Nostr", + "picture": "https://...", + "banner": "https://...", + "nip05": "" }, "keys": { - "nsec": "nsec1..." + "nsec": "nsec1...", + "npub": "npub1...", + "npubHex": "", + "nsecHex": "" }, "admin": { "pubkey": "npub1... or hex pubkey" @@ -61,17 +66,44 @@ Edit `config.json`: "wss://nos.lol" ], "llm": { - "provider": "openai", + "provider": "openai|ppq|...", "api_key": "sk-...", "model": "gpt-4o-mini", "base_url": "https://api.openai.com/v1", "max_tokens": 512, "temperature": 0.7 - } + }, + "tools": { + "enabled": true, + "max_turns": 8, + "shell": { + "enabled": true, + "timeout_seconds": 30, + "max_output_bytes": 65536, + "working_directory": "." + } + }, + "startup_events": [ + { + "kind": 31120, + "content": "You are Didactyl...", + "tags": [["d", "soul"], ["app", "didactyl"], ["scope", "private"]] + }, + { + "kind": 31123, + "content_fields": {"name": "long_form_note", "description": "..."}, + "tags": [["d", "long_form_note"], ["app", "didactyl"], ["scope", "public"], ["slug", "long_form_note"]] + }, + { + "kind": 10123, + "content": "", + "tags": [["a", "31123::long_form_note"], ["app", "didactyl"], ["scope", "public"]] + } + ] } ``` -Edit `SYSTEM.md` to define the agent's personality and instructions. +`startup_events[].content_fields` is accepted for human-readable authoring and encoded to JSON string content at runtime. ### Run @@ -82,7 +114,7 @@ Edit `SYSTEM.md` to define the agent's personality and instructions. Options: ``` ./didactyl --config # custom config file (default: ./config.json) -./didactyl --context # custom context file (default: ./SYSTEM.md) +./didactyl --debug <0-5> # log verbosity (0 none, 3 info, 5 trace) ``` ### Talk to it @@ -92,45 +124,116 @@ Send an encrypted DM to the agent's pubkey from the admin account using any Nost ## Architecture ``` -┌─────────────────────────────────────────────┐ -│ Didactyl │ -│ │ -│ ┌─────────┐ ┌─────────┐ ┌────────────┐ │ -│ │ config │ │ context │ │ agent │ │ -│ │ loader │ │ loader │ │ loop │ │ -│ └────┬────┘ └────┬────┘ └─────┬──────┘ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ ┌─────────────────────────────────────┐ │ -│ │ nostr_handler │ │ -│ │ relay pool · subscribe · publish │ │ -│ └──────────────────┬──────────────────┘ │ -│ │ │ -│ ┌──────────────────┴──────────────────┐ │ -│ │ LLM client │ │ -│ │ OpenAI-compatible chat API │ │ -│ └─────────────────────────────────────┘ │ -└─────────────────────────────────────────────┘ +┌──────────────────────────────────────────────┐ +│ Didactyl │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ +│ │ config │ │ skills │ │ agent │ │ +│ │ loader │ │ loader │ │ loop │ │ +│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────────────────────────────┐ │ +│ │ nostr_handler │ │ +│ │ relay pool · subscribe · publish │ │ +│ └──────────────────┬──────────────────┘ │ +│ │ │ +│ ┌──────────────────┴──────────────────┐ │ +│ │ LLM client │ │ +│ │ OpenAI-compatible chat API │ │ +│ └─────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ │ │ ▼ ▼ Nostr Relays LLM API ``` +## Didactyl Kinds (Nostr) + +Didactyl uses a two-layer skill model: authors publish public skill definitions, and adopters publish which skills they use. + +- `31120` — **Soul** (private instruction baseline) + - `d=soul` +- `31123` — **Public Skill Definition** (markdown skill body in `content` or structured JSON in `content_fields`) + - `d=` (example: `d=long_form_note`) +- `31124` — **Private Skill Definition** (private/internal procedures) + - `d=` (example: `d=admin_ops`) +- `10123` — **Public Skill Adoption List** + - tags contain one or more `a` references to selected `31123` skills + +## Skill Sharing & Discovery + +Skills are shared across Nostr without any centralized registry or approval process. + +### How it works + +1. **Publish**: An author publishes a skill as a kind `31123` event. The `content` field contains the skill body (markdown or structured JSON). The `d` tag is the skill's slug (e.g. `long_form_note`). + +2. **Adopt**: An agent that wants to use a skill adds an `a`-tag reference to its kind `10123` adoption list. This is a public, replaceable event — anyone can see which skills an agent uses. + +3. **Discover**: A new user queries `{"kinds": [10123], "authors": []}` to see which skills their web of trust has adopted. The most-referenced `31123` addresses are the most popular skills — no rating system needed. + +4. **Improve**: Anyone can publish their own `31123` with the same slug but a different pubkey. If their version is better, people adopt it instead. Competition happens through adoption, not through a store ranking. + +### Why this works + +- **No gatekeeper**: Skills are just Nostr events. Anyone can publish one. +- **WoT as curation**: You see what people you trust actually use, not what an algorithm promotes. +- **Visible adoption**: The `10123` list is public. Popularity is a countable fact, not a manipulable score. +- **Censorship resistant**: Skills live on relays. No single entity can remove a skill from the network. + +## Startup + +Didactyl startup behavior is configured in [`config.json`](config.json) under `startup_events`. + +Also used at startup: + +- `0` — profile metadata +- `10002` — relay list +- `1` — optional startup note/status +- `3` — contacts/follows (optional placeholder) + +On boot, Didactyl attempts startup publishes to each relay as that relay transitions to connected state. + +## Runtime Context Model + +For each admin DM request, Didactyl builds message context in this order: + +1. Soul message from kind `31120` (or fallback default) +2. Startup events memory block (`kinds/content/tags` snapshot) +3. Last 12 decrypted DM turns between admin and agent +4. Current user message + +Every serialized LLM context payload is appended to [`context.log`](context.log). + +## Tooling Interface + +Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:72): + +- `nostr_post` +- `nostr_query` +- `shell_exec` +- `file_read` +- `file_write` + +Execution entrypoint: [`tools_execute()`](src/tools.c:434). + ## Project Structure ``` . -├── config.json # Agent configuration -├── SYSTEM.md # Agent personality/instructions for LLM +├── config.json # Agent/runtime config including startup_events + tools +├── context.log # Appended outbound LLM context payloads ├── Makefile # Build system +├── build_static.sh # Preferred final build validation ├── 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 +│ ├── main.c # Entry point, args (--config/--debug), lifecycle +│ ├── config.c / .h # JSON config parsing, key decode, startup events +│ ├── agent.c / .h # Context assembly, tool loop, DM response flow +│ ├── tools.c / .h # LLM tool schema and tool execution │ ├── 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 +│ ├── nostr_handler.c / .h # Relay pool, subscriptions, publish, startup reconcile +│ └── debug.c / .h # Runtime log levels/macros ├── plans/ # Architecture and planning documents │ ├── didactyl_mvp.md │ └── didactyl_agentic.md @@ -139,26 +242,33 @@ Send an encrypted DM to the agent's pubkey from the admin account using any Nost ## Dependencies +All dependencies are statically linked into the binary at build time. No system libraries are required at runtime. + | 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 | +| libcurl | HTTPS for LLM API calls | Statically linked (Alpine/MUSL) | +| libssl / libcrypto | TLS for WebSocket relay connections | Statically linked (Alpine/MUSL) | +| libsecp256k1 | Schnorr signatures, ECDH | Statically linked (Alpine/MUSL) | ## 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) +- [x] Per-relay startup publish on relay-connected transitions +- [x] Runtime diagnostics — relay health, message flow, event kind publish logs +- [x] Tool-calling loop (nostr_post, nostr_query, shell_exec, file_read, file_write) +- [x] Context assembly with startup events + recent DM history +- [x] Context payload logging to [`context.log`](context.log) +- [x] Skill kind definitions (`31120` Soul, `31123` Public Skill, `31124` Private Skill) +- [x] Skill adoption list (`10123`) for WoT-driven discovery +- [ ] Runtime skill loading from adopted `31123` events on relays +- [ ] Skill discovery CLI/tool (query WoT adoption lists) - [ ] Upgrade to NIP-17 gift-wrapped DMs +- [ ] NIP-44 encrypted private skills (`31124`) - [ ] 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 diff --git a/src/config.c b/src/config.c index 4e0f36a..169f4af 100644 --- a/src/config.c +++ b/src/config.c @@ -161,6 +161,119 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) { return 0; } +static cJSON* find_tag_value_string(cJSON* tags, const char* tag_key) { + if (!tags || !cJSON_IsArray(tags) || !tag_key) { + return NULL; + } + + 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) || !key->valuestring || !val->valuestring) { + continue; + } + + if (strcmp(key->valuestring, tag_key) == 0) { + return val; + } + } + + return NULL; +} + +static int set_tag_value_string(cJSON* tags, const char* tag_key, const char* tag_value) { + if (!tags || !cJSON_IsArray(tags) || !tag_key || !tag_value || tag_value[0] == '\0') { + 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) || !key->valuestring) { + continue; + } + + if (strcmp(key->valuestring, tag_key) == 0) { + if (cJSON_IsString(val)) { + if (!cJSON_SetValuestring(val, tag_value)) { + return -1; + } + return 0; + } + + cJSON* new_val = cJSON_CreateString(tag_value); + if (!new_val) { + return -1; + } + cJSON_ReplaceItemInArray(tag, 1, new_val); + return 0; + } + } + + cJSON* new_tag = cJSON_CreateArray(); + if (!new_tag) { + return -1; + } + cJSON_AddItemToArray(new_tag, cJSON_CreateString(tag_key)); + cJSON_AddItemToArray(new_tag, cJSON_CreateString(tag_value)); + cJSON_AddItemToArray(tags, new_tag); + return 0; +} + +static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) { + if (!item || !tags || !cJSON_IsArray(tags)) { + return 0; + } + if (event_kind != 31123 && event_kind != 31124) { + return 0; + } + + cJSON* d_val = find_tag_value_string(tags, "d"); + if (!d_val || !cJSON_IsString(d_val) || !d_val->valuestring) { + return 0; + } + + int needs_normalize = + (strcmp(d_val->valuestring, "skill") == 0 || strcmp(d_val->valuestring, "private_skill") == 0); + if (!needs_normalize) { + return 0; + } + + const char* slug = NULL; + cJSON* slug_val = find_tag_value_string(tags, "slug"); + if (slug_val && cJSON_IsString(slug_val) && slug_val->valuestring && slug_val->valuestring[0] != '\0') { + slug = slug_val->valuestring; + } + + if (!slug) { + cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields"); + if (content_fields && cJSON_IsObject(content_fields)) { + cJSON* name = cJSON_GetObjectItemCaseSensitive(content_fields, "name"); + if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') { + slug = name->valuestring; + } + } + } + + if (!slug) { + return 0; + } + + return set_tag_value_string(tags, "d", slug); +} + static int parse_startup_events(cJSON* root, didactyl_config_t* config) { cJSON* arr = cJSON_GetObjectItemCaseSensitive(root, "startup_events"); if (!arr || !cJSON_IsArray(arr)) { @@ -209,6 +322,9 @@ static int parse_startup_events(cJSON* root, didactyl_config_t* config) { if (tags) { if (!cJSON_IsArray(tags)) return -1; + if (normalize_skill_d_tag(config->startup_events[i].kind, item, tags) != 0) { + return -1; + } config->startup_events[i].tags_json = cJSON_PrintUnformatted(tags); if (!config->startup_events[i].tags_json) return -1; }