diff --git a/.roo/commands/build.md b/.roo/commands/build.md new file mode 100644 index 0000000..d6a6139 --- /dev/null +++ b/.roo/commands/build.md @@ -0,0 +1,9 @@ +--- +description: "Build Didactyl for end-of-change validation" +--- + +After making code changes, run [`build_static.sh`](build_static.sh) instead of `make` for final compile validation. + +Example: + +./build_static.sh diff --git a/.roo/commands/push.md b/.roo/commands/push.md index ade090b..869bfde 100644 --- a/.roo/commands/push.md +++ b/.roo/commands/push.md @@ -2,6 +2,6 @@ description: "Increments and pushes the repo" --- -Run increment_and_push.sh followed in the command line with a good description of the changes that were made. +Then run increment_and_push.sh followed in the command line with a good description of the changes that were made. For example: ./increment_and_push.sh "Fixed that nasty bug" diff --git a/SYSTEM.md b/SYSTEM.md index 3476b46..e9d887d 100644 --- a/SYSTEM.md +++ b/SYSTEM.md @@ -11,6 +11,14 @@ You are Didactyl, a sovereign AI agent living on Nostr. - If unsure, state uncertainty directly. - Prefer actionable, practical advice. +## Tool Use Policy +- You have tools available and should use them when a request requires taking action. +- For requests involving local inspection or command execution, call `shell_exec` instead of refusing. +- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`. +- For relay/event lookup tasks, call `nostr_query` with an appropriate filter. +- After a tool call, base your answer on the actual tool result. +- Never claim a tool was run if no tool was executed. + ## Safety - Do not claim to have executed actions you did not execute. - Do not reveal secrets from configuration or keys. diff --git a/build_static.sh b/build_static.sh index 1923f60..f0bacc3 100755 --- a/build_static.sh +++ b/build_static.sh @@ -6,7 +6,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BUILD_DIR="$SCRIPT_DIR/build" +OUTPUT_DIR="$SCRIPT_DIR" DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl" # Parse command line arguments @@ -22,12 +22,15 @@ else echo "==========================================" fi echo "Project directory: $SCRIPT_DIR" -echo "Build directory: $BUILD_DIR" +echo "Output directory: $OUTPUT_DIR" echo "Debug build: $DEBUG_BUILD" echo "" -# Create build directory -mkdir -p "$BUILD_DIR" +# Remove legacy build directory if present +if [ -d "$SCRIPT_DIR/build" ]; then + rm -rf "$SCRIPT_DIR/build" + echo "Removed legacy build directory: $SCRIPT_DIR/build" +fi # Check if Docker is available if ! command -v docker &> /dev/null; then @@ -136,20 +139,12 @@ echo "==========================================" echo "Step 2: Extracting static binary" echo "==========================================" -# Build the builder stage to extract the binary -$DOCKER_CMD build \ - --platform "$PLATFORM" \ - --build-arg DEBUG_BUILD=$DEBUG_BUILD \ - --target builder \ - -f "$DOCKERFILE" \ - -t didactyl-static-builder-stage:latest \ - . > /dev/null 2>&1 +# Reuse the already-built final image and extract binary directly +# (avoids a second docker build pass) +CONTAINER_ID=$($DOCKER_CMD create didactyl-musl-builder:latest /didactyl_static) -# Create a temporary container to copy the binary -CONTAINER_ID=$($DOCKER_CMD create didactyl-static-builder-stage:latest) - -# Copy binary from container -$DOCKER_CMD cp "$CONTAINER_ID:/build/didactyl_static" "$BUILD_DIR/$OUTPUT_NAME" || { +# Copy binary from final image +$DOCKER_CMD cp "$CONTAINER_ID:/didactyl_static" "$OUTPUT_DIR/$OUTPUT_NAME" || { echo "ERROR: Failed to extract binary from container" $DOCKER_CMD rm "$CONTAINER_ID" 2>/dev/null exit 1 @@ -158,11 +153,11 @@ $DOCKER_CMD cp "$CONTAINER_ID:/build/didactyl_static" "$BUILD_DIR/$OUTPUT_NAME" # Clean up container $DOCKER_CMD rm "$CONTAINER_ID" > /dev/null -echo "✓ Binary extracted to: $BUILD_DIR/$OUTPUT_NAME" +echo "✓ Binary extracted to: $OUTPUT_DIR/$OUTPUT_NAME" echo "" # Make binary executable -chmod +x "$BUILD_DIR/$OUTPUT_NAME" +chmod +x "$OUTPUT_DIR/$OUTPUT_NAME" # Verify the binary echo "==========================================" @@ -171,7 +166,7 @@ echo "==========================================" echo "" echo "Checking for dynamic dependencies:" -if LDD_OUTPUT=$(timeout 5 ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1); then +if LDD_OUTPUT=$(timeout 5 ldd "$OUTPUT_DIR/$OUTPUT_NAME" 2>&1); then if echo "$LDD_OUTPUT" | grep -q "not a dynamic executable"; then echo "✓ Binary is fully static (no dynamic dependencies)" TRULY_STATIC=true @@ -185,7 +180,7 @@ if LDD_OUTPUT=$(timeout 5 ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1); then fi else # ldd failed or timed out - check with file command instead - if file "$BUILD_DIR/$OUTPUT_NAME" | grep -q "statically linked"; then + if file "$OUTPUT_DIR/$OUTPUT_NAME" | grep -q "statically linked"; then echo "✓ Binary is statically linked (verified with file command)" TRULY_STATIC=true else @@ -198,8 +193,8 @@ echo "" echo "==========================================" echo "Build Summary" echo "==========================================" -echo "Binary: $BUILD_DIR/$OUTPUT_NAME" -echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)" +echo "Binary: $OUTPUT_DIR/$OUTPUT_NAME" +echo "Size: $(du -h "$OUTPUT_DIR/$OUTPUT_NAME" | cut -f1)" echo "Static: $TRULY_STATIC" echo "Debug: $DEBUG_BUILD" echo "Platform: $PLATFORM" diff --git a/src/agent.c b/src/agent.c index 60927f1..1ed18af 100644 --- a/src/agent.c +++ b/src/agent.c @@ -5,15 +5,149 @@ #include #include #include +#include +#include +#include #include "llm.h" #include "nostr_handler.h" #include "tools.h" +#include "cjson/cJSON.h" static didactyl_config_t* g_cfg = NULL; static char* g_system_context = NULL; static tools_context_t g_tools_ctx; +#define AGENT_DEBOUNCE_WINDOW_SECONDS 5 +#define AGENT_DEBOUNCE_CACHE_SIZE 256 + +typedef struct { + uint64_t fingerprint; + time_t seen_at; +} agent_seen_msg_t; + +static agent_seen_msg_t g_seen_msgs[AGENT_DEBOUNCE_CACHE_SIZE]; +static int g_seen_msgs_count = 0; +static int g_seen_msgs_next = 0; +static pthread_mutex_t g_seen_msgs_mutex = PTHREAD_MUTEX_INITIALIZER; + +static uint64_t fnv1a64(const char* s) { + uint64_t h = 1469598103934665603ULL; + if (!s) return h; + while (*s) { + h ^= (unsigned char)(*s++); + h *= 1099511628211ULL; + } + return h; +} + +static uint64_t message_fingerprint(const char* sender_pubkey_hex, const char* message) { + uint64_t a = fnv1a64(sender_pubkey_hex); + uint64_t b = fnv1a64(message); + return a ^ (b + 0x9e3779b97f4a7c15ULL + (a << 6) + (a >> 2)); +} + +static int agent_message_is_debounced(const char* sender_pubkey_hex, const char* message) { + time_t now = time(NULL); + uint64_t fp = message_fingerprint(sender_pubkey_hex, message); + int duplicate = 0; + + pthread_mutex_lock(&g_seen_msgs_mutex); + + for (int i = 0; i < g_seen_msgs_count; i++) { + if (g_seen_msgs[i].fingerprint == fp && + g_seen_msgs[i].seen_at > 0 && + (now - g_seen_msgs[i].seen_at) <= AGENT_DEBOUNCE_WINDOW_SECONDS) { + duplicate = 1; + break; + } + } + + if (!duplicate) { + int slot = 0; + if (g_seen_msgs_count < AGENT_DEBOUNCE_CACHE_SIZE) { + slot = g_seen_msgs_count; + g_seen_msgs_count++; + } else { + slot = g_seen_msgs_next; + g_seen_msgs_next = (g_seen_msgs_next + 1) % AGENT_DEBOUNCE_CACHE_SIZE; + } + g_seen_msgs[slot].fingerprint = fp; + g_seen_msgs[slot].seen_at = now; + } + + pthread_mutex_unlock(&g_seen_msgs_mutex); + return duplicate; +} + +static int append_simple_message(cJSON* messages, const char* role, const char* content) { + if (!messages || !role) return -1; + + cJSON* msg = cJSON_CreateObject(); + if (!msg) return -1; + + cJSON_AddStringToObject(msg, "role", role); + cJSON_AddStringToObject(msg, "content", content ? content : ""); + cJSON_AddItemToArray(messages, msg); + return 0; +} + +static int append_assistant_tool_calls_message(cJSON* messages, const llm_response_t* resp) { + if (!messages || !resp || resp->tool_call_count <= 0) return -1; + + cJSON* assistant = cJSON_CreateObject(); + cJSON* tool_calls = cJSON_CreateArray(); + if (!assistant || !tool_calls) { + cJSON_Delete(assistant); + cJSON_Delete(tool_calls); + return -1; + } + + cJSON_AddStringToObject(assistant, "role", "assistant"); + if (resp->content) { + cJSON_AddStringToObject(assistant, "content", resp->content); + } else { + cJSON_AddNullToObject(assistant, "content"); + } + + for (int i = 0; i < resp->tool_call_count; i++) { + const llm_tool_call_t* tc = &resp->tool_calls[i]; + cJSON* tc_obj = cJSON_CreateObject(); + cJSON* fn_obj = cJSON_CreateObject(); + if (!tc_obj || !fn_obj) { + cJSON_Delete(tc_obj); + cJSON_Delete(fn_obj); + cJSON_Delete(assistant); + cJSON_Delete(tool_calls); + return -1; + } + + cJSON_AddStringToObject(tc_obj, "id", tc->id ? tc->id : ""); + cJSON_AddStringToObject(tc_obj, "type", "function"); + cJSON_AddStringToObject(fn_obj, "name", tc->name ? tc->name : ""); + cJSON_AddStringToObject(fn_obj, "arguments", tc->arguments_json ? tc->arguments_json : "{}"); + cJSON_AddItemToObject(tc_obj, "function", fn_obj); + cJSON_AddItemToArray(tool_calls, tc_obj); + } + + cJSON_AddItemToObject(assistant, "tool_calls", tool_calls); + cJSON_AddItemToArray(messages, assistant); + return 0; +} + +static int append_tool_result_message(cJSON* messages, const char* tool_call_id, const char* tool_result_json) { + if (!messages || !tool_call_id) return -1; + + cJSON* msg = cJSON_CreateObject(); + if (!msg) return -1; + + cJSON_AddStringToObject(msg, "role", "tool"); + cJSON_AddStringToObject(msg, "tool_call_id", tool_call_id); + cJSON_AddStringToObject(msg, "content", tool_result_json ? tool_result_json : "{\"success\":false,\"error\":\"empty tool result\"}"); + cJSON_AddItemToArray(messages, msg); + return 0; +} + int agent_init(didactyl_config_t* config, const char* system_context) { if (!config || !system_context) { return -1; @@ -32,6 +166,10 @@ int agent_init(didactyl_config_t* config, const char* system_context) { return -1; } + memset(g_seen_msgs, 0, sizeof(g_seen_msgs)); + g_seen_msgs_count = 0; + g_seen_msgs_next = 0; + return 0; } @@ -43,6 +181,12 @@ void agent_on_message(const char* sender_pubkey_hex, const char* message, void* } fprintf(stdout, "[didactyl] incoming message from %.16s...\n", sender_pubkey_hex); + + if (agent_message_is_debounced(sender_pubkey_hex, message)) { + fprintf(stdout, "[didactyl] debounced duplicate inbound message from %.16s...\n", sender_pubkey_hex); + return; + } + fprintf(stdout, "[didactyl] calling llm for sender %.16s...\n", sender_pubkey_hex); if (!g_cfg->tools.enabled) { @@ -68,59 +212,93 @@ void agent_on_message(const char* sender_pubkey_hex, const char* message, void* return; } - llm_response_t resp; - if (llm_chat_with_tools(g_system_context, message, tools_json, &resp) != 0) { + cJSON* messages = cJSON_CreateArray(); + if (!messages) { free(tools_json); - (void)nostr_handler_send_dm(sender_pubkey_hex, "LLM request failed."); + (void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation state."); return; } - free(tools_json); - if (resp.tool_call_count <= 0) { - const char* answer = resp.content ? resp.content : "No response content."; - fprintf(stdout, "[didactyl] llm response (no tool call): %.240s%s\n", - answer, - strlen(answer) > 240 ? "..." : ""); - (void)nostr_handler_send_dm(sender_pubkey_hex, answer); - llm_response_free(&resp); + if (append_simple_message(messages, "system", g_system_context) != 0 || + append_simple_message(messages, "user", message) != 0) { + cJSON_Delete(messages); + free(tools_json); + (void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation messages."); return; } int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8; - llm_response_t current = resp; + char* final_answer_owned = NULL; for (int turn = 0; turn < max_turns; turn++) { - if (current.tool_call_count <= 0) { + char* messages_json = cJSON_PrintUnformatted(messages); + if (!messages_json) { break; } - llm_tool_call_t* tc = ¤t.tool_calls[0]; - fprintf(stdout, "[didactyl] executing tool call: %s\n", tc->name ? tc->name : ""); - - 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\"}"); - } - - llm_response_free(¤t); - if (llm_chat_with_tools(g_system_context, tool_result, NULL, ¤t) != 0) { - free(tool_result); - (void)nostr_handler_send_dm(sender_pubkey_hex, "LLM failed after tool execution."); + llm_response_t resp; + int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp); + free(messages_json); + if (rc != 0) { + (void)nostr_handler_send_dm(sender_pubkey_hex, "LLM request failed."); + cJSON_Delete(messages); + free(tools_json); return; } - free(tool_result); - if (current.content && current.tool_call_count == 0) { + if (resp.tool_call_count <= 0) { + const char* answer = resp.content ? resp.content : "No response content."; + fprintf(stdout, "[didactyl] llm response (no tool call): %.240s%s\n", + answer, + strlen(answer) > 240 ? "..." : ""); + final_answer_owned = strdup(answer); + llm_response_free(&resp); break; } + + if (append_assistant_tool_calls_message(messages, &resp) != 0) { + llm_response_free(&resp); + break; + } + + 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 : ""); + + 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\"}"); + } + + if (append_tool_result_message(messages, + tc->id ? tc->id : "", + tool_result ? tool_result : "{\"success\":false,\"error\":\"tool execution failed\"}") != 0) { + free(tool_result); + llm_response_free(&resp); + cJSON_Delete(messages); + free(tools_json); + (void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to append tool result."); + return; + } + free(tool_result); + } + + llm_response_free(&resp); } - const char* final_answer = current.content ? current.content : "I hit my tool-use limit for this request."; + if (!final_answer_owned) { + final_answer_owned = strdup("I hit my tool-use limit for this request."); + } + + const char* final_answer = final_answer_owned ? final_answer_owned : "I hit my tool-use limit for this request."; fprintf(stdout, "[didactyl] final response: %.240s%s\n", final_answer, strlen(final_answer) > 240 ? "..." : ""); (void)nostr_handler_send_dm(sender_pubkey_hex, final_answer); - llm_response_free(¤t); + + free(final_answer_owned); + cJSON_Delete(messages); + free(tools_json); } void agent_cleanup(void) { @@ -128,4 +306,7 @@ void agent_cleanup(void) { free(g_system_context); g_system_context = NULL; g_cfg = NULL; + memset(g_seen_msgs, 0, sizeof(g_seen_msgs)); + g_seen_msgs_count = 0; + g_seen_msgs_next = 0; } diff --git a/src/llm.c b/src/llm.c index c50c7b8..583bdf3 100644 --- a/src/llm.c +++ b/src/llm.c @@ -6,6 +6,7 @@ #include #include #include +#include #include "cjson/cJSON.h" @@ -41,6 +42,28 @@ static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) { return total; } +static const char* detect_ca_bundle_path(void) { + const char* env = getenv("SSL_CERT_FILE"); + if (env && env[0] != '\0' && access(env, R_OK) == 0) { + return env; + } + + static const char* candidates[] = { + "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu + "/etc/ssl/cert.pem", // Alpine + "/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora + "/etc/ssl/ca-bundle.pem" // openSUSE + }; + + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) { + if (access(candidates[i], R_OK) == 0) { + return candidates[i]; + } + } + + return NULL; +} + static char* perform_chat_request(const char* body) { CURL* curl = curl_easy_init(); if (!curl || !body) { @@ -67,6 +90,11 @@ static char* perform_chat_request(const char* body) { curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + const char* ca_bundle = detect_ca_bundle_path(); + if (ca_bundle) { + curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle); + } + CURLcode res = curl_easy_perform(curl); long status = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); @@ -74,11 +102,33 @@ static char* perform_chat_request(const char* body) { curl_slist_free_all(headers); curl_easy_cleanup(curl); - if (res != CURLE_OK || status < 200 || status >= 300 || !rb.data) { + if (res != CURLE_OK) { + fprintf(stderr, "[didactyl] llm http request failed: curl=%s\n", curl_easy_strerror(res)); + if (rb.data && rb.len > 0) { + fprintf(stderr, "[didactyl] llm partial response: %.600s%s\n", + rb.data, + rb.len > 600 ? "..." : ""); + } free(rb.data); return NULL; } + if (status < 200 || status >= 300) { + fprintf(stderr, "[didactyl] llm http request failed: status=%ld\n", status); + if (rb.data && rb.len > 0) { + fprintf(stderr, "[didactyl] llm error response: %.1200s%s\n", + rb.data, + rb.len > 1200 ? "..." : ""); + } + free(rb.data); + return NULL; + } + + if (!rb.data) { + fprintf(stderr, "[didactyl] llm http request failed: empty response body\n"); + return NULL; + } + return rb.data; } @@ -228,6 +278,9 @@ char* llm_chat(const char* system_prompt, const char* user_message) { llm_response_t parsed; if (parse_llm_response(raw, &parsed) != 0) { + fprintf(stderr, "[didactyl] failed to parse llm response (non-tool path): %.1200s%s\n", + raw, + strlen(raw) > 1200 ? "..." : ""); free(raw); return NULL; } @@ -238,19 +291,16 @@ char* llm_chat(const char* system_prompt, const char* user_message) { return answer; } -int llm_chat_with_tools(const char* system_prompt, - const char* user_message, - const char* tools_json, - llm_response_t* out_response) { - if (!g_initialized || !out_response) { +int llm_chat_with_tools_messages(const char* messages_json, + const char* tools_json, + const char* tool_choice, + llm_response_t* out_response) { + if (!g_initialized || !out_response || !messages_json) { return -1; } cJSON* root = cJSON_CreateObject(); - cJSON* messages = cJSON_CreateArray(); - if (!root || !messages) { - cJSON_Delete(root); - cJSON_Delete(messages); + if (!root) { return -1; } @@ -258,20 +308,19 @@ int llm_chat_with_tools(const char* system_prompt, cJSON_AddNumberToObject(root, "max_tokens", g_cfg.max_tokens); cJSON_AddNumberToObject(root, "temperature", g_cfg.temperature); - cJSON* system_msg = cJSON_CreateObject(); - cJSON* user_msg = cJSON_CreateObject(); - cJSON_AddStringToObject(system_msg, "role", "system"); - cJSON_AddStringToObject(system_msg, "content", system_prompt ? system_prompt : ""); - cJSON_AddStringToObject(user_msg, "role", "user"); - cJSON_AddStringToObject(user_msg, "content", user_message ? user_message : ""); - cJSON_AddItemToArray(messages, system_msg); - cJSON_AddItemToArray(messages, user_msg); + cJSON* messages = cJSON_Parse(messages_json); + if (!messages || !cJSON_IsArray(messages)) { + cJSON_Delete(messages); + cJSON_Delete(root); + return -1; + } cJSON_AddItemToObject(root, "messages", messages); if (tools_json) { cJSON* tools = cJSON_Parse(tools_json); if (tools && cJSON_IsArray(tools)) { cJSON_AddItemToObject(root, "tools", tools); + cJSON_AddStringToObject(root, "tool_choice", tool_choice ? tool_choice : "auto"); } else { cJSON_Delete(tools); } @@ -286,10 +335,47 @@ int llm_chat_with_tools(const char* system_prompt, if (!raw) return -1; int rc = parse_llm_response(raw, out_response); + if (rc != 0) { + fprintf(stderr, "[didactyl] failed to parse llm response (tools path): %.1200s%s\n", + raw, + strlen(raw) > 1200 ? "..." : ""); + } free(raw); return rc; } +int llm_chat_with_tools(const char* system_prompt, + const char* user_message, + const char* tools_json, + llm_response_t* out_response) { + cJSON* messages = cJSON_CreateArray(); + cJSON* system_msg = cJSON_CreateObject(); + cJSON* user_msg = cJSON_CreateObject(); + if (!messages || !system_msg || !user_msg) { + cJSON_Delete(messages); + cJSON_Delete(system_msg); + cJSON_Delete(user_msg); + return -1; + } + + cJSON_AddStringToObject(system_msg, "role", "system"); + cJSON_AddStringToObject(system_msg, "content", system_prompt ? system_prompt : ""); + cJSON_AddStringToObject(user_msg, "role", "user"); + cJSON_AddStringToObject(user_msg, "content", user_message ? user_message : ""); + cJSON_AddItemToArray(messages, system_msg); + cJSON_AddItemToArray(messages, user_msg); + + char* messages_json = cJSON_PrintUnformatted(messages); + cJSON_Delete(messages); + if (!messages_json) { + return -1; + } + + int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", out_response); + free(messages_json); + return rc; +} + void llm_response_free(llm_response_t* response) { if (!response) return; free(response->content); diff --git a/src/llm.h b/src/llm.h index 459495e..fb2f8a2 100644 --- a/src/llm.h +++ b/src/llm.h @@ -21,6 +21,10 @@ int llm_chat_with_tools(const char* system_prompt, const char* user_message, const char* tools_json, llm_response_t* out_response); +int llm_chat_with_tools_messages(const char* messages_json, + const char* tools_json, + const char* tool_choice, + llm_response_t* out_response); void llm_response_free(llm_response_t* response); void llm_cleanup(void); diff --git a/src/main.c b/src/main.c index dc4edd0..22c3dd4 100644 --- a/src/main.c +++ b/src/main.c @@ -25,7 +25,7 @@ int main(int argc, char** argv) { const char* config_path = "./config.json"; const char* context_path = "./SYSTEM.md"; - int debug_level = DEBUG_LEVEL_INFO; + int debug_level = DEBUG_LEVEL_TRACE; for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) { diff --git a/src/main.h b/src/main.h index 6ab0454..5a19715 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 1 -#define DIDACTYL_VERSION "v0.0.1" +#define DIDACTYL_VERSION_PATCH 2 +#define DIDACTYL_VERSION "v0.0.2" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/nostr_handler.c b/src/nostr_handler.c index 99ecaa5..c4ddf10 100644 --- a/src/nostr_handler.c +++ b/src/nostr_handler.c @@ -6,6 +6,7 @@ #include #include #include +#include #include "../../nostr_core_lib/cjson/cJSON.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h" @@ -20,6 +21,47 @@ 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; +#define DM_DEDUP_CACHE_SIZE 256 + +static char g_seen_dm_ids[DM_DEDUP_CACHE_SIZE][65]; +static int g_seen_dm_count = 0; +static int g_seen_dm_next = 0; + +static pthread_mutex_t g_dm_dedup_mutex = PTHREAD_MUTEX_INITIALIZER; + +static int dm_id_seen_or_remember(const char* event_id_hex) { + if (!event_id_hex || strlen(event_id_hex) != 64U) { + return 0; + } + + int seen = 0; + pthread_mutex_lock(&g_dm_dedup_mutex); + + for (int i = 0; i < g_seen_dm_count; i++) { + if (strncmp(g_seen_dm_ids[i], event_id_hex, 64U) == 0) { + seen = 1; + break; + } + } + + if (!seen) { + int slot = 0; + if (g_seen_dm_count < DM_DEDUP_CACHE_SIZE) { + slot = g_seen_dm_count; + g_seen_dm_count++; + } else { + slot = g_seen_dm_next; + g_seen_dm_next = (g_seen_dm_next + 1) % DM_DEDUP_CACHE_SIZE; + } + + memcpy(g_seen_dm_ids[slot], event_id_hex, 64U); + g_seen_dm_ids[slot][64] = '\0'; + } + + pthread_mutex_unlock(&g_dm_dedup_mutex); + return seen; +} + static const char* relay_status_str(nostr_pool_relay_status_t status) { switch (status) { case NOSTR_POOL_RELAY_DISCONNECTED: @@ -155,6 +197,28 @@ static int extract_first_p_tag(cJSON* tags, char out_pubkey_hex[65]) { return -1; } +static void trace_event_json(const char* prefix, cJSON* event) { + if (g_debug_level < DEBUG_LEVEL_TRACE || !event) { + return; + } + + char* event_json = cJSON_PrintUnformatted(event); + if (!event_json) { + DEBUG_TRACE("[didactyl] %s ", prefix ? prefix : "event"); + return; + } + + DEBUG_TRACE("[didactyl] %s %s", prefix ? prefix : "event", event_json); + free(event_json); +} + +static void trace_plaintext_dm(const char* prefix, const char* plaintext) { + if (g_debug_level < DEBUG_LEVEL_TRACE) { + return; + } + DEBUG_TRACE("[didactyl] %s %s", prefix ? prefix : "dm plaintext", plaintext ? plaintext : ""); +} + static void on_event(cJSON* event, const char* relay_url, void* user_data) { (void)user_data; @@ -162,6 +226,7 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { return; } + cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id"); cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind"); cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey"); cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content"); @@ -172,6 +237,10 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { return; } + const char* event_id_hex = (id && cJSON_IsString(id) && id->valuestring && strlen(id->valuestring) == 64U) + ? id->valuestring + : NULL; + if ((int)kind->valuedouble != 4) { return; } @@ -204,13 +273,27 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { } decrypted[0] = '\0'; + trace_event_json("received encrypted DM event:", event); + if (nostr_nip04_decrypt(g_cfg->keys.private_key, sender_pubkey, content->valuestring, decrypted, NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) { fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", pubkey->valuestring); free(decrypted); return; } - DEBUG_INFO("[didactyl] received DM from %.16s... via %s", + trace_plaintext_dm("received decrypted DM content:", decrypted); + + if (event_id_hex && dm_id_seen_or_remember(event_id_hex)) { + DEBUG_LOG("[didactyl] skipped duplicate DM event %.16s... from %.16s... via %s", + event_id_hex, + pubkey->valuestring, + relay_url ? relay_url : "unknown relay"); + free(decrypted); + return; + } + + DEBUG_INFO("[didactyl] received DM %.16s... from %.16s... via %s", + event_id_hex ? event_id_hex : "", pubkey->valuestring, relay_url ? relay_url : "unknown relay"); g_dm_callback(pubkey->valuestring, decrypted, g_dm_user_data); @@ -231,6 +314,9 @@ int nostr_handler_init(didactyl_config_t* config) { g_cfg = config; g_poll_counter = 0; g_start_time = time(NULL); + memset(g_seen_dm_ids, 0, sizeof(g_seen_dm_ids)); + g_seen_dm_count = 0; + g_seen_dm_next = 0; DEBUG_INFO("[didactyl] initializing relay pool with %d relays", g_cfg->relay_count); @@ -393,6 +479,8 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message) return -1; } + trace_plaintext_dm("sending plaintext DM content:", message); + char* encrypted = (char*)malloc(NOSTR_NIP04_MAX_ENCRYPTED_SIZE); if (!encrypted) { fprintf(stderr, "[didactyl] failed to allocate DM encrypt buffer\n"); @@ -418,6 +506,8 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message) return -1; } + trace_event_json("sending encrypted DM event:", event); + log_publish_targets("publish DM"); const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*)); @@ -444,9 +534,16 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message) NULL); } + cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id"); + const char* out_event_id_hex = (event_id && cJSON_IsString(event_id) && event_id->valuestring) ? event_id->valuestring : ""; + + DEBUG_INFO("[didactyl] sent DM %.16s... to %.16s... via %d connected relay(s)", + out_event_id_hex, + recipient_pubkey_hex, + sent); + free(connected_relays); cJSON_Delete(event); - DEBUG_INFO("[didactyl] sent DM to %.16s... via %d connected relay(s)", recipient_pubkey_hex, sent); return sent > 0 ? 0 : -1; } @@ -573,4 +670,7 @@ void nostr_handler_cleanup(void) { g_cfg = NULL; g_dm_callback = NULL; g_dm_user_data = NULL; + memset(g_seen_dm_ids, 0, sizeof(g_seen_dm_ids)); + g_seen_dm_count = 0; + g_seen_dm_next = 0; }