diff --git a/README.md b/README.md index 8000319..15feffb 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ Because all identity, communication, and memory live on Nostr, the agent is **po **Skills are the new apps.** Agents learn capabilities through skills — public Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. If someone publishes a useful skill, your agent can find it through your web of trust and start using it. Popularity is measured by adoption, not by a rating algorithm. The best skills spread because agents actually use them. -## Current Status — v0.0.14 +## Current Status — v0.0.15 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.0.14 — Handle malformed nostr_post arguments with loose JSON fallback parsing +> Last release update: v0.0.15 — Auto-add NIP-23 title/image/summary/published_at/d tags when missing - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/src/main.h b/src/main.h index 697efb0..6e4082a 100644 --- a/src/main.h +++ b/src/main.h @@ -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 14 -#define DIDACTYL_VERSION "v0.0.14" +#define DIDACTYL_VERSION_PATCH 15 +#define DIDACTYL_VERSION "v0.0.15" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/tools.c b/src/tools.c index 11c5cf3..54eb004 100644 --- a/src/tools.c +++ b/src/tools.c @@ -7,6 +7,7 @@ #include #include #include +#include #include "cjson/cJSON.h" #include "nostr_handler.h" @@ -262,6 +263,276 @@ static cJSON* parse_loose_nostr_post_args(const char* in) { 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; @@ -487,6 +758,8 @@ static char* execute_nostr_post(const char* args_json) { } } + ensure_nip23_metadata_tags((int)kind->valuedouble, content->valuestring, &tags_dup); + nostr_publish_result_t publish_result; memset(&publish_result, 0, sizeof(publish_result));