Compare commits

...
6 Commits
10 changed files with 1387 additions and 158 deletions
+62 -21
View File
@@ -1,22 +1,61 @@
# Didactyl
An unstoppable 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.
### 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.
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.
### Free speech for agents.
Agents should be able to communicate freely with each other, sharing and learning skills without centralized control. Free speech for agents!
### 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 — 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.
### Skills are the new apps.
## Current Status — v0.0.11
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.
**Active build — relay-aware autonomous agent with tool-use and Nostr-native startup memory.**
### Private inference.
> Last release update: v0.0.11 — auto update readme.md
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.17
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.17 — Add -h/-v CLI flags and make post_readme_to_nostr skill call nostr_post_readme tool
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
@@ -33,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
@@ -70,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-...",
@@ -107,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...",
@@ -128,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
@@ -135,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)
@@ -148,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 │ │
│ └──────────────────┬──────────────────┘ │
@@ -191,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
@@ -308,3 +348,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"
+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) {
+86 -36
View File
@@ -443,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) {
@@ -578,8 +628,13 @@ int config_load(const char* path, didactyl_config_t* config) {
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("relays must be a non-empty array of relay URLs");
config_set_error("relay configuration is invalid: startup_events must include kind 10002 with non-empty r tags");
goto cleanup;
}
@@ -625,11 +680,6 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup;
}
if (parse_startup_events(root, config) != 0) {
config_set_error("invalid startup_events 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;
+28 -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;
}
}
@@ -91,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 11
#define DIDACTYL_VERSION "v0.0.11"
#define DIDACTYL_VERSION_PATCH 17
#define DIDACTYL_VERSION "v0.0.17"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+337 -72
View File
@@ -18,11 +18,12 @@ 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 +102,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 +145,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 +244,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 +712,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 +734,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 +942,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 +970,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 +1024,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 +1035,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 +1073,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 +1114,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 +1183,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 +1201,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 +1255,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 +1276,25 @@ 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_get_admin_kind0_context(void) {
if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_0) {
return NULL;
@@ -1108,12 +1379,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 +1389,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;
+16 -1
View File
@@ -15,15 +15,30 @@ 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);
+680 -11
View File
@@ -6,6 +6,8 @@
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <ctype.h>
#include <time.h>
#include "cjson/cJSON.h"
#include "nostr_handler.h"
@@ -20,16 +22,453 @@ static char* json_error(const char* msg) {
return out;
}
static char* json_success_with_message(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddStringToObject(root, "message", msg ? msg : "ok");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
static char* sanitize_json_string_controls(const char* in) {
if (!in) return NULL;
size_t len = strlen(in);
size_t cap = (len * 2U) + 1U;
char* out = (char*)malloc(cap);
if (!out) return NULL;
int in_string = 0;
int escaping = 0;
size_t j = 0;
for (size_t i = 0; i < len; i++) {
char c = in[i];
if (escaping) {
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
escaping = 0;
continue;
}
if (c == '\\') {
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
if (in_string) escaping = 1;
continue;
}
if (c == '"') {
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
in_string = !in_string;
continue;
}
if (in_string && (c == '\n' || c == '\r' || c == '\t')) {
if (j + 2U >= cap) {
free(out);
return NULL;
}
out[j++] = '\\';
out[j++] = (c == '\n') ? 'n' : (c == '\r') ? 'r' : 't';
continue;
}
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
}
out[j] = '\0';
return out;
}
static const char* find_key_start(const char* in, const char* key) {
if (!in || !key) return NULL;
char pattern[64];
int n = snprintf(pattern, sizeof(pattern), "\"%s\"", key);
if (n <= 0 || (size_t)n >= sizeof(pattern)) return NULL;
return strstr(in, pattern);
}
static const char* skip_ws(const char* p) {
while (p && *p && isspace((unsigned char)*p)) p++;
return p;
}
static int parse_loose_kind(const char* in, int* out_kind) {
if (!in || !out_kind) return -1;
const char* p = find_key_start(in, "kind");
if (!p) return -1;
p = strchr(p, ':');
if (!p) return -1;
p = skip_ws(p + 1);
if (!p || !*p) return -1;
char* end = NULL;
long v = strtol(p, &end, 10);
if (end == p) return -1;
*out_kind = (int)v;
return 0;
}
static char* parse_loose_json_string_value(const char* in, const char* key) {
if (!in || !key) return NULL;
const char* p = find_key_start(in, key);
if (!p) return NULL;
p = strchr(p, ':');
if (!p) return NULL;
p = skip_ws(p + 1);
if (!p || *p != '"') return NULL;
p++;
size_t max_len = strlen(p);
char* out = (char*)malloc(max_len + 1U);
if (!out) return NULL;
size_t j = 0;
int escaping = 0;
for (size_t i = 0; p[i] != '\0'; i++) {
char c = p[i];
if (escaping) {
switch (c) {
case 'n': out[j++] = '\n'; break;
case 'r': out[j++] = '\r'; break;
case 't': out[j++] = '\t'; break;
case '"': out[j++] = '"'; break;
case '\\': out[j++] = '\\'; break;
default: out[j++] = c; break;
}
escaping = 0;
continue;
}
if (c == '\\') {
escaping = 1;
continue;
}
if (c == '"') {
out[j] = '\0';
return out;
}
out[j++] = c;
}
out[j] = '\0';
return out;
}
static cJSON* parse_loose_nostr_post_args(const char* in) {
if (!in) return NULL;
int kind = 0;
if (parse_loose_kind(in, &kind) != 0) {
return NULL;
}
char* content = parse_loose_json_string_value(in, "content");
if (!content) {
return NULL;
}
cJSON* args = cJSON_CreateObject();
if (!args) {
free(content);
return NULL;
}
cJSON_AddNumberToObject(args, "kind", kind);
cJSON_AddStringToObject(args, "content", content);
free(content);
return args;
}
static cJSON* ensure_tags_array(cJSON** tags_inout) {
if (!tags_inout) return NULL;
if (*tags_inout) {
if (cJSON_IsArray(*tags_inout)) {
return *tags_inout;
}
cJSON_Delete(*tags_inout);
*tags_inout = NULL;
}
*tags_inout = cJSON_CreateArray();
return *tags_inout;
}
static int has_tag_key(cJSON* tags, const char* key) {
if (!tags || !cJSON_IsArray(tags) || !key) return 0;
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag)) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
if (k && cJSON_IsString(k) && k->valuestring && strcmp(k->valuestring, key) == 0) {
return 1;
}
}
return 0;
}
static int add_string_tag(cJSON* tags, const char* key, const char* value) {
if (!tags || !cJSON_IsArray(tags) || !key || !value || value[0] == '\0') return -1;
cJSON* tag = cJSON_CreateArray();
if (!tag) return -1;
cJSON_AddItemToArray(tag, cJSON_CreateString(key));
cJSON_AddItemToArray(tag, cJSON_CreateString(value));
cJSON_AddItemToArray(tags, tag);
return 0;
}
static char* trim_copy(const char* start, size_t len) {
while (len > 0 && isspace((unsigned char)start[0])) {
start++;
len--;
}
while (len > 0 && isspace((unsigned char)start[len - 1])) {
len--;
}
char* out = (char*)malloc(len + 1U);
if (!out) return NULL;
memcpy(out, start, len);
out[len] = '\0';
return out;
}
static char* first_markdown_h1(const char* content) {
if (!content) return NULL;
const char* p = content;
while (*p) {
const char* line = p;
const char* nl = strchr(line, '\n');
size_t len = nl ? (size_t)(nl - line) : strlen(line);
while (len > 0 && (line[len - 1] == '\r')) len--;
size_t i = 0;
while (i < len && isspace((unsigned char)line[i])) i++;
if (i < len && line[i] == '#') {
size_t j = i;
while (j < len && line[j] == '#') j++;
if (j < len && isspace((unsigned char)line[j])) {
while (j < len && isspace((unsigned char)line[j])) j++;
if (j < len) return trim_copy(line + j, len - j);
}
}
if (!nl) break;
p = nl + 1;
}
return NULL;
}
static int is_paragraph_line(const char* line, size_t len) {
size_t i = 0;
while (i < len && isspace((unsigned char)line[i])) i++;
if (i >= len) return 0;
char c = line[i];
if (c == '#' || c == '>' || c == '-' || c == '*' || c == '`' || c == '|') return 0;
if (isdigit((unsigned char)c)) {
size_t j = i;
while (j < len && isdigit((unsigned char)line[j])) j++;
if (j < len && line[j] == '.') return 0;
}
return 1;
}
static char* first_markdown_paragraph(const char* content) {
if (!content) return NULL;
const char* p = content;
while (*p) {
const char* line = p;
const char* nl = strchr(line, '\n');
size_t len = nl ? (size_t)(nl - line) : strlen(line);
while (len > 0 && line[len - 1] == '\r') len--;
if (is_paragraph_line(line, len)) {
size_t cap = 1024;
size_t used = 0;
char* out = (char*)malloc(cap);
if (!out) return NULL;
const char* q = line;
const char* qnl = nl;
size_t qlen = len;
while (1) {
if (!is_paragraph_line(q, qlen)) break;
size_t start = 0;
while (start < qlen && isspace((unsigned char)q[start])) start++;
size_t end = qlen;
while (end > start && isspace((unsigned char)q[end - 1])) end--;
size_t part_len = end - start;
if (used + part_len + 2 >= cap) {
cap = (cap * 2U) + part_len + 16U;
char* bigger = (char*)realloc(out, cap);
if (!bigger) {
free(out);
return NULL;
}
out = bigger;
}
if (part_len > 0) {
if (used > 0) out[used++] = ' ';
memcpy(out + used, q + start, part_len);
used += part_len;
}
if (!qnl) break;
q = qnl + 1;
qnl = strchr(q, '\n');
qlen = qnl ? (size_t)(qnl - q) : strlen(q);
while (qlen > 0 && q[qlen - 1] == '\r') qlen--;
}
out[used] = '\0';
return out;
}
if (!nl) break;
p = nl + 1;
}
return NULL;
}
static char* first_markdown_image_url(const char* content) {
if (!content) return NULL;
const char* p = content;
while ((p = strstr(p, "![")) != NULL) {
const char* close_bracket = strchr(p + 2, ']');
if (!close_bracket || close_bracket[1] != '(') {
p += 2;
continue;
}
const char* url_start = close_bracket + 2;
const char* url_end = strchr(url_start, ')');
if (!url_end || url_end <= url_start) {
p += 2;
continue;
}
return trim_copy(url_start, (size_t)(url_end - url_start));
}
return NULL;
}
static char* slugify_string(const char* in, const char* fallback) {
if (!in || in[0] == '\0') {
return fallback ? strdup(fallback) : NULL;
}
size_t len = strlen(in);
char* out = (char*)malloc(len + 1U);
if (!out) return NULL;
size_t j = 0;
int prev_dash = 0;
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)in[i];
if (isalnum(c)) {
out[j++] = (char)tolower(c);
prev_dash = 0;
} else if (!prev_dash && j > 0) {
out[j++] = '-';
prev_dash = 1;
}
}
while (j > 0 && out[j - 1] == '-') j--;
out[j] = '\0';
if (j == 0) {
free(out);
return fallback ? strdup(fallback) : NULL;
}
return out;
}
static void ensure_nip23_metadata_tags(int kind, const char* content, cJSON** tags_inout) {
if (!content || (kind != 30023 && kind != 30024) || !tags_inout) {
return;
}
cJSON* tags = ensure_tags_array(tags_inout);
if (!tags) return;
char* title = has_tag_key(tags, "title") ? NULL : first_markdown_h1(content);
char* summary = has_tag_key(tags, "summary") ? NULL : first_markdown_paragraph(content);
char* image = has_tag_key(tags, "image") ? NULL : first_markdown_image_url(content);
if (title) {
(void)add_string_tag(tags, "title", title);
}
if (summary) {
(void)add_string_tag(tags, "summary", summary);
}
if (image) {
(void)add_string_tag(tags, "image", image);
}
if (!has_tag_key(tags, "published_at")) {
char published_at[32];
snprintf(published_at, sizeof(published_at), "%lld", (long long)time(NULL));
(void)add_string_tag(tags, "published_at", published_at);
}
if (!has_tag_key(tags, "d")) {
char* slug = NULL;
if (title) {
slug = slugify_string(title, "long-form-note");
} else {
char* derived_title = first_markdown_h1(content);
slug = slugify_string(derived_title, "long-form-note");
free(derived_title);
}
if (slug) {
(void)add_string_tag(tags, "d", slug);
free(slug);
}
}
free(title);
free(summary);
free(image);
}
static int is_safe_relative_path(const char* path) {
if (!path || path[0] == '\0') return 0;
if (path[0] == '/') return 0;
@@ -94,6 +533,16 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON* p_content = cJSON_CreateObject();
cJSON_AddStringToObject(p_content, "type", "string");
cJSON_AddItemToObject(t1_props, "content", p_content);
cJSON* p_tags = cJSON_CreateObject();
cJSON_AddStringToObject(p_tags, "type", "array");
cJSON_AddStringToObject(p_tags, "description", "Optional Nostr tags array, e.g. [[\"d\",\"slug\"],[\"t\",\"nostr\"]]");
cJSON* p_tags_items = cJSON_CreateObject();
cJSON_AddStringToObject(p_tags_items, "type", "array");
cJSON* p_tag_item = cJSON_CreateObject();
cJSON_AddStringToObject(p_tag_item, "type", "string");
cJSON_AddItemToObject(p_tags_items, "items", p_tag_item);
cJSON_AddItemToObject(p_tags, "items", p_tags_items);
cJSON_AddItemToObject(t1_props, "tags", p_tags);
cJSON_AddItemToArray(t1_required, cJSON_CreateString("kind"));
cJSON_AddItemToArray(t1_required, cJSON_CreateString("content"));
@@ -202,6 +651,23 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON_AddItemToObject(t5, "function", t5_fn);
cJSON_AddItemToArray(tools, t5);
cJSON* t6 = cJSON_CreateObject();
cJSON* t6_fn = cJSON_CreateObject();
cJSON* t6_params = cJSON_CreateObject();
cJSON* t6_props = cJSON_CreateObject();
cJSON* t6_required = cJSON_CreateArray();
cJSON_AddStringToObject(t6, "type", "function");
cJSON_AddStringToObject(t6_fn, "name", "nostr_post_readme");
cJSON_AddStringToObject(t6_fn, "description", "Publish README.md as kind 30023 with deterministic d tag readme.md");
cJSON_AddStringToObject(t6_params, "type", "object");
cJSON_AddItemToObject(t6_params, "properties", t6_props);
cJSON_AddItemToObject(t6_params, "required", t6_required);
cJSON_AddItemToObject(t6_fn, "parameters", t6_params);
cJSON_AddItemToObject(t6, "function", t6_fn);
cJSON_AddItemToArray(tools, t6);
char* out = cJSON_PrintUnformatted(tools);
cJSON_Delete(tools);
return out;
@@ -209,20 +675,219 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
static char* execute_nostr_post(const char* args_json) {
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
if (!args) return json_error("invalid arguments JSON");
char* repaired_args_json = NULL;
if (!args && args_json) {
repaired_args_json = sanitize_json_string_controls(args_json);
if (repaired_args_json) {
args = cJSON_Parse(repaired_args_json);
}
}
if (!args && args_json) {
args = parse_loose_nostr_post_args(args_json);
}
if (!args) {
free(repaired_args_json);
return json_error("invalid arguments JSON");
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(args, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(args, "content");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(args, "tags");
if (!kind || !cJSON_IsNumber(kind) || !content || !cJSON_IsString(content) || !content->valuestring) {
cJSON_Delete(args);
return json_error("nostr_post requires integer kind and string content");
}
if (tags && !cJSON_IsArray(tags)) {
cJSON_Delete(args);
return json_error("nostr_post tags must be an array when provided");
}
int rc = nostr_handler_publish_kind_event((int)kind->valuedouble, content->valuestring, NULL);
cJSON* tags_dup = NULL;
if (tags) {
tags_dup = cJSON_Duplicate(tags, 1);
if (!tags_dup) {
cJSON_Delete(args);
return json_error("nostr_post failed to duplicate tags");
}
}
ensure_nip23_metadata_tags((int)kind->valuedouble, content->valuestring, &tags_dup);
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event((int)kind->valuedouble,
content->valuestring,
tags_dup,
&publish_result);
cJSON_Delete(tags_dup);
cJSON_Delete(args);
if (rc != 0) return json_error("nostr_post failed");
free(repaired_args_json);
if (rc != 0) {
nostr_handler_publish_result_free(&publish_result);
return json_error("nostr_post failed");
}
return json_success_with_message("nostr_post published");
cJSON* out = cJSON_CreateObject();
if (!out) {
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "nostr_post published");
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
if (publish_result.note_uri[0] != '\0') {
cJSON_AddStringToObject(out, "note_uri", publish_result.note_uri);
}
if (publish_result.naddr_uri[0] != '\0') {
cJSON_AddStringToObject(out, "naddr_uri", publish_result.naddr_uri);
}
if (publish_result.d_tag[0] != '\0') {
cJSON_AddStringToObject(out, "d_tag", publish_result.d_tag);
}
cJSON* relays = cJSON_CreateArray();
if (!relays) {
nostr_handler_publish_result_free(&publish_result);
cJSON_Delete(out);
return NULL;
}
for (int i = 0; i < publish_result.relay_count; i++) {
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
}
cJSON_AddItemToObject(out, "relays", relays);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
nostr_handler_publish_result_free(&publish_result);
return json;
}
static char* read_entire_file(const char* file_path, size_t* out_bytes) {
if (!file_path) return NULL;
FILE* fp = fopen(file_path, "rb");
if (!fp) return NULL;
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return NULL;
}
long size = ftell(fp);
if (size < 0) {
fclose(fp);
return NULL;
}
if (fseek(fp, 0, SEEK_SET) != 0) {
fclose(fp);
return NULL;
}
char* buf = (char*)malloc((size_t)size + 1U);
if (!buf) {
fclose(fp);
return NULL;
}
size_t n = fread(buf, 1, (size_t)size, fp);
fclose(fp);
buf[n] = '\0';
if (out_bytes) *out_bytes = n;
return buf;
}
static char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
char readme_path[PATH_MAX];
if (build_tool_path(ctx, "README.md", readme_path, sizeof(readme_path)) != 0) {
return json_error("failed to resolve README path");
}
size_t bytes_read = 0;
char* content = read_entire_file(readme_path, &bytes_read);
if (!content) {
return json_error("failed to read README.md");
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
free(content);
return json_error("failed to create tags");
}
if (add_string_tag(tags, "d", "readme.md") != 0) {
cJSON_Delete(tags);
free(content);
return json_error("failed to set d tag");
}
ensure_nip23_metadata_tags(30023, content, &tags);
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(30023, content, tags, &publish_result);
cJSON_Delete(tags);
free(content);
if (rc != 0) {
nostr_handler_publish_result_free(&publish_result);
return json_error("nostr_post_readme failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "nostr_post_readme published");
cJSON_AddStringToObject(out, "path", readme_path);
cJSON_AddNumberToObject(out, "bytes_read", (double)bytes_read);
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
if (publish_result.note_uri[0] != '\0') {
cJSON_AddStringToObject(out, "note_uri", publish_result.note_uri);
}
if (publish_result.naddr_uri[0] != '\0') {
cJSON_AddStringToObject(out, "naddr_uri", publish_result.naddr_uri);
}
if (publish_result.d_tag[0] != '\0') {
cJSON_AddStringToObject(out, "d_tag", publish_result.d_tag);
}
cJSON* relays = cJSON_CreateArray();
if (!relays) {
nostr_handler_publish_result_free(&publish_result);
cJSON_Delete(out);
return NULL;
}
for (int i = 0; i < publish_result.relay_count; i++) {
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
}
cJSON_AddItemToObject(out, "relays", relays);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
nostr_handler_publish_result_free(&publish_result);
return json;
}
static char* execute_nostr_query(const char* args_json) {
@@ -450,5 +1115,9 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
return execute_file_write(ctx, args_json);
}
if (strcmp(tool_name, "nostr_post_readme") == 0) {
return execute_nostr_post_readme(ctx, args_json);
}
return json_error("unknown tool");
}