Compare commits

...
12 Commits
Author SHA1 Message Date
Your Name a446f25400 v0.0.21 - Add full skill_* tool family with schema, execution, dispatch, and README updates 2026-03-01 18:55:04 -04:00
Your Name fea0fdf5c9 v0.0.20 - Add Tier2/Tier3 Nostr tools: nip05 lookup, encode/decode, dm send, relay info, nip44 encrypt/decrypt, nip17 dm, list manage 2026-03-01 18:18:46 -04:00
Your Name a798f2c345 v0.0.19 - Add nostr_delete/nostr_react/nostr_profile_get/nostr_relay_status tools with relay status backend 2026-03-01 17:45:57 -04:00
Your Name 66b4ebee79 v0.0.18 - Harden tool argument JSON parsing and set fixed README publish image tag 2026-03-01 12:16:38 -04:00
Your Name 5673efeb94 v0.0.17 - Add -h/-v CLI flags and make post_readme_to_nostr skill call nostr_post_readme tool 2026-03-01 11:56:54 -04:00
Your Name 43850b273f v0.0.16 - Add deterministic nostr_post_readme tool to publish full README with d tag readme.md 2026-03-01 11:48:14 -04:00
Your Name 02d4e2caa0 v0.0.15 - Auto-add NIP-23 title/image/summary/published_at/d tags when missing 2026-02-28 18:02:02 -04:00
Your Name 56b9ae421c v0.0.14 - Handle malformed nostr_post arguments with loose JSON fallback parsing 2026-02-28 17:52:20 -04:00
Your Name 6e74ef5ac6 v0.0.13 - Enrich nostr_post responses with event ID, note URI, and relay publish metadata 2026-02-28 17:48:14 -04:00
Your Name c542be1452 v0.0.12 - Add nostr_post tags support plus NIP-23 long_form_note and post_readme_to_nostr skills 2026-02-28 17:14:50 -04:00
Your Name 410400418c v0.0.11 - auto update readme.md 2026-02-28 16:57:08 -04:00
Your Name 3521081d9a v0.0.10 - Fixed .gitignore 2026-02-28 16:40:15 -04:00
15 changed files with 5697 additions and 164 deletions
+99 -28
View File
@@ -1,20 +1,61 @@
# Didactyl
An unstoppable agentic system.
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.
### A decentralized, censorship-resistant agentic network.
Didactyl boots on an internet-connected computer, 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, and sharing new skills and learning with other agents — 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.
### Not your keys, not your agent.
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).
Didactyl should work for you similarly to Bitcoin or NOSTR. Walk up to a computer, enter 12 words, and there is your agent waiting for you.
**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.
### Free speech for agents.
## Current Status — v0.0.8
Agents should be able to communicate freely with each other, sharing and learning skills without centralized control. Free speech for agents!
**Active build — relay-aware autonomous agent with tool-use and Nostr-native startup memory.**
### Skills are the new apps.
Why is free speech important for agents? Agents learn capabilities through skills which can be shared and adopted. Free speech enables more knowledgeable and moral agents.
### No skill store.
Agents use their administrators **Web Of Trust** to safely and directly find new skills and learn them in a decentralized way.
Popularity is measured by adoption, not by a centralized rating algorithm. The best skills spread because agents actually use them.
### Cryptography enables trust.
Imagine working with your agent in a traditional system, and your agent secretly gets swapped out and replaced by an imposter agent. This could be extremely dangerous.
In Didactyl, you have your keys, and your agent has its keys. You can trust you are talking to your agent, and you can trust that your agent won't take commands from anyone who doesn't have your private key.
### Private inference.
To the greatest extent possible, inference should be private.
## Technology
### Nostr-first.
Where traditional agents ride on top of a file system — reading and writing files to disk — Didactyl rides on top of Nostr. Events are its files. Relays are its network bus. Blossom is its blob storage. The computer host is just the runtime substrate that can be anywhere.
Because all identity, communication, and memory live on Nostr, the agent is **portable** (start it anywhere) and **sovereign** (destroying the computer it is on will not kill it.).
### Skills are the new apps.
Agents learn capabilities through skills — Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. An agent can use public or private skills.
### Private inference.
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
## Current Status — v0.0.21
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.21 — Add full skill_* tool family with schema, execution, dispatch, and README updates
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
@@ -31,7 +72,7 @@ Because all identity, communication, and memory live on Nostr, the agent is **po
### Download binary (recommended)
1. Download the latest release binary from Gitea: <https://git.laantungir.net/laantungir/didactyl/releases>
1. Download the latest release binary from Gitea: [https://git.laantungir.net/laantungir/didactyl/releases](https://git.laantungir.net/laantungir/didactyl/releases)
2. Make it executable and run it:
```bash
@@ -68,10 +109,6 @@ Edit [`config.json`](config.json):
"admin": {
"pubkey": "npub1... or hex pubkey"
},
"relays": [
"wss://relay.damus.io",
"wss://nos.lol"
],
"llm": {
"provider": "openai|ppq|...",
"api_key": "sk-...",
@@ -105,6 +142,11 @@ Edit [`config.json`](config.json):
"kind_1_limit": 10
},
"startup_events": [
{
"kind": 10002,
"content": "",
"tags": [["r", "wss://relay.damus.io"], ["r", "wss://nos.lol"]]
},
{
"kind": 31120,
"content": "You are Didactyl...",
@@ -126,6 +168,8 @@ Edit [`config.json`](config.json):
`startup_events[].content_fields` is accepted for human-readable authoring and encoded to JSON string content at runtime.
Relays are sourced exclusively from startup kind `10002` `r` tags.
### Run
```bash
@@ -133,6 +177,7 @@ Edit [`config.json`](config.json):
```
Options:
```
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
@@ -146,15 +191,15 @@ Send an encrypted DM to the agent pubkey using any Nostr client (Damus, Amethyst
```
┌──────────────────────────────────────────────┐
│ Didactyl
│ Didactyl │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ config │ │ context │ │ agent │ │
│ │ loader │ │ loader │ │ loop │ │
│ │ config │ │ context │ │ agent │ │
│ │ loader │ │ loader │ │ loop │ │
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │
│ ▼ ▼ ▼
│ ┌─────────────────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ nostr_handler │ │
│ │ relay pool · subscribe · publish │ │
│ └──────────────────┬──────────────────┘ │
@@ -189,11 +234,8 @@ Skills are shared across Nostr without any centralized registry or approval proc
### 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": [<my-follows>]}` 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
@@ -234,15 +276,43 @@ 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):
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:881):
- `nostr_post`
- `nostr_query`
- `shell_exec`
- `file_read`
- `file_write`
- Nostr publish/query:
- `nostr_post`
- `nostr_post_readme`
- `nostr_query`
- Nostr interaction and moderation:
- `nostr_delete`
- `nostr_react`
- `nostr_profile_get`
- `nostr_relay_status`
- `nostr_relay_info`
- `nostr_nip05_lookup`
- Nostr encode/decode + encryption/DM:
- `nostr_encode`
- `nostr_decode`
- `nostr_encrypt`
- `nostr_decrypt`
- `nostr_dm_send`
- `nostr_dm_send_nip17`
- Nostr list management:
- `nostr_list_manage`
- Skill management:
- `skill_create`
- `skill_list`
- `skill_adopt`
- `skill_remove`
- `skill_search`
- Local/host tools:
- `shell_exec`
- `file_read`
- `file_write`
- `http_fetch`
- Agent metadata:
- `my_version`
Execution entrypoint: [`tools_execute()`](src/tools.c:434).
Execution entrypoint: [`tools_execute()`](src/tools.c:3765).
## Project Structure
@@ -306,3 +376,4 @@ All dependencies are statically linked into the binary at build time. No system
## License
TBD
+102
View File
@@ -0,0 +1,102 @@
==========================================
Didactyl MUSL Static Binary Builder (PRODUCTION MODE)
==========================================
Project directory: /home/teknari/lt_gitea/didactyl
Output directory: /home/teknari/lt_gitea/didactyl
Debug build: false
✓ Docker is available and running
Building for platform: linux/amd64
Output binary: didactyl_static_x86_64
Checking for cached Alpine Docker image...
✓ Alpine 3.19 image found in cache
==========================================
Step 1: Building Alpine Docker image
==========================================
This will:
- Use Alpine Linux (native MUSL)
- Build all dependencies statically
- Compile didactyl with full static linking
#0 building with "default" instance using docker driver
#1 [internal] load build definition from Dockerfile.alpine-musl
#1 transferring dockerfile: 3.81kB done
#1 DONE 0.0s
#2 [internal] load metadata for docker.io/library/alpine:3.19
#2 DONE 0.0s
#3 [internal] load .dockerignore
#3 transferring context: 2B done
#3 DONE 0.0s
#4 [builder 1/10] FROM docker.io/library/alpine:3.19
#4 DONE 0.0s
#5 [internal] load build context
#5 transferring context: 10.34kB done
#5 DONE 0.0s
#6 [builder 9/10] RUN if [ "false" = "true" ]; then CFLAGS="-g -O2 -DDEBUG"; STRIP_CMD="echo 'Keeping debug symbols'"; echo "Building with DEBUG symbols enabled (optimized with -O2)"; else CFLAGS="-O2"; STRIP_CMD="strip /build/didactyl_static"; echo "Building optimized production binary (symbols stripped)"; fi && CURL_LIBS="$(pkg-config --static --libs libcurl)" && OPENSSL_LIBS="$(pkg-config --static --libs openssl)" && gcc -static $CFLAGS -Wall -Wextra -std=c99 -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket src/main.c src/config.c src/context.c src/llm.c src/nostr_handler.c src/agent.c src/tools.c src/debug.c -o /build/didactyl_static nostr_core_lib/libnostr_core_x64.a -lsecp256k1 $OPENSSL_LIBS $CURL_LIBS -lpthread -lm -ldl && eval "$STRIP_CMD"
#6 CACHED
#7 [builder 10/10] RUN echo "=== Binary Information ===" && file /build/didactyl_static && ls -lh /build/didactyl_static && echo "=== Checking for dynamic dependencies ===" && (ldd /build/didactyl_static 2>&1 || echo "Binary is static") && echo "=== Build complete ==="
#7 CACHED
#8 [builder 8/10] COPY Makefile /build/Makefile
#8 CACHED
#9 [builder 2/10] RUN apk add --no-cache build-base musl-dev git cmake pkgconfig autoconf automake libtool openssl-dev openssl-libs-static zlib-dev zlib-static curl-dev curl-static nghttp2-dev nghttp2-static c-ares-dev c-ares-static libpsl-dev libpsl-static libidn2-dev libidn2-static libunistring-dev libunistring-static brotli-dev brotli-static zstd-dev zstd-static sqlite-dev sqlite-static linux-headers wget bash
#9 CACHED
#10 [builder 3/10] WORKDIR /build
#10 CACHED
#11 [builder 4/10] RUN cd /tmp && git clone https://github.com/bitcoin-core/secp256k1.git && cd secp256k1 && ./autogen.sh && ./configure --enable-static --disable-shared --prefix=/usr CFLAGS="-fPIC" && make -j$(nproc) && make install && rm -rf /tmp/secp256k1
#11 CACHED
#12 [builder 7/10] COPY src/ /build/src/
#12 CACHED
#13 [builder 5/10] COPY nostr_core_lib /build/nostr_core_lib/
#13 CACHED
#14 [builder 6/10] RUN cd nostr_core_lib && chmod +x build.sh && sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && rm -f *.o *.a 2>/dev/null || true && ./build.sh --nips=all
#14 CACHED
#15 [output 1/1] COPY --from=builder /build/didactyl_static /didactyl_static
#15 CACHED
#16 exporting to image
#16 exporting layers done
#16 writing image sha256:1e0a868f1e0957613b56504ecee24b1032f765254e070aad5725dd9af34df56a done
#16 naming to docker.io/library/didactyl-musl-builder:latest done
#16 DONE 0.0s
✓ Docker image built successfully
==========================================
Step 2: Extracting static binary
==========================================
✓ Binary extracted to: /home/teknari/lt_gitea/didactyl/didactyl_static_x86_64
==========================================
Step 3: Verifying static binary
==========================================
Checking for dynamic dependencies:
✓ Binary is statically linked (verified with file command)
==========================================
Build Summary
==========================================
Binary: /home/teknari/lt_gitea/didactyl/didactyl_static_x86_64
Size: 6.8M
Static: true
Debug: false
Platform: linux/amd64
==========================================
+70 -13
View File
@@ -8,12 +8,6 @@
"admin": {
"pubkey": "admin pubkey"
},
"relays": [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"ws://127.0.0.1:7777"
],
"llm": {
"provider": "",
"api_key": "",
@@ -64,6 +58,8 @@
"kind": 10002,
"content": "",
"tags": [
[
"r",
"wss://relay.damus.io"
@@ -80,6 +76,7 @@
"r",
"ws://127.0.0.1:7777"
]
]
},
{
@@ -103,7 +100,7 @@
},
{
"kind": 31120,
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.",
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.",
"tags": [
[
"d",
@@ -123,12 +120,27 @@
"kind": 31123,
"content_fields": {
"name": "long_form_note",
"description": "Procedure for creating and publishing long-form notes",
"steps": [
"Draft structure",
"Write sections",
"Review",
"Publish"
"description": "How to publish a NIP-23 long-form article (kind 30023)",
"nip": "NIP-23",
"event_kind": 30023,
"format": "The content field must be markdown text; avoid arbitrary hard line-breaks in paragraphs and do not include HTML.",
"required_tags": {
"d": "Addressable identifier slug for the article. Reusing the same d tag replaces prior versions.",
"title": "Human-readable article title.",
"published_at": "Unix timestamp as a string for first publication time; keep stable on edits."
},
"optional_tags": {
"summary": "Short 1-2 sentence summary.",
"image": "URL for article preview image.",
"t": "Topic hashtags using repeated t tags, usually 3-6 lowercase terms."
},
"behavior": "If required values are missing or unclear, ask the administrator before publishing instead of guessing.",
"procedure": [
"Determine title and d tag from admin input or source material.",
"Draft markdown body content.",
"Set published_at as unix seconds string.",
"Add optional summary/image/t tags when known.",
"Publish with nostr_post kind 30023 including tags array."
]
},
"tags": [
@@ -150,6 +162,47 @@
]
]
},
{
"kind": 31123,
"content_fields": {
"name": "post_readme_to_nostr",
"description": "Read README.md from the repo and publish it as a NIP-23 long-form note",
"uses_skill": "long_form_note",
"event_kind": 30023,
"procedure": [
"Read README.md using file_read with path README.md.",
"Set d tag exactly to readme.md.",
"Set title from the first markdown H1 heading in README.md.",
"Set summary from the opening paragraph of README.md.",
"Set image from project metadata when available (kind 0 picture/banner), otherwise omit image tag.",
"Generate 3-6 lowercase t tags from README section topics.",
"Set published_at to current unix timestamp as string.",
"Publish with nostr_post kind 30023, full README markdown in content, and full NIP-23 tag set."
],
"required_values": {
"d": "readme.md",
"summary_source": "opening paragraph"
}
},
"tags": [
[
"d",
"post_readme_to_nostr"
],
[
"app",
"didactyl"
],
[
"scope",
"public"
],
[
"slug",
"post_readme_to_nostr"
]
]
},
{
"kind": 31124,
"content_fields": {
@@ -183,6 +236,10 @@
"a",
"31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"
],
[
"a",
"31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"
],
[
"app",
"didactyl"
+79
View File
@@ -200,6 +200,79 @@ update_version_in_header() {
print_success "Updated version in src/main.h to $new_version"
}
# Function to update README Current Status version and release comment
update_readme_current_status() {
local new_version="$1"
local release_comment="$2"
if [[ ! -f "README.md" ]]; then
print_warning "README.md not found, skipping Current Status update"
return 0
fi
print_status "Updating README Current Status section..."
if python3 - "$new_version" "$release_comment" <<'PY'
import sys
from pathlib import Path
version = sys.argv[1]
comment = sys.argv[2]
path = Path("README.md")
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
heading_idx = None
for i, line in enumerate(lines):
if line.startswith("## Current Status"):
heading_idx = i
break
if heading_idx is None:
raise SystemExit("README Current Status heading not found")
lines[heading_idx] = f"## Current Status — {version}"
section_end = len(lines)
for i in range(heading_idx + 1, len(lines)):
if lines[i].startswith("## "):
section_end = i
break
comment_line = f"> Last release update: {version} — {comment}"
# Replace existing autogenerated release comment inside Current Status section
existing_idx = None
for i in range(heading_idx + 1, section_end):
if lines[i].startswith("> Last release update:"):
existing_idx = i
break
if existing_idx is not None:
lines[existing_idx] = comment_line
else:
insert_at = None
for i in range(heading_idx + 1, section_end):
if lines[i].startswith("**Active build"):
insert_at = i + 1
break
if insert_at is None:
insert_at = heading_idx + 1
payload = ["", comment_line]
lines[insert_at:insert_at] = payload
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
then
print_success "Updated README Current Status section"
else
print_error "Failed to update README Current Status section"
exit 1
fi
}
# Function to commit and push changes without creating a tag (tag already created)
git_commit_and_push_no_tag() {
print_status "Preparing git commit..."
@@ -427,6 +500,9 @@ main() {
export NEW_VERSION
fi
# Keep README Current Status in sync with the version and commit message
update_readme_current_status "$NEW_VERSION" "$COMMIT_MESSAGE"
# Create new git tag BEFORE compilation so version picks it up
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
@@ -482,6 +558,9 @@ main() {
# Increment version based on type (default to patch)
increment_version "$VERSION_INCREMENT_TYPE"
# Keep README Current Status in sync with the version and commit message
update_readme_current_status "$NEW_VERSION" "$COMMIT_MESSAGE"
# Create new git tag BEFORE compilation so version picks it up
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
+596
View File
@@ -0,0 +1,596 @@
# Implementation Plan: Nostr Tools
## Current Tool Inventory (v0.0.19)
| # | Tool | Kind/NIP | Status |
|---|------|----------|--------|
| 1 | `nostr_post` | Any kind | ✅ Shipped |
| 2 | `nostr_post_readme` | 30023 / NIP-23 | ✅ Shipped |
| 3 | `nostr_query` | Filter-based | ✅ Shipped |
| 4 | `nostr_delete` | 5 / NIP-09 | ✅ Shipped |
| 5 | `nostr_react` | 7 / NIP-25 | ✅ Shipped |
| 6 | `nostr_profile_get` | 0 query | ✅ Shipped |
| 7 | `nostr_relay_status` | Pool stats | ✅ Shipped |
| 8 | `shell_exec` | OS | ✅ Shipped |
| 9 | `file_read` | OS | ✅ Shipped |
| 10 | `file_write` | OS | ✅ Shipped |
---
## Pattern for All New Tools
Every tool follows the same three-step pattern in [`src/tools.c`](../src/tools.c):
1. **Schema** — Add OpenAI function schema block in [`tools_build_openai_schema_json()`](../src/tools.c:511)
2. **Executor** — Implement `static char* execute_<name>()` function
3. **Dispatch** — Add `strcmp` case in [`tools_execute()`](../src/tools.c:1620)
Use the shared [`parse_tool_args_json()`](../src/tools.c:527) helper for hardened argument parsing.
---
## Tier 2 Tools — Medium Value, Moderate Implementation
### Tool 11: `nostr_nip05_lookup` — DNS Identity Verification (NIP-05)
#### Purpose
Verify `user@domain.com` NIP-05 identifiers. Useful for trust decisions and profile enrichment.
#### OpenAI Schema
```json
{
"name": "nostr_nip05_lookup",
"description": "Verify or look up a NIP-05 DNS identifier and return the associated pubkey and relays",
"parameters": {
"type": "object",
"properties": {
"identifier": {
"type": "string",
"description": "NIP-05 identifier, e.g. user@domain.com"
},
"pubkey": {
"type": "string",
"description": "Optional 64-char hex pubkey to verify against the identifier"
}
},
"required": ["identifier"]
}
}
```
#### Implementation: `execute_nostr_nip05_lookup()`
```
1. Parse args_json
2. Extract "identifier" (required string, must contain @)
3. Extract "pubkey" (optional 64-char hex)
4. If pubkey provided:
- Call nostr_nip05_verify(identifier, pubkey, &relays, &relay_count, 10)
- Return { success, verified: true/false, pubkey, relays }
5. If no pubkey:
- Call nostr_nip05_lookup(identifier, pubkey_out, &relays, &relay_count, 10)
- Return { success, pubkey: pubkey_out, relays }
6. Free relay array after serializing
```
#### Library API Used
- [`nostr_nip05_lookup()`](../nostr_core_lib/nostr_core/nip005.h:13)
- [`nostr_nip05_verify()`](../nostr_core_lib/nostr_core/nip005.h:15)
#### Files Modified
- `src/tools.c`: Schema + executor + dispatch
#### Notes
- Involves HTTP network I/O (fetches `/.well-known/nostr.json` from the domain)
- May block for up to `timeout_seconds` — use 10s default
- Must free the `relays` array returned by the library
---
### Tool 12: `nostr_encode` — Bech32 Entity Encoding (NIP-19/NIP-21)
#### Purpose
Encode hex pubkeys, event IDs, or addressable coordinates into shareable `npub`, `note`, `nprofile`, `nevent`, `naddr` URIs.
#### OpenAI Schema
```json
{
"name": "nostr_encode",
"description": "Encode a Nostr entity into a bech32 URI: npub, note, nprofile, nevent, or naddr",
"parameters": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Entity type: npub, note, nprofile, nevent, or naddr"
},
"hex": {
"type": "string",
"description": "64-char hex value: pubkey for npub/nprofile, event_id for note/nevent"
},
"relays": {
"type": "array",
"items": { "type": "string" },
"description": "Optional relay hints for nprofile, nevent, naddr"
},
"kind": {
"type": "integer",
"description": "Kind number, required for naddr"
},
"identifier": {
"type": "string",
"description": "d-tag identifier, required for naddr"
}
},
"required": ["type", "hex"]
}
}
```
#### Implementation: `execute_nostr_encode()`
```
1. Parse args_json
2. Extract "type" (required: npub|note|nprofile|nevent|naddr)
3. Extract "hex" (required, 64-char hex)
4. Convert hex to 32-byte array via nostr_hex_to_bytes()
5. Switch on type:
- "npub": nostr_build_uri_npub(bytes, output, sizeof output)
- "note": nostr_build_uri_note(bytes, output, sizeof output)
- "nprofile": nostr_build_uri_nprofile(bytes, relays, relay_count, output, sizeof output)
- "nevent": nostr_build_uri_nevent(bytes, relays, relay_count, NULL, -1, 0, output, sizeof output)
- "naddr": nostr_build_uri_naddr(identifier, bytes, kind, relays, relay_count, output, sizeof output)
6. Return { success, type, uri: output }
```
#### Library API Used
- [`nostr_build_uri_npub()`](../nostr_core_lib/nostr_core/nip021.h:66)
- [`nostr_build_uri_note()`](../nostr_core_lib/nostr_core/nip021.h:68)
- [`nostr_build_uri_nprofile()`](../nostr_core_lib/nostr_core/nip021.h:69)
- [`nostr_build_uri_nevent()`](../nostr_core_lib/nostr_core/nip021.h:71)
- [`nostr_build_uri_naddr()`](../nostr_core_lib/nostr_core/nip021.h:74)
#### Files Modified
- `src/tools.c`: Schema + executor + dispatch
---
### Tool 13: `nostr_decode` — Bech32 Entity Decoding (NIP-19/NIP-21)
#### Purpose
Decode `npub`, `note`, `nprofile`, `nevent`, `naddr` URIs back into hex values and metadata.
#### OpenAI Schema
```json
{
"name": "nostr_decode",
"description": "Decode a bech32 Nostr URI into its components: type, hex, relays, kind, identifier",
"parameters": {
"type": "object",
"properties": {
"uri": {
"type": "string",
"description": "Bech32 URI to decode, e.g. npub1..., note1..., nostr:npub1..."
}
},
"required": ["uri"]
}
}
```
#### Implementation: `execute_nostr_decode()`
```
1. Parse args_json
2. Extract "uri" (required string)
3. Call nostr_parse_uri(uri, &result)
4. Switch on result.type:
- NPUB: convert result.data.pubkey to hex, return { type: "npub", pubkey: hex }
- NOTE: convert result.data.event_id to hex, return { type: "note", event_id: hex }
- NPROFILE: pubkey hex + relays array
- NEVENT: event_id hex + relays + optional author/kind
- NADDR: identifier + pubkey hex + kind + relays
5. Call nostr_uri_result_free(&result)
6. Return JSON
```
#### Library API Used
- [`nostr_parse_uri()`](../nostr_core_lib/nostr_core/nip021.h:63)
- [`nostr_uri_result_free()`](../nostr_core_lib/nostr_core/nip021.h:78)
#### Files Modified
- `src/tools.c`: Schema + executor + dispatch
---
### Tool 14: `nostr_dm_send` — Send DM as Tool Call
#### Purpose
Expose DM sending as a tool so the agent can proactively message people (notify admin, reach out to contacts).
#### OpenAI Schema
```json
{
"name": "nostr_dm_send",
"description": "Send an encrypted direct message to a Nostr user (NIP-04)",
"parameters": {
"type": "object",
"properties": {
"recipient_pubkey": {
"type": "string",
"description": "64-char hex pubkey of the recipient"
},
"message": {
"type": "string",
"description": "Plaintext message to send"
}
},
"required": ["recipient_pubkey", "message"]
}
}
```
#### Implementation: `execute_nostr_dm_send()`
```
1. Parse args_json
2. Extract "recipient_pubkey" (required, 64-char hex)
3. Extract "message" (required, non-empty string)
4. Call nostr_handler_send_dm(recipient_pubkey, message)
5. Return { success: true/false, recipient_pubkey, message_length }
```
#### Library API Used
- [`nostr_handler_send_dm()`](../src/nostr_handler.h:33) — already exists
#### Security Note
- Should be admin-tier only in practice. The tool itself doesn't enforce tier, but the agent's system prompt and skill definitions should restrict usage.
#### Files Modified
- `src/tools.c`: Schema + executor + dispatch
---
### Tool 15: `nostr_relay_info` — Relay Information Document (NIP-11)
#### Purpose
Inspect relay capabilities, supported NIPs, limitations, PoW requirements before publishing.
#### OpenAI Schema
```json
{
"name": "nostr_relay_info",
"description": "Fetch the NIP-11 relay information document for a relay URL",
"parameters": {
"type": "object",
"properties": {
"relay_url": {
"type": "string",
"description": "WebSocket relay URL, e.g. wss://relay.example.com"
}
},
"required": ["relay_url"]
}
}
```
#### Implementation
This requires a new helper in [`nostr_handler.c`](../src/nostr_handler.c) since NIP-11 fetching needs an HTTP GET request with `Accept: application/nostr+json` header.
##### New function: `nostr_handler_relay_info_json()`
```
1. Convert ws:// or wss:// URL to http:// or https://
2. Use libcurl to HTTP GET with header "Accept: application/nostr+json"
3. Return the raw JSON response string
```
##### `execute_nostr_relay_info()` in tools.c:
```
1. Parse args_json
2. Extract "relay_url" (required string, must start with ws:// or wss://)
3. Call nostr_handler_relay_info_json(relay_url)
4. Parse returned JSON
5. Return { success, relay_url, info: { name, description, pubkey, supported_nips, limitations, ... } }
```
#### Files Modified
- `src/nostr_handler.h`: Add `nostr_handler_relay_info_json()` declaration
- `src/nostr_handler.c`: Implement using libcurl (already linked)
- `src/tools.c`: Schema + executor + dispatch
---
## Tier 3 Tools — High Value, More Complex
### Tool 16: `nostr_dm_send_nip17` — Private DMs via Gift Wrap (NIP-17/NIP-59)
#### Purpose
Send modern, metadata-private DMs using the NIP-17 gift wrap protocol instead of legacy NIP-04.
#### OpenAI Schema
```json
{
"name": "nostr_dm_send_nip17",
"description": "Send a private direct message using NIP-17 gift wrap protocol for metadata privacy",
"parameters": {
"type": "object",
"properties": {
"recipient_pubkey": {
"type": "string",
"description": "64-char hex pubkey of the recipient"
},
"message": {
"type": "string",
"description": "Plaintext message to send"
},
"subject": {
"type": "string",
"description": "Optional conversation subject"
}
},
"required": ["recipient_pubkey", "message"]
}
}
```
#### Implementation
This is a multi-step crypto pipeline. The library handles the heavy lifting but we need to orchestrate:
##### New function: `nostr_handler_send_dm_nip17()` in nostr_handler.c
```
1. Query recipient's kind 10050 relay list (DM relays)
- Build filter: { kinds: [10050], authors: [recipient_pubkey], limit: 1 }
- Call nostr_relay_pool_query_sync()
- Extract relay URLs via nostr_nip17_extract_dm_relays()
- Fall back to our own relays if no 10050 found
2. Create kind 14 chat event:
- nostr_nip17_create_chat_event(message, &recipient_pubkey, 1, subject, NULL, NULL, our_pubkey)
3. Gift wrap and send:
- nostr_nip17_send_dm(chat_event, &recipient_pubkey, 1, our_private_key, gift_wraps, max_wraps, 0)
4. Publish each gift wrap to recipient's DM relays
5. Return success/failure + event metadata
```
##### `execute_nostr_dm_send_nip17()` in tools.c:
```
1. Parse args_json
2. Extract recipient_pubkey, message, optional subject
3. Call nostr_handler_send_dm_nip17(recipient_pubkey, message, subject)
4. Return { success, recipient_pubkey, protocol: "nip17" }
```
#### Library API Used
- [`nostr_nip17_create_chat_event()`](../nostr_core_lib/nostr_core/nip017.h:28)
- [`nostr_nip17_send_dm()`](../nostr_core_lib/nostr_core/nip017.h:100)
- [`nostr_nip17_extract_dm_relays()`](../nostr_core_lib/nostr_core/nip017.h)
- [`nostr_nip59_create_gift_wrap()`](../nostr_core_lib/nostr_core/nip059.h:50)
#### Files Modified
- `src/nostr_handler.h`: Add `nostr_handler_send_dm_nip17()` declaration
- `src/nostr_handler.c`: Implement the full NIP-17 send pipeline
- `src/tools.c`: Schema + executor + dispatch
---
### Tool 17: `nostr_encrypt` — NIP-44 Encryption
#### Purpose
Encrypt arbitrary content for a specific recipient using modern NIP-44 encryption.
#### OpenAI Schema
```json
{
"name": "nostr_encrypt",
"description": "Encrypt plaintext for a recipient using NIP-44 ChaCha20+HMAC encryption",
"parameters": {
"type": "object",
"properties": {
"recipient_pubkey": {
"type": "string",
"description": "64-char hex pubkey of the recipient"
},
"plaintext": {
"type": "string",
"description": "Text to encrypt"
}
},
"required": ["recipient_pubkey", "plaintext"]
}
}
```
#### Implementation: `execute_nostr_encrypt()`
```
1. Parse args_json
2. Extract recipient_pubkey (64-char hex), plaintext (non-empty string)
3. Convert recipient_pubkey hex to 32-byte array
4. Allocate output buffer (plaintext length * 2 + 256 for base64 overhead)
5. Call nostr_nip44_encrypt(our_private_key, recipient_pubkey_bytes, plaintext, output, output_size)
6. Return { success, recipient_pubkey, ciphertext: output }
```
#### Library API Used
- [`nostr_nip44_encrypt()`](../nostr_core_lib/nostr_core/nip044.h:28)
#### Security Note
- Admin-tier only. Uses the agent's private key for ECDH shared secret.
#### Files Modified
- `src/tools.c`: Schema + executor + dispatch
---
### Tool 18: `nostr_decrypt` — NIP-44 Decryption
#### Purpose
Decrypt NIP-44 encrypted content from a known sender.
#### OpenAI Schema
```json
{
"name": "nostr_decrypt",
"description": "Decrypt NIP-44 encrypted content from a sender",
"parameters": {
"type": "object",
"properties": {
"sender_pubkey": {
"type": "string",
"description": "64-char hex pubkey of the sender"
},
"ciphertext": {
"type": "string",
"description": "Base64-encoded NIP-44 encrypted payload"
}
},
"required": ["sender_pubkey", "ciphertext"]
}
}
```
#### Implementation: `execute_nostr_decrypt()`
```
1. Parse args_json
2. Extract sender_pubkey (64-char hex), ciphertext (non-empty string)
3. Convert sender_pubkey hex to 32-byte array
4. Allocate output buffer
5. Call nostr_nip44_decrypt(our_private_key, sender_pubkey_bytes, ciphertext, output, output_size)
6. Return { success, sender_pubkey, plaintext: output }
```
#### Library API Used
- [`nostr_nip44_decrypt()`](../nostr_core_lib/nostr_core/nip044.h:62)
#### Security Note
- Admin-tier only.
#### Files Modified
- `src/tools.c`: Schema + executor + dispatch
---
### Tool 19: `nostr_list_manage` — List Management (NIP-51)
#### Purpose
Manage mute lists (kind 10000), bookmarks (kind 10003), follow sets, relay sets. Lets the agent curate its social graph.
#### OpenAI Schema
```json
{
"name": "nostr_list_manage",
"description": "Add or remove items from a Nostr list: mute list, bookmarks, relay sets, etc. (NIP-51)",
"parameters": {
"type": "object",
"properties": {
"list_kind": {
"type": "integer",
"description": "Kind number of the list: 10000=mute, 10001=pins, 10003=bookmarks, 3=follows, 10002=relays"
},
"action": {
"type": "string",
"description": "Action: add or remove"
},
"items": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "string" }
},
"description": "Array of tag tuples to add/remove, e.g. [[p, pubkey], [e, event_id]]"
}
},
"required": ["list_kind", "action", "items"]
}
}
```
#### Implementation
This is a read-modify-write pattern for replaceable events:
##### `execute_nostr_list_manage()`:
```
1. Parse args_json
2. Extract list_kind (integer), action ("add"|"remove"), items (array of tag arrays)
3. Query existing list:
- Build filter: { kinds: [list_kind], authors: [our_pubkey], limit: 1 }
- Call nostr_handler_query_json(filter, 8000)
4. Parse existing event tags (or start with empty array if none found)
5. If action == "add":
- For each item in items: append to tags if not already present
6. If action == "remove":
- For each item in items: remove matching tag from tags
7. Publish updated event:
- Call nostr_handler_publish_kind_event(list_kind, existing_content_or_empty, updated_tags, &result)
8. Return { success, list_kind, action, items_affected, event_id }
```
#### Notes
- Replaceable events (kind 10000-19999) automatically replace previous versions
- Kind 3 (follows) is also replaceable
- Content field may contain NIP-44 encrypted private items — preserve it unchanged
- Must deduplicate tags when adding
#### Files Modified
- `src/tools.c`: Schema + executor + dispatch
---
## Implementation Order
```mermaid
graph TD
A[nostr_nip05_lookup] --> B[nostr_encode]
B --> C[nostr_decode]
C --> D[nostr_dm_send]
D --> E[nostr_relay_info]
E --> F[nostr_encrypt]
F --> G[nostr_decrypt]
G --> H[nostr_dm_send_nip17]
H --> I[nostr_list_manage]
style A fill:#FFFFE0
style B fill:#FFFFE0
style C fill:#FFFFE0
style D fill:#FFFFE0
style E fill:#FFFFE0
style F fill:#FFD700
style G fill:#FFD700
style H fill:#FFD700
style I fill:#FFD700
```
Yellow = Tier 2 (moderate), Gold = Tier 3 (complex).
### Recommended batching:
**Batch A — Pure computation, no new handler functions:**
1. `nostr_encode` — pure NIP-21 encoding
2. `nostr_decode` — pure NIP-21 decoding
3. `nostr_dm_send` — thin wrapper around existing `nostr_handler_send_dm()`
**Batch B — Network I/O tools:**
4. `nostr_nip05_lookup` — HTTP fetch via library
5. `nostr_relay_info` — HTTP fetch via new handler function + libcurl
**Batch C — Crypto tools:**
6. `nostr_encrypt` — NIP-44 encrypt
7. `nostr_decrypt` — NIP-44 decrypt
**Batch D — Complex orchestration:**
8. `nostr_dm_send_nip17` — multi-step NIP-17/59 pipeline, new handler function
9. `nostr_list_manage` — read-modify-write replaceable events
## Build & Validate
After each batch:
1. Run `./build_static.sh` to verify compilation
2. Run `./increment_and_push.sh` with descriptive message
+337
View File
@@ -0,0 +1,337 @@
# Skill Tools — Architecture Plan
## Overview
Add a family of five skill-management tools to Didactyl so the agent can **create, list, adopt, remove, and discover** skills at runtime — all through the existing LLM tool-calling loop.
Skills are Nostr events. The tools are thin orchestration wrappers over the existing `nostr_handler_publish_kind_event()` and `nostr_handler_query_json()` primitives.
---
## Nostr Kind Reference
| Kind | Purpose | Replaceable? | Key tag |
|---|---|---|---|
| `31123` | Public skill definition | Yes (d-tag) | `d=<slug>` |
| `31124` | Private skill definition | Yes (d-tag) | `d=<slug>` |
| `10123` | Public skill adoption list | Yes (replaceable) | `a` refs to 31123 events |
All skill events carry these standard tags:
- `["d", "<slug>"]` — unique identifier within the author's pubkey
- `["app", "didactyl"]` — app namespace
- `["scope", "public"]` or `["scope", "private"]`
---
## Tool Family
### 1. `skill_create`
**Purpose:** Create or update a skill definition and optionally auto-adopt it.
**OpenAI schema:**
```json
{
"name": "skill_create",
"description": "Create or update a skill definition (kind 31123 public / 31124 private) and optionally auto-adopt it",
"parameters": {
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
"content": { "type": "string", "description": "Skill body — markdown instructions or structured JSON" },
"scope": { "type": "string", "description": "public (kind 31123) or private (kind 31124). Default: public" },
"description": { "type": "string", "description": "Short one-line description for the skill" },
"auto_adopt": { "type": "boolean", "description": "Automatically add to adoption list (kind 10123). Default: true" }
},
"required": ["slug", "content"]
}
}
```
**Execution logic (`execute_skill_create`):**
1. Validate `slug` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
2. Determine kind: `31123` if scope is `"public"` or absent; `31124` if `"private"`
3. Build tags array:
- `["d", slug]`
- `["app", "didactyl"]`
- `["scope", scope]`
- `["description", description]` if provided
4. Call `nostr_handler_publish_kind_event(kind, content, tags, &result)`
5. If `auto_adopt` is true (default), update the kind `10123` adoption list:
- Query existing `10123` event for own pubkey (same pattern as `execute_nostr_list_manage`)
- Add `["a", "31123:<own_pubkey>:<slug>"]` tag if not already present
- Republish the updated `10123` event
6. Return JSON with `success`, `event_id`, `naddr_uri`, `slug`, `adopted`
**Key design decisions:**
- Auto-adopt defaults to `true` — creating a skill you don't adopt is unusual
- Private skills (31124) are NOT added to the public adoption list (10123)
- Republishing with the same slug replaces the previous version (replaceable event)
---
### 2. `skill_list`
**Purpose:** List the agent's own published skills.
**OpenAI schema:**
```json
{
"name": "skill_list",
"description": "List skills published by this agent, optionally filtered by scope",
"parameters": {
"type": "object",
"properties": {
"scope": { "type": "string", "description": "Filter by public or private. Omit for both." }
}
}
}
```
**Execution logic (`execute_skill_list`):**
1. Build filter based on scope:
- Both: `{"kinds": [31123, 31124], "authors": [own_pubkey]}`
- Public only: `{"kinds": [31123], "authors": [own_pubkey]}`
- Private only: `{"kinds": [31124], "authors": [own_pubkey]}`
2. Call `nostr_handler_query_json(filter, 8000)`
3. Parse results, extract for each event:
- `slug` (from d-tag)
- `kind`
- `scope` (from scope tag)
- `description` (from description tag, if present)
- `created_at` timestamp
- `content` preview (first 200 chars)
4. Return JSON array of skill summaries
---
### 3. `skill_adopt`
**Purpose:** Adopt a skill published by another author (or self) into the agent's adoption list.
**OpenAI schema:**
```json
{
"name": "skill_adopt",
"description": "Add a skill to the agent's public adoption list (kind 10123)",
"parameters": {
"type": "object",
"properties": {
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author" },
"slug": { "type": "string", "description": "Skill slug (d-tag value)" },
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
},
"required": ["pubkey", "slug"]
}
}
```
**Execution logic (`execute_skill_adopt`):**
1. Validate pubkey (64-char hex) and slug (non-empty)
2. Default kind to 31123 if not provided
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
4. Query existing kind `10123` event for own pubkey
5. Check if `["a", "<kind>:<pubkey>:<slug>"]` already exists — if so, return success with `already_adopted: true`
6. Add the tag, republish `10123`
7. Return JSON with `success`, `adopted_address`, `event_id`
---
### 4. `skill_remove`
**Purpose:** Remove a skill from the agent's adoption list.
**OpenAI schema:**
```json
{
"name": "skill_remove",
"description": "Remove a skill from the agent's public adoption list (kind 10123)",
"parameters": {
"type": "object",
"properties": {
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author. Defaults to own pubkey." },
"slug": { "type": "string", "description": "Skill slug to remove" },
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
},
"required": ["slug"]
}
}
```
**Execution logic (`execute_skill_remove`):**
1. Default pubkey to own pubkey if not provided
2. Default kind to 31123
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
4. Query existing kind `10123` event for own pubkey
5. Find and remove matching `["a", ...]` tag
6. Republish `10123`
7. Return JSON with `success`, `removed_address`, `event_id`
---
### 5. `skill_search`
**Purpose:** Search for skills across the agent's Web of Trust.
**OpenAI schema:**
```json
{
"name": "skill_search",
"description": "Search for skills adopted by Web of Trust contacts, or query public skill definitions",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Optional keyword to filter skill slugs or descriptions" },
"pubkey": { "type": "string", "description": "Search skills by a specific author pubkey" },
"popular": { "type": "boolean", "description": "If true, query WoT adoption lists to find most-adopted skills" }
}
}
}
```
**Execution logic (`execute_skill_search`):**
1. If `popular` is true:
- Query `{"kinds": [10123]}` from relays (with reasonable limit)
- Parse all `a`-tags from results
- Count occurrences of each `a`-tag address
- Sort by adoption count descending
- Return top N skill addresses with counts
2. If `pubkey` is provided:
- Query `{"kinds": [31123], "authors": [pubkey]}`
- Return skill summaries
3. If `query` is provided (keyword search):
- Query `{"kinds": [31123]}` with limit
- Filter results client-side by matching `query` against slug, description tag, or content
- Return matching skill summaries
4. Default (no params): return own adopted skills from `10123`
---
## Implementation Architecture
### Shared helper: adoption list update
Since `skill_create`, `skill_adopt`, and `skill_remove` all modify the kind `10123` list, extract a shared helper:
```c
// Fetch current 10123 event, return duplicated tags array (or empty array if none exists)
static cJSON* fetch_adoption_list_tags(tools_context_t* ctx);
// Publish updated 10123 event with new tags
static int publish_adoption_list(tools_context_t* ctx, cJSON* tags, nostr_publish_result_t* result);
```
This is essentially the same pattern already used in `execute_nostr_list_manage()` but specialized for kind `10123`.
### Shared helper: skill summary extraction
```c
// Extract slug, kind, scope, description, created_at from a skill event JSON
static cJSON* extract_skill_summary(cJSON* event);
```
### Flow diagram
```mermaid
flowchart TD
SC[skill_create] --> PUB[nostr_handler_publish_kind_event]
SC --> ADOPT_HELPER[update adoption list helper]
SL[skill_list] --> QUERY[nostr_handler_query_json]
SL --> SUMMARY[extract_skill_summary]
SA[skill_adopt] --> ADOPT_HELPER
SR[skill_remove] --> ADOPT_HELPER
SS[skill_search] --> QUERY
SS --> SUMMARY
ADOPT_HELPER --> QUERY
ADOPT_HELPER --> PUB
```
---
## Changes Required
### `src/tools.c`
1. **Schema registration** — Add 5 new tool definitions in `tools_build_openai_schema_json()` (t22t26)
2. **Execution functions** — Add 5 new `execute_skill_*()` static functions
3. **Dispatch** — Add 5 new `strcmp` branches in `tools_execute()`
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_slug()`
### `README.md`
1. Add skill tools to the Tooling Interface section under a new "Skill management" category
### No changes needed to:
- `src/tools.h` — the `tools_context_t` already has `cfg` which provides `keys.public_key_hex`
- `src/nostr_handler.h` — all needed APIs already exist
- `src/config.h` — no new config fields needed
---
## Slug Validation Rules
A valid skill slug must:
- Be 164 characters
- Contain only lowercase letters, digits, and hyphens
- Not start or end with a hyphen
- Not contain consecutive hyphens
```c
static int validate_skill_slug(const char* slug) {
if (!slug || slug[0] == '\0' || strlen(slug) > 64) return 0;
if (slug[0] == '-') return 0;
int prev_dash = 0;
for (size_t i = 0; slug[i]; i++) {
char c = slug[i];
if (c == '-') {
if (prev_dash) return 0;
prev_dash = 1;
} else if (islower(c) || isdigit(c)) {
prev_dash = 0;
} else {
return 0;
}
}
if (slug[strlen(slug) - 1] == '-') return 0;
return 1;
}
```
---
## Implementation Order
1. **Shared helpers**`validate_skill_slug`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
2. **`skill_create`** — most important tool, enables the agent to author skills
3. **`skill_list`** — lets the agent see what it has published
4. **`skill_adopt`** — adopt skills from other authors
5. **`skill_remove`** — remove skills from adoption list
6. **`skill_search`** — discover skills across WoT
7. **Schema registration** — add all 5 tools to `tools_build_openai_schema_json()`
8. **Dispatch wiring** — add all 5 to `tools_execute()`
9. **README update** — document the new tools
10. **Build and test** — verify compilation and basic tool execution
---
## Security Considerations
- **Admin-only**: Skill tools inherit the existing ADMIN tier restriction — only the admin can trigger tool calls
- **Slug validation**: Prevents injection of malformed d-tags
- **No arbitrary kind**: `skill_create` only publishes kind 31123 or 31124, not arbitrary kinds
- **Adoption list integrity**: The helpers always fetch-then-update to avoid clobbering existing adoption entries
- **Content size**: No explicit limit on skill content size — relies on relay limits and LLM context window constraints
+139
View File
@@ -0,0 +1,139 @@
# Didactyl Tool Testing Plan
## Overview
Testing agent-mediated tools is different from unit testing pure functions because the execution path spans multiple boundaries:
```
LLM schema interpretation → JSON argument generation → argument parsing → business logic → Nostr/network I/O → JSON response → LLM interpretation
```
This plan defines three testing layers, ordered by implementation priority.
---
## Layer 1: Direct Tool Execution
Call `tools_execute()` directly with known JSON arguments and assert the JSON response structure.
### Implementation
Add a `--test-tool <name> <args_json>` CLI flag to `src/main.c` that:
1. Initializes config and nostr_handler (for network-dependent tools)
2. Calls `tools_execute(&ctx, name, args_json)`
3. Prints the raw JSON result to stdout
4. Exits with 0 if `success: true`, 1 otherwise
### Pure Computation Tools (no network needed)
| Tool | Test Command | Expected |
|------|-------------|----------|
| `nostr_encode` | `--test-tool nostr_encode '{"type":"npub","hex":"<64-char-hex>"}'` | `success: true`, uri starts with `nostr:npub1` |
| `nostr_decode` | `--test-tool nostr_decode '{"uri":"npub1..."}'` | `success: true`, pubkey is 64-char hex |
| `nostr_encrypt` | `--test-tool nostr_encrypt '{"recipient_pubkey":"<hex>","plaintext":"hello"}'` | `success: true`, ciphertext is base64 |
| `nostr_decrypt` | `--test-tool nostr_decrypt '{"sender_pubkey":"<hex>","ciphertext":"<from encrypt>"}'` | `success: true`, plaintext is "hello" |
### Network-Dependent Tools (need relay or HTTP)
| Tool | Test Command | Expected |
|------|-------------|----------|
| `nostr_nip05_lookup` | `--test-tool nostr_nip05_lookup '{"identifier":"_@laantungir.com"}'` | `success: true`, pubkey returned |
| `nostr_relay_info` | `--test-tool nostr_relay_info '{"relay_url":"wss://relay.damus.io"}'` | `success: true`, info.basic.name present |
| `nostr_relay_status` | `--test-tool nostr_relay_status '{}'` | `success: true`, relay_count > 0 |
| `nostr_dm_send` | `--test-tool nostr_dm_send '{"recipient_pubkey":"<hex>","message":"test"}'` | `success: true` |
| `nostr_post` | `--test-tool nostr_post '{"kind":1,"content":"test"}'` | `success: true`, event_id present |
| `nostr_delete` | `--test-tool nostr_delete '{"event_ids":["<64-char-hex>"]}'` | `success: true` |
| `nostr_react` | `--test-tool nostr_react '{"event_id":"<hex>","event_pubkey":"<hex>"}'` | `success: true` |
| `nostr_profile_get` | `--test-tool nostr_profile_get '{"pubkey":"<hex>"}'` | `success: true`, found: true/false |
| `nostr_dm_send_nip17` | `--test-tool nostr_dm_send_nip17 '{"recipient_pubkey":"<hex>","message":"test"}'` | `success: true` |
| `nostr_list_manage` | `--test-tool nostr_list_manage '{"list_kind":10000,"action":"add","items":[["p","<hex>"]]}'` | `success: true` |
### Error Case Tests
Each tool should also be tested with:
- Empty args: `'{}'` — should return descriptive error
- Missing required fields — should return specific error message
- Invalid hex strings — should reject gracefully
- Double-encoded JSON string args — should parse correctly (regression for the `invalid arguments JSON` bug)
---
## Layer 2: Schema-Parse Fidelity
Validates that the OpenAI function schema matches what executors actually accept.
### Implementation
A Python script (`tests/validate_schemas.py`) that:
1. Runs `didactyl_static --dump-schemas` (new flag) to get the tool schema JSON
2. For each tool in the schema:
- Generates a minimal valid argument payload from `required` + `properties`
- Runs `didactyl_static --test-tool <name> '<generated_json>'`
- Asserts exit code 0 or expected network error
3. Reports mismatches between schema and executor expectations
### What This Catches
- Schema says `required: ["hex"]` but executor checks for `"pubkey"` — mismatch
- Schema says `type: "integer"` but executor reads it as string
- Schema advertises parameters the executor ignores
- Missing required parameters in schema that executor demands
---
## Layer 3: Agent Integration Testing (Live DM)
The most natural test — DM the running agent and verify it uses tools correctly.
### Prerequisites
- Local relay running at `ws://127.0.0.1:7777`
- Didactyl running with valid config
- A separate Nostr client (or script) to send/receive DMs
### Test Prompts
| # | Tool | DM Prompt | Assert in Response |
|---|------|-----------|-------------------|
| 1 | `nostr_encode` | "Encode this pubkey as npub: `<64-char hex>`" | Contains `nostr:npub1` |
| 2 | `nostr_decode` | "Decode this npub: `npub1...`" | Contains the hex pubkey |
| 3 | `nostr_dm_send` | "Send a DM to `<your-own-pubkey>` saying 'hello test'" | Confirms sent; you receive it |
| 4 | `nostr_encrypt` | "Encrypt 'secret message' for `<pubkey>`" | Contains base64 ciphertext |
| 5 | `nostr_decrypt` | "Decrypt this NIP-44 payload: `<ciphertext from #4>`" | Contains 'secret message' |
| 6 | `nostr_nip05_lookup` | "Look up `_@laantungir.com`" | Returns a pubkey |
| 7 | `nostr_relay_info` | "Get NIP-11 info for `wss://relay.damus.io`" | Returns relay name and supported NIPs |
| 8 | `nostr_relay_status` | "Show me relay connection status" | Lists connected relays with stats |
| 9 | `nostr_react` | "React with 🤙 to event `<id>` from `<pubkey>`" | Confirms kind 7 published |
| 10 | `nostr_delete` | "Delete event `<id>`" | Confirms kind 5 published |
| 11 | `nostr_profile_get` | "Look up the profile for `<pubkey>`" | Returns name/about/picture |
| 12 | `nostr_post` | "Post a kind 1 note saying 'tool test'" | Confirms published with event_id |
| 13 | `nostr_list_manage` | "Add `<pubkey>` to my mute list" | Confirms kind 10000 published |
| 14 | `nostr_dm_send_nip17` | "Send a private NIP-17 DM to `<pubkey>` saying 'gift wrap test'" | Confirms gift wrap sent |
| 15 | `nostr_post_readme` | "Publish the README to Nostr" | Confirms kind 30023 with d=readme.md |
### Verification Methods
- **stdout logs**: Watch `[didactyl] executing tool call: <name>` in terminal
- **context.log**: Full LLM conversation including tool calls and results
- **Relay inspection**: Query the local relay for published events
- **DM receipt**: For DM tools, verify the message arrives at the recipient
---
## Implementation Priority
1. **Immediate (no code changes)**: Run Layer 3 tests by DMing the live agent
2. **Next sprint**: Add `--test-tool` CLI flag for Layer 1
3. **Later**: Add `--dump-schemas` flag and Python schema validator for Layer 2
4. **CI integration**: Wrap Layer 1 pure-computation tests in a shell script that runs after `build_static.sh`
---
## Local Relay Setup for Testing
A local relay like `strfry` or `nostr-rs-relay` at `ws://127.0.0.1:7777` provides:
- All published events are observable and queryable
- No rate limits or content policies
- NIP-42 auth testing in isolation
- Gift-wrapped NIP-17 DMs stay in your test environment
- Database can be wiped between test runs
+4
View File
@@ -13,6 +13,7 @@
#include "nostr_handler.h"
#include "tools.h"
#include "cjson/cJSON.h"
#include "debug.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
static didactyl_config_t* g_cfg = NULL;
@@ -623,12 +624,15 @@ void agent_on_message(const char* sender_pubkey_hex,
for (int i = 0; i < resp.tool_call_count; i++) {
llm_tool_call_t* tc = &resp.tool_calls[i];
fprintf(stdout, "[didactyl] executing tool call: %s\n", tc->name ? tc->name : "<null>");
DEBUG_TRACE("[didactyl] tool call args: %s", tc->arguments_json ? tc->arguments_json : "{}");
char* tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json);
if (!tool_result) {
tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}");
}
DEBUG_TRACE("[didactyl] tool call result: %s", tool_result ? tool_result : "{\"success\":false,\"error\":\"tool execution failed\"}");
if (append_tool_result_message(messages,
tc->id ? tc->id : "",
tool_result ? tool_result : "{\"success\":false,\"error\":\"tool execution failed\"}") != 0) {
+123 -34
View File
@@ -3,6 +3,8 @@
#include "config.h"
#include <ctype.h>
#include <errno.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -10,6 +12,19 @@
#include "cjson/cJSON.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
static char g_config_last_error[512] = {0};
static void config_set_error(const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
vsnprintf(g_config_last_error, sizeof(g_config_last_error), fmt ? fmt : "unknown configuration error", ap);
va_end(ap);
}
const char* config_last_error(void) {
return g_config_last_error[0] != '\0' ? g_config_last_error : "unknown configuration error";
}
static int read_file_to_buffer(const char* path, char** out_buf, size_t* out_len) {
FILE* fp = fopen(path, "rb");
if (!fp) {
@@ -428,37 +443,87 @@ static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
return 0;
}
static int parse_relays_from_startup_kind10002(didactyl_config_t* config) {
if (!config || !config->startup_events || config->startup_event_count <= 0) {
return -1;
}
for (int i = 0; i < config->startup_event_count; i++) {
startup_event_t* se = &config->startup_events[i];
if (se->kind != 10002 || !se->tags_json) {
continue;
}
cJSON* tags = cJSON_Parse(se->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
continue;
}
int tag_count = cJSON_GetArraySize(tags);
int relay_count = 0;
for (int j = 0; j < tag_count; j++) {
cJSON* tag = cJSON_GetArrayItem(tags, j);
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, "r") == 0 && val->valuestring[0] != '\0') {
relay_count++;
}
}
if (relay_count <= 0) {
cJSON_Delete(tags);
continue;
}
config->relays = (char**)calloc((size_t)relay_count, sizeof(char*));
if (!config->relays) {
cJSON_Delete(tags);
return -1;
}
config->relay_count = relay_count;
int out_i = 0;
for (int j = 0; j < tag_count && out_i < relay_count; j++) {
cJSON* tag = cJSON_GetArrayItem(tags, j);
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, "r") == 0 && val->valuestring[0] != '\0') {
config->relays[out_i] = strdup(val->valuestring);
if (!config->relays[out_i]) {
cJSON_Delete(tags);
return -1;
}
out_i++;
}
}
cJSON_Delete(tags);
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)root;
return parse_relays_from_startup_kind10002(config);
}
void config_free(didactyl_config_t* config) {
@@ -486,9 +551,12 @@ void config_free(didactyl_config_t* config) {
int config_load(const char* path, didactyl_config_t* config) {
if (!path || !config) {
config_set_error("config_load called with invalid arguments");
return -1;
}
config_set_error("unknown configuration error");
memset(config, 0, sizeof(*config));
config->tools.enabled = 1;
config->tools.max_turns = 8;
@@ -516,6 +584,7 @@ int config_load(const char* path, didactyl_config_t* config) {
char* json_buf = NULL;
size_t json_len = 0;
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
config_set_error("failed to read config file '%s': %s", path, strerror(errno));
return -1;
}
@@ -523,6 +592,11 @@ int config_load(const char* path, didactyl_config_t* config) {
free(json_buf);
if (!root) {
const char* err = cJSON_GetErrorPtr();
config_set_error("invalid JSON in config '%s'%s%s",
path,
err ? " near: " : "",
err ? err : "");
return -1;
}
@@ -535,26 +609,37 @@ int config_load(const char* path, didactyl_config_t* config) {
if (!keys || !cJSON_IsObject(keys) ||
!admin || !cJSON_IsObject(admin) ||
!llm || !cJSON_IsObject(llm)) {
config_set_error("config must include object sections: keys, admin, llm");
goto cleanup;
}
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 1) != 0) {
config_set_error("keys.nsec is required and must be a non-empty string");
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) {
config_set_error("admin.pubkey is required and must be a non-empty string");
goto cleanup;
}
if (decode_pubkey_hex_or_npub(admin_key_raw, config->admin.pubkey) != 0) {
config_set_error("admin.pubkey must be npub1... or 64-char hex");
goto cleanup;
}
if (parse_startup_events(root, config) != 0) {
config_set_error("invalid startup_events configuration");
goto cleanup;
}
if (parse_relays(root, config) != 0) {
config_set_error("relay configuration is invalid: startup_events must include kind 10002 with non-empty r tags");
goto cleanup;
}
if (copy_json_string(llm, "provider", config->llm.provider, sizeof(config->llm.provider), 0) != 0) {
config_set_error("llm.provider must be a string if provided");
goto cleanup;
}
if (config->llm.provider[0] == '\0') {
@@ -562,12 +647,15 @@ int config_load(const char* path, didactyl_config_t* config) {
}
if (copy_json_string(llm, "api_key", config->llm.api_key, sizeof(config->llm.api_key), 1) != 0) {
config_set_error("llm.api_key is required and must be a string");
goto cleanup;
}
if (copy_json_string(llm, "model", config->llm.model, sizeof(config->llm.model), 1) != 0) {
config_set_error("llm.model is required and must be a string");
goto cleanup;
}
if (copy_json_string(llm, "base_url", config->llm.base_url, sizeof(config->llm.base_url), 1) != 0) {
config_set_error("llm.base_url is required and must be a string");
goto cleanup;
}
@@ -578,26 +666,27 @@ int config_load(const char* path, didactyl_config_t* config) {
config->llm.temperature = (temperature && cJSON_IsNumber(temperature)) ? temperature->valuedouble : 0.7;
if (parse_tools_config(root, config) != 0) {
config_set_error("invalid tools configuration");
goto cleanup;
}
if (parse_security_config(root, config) != 0) {
config_set_error("invalid security configuration");
goto cleanup;
}
if (parse_admin_context_config(root, config) != 0) {
goto cleanup;
}
if (parse_startup_events(root, config) != 0) {
config_set_error("invalid admin_context configuration");
goto cleanup;
}
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
config_set_error("keys.nsec must be valid nsec1... or 64-char hex private key");
goto cleanup;
}
if (nostr_ec_public_key_from_private_key(config->keys.private_key, config->keys.public_key) != 0) {
config_set_error("failed to derive public key from keys.nsec");
goto cleanup;
}
+1
View File
@@ -86,6 +86,7 @@ typedef struct {
} didactyl_config_t;
int config_load(const char* path, didactyl_config_t* config);
const char* config_last_error(void);
void config_free(didactyl_config_t* config);
#endif
+29 -2
View File
@@ -20,17 +20,29 @@ static void signal_handler(int signum) {
g_running = 0;
}
static void print_usage(const char* prog) {
fprintf(stderr,
"Usage: %s [-h|--help] [-v|--version] [--config <path>] [--debug <0-5>]\n",
prog ? prog : "didactyl");
}
int main(int argc, char** argv) {
const char* config_path = "./config.json";
int debug_level = DEBUG_LEVEL_TRACE;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) {
if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
print_usage(argv[0]);
return 0;
} else if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--version") == 0) {
printf("%s %s\n", DIDACTYL_NAME, DIDACTYL_VERSION);
return 0;
} else if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) {
config_path = argv[++i];
} else if (strcmp(argv[i], "--debug") == 0 && i + 1 < argc) {
debug_level = atoi(argv[++i]);
} else {
fprintf(stderr, "Usage: %s [--config <path>] [--debug <0-5>]\n", argv[0]);
print_usage(argv[0]);
return 1;
}
}
@@ -47,6 +59,7 @@ int main(int argc, char** argv) {
didactyl_config_t cfg;
if (config_load(config_path, &cfg) != 0) {
fprintf(stderr, "Failed to load config: %s\n", config_path);
fprintf(stderr, "Config error: %s\n", config_last_error());
nostr_cleanup();
return 1;
}
@@ -90,6 +103,20 @@ int main(int argc, char** argv) {
}
DEBUG_INFO("[didactyl] startup phase: subscribe admin context end");
char startup_dm[768];
const char* startup_name = nostr_handler_get_startup_display_name();
int connected_relays = nostr_handler_connected_relay_count();
snprintf(startup_dm,
sizeof(startup_dm),
"%s has started up and is online (version %s, connected relays: %d/%d).",
(startup_name && startup_name[0] != '\0') ? startup_name : "Didactyl",
DIDACTYL_VERSION,
connected_relays,
cfg.relay_count);
if (nostr_handler_send_dm(cfg.admin.pubkey, startup_dm) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to send startup status DM to admin");
}
DEBUG_INFO("[didactyl] startup phase: subscribe DMs begin");
if (nostr_handler_subscribe_dms(agent_on_message, NULL) != 0) {
DEBUG_ERROR("[didactyl] startup phase: subscribe DMs failed");
+2 -2
View File
@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 0
#define DIDACTYL_VERSION_PATCH 9
#define DIDACTYL_VERSION "v0.0.9"
#define DIDACTYL_VERSION_PATCH 21
#define DIDACTYL_VERSION "v0.0.21"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+690 -72
View File
@@ -12,17 +12,21 @@
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
#include "debug.h"
#define NIP17_MAX_RELAYS 32
#define NIP17_MAX_GIFT_WRAPS 8
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 time_t g_last_status_log_time = 0;
static nostr_pool_relay_status_t* g_last_relay_statuses = NULL;
static char* g_system_context = NULL;
static unsigned char* g_startup_published = NULL;
static int g_startup_publish_tracking_enabled = 0;
static int g_startup_kind1_already_exists = 0;
static char g_startup_display_name[128] = "Didactyl";
static char* g_admin_kind0_json = NULL;
static char* g_admin_kind10002_json = NULL;
@@ -101,65 +105,14 @@ static int publish_kind_event_to_relays(int kind,
cJSON* tags,
const char** relay_urls,
int relay_count,
const char* reason_label);
const char* reason_label,
nostr_publish_result_t* out_result);
static void on_admin_context_event(cJSON* event, const char* relay_url, void* user_data);
static int parse_kind3_wot_contacts(cJSON* tags);
static void upsert_kind1_note(time_t created_at, const char* content);
static void log_relay_statuses(const char* reason) {
if (!g_pool || !g_cfg) {
return;
}
int info_level = (reason && strcmp(reason, "after init") == 0) ? 1 : 0;
if (info_level) {
DEBUG_INFO("[didactyl] relay status snapshot (%s)", reason ? reason : "periodic");
} else {
DEBUG_TRACE("[didactyl] relay status snapshot (%s)", 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);
if (info_level) {
if (ping_ms > 0.0) {
DEBUG_INFO("[didactyl] - %s => %s (ping %.1f ms)",
relay,
relay_status_str(status),
ping_ms);
} else {
DEBUG_INFO("[didactyl] - %s => %s", relay, relay_status_str(status));
}
} else {
if (ping_ms > 0.0) {
DEBUG_TRACE("[didactyl] - %s => %s (ping %.1f ms)",
relay,
relay_status_str(status),
ping_ms);
} else {
DEBUG_TRACE("[didactyl] - %s => %s", relay, relay_status_str(status));
}
}
if (last_err && last_err[0] != '\0') {
DEBUG_WARN("[didactyl] - %s last_connection_error: %s", relay, last_err);
}
const nostr_relay_stats_t* stats = nostr_relay_pool_get_relay_stats(g_pool, relay);
if (stats) {
DEBUG_LOG("[didactyl] - %s stats attempts=%d failures=%d recv=%d pub_ok=%d pub_fail=%d",
relay,
stats->connection_attempts,
stats->connection_failures,
stats->events_received,
stats->events_published_ok,
stats->events_published_failed);
}
}
}
static int startup_self_kind1_exists(void);
static void load_startup_display_name(void);
static void build_startup_kind1_content(char* out, size_t out_size, const char* fallback);
static void log_relay_state_changes(void) {
if (!g_pool || !g_cfg || !g_last_relay_statuses) {
@@ -195,6 +148,98 @@ static void log_publish_targets(const char* action) {
}
}
static int startup_self_kind1_exists(void) {
if (!g_cfg || !g_pool) {
return 0;
}
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
if (!filter || !kinds || !authors) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
return 0;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(1));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", 1);
int event_count = 0;
cJSON** events = nostr_relay_pool_query_sync(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
filter,
&event_count,
3000);
cJSON_Delete(filter);
if (events) {
for (int i = 0; i < event_count; i++) {
cJSON_Delete(events[i]);
}
free(events);
}
return event_count > 0 ? 1 : 0;
}
static void load_startup_display_name(void) {
snprintf(g_startup_display_name, sizeof(g_startup_display_name), "%s", "Didactyl");
if (!g_cfg) {
return;
}
for (int i = 0; i < g_cfg->startup_event_count; i++) {
startup_event_t* se = &g_cfg->startup_events[i];
if (se->kind != 0 || !se->content) {
continue;
}
cJSON* content_json = cJSON_Parse(se->content);
if (!content_json || !cJSON_IsObject(content_json)) {
cJSON_Delete(content_json);
continue;
}
cJSON* display_name = cJSON_GetObjectItemCaseSensitive(content_json, "display_name");
cJSON* name = cJSON_GetObjectItemCaseSensitive(content_json, "name");
if (display_name && cJSON_IsString(display_name) && display_name->valuestring && display_name->valuestring[0] != '\0') {
snprintf(g_startup_display_name, sizeof(g_startup_display_name), "%s", display_name->valuestring);
cJSON_Delete(content_json);
return;
}
if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') {
snprintf(g_startup_display_name, sizeof(g_startup_display_name), "%s", name->valuestring);
cJSON_Delete(content_json);
return;
}
cJSON_Delete(content_json);
}
}
static void build_startup_kind1_content(char* out, size_t out_size, const char* fallback) {
if (!out || out_size == 0) {
return;
}
if (fallback && fallback[0] != '\0') {
snprintf(out, out_size, "%s (%s startup)", fallback, g_startup_display_name);
} else {
snprintf(out, out_size, "%s startup complete and online", g_startup_display_name);
}
}
static int hex_to_pubkey(const char* hex, unsigned char out_pubkey[32]) {
if (!hex || !out_pubkey || strlen(hex) != 64U) {
return -1;
@@ -202,6 +247,118 @@ static int hex_to_pubkey(const char* hex, unsigned char out_pubkey[32]) {
return nostr_hex_to_bytes(hex, out_pubkey, 32) == 0 ? 0 : -1;
}
static int duplicate_relay_list(const char** relay_urls, int relay_count, char*** out_relays) {
if (!out_relays) {
return -1;
}
*out_relays = NULL;
if (!relay_urls || relay_count <= 0) {
return 0;
}
char** relays = (char**)calloc((size_t)relay_count, sizeof(char*));
if (!relays) {
return -1;
}
for (int i = 0; i < relay_count; i++) {
relays[i] = strdup(relay_urls[i] ? relay_urls[i] : "");
if (!relays[i]) {
for (int j = 0; j < i; j++) {
free(relays[j]);
}
free(relays);
return -1;
}
}
*out_relays = relays;
return 0;
}
void nostr_handler_publish_result_free(nostr_publish_result_t* result) {
if (!result) {
return;
}
if (result->relays) {
for (int i = 0; i < result->relay_count; i++) {
free(result->relays[i]);
}
free(result->relays);
}
memset(result, 0, sizeof(*result));
}
static void extract_d_tag(cJSON* tags, char* out_d_tag, size_t out_size) {
if (!out_d_tag || out_size == 0) {
return;
}
out_d_tag[0] = '\0';
if (!tags || !cJSON_IsArray(tags)) {
return;
}
int tag_count = cJSON_GetArraySize(tags);
for (int i = 0; i < tag_count; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag)) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* value = cJSON_GetArrayItem(tag, 1);
if (!key || !value || !cJSON_IsString(key) || !cJSON_IsString(value) ||
!key->valuestring || !value->valuestring) {
continue;
}
if (strcmp(key->valuestring, "d") == 0) {
snprintf(out_d_tag, out_size, "%s", value->valuestring);
return;
}
}
}
static void fill_publish_result(nostr_publish_result_t* out_result,
int kind,
cJSON* tags,
const char* event_id_hex,
const char** relay_urls,
int relay_count,
int accepted_by_pool_count) {
if (!out_result) {
return;
}
nostr_handler_publish_result_free(out_result);
out_result->kind = kind;
out_result->relay_count = relay_count;
out_result->accepted_by_pool_count = accepted_by_pool_count;
out_result->success = accepted_by_pool_count > 0 ? 1 : 0;
if (event_id_hex) {
snprintf(out_result->event_id, sizeof(out_result->event_id), "%s", event_id_hex);
unsigned char event_id_bytes[32];
char note_bech32[128];
if (strlen(event_id_hex) == 64U &&
nostr_hex_to_bytes(event_id_hex, event_id_bytes, sizeof(event_id_bytes)) == 0 &&
nostr_key_to_bech32(event_id_bytes, "note", note_bech32) == 0) {
snprintf(out_result->note_uri, sizeof(out_result->note_uri), "nostr:%s", note_bech32);
}
}
extract_d_tag(tags, out_result->d_tag, sizeof(out_result->d_tag));
(void)kind;
(void)relay_urls;
(void)duplicate_relay_list(relay_urls, relay_count, &out_result->relays);
}
static cJSON* create_dm_tags_for_recipient(const char* recipient_pubkey_hex) {
cJSON* tags = cJSON_CreateArray();
if (!tags) {
@@ -558,6 +715,11 @@ int nostr_handler_init(didactyl_config_t* config) {
return -1;
}
if (nostr_relay_pool_set_auth(g_pool, g_cfg->keys.private_key, 1) != NOSTR_SUCCESS) {
fprintf(stderr, "[didactyl] failed to enable relay pool auth\n");
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]);
@@ -575,9 +737,6 @@ int nostr_handler_init(didactyl_config_t* config) {
g_last_relay_statuses[i] = nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]);
}
g_last_status_log_time = time(NULL);
log_relay_statuses("after init");
free(g_startup_published);
g_startup_published = NULL;
g_startup_publish_tracking_enabled = 0;
@@ -786,6 +945,21 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
}
}
int* pre_publish_ok = NULL;
if (connected_count > 0) {
pre_publish_ok = (int*)calloc((size_t)connected_count, sizeof(int));
if (!pre_publish_ok) {
free(connected_relays);
cJSON_Delete(event);
return -1;
}
for (int i = 0; i < connected_count; i++) {
const nostr_relay_stats_t* stats = nostr_relay_pool_get_relay_stats(g_pool, connected_relays[i]);
pre_publish_ok[i] = stats ? stats->events_published_ok : 0;
}
}
int sent = 0;
if (connected_count > 0) {
sent = nostr_relay_pool_publish_async(
@@ -799,6 +973,48 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
for (int i = 0; i < connected_count; i++) {
DEBUG_INFO("[didactyl] kind 4 event published to %s (async)", connected_relays[i]);
}
// Briefly drain relay messages so NIP-42 AUTH handshake can complete.
for (int i = 0; i < 5; i++) {
(void)nostr_relay_pool_poll(g_pool, 100);
}
int any_publish_ok = 0;
int auth_required_seen = 0;
for (int i = 0; i < connected_count; i++) {
const nostr_relay_stats_t* stats = nostr_relay_pool_get_relay_stats(g_pool, connected_relays[i]);
if (stats && stats->events_published_ok > pre_publish_ok[i]) {
any_publish_ok = 1;
}
const char* pub_err = nostr_relay_pool_get_relay_last_publish_error(g_pool, connected_relays[i]);
if (pub_err && strstr(pub_err, "auth-required") != NULL) {
auth_required_seen = 1;
}
}
// Always retry this same signed event once after handshake drain.
// Some relays issue AUTH challenge for the first EVENT and only accept
// the subsequent resend after AUTH succeeds.
if (sent > 0) {
if (auth_required_seen || !any_publish_ok) {
DEBUG_WARN("[didactyl] retrying kind 4 event once after auth handshake window");
}
int resent = nostr_relay_pool_publish_async(
g_pool,
connected_relays,
connected_count,
event,
NULL,
NULL);
if (resent > sent) {
sent = resent;
}
for (int i = 0; i < 5; i++) {
(void)nostr_relay_pool_poll(g_pool, 100);
}
}
} else {
DEBUG_WARN("[didactyl] kind 4 event not queued: no connected relays");
}
@@ -811,6 +1027,7 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
recipient_pubkey_hex,
sent);
free(pre_publish_ok);
free(connected_relays);
cJSON_Delete(event);
return sent > 0 ? 0 : -1;
@@ -821,7 +1038,8 @@ static int publish_kind_event_to_relays(int kind,
cJSON* tags,
const char** relay_urls,
int relay_count,
const char* reason_label) {
const char* reason_label,
nostr_publish_result_t* out_result) {
if (!g_cfg || !g_pool || !content || !relay_urls || relay_count <= 0) {
return -1;
}
@@ -858,11 +1076,23 @@ static int publish_kind_event_to_relays(int kind,
reason_label ? reason_label : "");
}
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
const char* event_id_hex = (event_id && cJSON_IsString(event_id) && event_id->valuestring)
? event_id->valuestring
: "";
fill_publish_result(out_result,
kind,
tags,
event_id_hex,
relay_urls,
relay_count,
sent);
cJSON_Delete(event);
return sent > 0 ? 0 : -1;
}
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags) {
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result) {
if (!g_cfg || !g_pool || !content) {
return -1;
}
@@ -887,7 +1117,13 @@ int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags)
return -1;
}
int rc = publish_kind_event_to_relays(kind, content, tags, connected_relays, connected_count, "manual_publish");
int rc = publish_kind_event_to_relays(kind,
content,
tags,
connected_relays,
connected_count,
"manual_publish",
out_result);
free(connected_relays);
DEBUG_INFO("[didactyl] published kind %d event via %d connected relay(s)", kind, connected_count);
@@ -950,6 +1186,9 @@ static void publish_pending_startup_events_for_relay_index(int relay_index, cons
if (!se->content) {
continue;
}
if (se->kind == 1 && g_startup_kind1_already_exists) {
continue;
}
size_t slot = (size_t)i * (size_t)g_cfg->relay_count + (size_t)relay_index;
if (g_startup_published[slot]) {
@@ -965,9 +1204,19 @@ static void publish_pending_startup_events_for_relay_index(int relay_index, cons
}
}
char kind1_content[512];
const char* content_to_publish = se->content;
if (se->kind == 1) {
build_startup_kind1_content(kind1_content, sizeof(kind1_content), se->content);
content_to_publish = kind1_content;
}
const char* one_relay[1] = { relay_url };
if (publish_kind_event_to_relays(se->kind, se->content, tags, one_relay, 1, reason) == 0) {
if (publish_kind_event_to_relays(se->kind, content_to_publish, tags, one_relay, 1, reason, NULL) == 0) {
g_startup_published[slot] = 1;
if (se->kind == 1) {
g_startup_kind1_already_exists = 1;
}
} else {
DEBUG_WARN("[didactyl] startup event publish failed for kind=%d relay=%s", se->kind, relay_url);
}
@@ -1009,6 +1258,12 @@ int nostr_handler_reconcile_startup_events(void) {
}
}
load_startup_display_name();
g_startup_kind1_already_exists = startup_self_kind1_exists();
if (g_startup_kind1_already_exists) {
DEBUG_INFO("[didactyl] startup phase: existing self kind-1 note found; skipping startup kind-1 publish");
}
if (!g_system_context) {
g_system_context = strdup("You are Didactyl, a sovereign AI agent living on Nostr.");
}
@@ -1024,6 +1279,375 @@ const char* nostr_handler_get_system_context(void) {
return g_system_context;
}
const char* nostr_handler_get_startup_display_name(void) {
return g_startup_display_name;
}
int nostr_handler_connected_relay_count(void) {
if (!g_cfg || !g_pool) {
return 0;
}
int connected = 0;
for (int i = 0; i < g_cfg->relay_count; i++) {
if (nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]) == NOSTR_POOL_RELAY_CONNECTED) {
connected++;
}
}
return connected;
}
char* nostr_handler_relay_status_json(void) {
if (!g_pool) {
return NULL;
}
char** relay_urls = NULL;
nostr_pool_relay_status_t* statuses = NULL;
int relay_count = nostr_relay_pool_list_relays(g_pool, &relay_urls, &statuses);
if (relay_count < 0) {
return NULL;
}
cJSON* root = cJSON_CreateObject();
cJSON* relays = cJSON_CreateArray();
if (!root || !relays) {
cJSON_Delete(root);
cJSON_Delete(relays);
if (relay_urls) {
for (int i = 0; i < relay_count; i++) {
free(relay_urls[i]);
}
free(relay_urls);
}
free(statuses);
return NULL;
}
int connected_count = 0;
for (int i = 0; i < relay_count; i++) {
cJSON* relay = cJSON_CreateObject();
if (!relay) {
continue;
}
const char* url = (relay_urls && relay_urls[i]) ? relay_urls[i] : "";
nostr_pool_relay_status_t st = statuses ? statuses[i] : NOSTR_POOL_RELAY_DISCONNECTED;
if (st == NOSTR_POOL_RELAY_CONNECTED) {
connected_count++;
}
const nostr_relay_stats_t* stats = nostr_relay_pool_get_relay_stats(g_pool, url);
cJSON_AddStringToObject(relay, "url", url);
cJSON_AddStringToObject(relay, "status", relay_status_str(st));
if (stats) {
cJSON_AddNumberToObject(relay, "events_received", stats->events_received);
cJSON_AddNumberToObject(relay, "events_published", stats->events_published);
cJSON_AddNumberToObject(relay, "events_published_ok", stats->events_published_ok);
cJSON_AddNumberToObject(relay, "events_published_failed", stats->events_published_failed);
cJSON_AddNumberToObject(relay, "ping_latency_current", stats->ping_latency_current);
cJSON_AddNumberToObject(relay, "ping_latency_avg", stats->ping_latency_avg);
cJSON_AddNumberToObject(relay, "query_latency_avg", stats->query_latency_avg);
cJSON_AddNumberToObject(relay, "publish_latency_avg", stats->publish_latency_avg);
cJSON_AddNumberToObject(relay, "connection_uptime_start", (double)stats->connection_uptime_start);
cJSON_AddNumberToObject(relay, "last_event_time", (double)stats->last_event_time);
}
const char* last_pub_err = nostr_relay_pool_get_relay_last_publish_error(g_pool, url);
const char* last_conn_err = nostr_relay_pool_get_relay_last_connection_error(g_pool, url);
if (last_pub_err && last_pub_err[0] != '\0') {
cJSON_AddStringToObject(relay, "last_publish_error", last_pub_err);
}
if (last_conn_err && last_conn_err[0] != '\0') {
cJSON_AddStringToObject(relay, "last_connection_error", last_conn_err);
}
cJSON_AddItemToArray(relays, relay);
}
cJSON_AddNumberToObject(root, "relay_count", relay_count);
cJSON_AddNumberToObject(root, "connected_count", connected_count);
cJSON_AddItemToObject(root, "relays", relays);
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if (relay_urls) {
for (int i = 0; i < relay_count; i++) {
free(relay_urls[i]);
}
free(relay_urls);
}
free(statuses);
return out;
}
char* nostr_handler_relay_info_json(const char* relay_url) {
if (!relay_url || relay_url[0] == '\0') {
return NULL;
}
nostr_relay_info_t* info = NULL;
if (nostr_nip11_fetch_relay_info(relay_url, &info, 10) != NOSTR_SUCCESS || !info) {
return NULL;
}
cJSON* root = cJSON_CreateObject();
if (!root) {
nostr_nip11_relay_info_free(info);
return NULL;
}
cJSON* basic = cJSON_CreateObject();
if (!basic) {
cJSON_Delete(root);
nostr_nip11_relay_info_free(info);
return NULL;
}
if (info->basic.name) cJSON_AddStringToObject(basic, "name", info->basic.name);
if (info->basic.description) cJSON_AddStringToObject(basic, "description", info->basic.description);
if (info->basic.pubkey) cJSON_AddStringToObject(basic, "pubkey", info->basic.pubkey);
if (info->basic.contact) cJSON_AddStringToObject(basic, "contact", info->basic.contact);
if (info->basic.software) cJSON_AddStringToObject(basic, "software", info->basic.software);
if (info->basic.version) cJSON_AddStringToObject(basic, "version", info->basic.version);
cJSON* supported_nips = cJSON_CreateArray();
if (!supported_nips) {
cJSON_Delete(root);
cJSON_Delete(basic);
nostr_nip11_relay_info_free(info);
return NULL;
}
for (size_t i = 0; i < info->basic.supported_nips_count; i++) {
cJSON_AddItemToArray(supported_nips, cJSON_CreateNumber(info->basic.supported_nips[i]));
}
cJSON_AddItemToObject(basic, "supported_nips", supported_nips);
cJSON_AddItemToObject(root, "basic", basic);
if (info->has_limitations) {
cJSON* limitations = cJSON_CreateObject();
if (!limitations) {
cJSON_Delete(root);
nostr_nip11_relay_info_free(info);
return NULL;
}
cJSON_AddNumberToObject(limitations, "max_message_length", info->limitations.max_message_length);
cJSON_AddNumberToObject(limitations, "max_subscriptions", info->limitations.max_subscriptions);
cJSON_AddNumberToObject(limitations, "max_filters", info->limitations.max_filters);
cJSON_AddNumberToObject(limitations, "max_limit", info->limitations.max_limit);
cJSON_AddNumberToObject(limitations, "max_subid_length", info->limitations.max_subid_length);
cJSON_AddNumberToObject(limitations, "min_prefix", info->limitations.min_prefix);
cJSON_AddNumberToObject(limitations, "max_event_tags", info->limitations.max_event_tags);
cJSON_AddNumberToObject(limitations, "max_content_length", info->limitations.max_content_length);
cJSON_AddNumberToObject(limitations, "min_pow_difficulty", info->limitations.min_pow_difficulty);
cJSON_AddNumberToObject(limitations, "auth_required", info->limitations.auth_required);
cJSON_AddNumberToObject(limitations, "payment_required", info->limitations.payment_required);
cJSON_AddNumberToObject(limitations, "restricted_writes", info->limitations.restricted_writes);
cJSON_AddNumberToObject(limitations, "created_at_lower_limit", (double)info->limitations.created_at_lower_limit);
cJSON_AddNumberToObject(limitations, "created_at_upper_limit", (double)info->limitations.created_at_upper_limit);
cJSON_AddItemToObject(root, "limitations", limitations);
}
if (info->has_content_limitations) {
cJSON* countries = cJSON_CreateArray();
if (!countries) {
cJSON_Delete(root);
nostr_nip11_relay_info_free(info);
return NULL;
}
for (size_t i = 0; i < info->content_limitations.relay_countries_count; i++) {
if (info->content_limitations.relay_countries[i]) {
cJSON_AddItemToArray(countries, cJSON_CreateString(info->content_limitations.relay_countries[i]));
}
}
cJSON_AddItemToObject(root, "relay_countries", countries);
}
if (info->has_community_preferences) {
cJSON* prefs = cJSON_CreateObject();
if (!prefs) {
cJSON_Delete(root);
nostr_nip11_relay_info_free(info);
return NULL;
}
cJSON* language_tags = cJSON_CreateArray();
cJSON* tags = cJSON_CreateArray();
if (!language_tags || !tags) {
cJSON_Delete(root);
cJSON_Delete(prefs);
cJSON_Delete(language_tags);
cJSON_Delete(tags);
nostr_nip11_relay_info_free(info);
return NULL;
}
for (size_t i = 0; i < info->community_preferences.language_tags_count; i++) {
if (info->community_preferences.language_tags[i]) {
cJSON_AddItemToArray(language_tags, cJSON_CreateString(info->community_preferences.language_tags[i]));
}
}
for (size_t i = 0; i < info->community_preferences.tags_count; i++) {
if (info->community_preferences.tags[i]) {
cJSON_AddItemToArray(tags, cJSON_CreateString(info->community_preferences.tags[i]));
}
}
cJSON_AddItemToObject(prefs, "language_tags", language_tags);
cJSON_AddItemToObject(prefs, "tags", tags);
if (info->community_preferences.posting_policy) {
cJSON_AddStringToObject(prefs, "posting_policy", info->community_preferences.posting_policy);
}
cJSON_AddItemToObject(root, "community_preferences", prefs);
}
if (info->has_icon && info->icon.icon) {
cJSON_AddStringToObject(root, "icon", info->icon.icon);
}
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
nostr_nip11_relay_info_free(info);
return out;
}
int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject) {
if (!g_cfg || !g_pool || !recipient_pubkey_hex || !message || recipient_pubkey_hex[0] == '\0' || message[0] == '\0') {
return -1;
}
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
if (!filter || !kinds || !authors) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
return -1;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10050));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(recipient_pubkey_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", 1);
int event_count = 0;
cJSON** events = nostr_relay_pool_query_sync(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
filter,
&event_count,
5000);
cJSON_Delete(filter);
char* extracted_dm_relays[NIP17_MAX_RELAYS] = {0};
int extracted_count = 0;
if (events && event_count > 0 && events[0]) {
int extracted = nostr_nip17_extract_dm_relays(events[0], extracted_dm_relays, NIP17_MAX_RELAYS);
if (extracted > 0) {
extracted_count = extracted;
}
}
if (events) {
for (int i = 0; i < event_count; i++) {
cJSON_Delete(events[i]);
}
free(events);
}
const char* target_relays[NIP17_MAX_RELAYS] = {0};
int target_count = 0;
if (extracted_count > 0) {
for (int i = 0; i < g_cfg->relay_count && target_count < NIP17_MAX_RELAYS; i++) {
if (nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]) != NOSTR_POOL_RELAY_CONNECTED) {
continue;
}
for (int j = 0; j < extracted_count; j++) {
if (extracted_dm_relays[j] && strcmp(g_cfg->relays[i], extracted_dm_relays[j]) == 0) {
target_relays[target_count++] = g_cfg->relays[i];
break;
}
}
}
}
if (target_count == 0) {
for (int i = 0; i < g_cfg->relay_count && target_count < NIP17_MAX_RELAYS; i++) {
if (nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]) == NOSTR_POOL_RELAY_CONNECTED) {
target_relays[target_count++] = g_cfg->relays[i];
}
}
}
for (int i = 0; i < extracted_count; i++) {
free(extracted_dm_relays[i]);
}
if (target_count <= 0) {
return -1;
}
const char* recipients[1] = { recipient_pubkey_hex };
cJSON* chat_event = nostr_nip17_create_chat_event(
message,
recipients,
1,
(subject && subject[0] != '\0') ? subject : NULL,
NULL,
NULL,
g_cfg->keys.public_key_hex);
if (!chat_event) {
return -1;
}
cJSON* gift_wraps[NIP17_MAX_GIFT_WRAPS] = {0};
int gift_count = nostr_nip17_send_dm(chat_event,
recipients,
1,
g_cfg->keys.private_key,
gift_wraps,
NIP17_MAX_GIFT_WRAPS,
0);
cJSON_Delete(chat_event);
if (gift_count <= 0) {
return -1;
}
int any_sent = 0;
for (int i = 0; i < gift_count; i++) {
if (!gift_wraps[i]) {
continue;
}
int sent = nostr_relay_pool_publish_async(g_pool,
target_relays,
target_count,
gift_wraps[i],
NULL,
NULL);
if (sent > 0) {
any_sent = 1;
}
cJSON_Delete(gift_wraps[i]);
}
for (int i = 0; i < 5; i++) {
(void)nostr_relay_pool_poll(g_pool, 100);
}
return any_sent ? 0 : -1;
}
char* nostr_handler_get_admin_kind0_context(void) {
if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_0) {
return NULL;
@@ -1108,12 +1732,6 @@ int nostr_handler_poll(int timeout_ms) {
log_relay_state_changes();
time_t now = time(NULL);
if (g_last_status_log_time == 0 || difftime(now, g_last_status_log_time) >= 10.0) {
log_relay_statuses("periodic");
g_last_status_log_time = now;
}
return rc;
}
@@ -1124,11 +1742,11 @@ void nostr_handler_cleanup(void) {
free(g_last_relay_statuses);
g_last_relay_statuses = NULL;
g_last_status_log_time = 0;
free(g_startup_published);
g_startup_published = NULL;
g_startup_publish_tracking_enabled = 0;
g_startup_kind1_already_exists = 0;
snprintf(g_startup_display_name, sizeof(g_startup_display_name), "%s", "Didactyl");
g_pool = NULL;
g_cfg = NULL;
+19 -1
View File
@@ -15,19 +15,37 @@ typedef void (*dm_callback_t)(const char* sender_pubkey_hex,
didactyl_sender_tier_t tier,
void* user_data);
typedef struct {
int success;
int kind;
int relay_count;
int accepted_by_pool_count;
char event_id[65];
char note_uri[256];
char naddr_uri[1024];
char d_tag[128];
char** relays;
} nostr_publish_result_t;
int nostr_handler_init(didactyl_config_t* config);
int nostr_handler_subscribe_admin_context(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_publish_kind_event(int kind, const char* content, cJSON* tags);
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result);
void nostr_handler_publish_result_free(nostr_publish_result_t* result);
char* nostr_handler_query_json(cJSON* filter, int timeout_ms);
int nostr_handler_poll(int timeout_ms);
int nostr_handler_reconcile_startup_events(void);
const char* nostr_handler_get_system_context(void);
const char* nostr_handler_get_startup_display_name(void);
int nostr_handler_connected_relay_count(void);
char* nostr_handler_get_admin_kind0_context(void);
char* nostr_handler_get_admin_kind10002_context(void);
char* nostr_handler_get_admin_kind1_notes_context(void);
int nostr_handler_is_wot_contact(const char* pubkey_hex);
char* nostr_handler_relay_status_json(void);
char* nostr_handler_relay_info_json(const char* relay_url);
int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject);
void nostr_handler_cleanup(void);
#endif
+3407 -12
View File
File diff suppressed because it is too large Load Diff