From 7472809aa232efbcdfba1f7ed9981fefb6a0dc2c Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 27 Feb 2026 12:02:07 -0400 Subject: [PATCH] v0.0.1 - Added static MUSL build pipeline, Dockerfile.alpine-musl, versioned main.h integration, and release tooling updates --- .gitignore | 16 ++ .roo/commands/push.md | 7 + Dockerfile.alpine-musl | 116 ++++++++++ Makefile | 56 ++++- README.md | 2 +- build_static.sh | 206 +++++++++++++++++ increment_and_push.sh | 503 +++++++++++++++++++++++++++++++++++++++++ src/agent.c | 93 +++++++- src/config.c | 54 +++++ src/config.h | 14 ++ src/debug.c | 45 ++++ src/debug.h | 36 +++ src/llm.c | 242 +++++++++++++++----- src/llm.h | 17 ++ src/main.c | 25 +- src/main.h | 23 ++ src/nostr_handler.c | 235 +++++++++++++++++-- src/nostr_handler.h | 3 + src/secp_compat.c | 13 -- src/tools.c | 266 ++++++++++++++++++++++ src/tools.h | 15 ++ 21 files changed, 1877 insertions(+), 110 deletions(-) create mode 100644 .roo/commands/push.md create mode 100644 Dockerfile.alpine-musl create mode 100755 build_static.sh create mode 100755 increment_and_push.sh create mode 100644 src/debug.c create mode 100644 src/debug.h create mode 100644 src/main.h delete mode 100644 src/secp_compat.c create mode 100644 src/tools.c create mode 100644 src/tools.h diff --git a/.gitignore b/.gitignore index 0f9ee2f..8d53a01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,20 @@ +# Sibling workspace directories (not part of this project) /c_utils_lib/ /nostr_core_lib/ /openclaw/ +/c-relay/ /nips/ + +# Build artifacts +/build/ +/didactyl +*.o +*.a +*.tar.gz +debug.log +a.out + +# Editor/IDE +*.swp +*.swo +*~ diff --git a/.roo/commands/push.md b/.roo/commands/push.md new file mode 100644 index 0000000..ade090b --- /dev/null +++ b/.roo/commands/push.md @@ -0,0 +1,7 @@ +--- +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. + +For example: ./increment_and_push.sh "Fixed that nasty bug" diff --git a/Dockerfile.alpine-musl b/Dockerfile.alpine-musl new file mode 100644 index 0000000..3f7f532 --- /dev/null +++ b/Dockerfile.alpine-musl @@ -0,0 +1,116 @@ +# Alpine-based MUSL static binary builder for Didactyl +# Produces truly portable binaries with zero runtime dependencies + +ARG DEBUG_BUILD=false + +FROM alpine:3.19 AS builder + +# Re-declare build argument in this stage +ARG DEBUG_BUILD=false + +# Install build dependencies +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 + +# Set working directory +WORKDIR /build + +# Build libsecp256k1 static (cached layer - only rebuilds if Alpine version changes) +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 + +# Copy nostr_core_lib source files (cached unless nostr_core_lib changes) +# NOTE: nostr_core_lib currently compiles request_validator, which includes , +# so sqlite-dev/sqlite-static are required in this image even if didactyl does not use sqlite directly. +COPY nostr_core_lib /build/nostr_core_lib/ + +# Build nostr_core_lib with all NIPs +# Disable fortification in build.sh to prevent __*_chk symbol issues +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 + +# Copy didactyl source files LAST (only this layer rebuilds on source changes) +COPY src/ /build/src/ +COPY Makefile /build/Makefile + +# Build didactyl with full static linking (only rebuilds when src/ changes) +# Disable fortification to avoid __*_chk symbols that don't exist in MUSL +# Use conditional compilation flags based on DEBUG_BUILD argument +RUN if [ "$DEBUG_BUILD" = "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" + +# Verify it's truly static +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 ===" + +# Output stage - just the binary +FROM scratch AS output +COPY --from=builder /build/didactyl_static /didactyl_static diff --git a/Makefile b/Makefile index 72cd2ee..dbaf6f1 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,8 @@ SRCS = \ $(SRC_DIR)/llm.c \ $(SRC_DIR)/nostr_handler.c \ $(SRC_DIR)/agent.c \ - $(SRC_DIR)/secp_compat.c + $(SRC_DIR)/tools.c \ + $(SRC_DIR)/debug.c INCLUDES = \ -I$(SRC_DIR) \ @@ -21,11 +22,57 @@ INCLUDES = \ NOSTR_LIB = $(firstword $(wildcard ../nostr_core_lib/libnostr_core_*.a)) -LDFLAGS = -lcurl -lssl -lcrypto -lm -lpthread -ldl -lz -lsecp256k1 +LDFLAGS = -lcurl -lssl -lcrypto -lm -lpthread -ldl -lz -L/usr/local/lib -lsecp256k1 + +# Build directory +BUILD_DIR = build all: deps $(TARGET) -$(TARGET): $(SRCS) +$(BUILD_DIR): + mkdir -p $(BUILD_DIR) + +# Build nostr_core_lib +$(NOSTR_LIB): + @echo "Building nostr_core_lib with all NIPs..." + cd ../nostr_core_lib && ./build.sh --nips=all + +# Update main.h version information (requires main.h to exist) +src/main.h: + @if [ ! -f src/main.h ]; then \ + echo "ERROR: src/main.h not found!"; \ + echo "Please ensure src/main.h exists with version metadata."; \ + exit 1; \ + fi; \ + if [ -d .git ]; then \ + echo "Updating main.h version information from git tags..."; \ + RAW_VERSION=$$(git describe --tags --always 2>/dev/null || echo "unknown"); \ + if echo "$$RAW_VERSION" | grep -q "^v[0-9]"; then \ + CLEAN_VERSION=$$(echo "$$RAW_VERSION" | sed 's/^v//' | cut -d- -f1); \ + VERSION="v$$CLEAN_VERSION"; \ + MAJOR=$$(echo "$$CLEAN_VERSION" | cut -d. -f1); \ + MINOR=$$(echo "$$CLEAN_VERSION" | cut -d. -f2); \ + PATCH=$$(echo "$$CLEAN_VERSION" | cut -d. -f3); \ + else \ + VERSION="v0.0.0"; \ + MAJOR=0; MINOR=0; PATCH=0; \ + fi; \ + echo "Updating version information in existing main.h..."; \ + sed -i "s/#define DIDACTYL_VERSION \".*\"/#define DIDACTYL_VERSION \"$$VERSION\"/g" src/main.h; \ + sed -i "s/#define DIDACTYL_VERSION_MAJOR [0-9]*/#define DIDACTYL_VERSION_MAJOR $$MAJOR/g" src/main.h; \ + sed -i "s/#define DIDACTYL_VERSION_MINOR [0-9]*/#define DIDACTYL_VERSION_MINOR $$MINOR/g" src/main.h; \ + sed -i "s/#define DIDACTYL_VERSION_PATCH [0-9]*/#define DIDACTYL_VERSION_PATCH $$PATCH/g" src/main.h; \ + echo "Updated main.h version to: $$VERSION"; \ + else \ + echo "Git not available, preserving existing main.h version information"; \ + fi + +# Force update version +force-version: + @echo "Force updating main.h version information..." + @$(MAKE) src/main.h + +$(TARGET): src/main.h $(SRCS) $(NOSTR_LIB) @if [ -z "$(NOSTR_LIB)" ]; then echo "nostr_core_lib static library not found; run 'make deps'"; exit 1; fi $(CC) $(CFLAGS) $(INCLUDES) -o $@ $(SRCS) $(NOSTR_LIB) $(LDFLAGS) @@ -34,5 +81,6 @@ deps: clean: rm -f $(TARGET) + rm -rf $(BUILD_DIR) -.PHONY: all deps clean +.PHONY: all deps clean force-version diff --git a/README.md b/README.md index f843a68..8b3ee9e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Didactyl -A sovereign AI agent daemon written in C99 that uses Nostr as its primary operating layer. +An unstoppable agentic system. Didactyl boots on any internet-connected machine, connects to Nostr relays, listens for encrypted commands from its administrator, reasons with an LLM, and takes actions — posting events, querying relays, running shell commands — all orchestrated through Nostr. diff --git a/build_static.sh b/build_static.sh new file mode 100755 index 0000000..1923f60 --- /dev/null +++ b/build_static.sh @@ -0,0 +1,206 @@ +#!/bin/bash + +# Build fully static MUSL binaries for Didactyl using Alpine Docker +# Produces truly portable binaries with zero runtime dependencies + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl" + +# Parse command line arguments +DEBUG_BUILD=false +if [[ "$1" == "--debug" ]]; then + DEBUG_BUILD=true + echo "==========================================" + echo "Didactyl MUSL Static Binary Builder (DEBUG MODE)" + echo "==========================================" +else + echo "==========================================" + echo "Didactyl MUSL Static Binary Builder (PRODUCTION MODE)" + echo "==========================================" +fi +echo "Project directory: $SCRIPT_DIR" +echo "Build directory: $BUILD_DIR" +echo "Debug build: $DEBUG_BUILD" +echo "" + +# Create build directory +mkdir -p "$BUILD_DIR" + +# Check if Docker is available +if ! command -v docker &> /dev/null; then + echo "ERROR: Docker is not installed or not in PATH" + echo "" + echo "Docker is required to build MUSL static binaries." + echo "Please install Docker:" + echo " - Ubuntu/Debian: sudo apt install docker.io" + echo " - Or visit: https://docs.docker.com/engine/install/" + echo "" + exit 1 +fi + +# Check if Docker daemon is running +if ! docker info &> /dev/null; then + echo "ERROR: Docker daemon is not running or user not in docker group" + echo "" + echo "Please start Docker and ensure you're in the docker group:" + echo " - sudo systemctl start docker" + echo " - sudo usermod -aG docker $USER && newgrp docker" + echo " - Or start Docker Desktop" + echo "" + exit 1 +fi + +DOCKER_CMD="docker" + +echo "✓ Docker is available and running" +echo "" + +# Detect architecture +ARCH=$(uname -m) +case "$ARCH" in + x86_64) + PLATFORM="linux/amd64" + OUTPUT_NAME="didactyl_static_x86_64" + ;; + aarch64|arm64) + PLATFORM="linux/arm64" + OUTPUT_NAME="didactyl_static_arm64" + ;; + *) + echo "WARNING: Unknown architecture: $ARCH" + echo "Defaulting to linux/amd64" + PLATFORM="linux/amd64" + OUTPUT_NAME="didactyl_static_${ARCH}" + ;; +esac + +# Append _debug suffix to output name for debug builds +if [ "$DEBUG_BUILD" = true ]; then + OUTPUT_NAME="${OUTPUT_NAME}_debug" +fi + +echo "Building for platform: $PLATFORM" +echo "Output binary: $OUTPUT_NAME" +echo "" + +# Check if Alpine base image is cached +echo "Checking for cached Alpine Docker image..." +if ! docker images alpine:3.19 --format "{{.Repository}}:{{.Tag}}" | grep -q "alpine:3.19"; then + echo "⚠ Alpine 3.19 image not found in cache" + echo "Attempting to pull Alpine 3.19 image..." + if ! docker pull alpine:3.19; then + echo "" + echo "ERROR: Failed to pull Alpine 3.19 image" + echo "This is required for the static build." + echo "" + exit 1 + fi + echo "✓ Alpine 3.19 image pulled successfully" +else + echo "✓ Alpine 3.19 image found in cache" +fi +echo "" + +# Build the Docker image +echo "==========================================" +echo "Step 1: Building Alpine Docker image" +echo "==========================================" +echo "This will:" +echo " - Use Alpine Linux (native MUSL)" +echo " - Build all dependencies statically" +echo " - Compile didactyl with full static linking" +echo "" + +$DOCKER_CMD build \ + --platform "$PLATFORM" \ + --build-arg DEBUG_BUILD=$DEBUG_BUILD \ + -f "$DOCKERFILE" \ + -t didactyl-musl-builder:latest \ + --progress=plain \ + . || { + echo "" + echo "ERROR: Docker build failed" + echo "Check the output above for details" + exit 1 + } + +echo "" +echo "✓ Docker image built successfully" +echo "" + +# Extract the binary from the container +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 + +# 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" || { + echo "ERROR: Failed to extract binary from container" + $DOCKER_CMD rm "$CONTAINER_ID" 2>/dev/null + exit 1 +} + +# Clean up container +$DOCKER_CMD rm "$CONTAINER_ID" > /dev/null + +echo "✓ Binary extracted to: $BUILD_DIR/$OUTPUT_NAME" +echo "" + +# Make binary executable +chmod +x "$BUILD_DIR/$OUTPUT_NAME" + +# Verify the binary +echo "==========================================" +echo "Step 3: Verifying static binary" +echo "==========================================" +echo "" + +echo "Checking for dynamic dependencies:" +if LDD_OUTPUT=$(timeout 5 ldd "$BUILD_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 + elif echo "$LDD_OUTPUT" | grep -q "statically linked"; then + echo "✓ Binary is statically linked" + TRULY_STATIC=true + else + echo "⚠ WARNING: Binary may have dynamic dependencies:" + echo "$LDD_OUTPUT" + TRULY_STATIC=false + fi +else + # ldd failed or timed out - check with file command instead + if file "$BUILD_DIR/$OUTPUT_NAME" | grep -q "statically linked"; then + echo "✓ Binary is statically linked (verified with file command)" + TRULY_STATIC=true + else + echo "⚠ Could not verify static linking (ldd check failed)" + TRULY_STATIC=false + fi +fi + +echo "" +echo "==========================================" +echo "Build Summary" +echo "==========================================" +echo "Binary: $BUILD_DIR/$OUTPUT_NAME" +echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)" +echo "Static: $TRULY_STATIC" +echo "Debug: $DEBUG_BUILD" +echo "Platform: $PLATFORM" +echo "==========================================" diff --git a/increment_and_push.sh b/increment_and_push.sh new file mode 100755 index 0000000..820ef9f --- /dev/null +++ b/increment_and_push.sh @@ -0,0 +1,503 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; } +print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; } +print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; } +print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } + +# Global variables +COMMIT_MESSAGE="" +RELEASE_MODE=false +VERSION_INCREMENT_TYPE="patch" # "patch", "minor", or "major" + +show_usage() { + echo "Didactyl Increment and Push Script" + echo "" + echo "USAGE:" + echo " $0 [OPTIONS] \"commit message\"" + echo "" + echo "COMMANDS:" + echo " $0 \"commit message\" Default: increment patch, commit & push" + echo " $0 -p \"commit message\" Increment patch version" + echo " $0 -m \"commit message\" Increment minor version" + echo " $0 -M \"commit message\" Increment major version" + echo " $0 -r \"commit message\" Create release with assets (no version increment)" + echo " $0 -r -m \"commit message\" Create release with minor version increment" + echo " $0 -h Show this help message" + echo "" + echo "OPTIONS:" + echo " -p, --patch Increment patch version (default)" + echo " -m, --minor Increment minor version" + echo " -M, --major Increment major version" + echo " -r, --release Create release with assets" + echo " -h, --help Show this help message" + echo "" + echo "EXAMPLES:" + echo " $0 \"Fixed event signing bug\"" + echo " $0 -m \"Added tool-use system\"" + echo " $0 -M \"Breaking API changes\"" + echo " $0 -r \"Release current version\"" + echo " $0 -r -m \"Release with minor increment\"" + echo "" + echo "VERSION INCREMENT MODES:" + echo " -p, --patch (default): Increment patch version (v0.1.0 → v0.1.1)" + echo " -m, --minor: Increment minor version, zero patch (v0.1.0 → v0.2.0)" + echo " -M, --major: Increment major version, zero minor+patch (v0.1.0 → v1.0.0)" + echo "" + echo "RELEASE MODE (-r flag):" + echo " - Build static binary using build_static.sh" + echo " - Create source tarball" + echo " - Git add, commit, push, and create Gitea release with assets" + echo " - Can be combined with version increment flags" + echo "" + echo "REQUIREMENTS FOR RELEASE MODE:" + echo " - Gitea token in ~/.gitea_token for release uploads" + echo " - Docker installed for static binary builds" +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -r|--release) + RELEASE_MODE=true + shift + ;; + -p|--patch) + VERSION_INCREMENT_TYPE="patch" + shift + ;; + -m|--minor) + VERSION_INCREMENT_TYPE="minor" + shift + ;; + -M|--major) + VERSION_INCREMENT_TYPE="major" + shift + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + # First non-flag argument is the commit message + if [[ -z "$COMMIT_MESSAGE" ]]; then + COMMIT_MESSAGE="$1" + fi + shift + ;; + esac +done + +# Validate inputs +if [[ -z "$COMMIT_MESSAGE" ]]; then + print_error "Commit message is required" + echo "" + show_usage + exit 1 +fi + +# Check if we're in a git repository +check_git_repo() { + if ! git rev-parse --git-dir > /dev/null 2>&1; then + print_error "Not in a git repository" + exit 1 + fi +} + +# Function to get current version and increment appropriately +increment_version() { + local increment_type="$1" # "patch", "minor", or "major" + + print_status "Getting current version..." + + # Get the highest version tag (not chronologically latest) + LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "") + if [[ -z "$LATEST_TAG" ]]; then + LATEST_TAG="v0.0.0" + print_warning "No version tags found, starting from $LATEST_TAG" + fi + + # Extract version components (remove 'v' prefix) + VERSION=${LATEST_TAG#v} + + # Parse major.minor.patch using regex + if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + MAJOR=${BASH_REMATCH[1]} + MINOR=${BASH_REMATCH[2]} + PATCH=${BASH_REMATCH[3]} + else + print_error "Invalid version format in tag: $LATEST_TAG" + print_error "Expected format: v0.1.0" + exit 1 + fi + + # Increment version based on type + if [[ "$increment_type" == "major" ]]; then + NEW_MAJOR=$((MAJOR + 1)) + NEW_MINOR=0 + NEW_PATCH=0 + NEW_VERSION="v${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}" + print_status "Major version increment: incrementing major version" + elif [[ "$increment_type" == "minor" ]]; then + NEW_MAJOR=$MAJOR + NEW_MINOR=$((MINOR + 1)) + NEW_PATCH=0 + NEW_VERSION="v${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}" + print_status "Minor version increment: incrementing minor version" + else + NEW_MAJOR=$MAJOR + NEW_MINOR=$MINOR + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="v${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}" + print_status "Patch version increment: incrementing patch version" + fi + + print_status "Current version: $LATEST_TAG" + print_status "New version: $NEW_VERSION" + + # Update version in src/main.h + update_version_in_header "$NEW_VERSION" "$NEW_MAJOR" "$NEW_MINOR" "$NEW_PATCH" + + # Export for use in other functions + export NEW_VERSION +} + +# Function to update version macros in src/main.h +update_version_in_header() { + local new_version="$1" + local major="$2" + local minor="$3" + local patch="$4" + + print_status "Updating version in src/main.h..." + + # Check if src/main.h exists + if [[ ! -f "src/main.h" ]]; then + print_error "src/main.h not found" + exit 1 + fi + + # Update DIDACTYL_VERSION macro + sed -i "s/#define DIDACTYL_VERSION \".*\"/#define DIDACTYL_VERSION \"$new_version\"/" src/main.h + + # Update DIDACTYL_VERSION_MAJOR macro + sed -i "s/#define DIDACTYL_VERSION_MAJOR [0-9]\+/#define DIDACTYL_VERSION_MAJOR $major/" src/main.h + + # Update DIDACTYL_VERSION_MINOR macro + sed -i "s/#define DIDACTYL_VERSION_MINOR .*/#define DIDACTYL_VERSION_MINOR $minor/" src/main.h + + # Update DIDACTYL_VERSION_PATCH macro + sed -i "s/#define DIDACTYL_VERSION_PATCH [0-9]\+/#define DIDACTYL_VERSION_PATCH $patch/" src/main.h + + print_success "Updated version in src/main.h to $new_version" +} + +# Function to commit and push changes without creating a tag (tag already created) +git_commit_and_push_no_tag() { + print_status "Preparing git commit..." + + # Stage all changes + if git add . > /dev/null 2>&1; then + print_success "Staged all changes" + else + print_error "Failed to stage changes" + exit 1 + fi + + # Check if there are changes to commit + if git diff --staged --quiet; then + print_warning "No changes to commit" + else + # Commit changes + if git commit -m "$NEW_VERSION - $COMMIT_MESSAGE" > /dev/null 2>&1; then + print_success "Committed changes" + else + print_error "Failed to commit changes" + exit 1 + fi + fi + + # Push changes + print_status "Pushing to remote repository..." + if git push > /dev/null 2>&1; then + print_success "Pushed changes" + else + print_error "Failed to push changes" + exit 1 + fi + + # Push only the new tag to avoid conflicts with existing tags + if git push origin "$NEW_VERSION" > /dev/null 2>&1; then + print_success "Pushed tag: $NEW_VERSION" + else + print_warning "Tag push failed, trying force push..." + if git push --force origin "$NEW_VERSION" > /dev/null 2>&1; then + print_success "Force-pushed updated tag: $NEW_VERSION" + else + print_error "Failed to push tag: $NEW_VERSION" + exit 1 + fi + fi +} + +# Function to build release binary +build_release_binary() { + print_status "Building release binary..." + + # Check if build_static.sh exists + if [[ ! -f "build_static.sh" ]]; then + print_error "build_static.sh not found" + return 1 + fi + + # Run the static build script + if ./build_static.sh > /dev/null 2>&1; then + print_success "Built static binary successfully" + return 0 + else + print_error "Failed to build static binary" + return 1 + fi +} + +# Function to create source tarball +create_source_tarball() { + print_status "Creating source tarball..." + + local tarball_name="didactyl-${NEW_VERSION#v}.tar.gz" + + # Create tarball excluding build artifacts and git files + if tar -czf "$tarball_name" \ + --exclude='build/*' \ + --exclude='.git*' \ + --exclude='*.log' \ + --exclude='*.tar.gz' \ + --exclude='c-relay/*' \ + . > /dev/null 2>&1; then + print_success "Created source tarball: $tarball_name" + echo "$tarball_name" + return 0 + else + print_error "Failed to create source tarball" + return 1 + fi +} + +# Function to upload release assets to Gitea +upload_release_assets() { + local release_id="$1" + local binary_path="$2" + local tarball_path="$3" + + print_status "Uploading release assets..." + + # Check for Gitea token + if [[ ! -f "$HOME/.gitea_token" ]]; then + print_warning "No ~/.gitea_token found. Skipping asset uploads." + return 0 + fi + + local token=$(cat "$HOME/.gitea_token" | tr -d '\n\r') + local api_url="https://git.laantungir.net/api/v1/repos/laantungir/didactyl" + local assets_url="$api_url/releases/$release_id/assets" + print_status "Assets URL: $assets_url" + + # Upload binary + if [[ -f "$binary_path" ]]; then + print_status "Uploading binary: $(basename "$binary_path")" + + # Retry loop for eventual consistency + local max_attempts=3 + local attempt=1 + while [[ $attempt -le $max_attempts ]]; do + print_status "Upload attempt $attempt/$max_attempts" + local binary_response=$(curl -fS -X POST "$assets_url" \ + -H "Authorization: token $token" \ + -F "attachment=@$binary_path;filename=$(basename "$binary_path")" \ + -F "name=$(basename "$binary_path")") + + if echo "$binary_response" | grep -q '"id"'; then + print_success "Uploaded binary successfully" + break + else + print_warning "Upload attempt $attempt failed" + if [[ $attempt -lt $max_attempts ]]; then + print_status "Retrying in 2 seconds..." + sleep 2 + else + print_error "Failed to upload binary after $max_attempts attempts" + print_error "Response: $binary_response" + fi + fi + ((attempt++)) + done + fi + + # Upload source tarball + if [[ -f "$tarball_path" ]]; then + print_status "Uploading source tarball: $(basename "$tarball_path")" + local tarball_response=$(curl -s -X POST "$api_url/releases/$release_id/assets" \ + -H "Authorization: token $token" \ + -F "attachment=@$tarball_path;filename=$(basename "$tarball_path")") + + if echo "$tarball_response" | grep -q '"id"'; then + print_success "Uploaded source tarball successfully" + else + print_warning "Failed to upload source tarball: $tarball_response" + fi + fi +} + +# Function to create Gitea release +create_gitea_release() { + print_status "Creating Gitea release..." + + # Check for Gitea token + if [[ ! -f "$HOME/.gitea_token" ]]; then + print_warning "No ~/.gitea_token found. Skipping release creation." + print_warning "Create ~/.gitea_token with your Gitea access token to enable releases." + return 0 + fi + + local token=$(cat "$HOME/.gitea_token" | tr -d '\n\r') + local api_url="https://git.laantungir.net/api/v1/repos/laantungir/didactyl" + + # Create release + print_status "Creating release $NEW_VERSION..." + local response=$(curl -s -X POST "$api_url/releases" \ + -H "Authorization: token $token" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"$NEW_VERSION\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\"}") + + if echo "$response" | grep -q '"id"'; then + print_success "Created release $NEW_VERSION" + local release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) + echo $release_id + elif echo "$response" | grep -q "already exists"; then + print_warning "Release $NEW_VERSION already exists" + local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION") + if echo "$check_response" | grep -q '"id"'; then + local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) + print_status "Using existing release ID: $release_id" + echo $release_id + else + print_error "Could not find existing release ID" + return 1 + fi + else + print_error "Failed to create release $NEW_VERSION" + print_error "Response: $response" + + local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION") + if echo "$check_response" | grep -q '"id"'; then + print_warning "Release exists but creation response was unexpected" + local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) + echo $release_id + else + print_error "Release does not exist and creation failed" + return 1 + fi + fi +} + +# Main execution +main() { + print_status "Didactyl Increment and Push Script" + + # Check prerequisites + check_git_repo + + if [[ "$RELEASE_MODE" == true ]]; then + print_status "=== RELEASE MODE ===" + + # Only increment version if explicitly requested + if [[ "$VERSION_INCREMENT_TYPE" != "patch" ]]; then + increment_version "$VERSION_INCREMENT_TYPE" + else + LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "v0.0.0") + NEW_VERSION="$LATEST_TAG" + export NEW_VERSION + fi + + # Create new git tag BEFORE compilation so version picks it up + if git tag "$NEW_VERSION" > /dev/null 2>&1; then + print_success "Created tag: $NEW_VERSION" + else + print_warning "Tag $NEW_VERSION already exists, removing and recreating..." + git tag -d "$NEW_VERSION" > /dev/null 2>&1 + git tag "$NEW_VERSION" > /dev/null 2>&1 + fi + + # Commit and push + git_commit_and_push_no_tag + + # Build release binary + if build_release_binary; then + local binary_path="build/didactyl_static_x86_64" + else + print_warning "Binary build failed, continuing with release creation" + if [[ -f "build/didactyl_static_x86_64" ]]; then + print_status "Using existing binary from previous build" + binary_path="build/didactyl_static_x86_64" + else + binary_path="" + fi + fi + + # Create source tarball + local tarball_path="" + if tarball_path=$(create_source_tarball); then + : # tarball_path is set by the function + else + print_warning "Source tarball creation failed, continuing with release creation" + fi + + # Create Gitea release + local release_id="" + if release_id=$(create_gitea_release); then + if [[ "$release_id" =~ ^[0-9]+$ ]]; then + if [[ -n "$release_id" && (-n "$binary_path" || -n "$tarball_path") ]]; then + upload_release_assets "$release_id" "$binary_path" "$tarball_path" + fi + print_success "Release $NEW_VERSION completed successfully!" + else + print_error "Invalid release_id: $release_id" + exit 1 + fi + else + print_error "Release creation failed" + fi + + else + print_status "=== DEFAULT MODE ===" + + # Increment version based on type (default to patch) + increment_version "$VERSION_INCREMENT_TYPE" + + # Create new git tag BEFORE compilation so version picks it up + if git tag "$NEW_VERSION" > /dev/null 2>&1; then + print_success "Created tag: $NEW_VERSION" + else + print_warning "Tag $NEW_VERSION already exists, removing and recreating..." + git tag -d "$NEW_VERSION" > /dev/null 2>&1 + git tag "$NEW_VERSION" > /dev/null 2>&1 + fi + + # Commit and push + git_commit_and_push_no_tag + + print_success "Increment and push completed successfully!" + print_status "Version $NEW_VERSION pushed to repository" + fi +} + +# Execute main function +main diff --git a/src/agent.c b/src/agent.c index cfa7b71..60927f1 100644 --- a/src/agent.c +++ b/src/agent.c @@ -8,9 +8,11 @@ #include "llm.h" #include "nostr_handler.h" +#include "tools.h" static didactyl_config_t* g_cfg = NULL; static char* g_system_context = NULL; +static tools_context_t g_tools_ctx; int agent_init(didactyl_config_t* config, const char* system_context) { if (!config || !system_context) { @@ -23,6 +25,13 @@ int agent_init(didactyl_config_t* config, const char* system_context) { return -1; } + if (tools_init(&g_tools_ctx, g_cfg) != 0) { + free(g_system_context); + g_system_context = NULL; + g_cfg = NULL; + return -1; + } + return 0; } @@ -36,22 +45,86 @@ void agent_on_message(const char* sender_pubkey_hex, const char* message, void* fprintf(stdout, "[didactyl] incoming message from %.16s...\n", sender_pubkey_hex); fprintf(stdout, "[didactyl] calling llm for sender %.16s...\n", sender_pubkey_hex); - char* response = llm_chat(g_system_context, message); - if (!response) { - const char* fallback = "I could not get a response from the LLM right now."; - fprintf(stdout, "[didactyl] llm response unavailable, sending fallback\n"); - (void)nostr_handler_send_dm(sender_pubkey_hex, fallback); + if (!g_cfg->tools.enabled) { + char* response = llm_chat(g_system_context, message); + if (!response) { + const char* fallback = "I could not get a response from the LLM right now."; + fprintf(stdout, "[didactyl] llm response unavailable, sending fallback\n"); + (void)nostr_handler_send_dm(sender_pubkey_hex, fallback); + return; + } + + fprintf(stdout, "[didactyl] llm response: %.240s%s\n", + response, + strlen(response) > 240 ? "..." : ""); + (void)nostr_handler_send_dm(sender_pubkey_hex, response); + free(response); return; } - fprintf(stdout, "[didactyl] llm response: %.240s%s\n", - response, - strlen(response) > 240 ? "..." : ""); - (void)nostr_handler_send_dm(sender_pubkey_hex, response); - free(response); + char* tools_json = tools_build_openai_schema_json(&g_tools_ctx); + if (!tools_json) { + (void)nostr_handler_send_dm(sender_pubkey_hex, "Tool schema generation failed."); + return; + } + + llm_response_t resp; + if (llm_chat_with_tools(g_system_context, message, tools_json, &resp) != 0) { + free(tools_json); + (void)nostr_handler_send_dm(sender_pubkey_hex, "LLM request failed."); + 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); + return; + } + + int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8; + llm_response_t current = resp; + + for (int turn = 0; turn < max_turns; turn++) { + if (current.tool_call_count <= 0) { + 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."); + return; + } + free(tool_result); + + if (current.content && current.tool_call_count == 0) { + break; + } + } + + const char* final_answer = current.content ? current.content : "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); } void agent_cleanup(void) { + tools_cleanup(&g_tools_ctx); free(g_system_context); g_system_context = NULL; g_cfg = NULL; diff --git a/src/config.c b/src/config.c index 7055778..e1b66da 100644 --- a/src/config.c +++ b/src/config.c @@ -117,6 +117,50 @@ static int decode_pubkey_hex_or_npub(const char* in, char out_hex[65]) { return -1; } +static int parse_tools_config(cJSON* root, didactyl_config_t* config) { + cJSON* tools = cJSON_GetObjectItemCaseSensitive(root, "tools"); + if (!tools || !cJSON_IsObject(tools)) { + return 0; + } + + cJSON* enabled = cJSON_GetObjectItemCaseSensitive(tools, "enabled"); + cJSON* max_turns = cJSON_GetObjectItemCaseSensitive(tools, "max_turns"); + if (enabled && cJSON_IsBool(enabled)) { + config->tools.enabled = cJSON_IsTrue(enabled) ? 1 : 0; + } + if (max_turns && cJSON_IsNumber(max_turns)) { + config->tools.max_turns = (int)max_turns->valuedouble; + } + + cJSON* shell = cJSON_GetObjectItemCaseSensitive(tools, "shell"); + if (!shell || !cJSON_IsObject(shell)) { + return 0; + } + + cJSON* shell_enabled = cJSON_GetObjectItemCaseSensitive(shell, "enabled"); + cJSON* timeout_seconds = cJSON_GetObjectItemCaseSensitive(shell, "timeout_seconds"); + cJSON* max_output_bytes = cJSON_GetObjectItemCaseSensitive(shell, "max_output_bytes"); + if (shell_enabled && cJSON_IsBool(shell_enabled)) { + config->tools.shell.enabled = cJSON_IsTrue(shell_enabled) ? 1 : 0; + } + if (timeout_seconds && cJSON_IsNumber(timeout_seconds)) { + config->tools.shell.timeout_seconds = (int)timeout_seconds->valuedouble; + } + if (max_output_bytes && cJSON_IsNumber(max_output_bytes)) { + config->tools.shell.max_output_bytes = (int)max_output_bytes->valuedouble; + } + + if (copy_json_string(shell, + "working_directory", + config->tools.shell.working_directory, + sizeof(config->tools.shell.working_directory), + 0) != 0) { + return -1; + } + + return 0; +} + static int parse_relays(cJSON* root, didactyl_config_t* config) { cJSON* relays = cJSON_GetObjectItemCaseSensitive(root, "relays"); if (!relays || !cJSON_IsArray(relays)) { @@ -171,6 +215,12 @@ int config_load(const char* path, didactyl_config_t* config) { } memset(config, 0, sizeof(*config)); + config->tools.enabled = 1; + config->tools.max_turns = 8; + config->tools.shell.enabled = 1; + config->tools.shell.timeout_seconds = 30; + config->tools.shell.max_output_bytes = 65536; + strcpy(config->tools.shell.working_directory, "."); char* json_buf = NULL; size_t json_len = 0; @@ -254,6 +304,10 @@ int config_load(const char* path, didactyl_config_t* config) { config->llm.max_tokens = (max_tokens && cJSON_IsNumber(max_tokens)) ? (int)max_tokens->valuedouble : 512; config->llm.temperature = (temperature && cJSON_IsNumber(temperature)) ? temperature->valuedouble : 0.7; + if (parse_tools_config(root, config) != 0) { + goto cleanup; + } + if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) { goto cleanup; } diff --git a/src/config.h b/src/config.h index dbb6438..e034ce7 100644 --- a/src/config.h +++ b/src/config.h @@ -37,6 +37,19 @@ typedef struct { double temperature; } llm_config_t; +typedef struct { + int enabled; + int timeout_seconds; + int max_output_bytes; + char working_directory[OW_MAX_URL_LEN]; +} shell_tools_config_t; + +typedef struct { + int enabled; + int max_turns; + shell_tools_config_t shell; +} tools_config_t; + typedef struct { agent_profile_t profile; agent_keys_t keys; @@ -44,6 +57,7 @@ typedef struct { char** relays; int relay_count; llm_config_t llm; + tools_config_t tools; } didactyl_config_t; int config_load(const char* path, didactyl_config_t* config); diff --git a/src/debug.c b/src/debug.c new file mode 100644 index 0000000..710b814 --- /dev/null +++ b/src/debug.c @@ -0,0 +1,45 @@ +#include "debug.h" + +#include +#include + +debug_level_t g_debug_level = DEBUG_LEVEL_NONE; + +void debug_init(int level) { + if (level < 0) level = 0; + if (level > 5) level = 5; + g_debug_level = (debug_level_t)level; +} + +void debug_log(debug_level_t level, const char* file, int line, const char* format, ...) { + time_t now = time(NULL); + struct tm* tm_info = localtime(&now); + char timestamp[32]; + strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", tm_info); + + const char* level_str = "UNKNOWN"; + switch (level) { + case DEBUG_LEVEL_ERROR: level_str = "ERROR"; break; + case DEBUG_LEVEL_WARN: level_str = "WARN "; break; + case DEBUG_LEVEL_INFO: level_str = "INFO "; break; + case DEBUG_LEVEL_DEBUG: level_str = "DEBUG"; break; + case DEBUG_LEVEL_TRACE: level_str = "TRACE"; break; + default: break; + } + + printf("[%s] [%s] ", timestamp, level_str); + + if (file && g_debug_level >= DEBUG_LEVEL_TRACE) { + const char* filename = strrchr(file, '/'); + filename = filename ? filename + 1 : file; + printf("[%s:%d] ", filename, line); + } + + va_list args; + va_start(args, format); + vprintf(format, args); + va_end(args); + + printf("\n"); + fflush(stdout); +} diff --git a/src/debug.h b/src/debug.h new file mode 100644 index 0000000..8a59e37 --- /dev/null +++ b/src/debug.h @@ -0,0 +1,36 @@ +#ifndef DIDACTYL_DEBUG_H +#define DIDACTYL_DEBUG_H + +#include +#include + +typedef enum { + DEBUG_LEVEL_NONE = 0, + DEBUG_LEVEL_ERROR = 1, + DEBUG_LEVEL_WARN = 2, + DEBUG_LEVEL_INFO = 3, + DEBUG_LEVEL_DEBUG = 4, + DEBUG_LEVEL_TRACE = 5 +} debug_level_t; + +extern debug_level_t g_debug_level; + +void debug_init(int level); +void debug_log(debug_level_t level, const char* file, int line, const char* format, ...); + +#define DEBUG_ERROR(...) \ + do { if (g_debug_level >= DEBUG_LEVEL_ERROR) debug_log(DEBUG_LEVEL_ERROR, __FILE__, __LINE__, __VA_ARGS__); } while(0) + +#define DEBUG_WARN(...) \ + do { if (g_debug_level >= DEBUG_LEVEL_WARN) debug_log(DEBUG_LEVEL_WARN, __FILE__, __LINE__, __VA_ARGS__); } while(0) + +#define DEBUG_INFO(...) \ + do { if (g_debug_level >= DEBUG_LEVEL_INFO) debug_log(DEBUG_LEVEL_INFO, __FILE__, __LINE__, __VA_ARGS__); } while(0) + +#define DEBUG_LOG(...) \ + do { if (g_debug_level >= DEBUG_LEVEL_DEBUG) debug_log(DEBUG_LEVEL_DEBUG, __FILE__, __LINE__, __VA_ARGS__); } while(0) + +#define DEBUG_TRACE(...) \ + do { if (g_debug_level >= DEBUG_LEVEL_TRACE) debug_log(DEBUG_LEVEL_TRACE, __FILE__, __LINE__, __VA_ARGS__); } while(0) + +#endif \ No newline at end of file diff --git a/src/llm.c b/src/llm.c index 9ebf7e0..c50c7b8 100644 --- a/src/llm.c +++ b/src/llm.c @@ -41,6 +41,47 @@ static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) { return total; } +static char* perform_chat_request(const char* body) { + CURL* curl = curl_easy_init(); + if (!curl || !body) { + if (curl) curl_easy_cleanup(curl); + return NULL; + } + + char url[OW_MAX_URL_LEN + 64]; + snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url); + + response_buffer_t rb = {0}; + struct curl_slist* headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + + char auth_header[OW_MAX_KEY_LEN + 32]; + snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", g_cfg.api_key); + headers = curl_slist_append(headers, auth_header); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + CURLcode res = curl_easy_perform(curl); + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK || status < 200 || status >= 300 || !rb.data) { + free(rb.data); + return NULL; + } + + return rb.data; +} + static char* build_request_json(const char* system_prompt, const char* user_message) { cJSON* root = cJSON_CreateObject(); cJSON* messages = cJSON_CreateArray(); @@ -76,29 +117,90 @@ static char* build_request_json(const char* system_prompt, const char* user_mess return body; } -static char* parse_response_content(const char* json) { - cJSON* root = cJSON_Parse(json); - if (!root) { - return NULL; +static int parse_tool_calls(cJSON* msg, llm_response_t* out) { + cJSON* tc = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls"); + if (!tc || !cJSON_IsArray(tc)) { + out->tool_calls = NULL; + out->tool_call_count = 0; + return 0; } + int n = cJSON_GetArraySize(tc); + if (n <= 0) { + out->tool_calls = NULL; + out->tool_call_count = 0; + return 0; + } + + llm_tool_call_t* calls = (llm_tool_call_t*)calloc((size_t)n, sizeof(llm_tool_call_t)); + if (!calls) return -1; + + int actual = 0; + for (int i = 0; i < n; i++) { + cJSON* item = cJSON_GetArrayItem(tc, i); + cJSON* id = item ? cJSON_GetObjectItemCaseSensitive(item, "id") : NULL; + cJSON* fn = item ? cJSON_GetObjectItemCaseSensitive(item, "function") : NULL; + cJSON* name = fn ? cJSON_GetObjectItemCaseSensitive(fn, "name") : NULL; + cJSON* args = fn ? cJSON_GetObjectItemCaseSensitive(fn, "arguments") : NULL; + + if (!id || !cJSON_IsString(id) || !id->valuestring || + !name || !cJSON_IsString(name) || !name->valuestring) { + continue; + } + + calls[actual].id = strdup(id->valuestring); + calls[actual].name = strdup(name->valuestring); + calls[actual].arguments_json = strdup((args && cJSON_IsString(args) && args->valuestring) ? args->valuestring : "{}"); + if (!calls[actual].id || !calls[actual].name || !calls[actual].arguments_json) { + free(calls[actual].id); + free(calls[actual].name); + free(calls[actual].arguments_json); + continue; + } + actual++; + } + + if (actual == 0) { + free(calls); + out->tool_calls = NULL; + out->tool_call_count = 0; + return 0; + } + + out->tool_calls = calls; + out->tool_call_count = actual; + return 0; +} + +static int parse_llm_response(const char* json, llm_response_t* out) { + memset(out, 0, sizeof(*out)); + + cJSON* root = cJSON_Parse(json); + if (!root) return -1; + cJSON* choices = cJSON_GetObjectItemCaseSensitive(root, "choices"); - if (!choices || !cJSON_IsArray(choices) || cJSON_GetArraySize(choices) == 0) { + cJSON* first = (choices && cJSON_IsArray(choices) && cJSON_GetArraySize(choices) > 0) + ? cJSON_GetArrayItem(choices, 0) + : NULL; + cJSON* msg = first ? cJSON_GetObjectItemCaseSensitive(first, "message") : NULL; + if (!msg || !cJSON_IsObject(msg)) { cJSON_Delete(root); - return NULL; + return -1; } - cJSON* first = cJSON_GetArrayItem(choices, 0); - cJSON* msg = cJSON_GetObjectItemCaseSensitive(first, "message"); - cJSON* content = msg ? cJSON_GetObjectItemCaseSensitive(msg, "content") : NULL; - if (!content || !cJSON_IsString(content) || !content->valuestring) { - cJSON_Delete(root); - return NULL; + cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content"); + if (content && cJSON_IsString(content) && content->valuestring) { + out->content = strdup(content->valuestring); + } + + if (parse_tool_calls(msg, out) != 0) { + cJSON_Delete(root); + llm_response_free(out); + return -1; } - char* out = strdup(content->valuestring); cJSON_Delete(root); - return out; + return 0; } int llm_init(const llm_config_t* config) { @@ -118,54 +220,88 @@ char* llm_chat(const char* system_prompt, const char* user_message) { } char* body = build_request_json(system_prompt, user_message); - if (!body) { - return NULL; - } + if (!body) return NULL; - CURL* curl = curl_easy_init(); - if (!curl) { - free(body); - return NULL; - } - - char url[OW_MAX_URL_LEN + 64]; - snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url); - - response_buffer_t rb = {0}; - struct curl_slist* headers = NULL; - - headers = curl_slist_append(headers, "Content-Type: application/json"); - - char auth_header[OW_MAX_KEY_LEN + 32]; - snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", g_cfg.api_key); - headers = curl_slist_append(headers, auth_header); - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_POST, 1L); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - - CURLcode res = curl_easy_perform(curl); - long status = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); + char* raw = perform_chat_request(body); free(body); + if (!raw) return NULL; - if (res != CURLE_OK || status < 200 || status >= 300 || !rb.data) { - free(rb.data); + llm_response_t parsed; + if (parse_llm_response(raw, &parsed) != 0) { + free(raw); return NULL; } + free(raw); - char* answer = parse_response_content(rb.data); - free(rb.data); + char* answer = parsed.content ? strdup(parsed.content) : NULL; + llm_response_free(&parsed); 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) { + return -1; + } + + cJSON* root = cJSON_CreateObject(); + cJSON* messages = cJSON_CreateArray(); + if (!root || !messages) { + cJSON_Delete(root); + cJSON_Delete(messages); + return -1; + } + + cJSON_AddStringToObject(root, "model", g_cfg.model); + 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_AddItemToObject(root, "messages", messages); + + if (tools_json) { + cJSON* tools = cJSON_Parse(tools_json); + if (tools && cJSON_IsArray(tools)) { + cJSON_AddItemToObject(root, "tools", tools); + } else { + cJSON_Delete(tools); + } + } + + char* body = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!body) return -1; + + char* raw = perform_chat_request(body); + free(body); + if (!raw) return -1; + + int rc = parse_llm_response(raw, out_response); + free(raw); + return rc; +} + +void llm_response_free(llm_response_t* response) { + if (!response) return; + free(response->content); + for (int i = 0; i < response->tool_call_count; i++) { + free(response->tool_calls[i].id); + free(response->tool_calls[i].name); + free(response->tool_calls[i].arguments_json); + } + free(response->tool_calls); + memset(response, 0, sizeof(*response)); +} + void llm_cleanup(void) { if (!g_initialized) { return; diff --git a/src/llm.h b/src/llm.h index 544062a..459495e 100644 --- a/src/llm.h +++ b/src/llm.h @@ -3,8 +3,25 @@ #include "config.h" +typedef struct { + char* id; + char* name; + char* arguments_json; +} llm_tool_call_t; + +typedef struct { + char* content; + llm_tool_call_t* tool_calls; + int tool_call_count; +} llm_response_t; + int llm_init(const llm_config_t* config); char* llm_chat(const char* system_prompt, const char* user_message); +int llm_chat_with_tools(const char* system_prompt, + const char* user_message, + const char* tools_json, + llm_response_t* out_response); +void llm_response_free(llm_response_t* response); void llm_cleanup(void); #endif \ No newline at end of file diff --git a/src/main.c b/src/main.c index 31a7b49..dc4edd0 100644 --- a/src/main.c +++ b/src/main.c @@ -6,11 +6,13 @@ #include #include "../../nostr_core_lib/nostr_core/nostr_core.h" +#include "main.h" #include "agent.h" #include "config.h" #include "context.h" #include "llm.h" #include "nostr_handler.h" +#include "debug.h" static volatile sig_atomic_t g_running = 1; @@ -23,17 +25,25 @@ int main(int argc, char** argv) { const char* config_path = "./config.json"; const char* context_path = "./SYSTEM.md"; + int debug_level = DEBUG_LEVEL_INFO; + for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) { config_path = argv[++i]; } else if (strcmp(argv[i], "--context") == 0 && i + 1 < argc) { context_path = argv[++i]; + } else if (strcmp(argv[i], "--debug") == 0 && i + 1 < argc) { + debug_level = atoi(argv[++i]); } else { - fprintf(stderr, "Usage: %s [--config ] [--context ]\n", argv[0]); + fprintf(stderr, "Usage: %s [--config ] [--context ] [--debug <0-5>]\n", argv[0]); return 1; } } + debug_init(debug_level); + + DEBUG_INFO("%s %s starting", DIDACTYL_NAME, DIDACTYL_VERSION); + if (nostr_init() != NOSTR_SUCCESS) { fprintf(stderr, "Failed to initialize nostr core\n"); return 1; @@ -81,11 +91,15 @@ int main(int argc, char** argv) { return 1; } + DEBUG_INFO("[didactyl] startup phase: publish profile begin"); if (nostr_handler_publish_profile() != 0) { - fprintf(stderr, "Warning: failed to publish profile\n"); + DEBUG_WARN("[didactyl] publish profile deferred/failed (will continue startup)"); } + DEBUG_INFO("[didactyl] startup phase: publish profile end"); + 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"); fprintf(stderr, "Failed to subscribe to DMs\n"); agent_cleanup(); nostr_handler_cleanup(); @@ -96,10 +110,13 @@ int main(int argc, char** argv) { return 1; } + DEBUG_INFO("[didactyl] startup phase: subscribe DMs end"); + signal(SIGINT, signal_handler); signal(SIGTERM, signal_handler); - fprintf(stdout, "[didactyl] running with pubkey %s\n", cfg.keys.public_key_hex); + DEBUG_INFO("[didactyl] entering main poll loop"); + DEBUG_INFO("[didactyl] running with pubkey %s", cfg.keys.public_key_hex); while (g_running) { (void)nostr_handler_poll(100); @@ -107,7 +124,7 @@ int main(int argc, char** argv) { nanosleep(&ts, NULL); } - fprintf(stdout, "[didactyl] shutting down\n"); + DEBUG_INFO("[didactyl] shutting down"); agent_cleanup(); nostr_handler_cleanup(); diff --git a/src/main.h b/src/main.h new file mode 100644 index 0000000..6ab0454 --- /dev/null +++ b/src/main.h @@ -0,0 +1,23 @@ +/* + * Didactyl Main Header - Version and Metadata Information + * + * This header contains version information and agent metadata. + * Version macros are auto-updated by the build system. + */ + +#ifndef DIDACTYL_MAIN_H +#define DIDACTYL_MAIN_H + +// Version information (auto-updated by build system) +// 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" + +// Agent metadata +#define DIDACTYL_NAME "Didactyl" +#define DIDACTYL_DESCRIPTION "A sovereign AI agent daemon on Nostr" +#define DIDACTYL_SOFTWARE "https://git.laantungir.net/laantungir/didactyl.git" + +#endif /* DIDACTYL_MAIN_H */ diff --git a/src/nostr_handler.c b/src/nostr_handler.c index b0c7bd9..99ecaa5 100644 --- a/src/nostr_handler.c +++ b/src/nostr_handler.c @@ -9,6 +9,7 @@ #include "../../nostr_core_lib/cjson/cJSON.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h" +#include "debug.h" static didactyl_config_t* g_cfg = NULL; static nostr_relay_pool_t* g_pool = NULL; @@ -16,6 +17,8 @@ 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 const char* relay_status_str(nostr_pool_relay_status_t status) { switch (status) { @@ -37,21 +40,55 @@ static void log_relay_statuses(const char* reason) { return; } - fprintf(stdout, "[didactyl] relay status snapshot (%s)\n", reason ? reason : "periodic"); + DEBUG_INFO("[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); - fprintf(stdout, "[didactyl] - %s => %s", relay, relay_status_str(status)); if (ping_ms > 0.0) { - fprintf(stdout, " (ping %.1f ms)", ping_ms); + 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)); } + if (last_err && last_err[0] != '\0') { - fprintf(stdout, " [last_error: %s]", last_err); + 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 void log_relay_state_changes(void) { + if (!g_pool || !g_cfg || !g_last_relay_statuses) { + return; + } + + for (int i = 0; i < g_cfg->relay_count; i++) { + const char* relay = g_cfg->relays[i]; + nostr_pool_relay_status_t now = nostr_relay_pool_get_relay_status(g_pool, relay); + nostr_pool_relay_status_t prev = g_last_relay_statuses[i]; + if (now != prev) { + DEBUG_INFO("[didactyl] relay state changed: %s %s -> %s", + relay, + relay_status_str(prev), + relay_status_str(now)); + g_last_relay_statuses[i] = now; } - fprintf(stdout, "\n"); } } @@ -60,9 +97,9 @@ static void log_publish_targets(const char* action) { return; } - fprintf(stdout, "[didactyl] %s target relays (%d):\n", action ? action : "publish", g_cfg->relay_count); + DEBUG_INFO("[didactyl] %s target relays (%d):", action ? action : "publish", g_cfg->relay_count); for (int i = 0; i < g_cfg->relay_count; i++) { - fprintf(stdout, "[didactyl] -> %s\n", g_cfg->relays[i]); + DEBUG_INFO("[didactyl] -> %s", g_cfg->relays[i]); } } @@ -173,9 +210,9 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { return; } - fprintf(stdout, "[didactyl] received DM from %.16s... via %s\n", - pubkey->valuestring, - relay_url ? relay_url : "unknown relay"); + DEBUG_INFO("[didactyl] received DM from %.16s... via %s", + pubkey->valuestring, + relay_url ? relay_url : "unknown relay"); g_dm_callback(pubkey->valuestring, decrypted, g_dm_user_data); free(decrypted); } @@ -195,7 +232,7 @@ int nostr_handler_init(didactyl_config_t* config) { g_poll_counter = 0; g_start_time = time(NULL); - fprintf(stdout, "[didactyl] initializing relay pool with %d relays\n", g_cfg->relay_count); + DEBUG_INFO("[didactyl] initializing relay pool with %d relays", g_cfg->relay_count); nostr_pool_reconnect_config_t reconnect = *nostr_pool_reconnect_config_default(); reconnect.enable_auto_reconnect = 1; @@ -212,9 +249,19 @@ int nostr_handler_init(didactyl_config_t* config) { fprintf(stderr, "[didactyl] failed to add relay: %s\n", g_cfg->relays[i]); return -1; } - fprintf(stdout, "[didactyl] added relay: %s\n", g_cfg->relays[i]); + DEBUG_INFO("[didactyl] added relay: %s", g_cfg->relays[i]); } + free(g_last_relay_statuses); + g_last_relay_statuses = (nostr_pool_relay_status_t*)calloc((size_t)g_cfg->relay_count, sizeof(nostr_pool_relay_status_t)); + if (!g_last_relay_statuses) { + return -1; + } + for (int i = 0; i < g_cfg->relay_count; i++) { + 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"); return 0; } @@ -224,6 +271,7 @@ int nostr_handler_publish_profile(void) { return -1; } + DEBUG_INFO("[didactyl] publish_profile: build metadata payload"); cJSON* profile = cJSON_CreateObject(); if (!profile) { return -1; @@ -243,6 +291,7 @@ int nostr_handler_publish_profile(void) { return -1; } + DEBUG_INFO("[didactyl] publish_profile: sign kind-0 event"); cJSON* event = nostr_create_and_sign_event(0, content, NULL, g_cfg->keys.private_key, time(NULL)); free(content); @@ -250,18 +299,38 @@ int nostr_handler_publish_profile(void) { return -1; } - log_publish_targets("publish profile"); + const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*)); + if (!connected_relays) { + cJSON_Delete(event); + return -1; + } + int connected_count = 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_relays[connected_count++] = g_cfg->relays[i]; + } + } + + if (connected_count == 0) { + DEBUG_WARN("[didactyl] publish_profile: no connected relays yet, deferring publish"); + free(connected_relays); + cJSON_Delete(event); + return 0; + } + + DEBUG_INFO("[didactyl] publish_profile: publishing to %d connected relay(s)", connected_count); int sent = nostr_relay_pool_publish_async( g_pool, - (const char**)g_cfg->relays, - g_cfg->relay_count, + connected_relays, + connected_count, event, NULL, NULL); + free(connected_relays); cJSON_Delete(event); - fprintf(stdout, "[didactyl] publish profile result: sent_to=%d relays\n", sent); + DEBUG_INFO("[didactyl] publish profile result: sent_to=%d connected relay(s)", sent); return sent > 0 ? 0 : -1; } @@ -310,7 +379,7 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) { return -1; } - fprintf(stdout, "[didactyl] DM subscription active for pubkey %.16s...\n", g_cfg->keys.public_key_hex); + DEBUG_INFO("[didactyl] DM subscription active for pubkey %.16s...", g_cfg->keys.public_key_hex); return 0; } @@ -351,17 +420,125 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message) log_publish_targets("publish DM"); - int sent = nostr_relay_pool_publish_async( + const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*)); + if (!connected_relays) { + cJSON_Delete(event); + return -1; + } + + int connected_count = 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_relays[connected_count++] = g_cfg->relays[i]; + } + } + + int sent = 0; + if (connected_count > 0) { + sent = nostr_relay_pool_publish_async( + g_pool, + connected_relays, + connected_count, + event, + NULL, + NULL); + } + + 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; +} + +int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags) { + if (!g_cfg || !g_pool || !content) { + return -1; + } + + cJSON* tags_copy = NULL; + if (tags) { + tags_copy = cJSON_Duplicate(tags, 1); + if (!tags_copy) { + return -1; + } + } + + cJSON* event = nostr_create_and_sign_event(kind, content, tags_copy, g_cfg->keys.private_key, time(NULL)); + if (tags_copy) { + cJSON_Delete(tags_copy); + } + if (!event) { + return -1; + } + + log_publish_targets("publish kind event"); + + const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*)); + if (!connected_relays) { + cJSON_Delete(event); + return -1; + } + + int connected_count = 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_relays[connected_count++] = g_cfg->relays[i]; + } + } + + int sent = 0; + if (connected_count > 0) { + sent = nostr_relay_pool_publish_async( + g_pool, + connected_relays, + connected_count, + event, + NULL, + NULL); + } + + free(connected_relays); + cJSON_Delete(event); + DEBUG_INFO("[didactyl] published kind %d event via %d connected relay(s)", kind, sent); + return sent > 0 ? 0 : -1; +} + +char* nostr_handler_query_json(cJSON* filter, int timeout_ms) { + if (!g_cfg || !g_pool || !filter) { + return NULL; + } + + int event_count = 0; + cJSON** events = nostr_relay_pool_query_sync( g_pool, (const char**)g_cfg->relays, g_cfg->relay_count, - event, - NULL, - NULL); + filter, + &event_count, + timeout_ms); - cJSON_Delete(event); - fprintf(stdout, "[didactyl] sent DM to %.16s... via %d relay(s)\n", recipient_pubkey_hex, sent); - return sent > 0 ? 0 : -1; + cJSON* arr = cJSON_CreateArray(); + if (!arr) { + return NULL; + } + + if (events && event_count > 0) { + for (int i = 0; i < event_count; i++) { + if (!events[i]) { + continue; + } + cJSON* dup = cJSON_Duplicate(events[i], 1); + if (dup) { + cJSON_AddItemToArray(arr, dup); + } + cJSON_Delete(events[i]); + } + free(events); + } + + char* out = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + return out; } int nostr_handler_poll(int timeout_ms) { @@ -372,8 +549,12 @@ int nostr_handler_poll(int timeout_ms) { int rc = nostr_relay_pool_poll(g_pool, timeout_ms); g_poll_counter++; - if ((g_poll_counter % 600) == 0) { + 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; @@ -384,6 +565,10 @@ void nostr_handler_cleanup(void) { nostr_relay_pool_destroy(g_pool); } + free(g_last_relay_statuses); + g_last_relay_statuses = NULL; + g_last_status_log_time = 0; + g_pool = NULL; g_cfg = NULL; g_dm_callback = NULL; diff --git a/src/nostr_handler.h b/src/nostr_handler.h index 541b552..9e70260 100644 --- a/src/nostr_handler.h +++ b/src/nostr_handler.h @@ -2,6 +2,7 @@ #define OPEN_WING_NOSTR_HANDLER_H #include "config.h" +#include "cjson/cJSON.h" typedef void (*dm_callback_t)(const char* sender_pubkey_hex, const char* message, void* user_data); @@ -9,6 +10,8 @@ int nostr_handler_init(didactyl_config_t* config); int nostr_handler_publish_profile(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); +char* nostr_handler_query_json(cJSON* filter, int timeout_ms); int nostr_handler_poll(int timeout_ms); void nostr_handler_cleanup(void); diff --git a/src/secp_compat.c b/src/secp_compat.c deleted file mode 100644 index be98a01..0000000 --- a/src/secp_compat.c +++ /dev/null @@ -1,13 +0,0 @@ -#include -#include -#include - -int secp256k1_schnorrsig_sign32( - const secp256k1_context* ctx, - unsigned char* sig64, - const unsigned char* msg32, - const secp256k1_keypair* keypair, - const unsigned char* aux_rand32 -) { - return secp256k1_schnorrsig_sign(ctx, sig64, msg32, keypair, aux_rand32); -} diff --git a/src/tools.c b/src/tools.c new file mode 100644 index 0000000..2961818 --- /dev/null +++ b/src/tools.c @@ -0,0 +1,266 @@ +#define _POSIX_C_SOURCE 200809L + +#include "tools.h" + +#include +#include +#include + +#include "cjson/cJSON.h" +#include "nostr_handler.h" + +static char* json_error(const char* msg) { + cJSON* root = cJSON_CreateObject(); + if (!root) return NULL; + cJSON_AddBoolToObject(root, "success", 0); + cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error"); + char* out = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + 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); + return out; +} + +int tools_init(tools_context_t* ctx, didactyl_config_t* cfg) { + if (!ctx || !cfg) return -1; + memset(ctx, 0, sizeof(*ctx)); + ctx->cfg = cfg; + return 0; +} + +void tools_cleanup(tools_context_t* ctx) { + if (!ctx) return; + memset(ctx, 0, sizeof(*ctx)); +} + +char* tools_build_openai_schema_json(const tools_context_t* ctx) { + (void)ctx; + + cJSON* tools = cJSON_CreateArray(); + if (!tools) return NULL; + + cJSON* t1 = cJSON_CreateObject(); + cJSON* t1_fn = cJSON_CreateObject(); + cJSON* t1_params = cJSON_CreateObject(); + cJSON* t1_props = cJSON_CreateObject(); + cJSON* t1_required = cJSON_CreateArray(); + + cJSON_AddStringToObject(t1, "type", "function"); + cJSON_AddStringToObject(t1_fn, "name", "nostr_post"); + cJSON_AddStringToObject(t1_fn, "description", "Publish a Nostr event to connected relays"); + cJSON_AddStringToObject(t1_params, "type", "object"); + cJSON_AddItemToObject(t1_params, "properties", t1_props); + cJSON_AddItemToObject(t1_params, "required", t1_required); + + cJSON* p_kind = cJSON_CreateObject(); + cJSON_AddStringToObject(p_kind, "type", "integer"); + cJSON_AddItemToObject(t1_props, "kind", p_kind); + cJSON* p_content = cJSON_CreateObject(); + cJSON_AddStringToObject(p_content, "type", "string"); + cJSON_AddItemToObject(t1_props, "content", p_content); + cJSON_AddItemToArray(t1_required, cJSON_CreateString("kind")); + cJSON_AddItemToArray(t1_required, cJSON_CreateString("content")); + + cJSON_AddItemToObject(t1_fn, "parameters", t1_params); + cJSON_AddItemToObject(t1, "function", t1_fn); + cJSON_AddItemToArray(tools, t1); + + cJSON* t2 = cJSON_CreateObject(); + cJSON* t2_fn = cJSON_CreateObject(); + cJSON* t2_params = cJSON_CreateObject(); + cJSON* t2_props = cJSON_CreateObject(); + cJSON* t2_required = cJSON_CreateArray(); + + cJSON_AddStringToObject(t2, "type", "function"); + cJSON_AddStringToObject(t2_fn, "name", "nostr_query"); + cJSON_AddStringToObject(t2_fn, "description", "Query events from relays using a Nostr filter"); + cJSON_AddStringToObject(t2_params, "type", "object"); + cJSON_AddItemToObject(t2_params, "properties", t2_props); + cJSON_AddItemToObject(t2_params, "required", t2_required); + + cJSON* p_filter = cJSON_CreateObject(); + cJSON_AddStringToObject(p_filter, "type", "object"); + cJSON_AddItemToObject(t2_props, "filter", p_filter); + cJSON* p_timeout = cJSON_CreateObject(); + cJSON_AddStringToObject(p_timeout, "type", "integer"); + cJSON_AddItemToObject(t2_props, "timeout_ms", p_timeout); + cJSON_AddItemToArray(t2_required, cJSON_CreateString("filter")); + + cJSON_AddItemToObject(t2_fn, "parameters", t2_params); + cJSON_AddItemToObject(t2, "function", t2_fn); + cJSON_AddItemToArray(tools, t2); + + cJSON* t3 = cJSON_CreateObject(); + cJSON* t3_fn = cJSON_CreateObject(); + cJSON* t3_params = cJSON_CreateObject(); + cJSON* t3_props = cJSON_CreateObject(); + cJSON* t3_required = cJSON_CreateArray(); + + cJSON_AddStringToObject(t3, "type", "function"); + cJSON_AddStringToObject(t3_fn, "name", "shell_exec"); + cJSON_AddStringToObject(t3_fn, "description", "Execute a shell command and return stdout/stderr"); + cJSON_AddStringToObject(t3_params, "type", "object"); + cJSON_AddItemToObject(t3_params, "properties", t3_props); + cJSON_AddItemToObject(t3_params, "required", t3_required); + + cJSON* p_cmd = cJSON_CreateObject(); + cJSON_AddStringToObject(p_cmd, "type", "string"); + cJSON_AddItemToObject(t3_props, "command", p_cmd); + cJSON_AddItemToArray(t3_required, cJSON_CreateString("command")); + + cJSON_AddItemToObject(t3_fn, "parameters", t3_params); + cJSON_AddItemToObject(t3, "function", t3_fn); + cJSON_AddItemToArray(tools, t3); + + char* out = cJSON_PrintUnformatted(tools); + cJSON_Delete(tools); + return out; +} + +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"); + + cJSON* kind = cJSON_GetObjectItemCaseSensitive(args, "kind"); + cJSON* content = cJSON_GetObjectItemCaseSensitive(args, "content"); + 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"); + } + + int rc = nostr_handler_publish_kind_event((int)kind->valuedouble, content->valuestring, NULL); + cJSON_Delete(args); + if (rc != 0) return json_error("nostr_post failed"); + + return json_success_with_message("nostr_post published"); +} + +static char* execute_nostr_query(const char* args_json) { + cJSON* args = cJSON_Parse(args_json ? args_json : "{}"); + if (!args) return json_error("invalid arguments JSON"); + + cJSON* filter = cJSON_GetObjectItemCaseSensitive(args, "filter"); + cJSON* timeout = cJSON_GetObjectItemCaseSensitive(args, "timeout_ms"); + if (!filter || !cJSON_IsObject(filter)) { + cJSON_Delete(args); + return json_error("nostr_query requires object filter"); + } + + cJSON* filter_dup = cJSON_Duplicate(filter, 1); + if (!filter_dup) { + cJSON_Delete(args); + return json_error("failed to duplicate filter"); + } + + int timeout_ms = (timeout && cJSON_IsNumber(timeout)) ? (int)timeout->valuedouble : 8000; + char* events_json = nostr_handler_query_json(filter_dup, timeout_ms); + cJSON_Delete(filter_dup); + cJSON_Delete(args); + + if (!events_json) return json_error("nostr_query failed"); + + cJSON* out = cJSON_CreateObject(); + if (!out) { + free(events_json); + return NULL; + } + + cJSON* events = cJSON_Parse(events_json); + free(events_json); + if (!events) { + cJSON_Delete(out); + return json_error("nostr_query returned invalid JSON"); + } + + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddItemToObject(out, "events", events); + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} + +static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) { + if (!ctx || !ctx->cfg) return json_error("tool context unavailable"); + if (!ctx->cfg->tools.shell.enabled) return json_error("shell tool disabled"); + + cJSON* args = cJSON_Parse(args_json ? args_json : "{}"); + if (!args) return json_error("invalid arguments JSON"); + + cJSON* command = cJSON_GetObjectItemCaseSensitive(args, "command"); + if (!command || !cJSON_IsString(command) || !command->valuestring) { + cJSON_Delete(args); + return json_error("shell_exec requires string command"); + } + + const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0' + ? ctx->cfg->tools.shell.working_directory + : "."; + int timeout_s = ctx->cfg->tools.shell.timeout_seconds > 0 ? ctx->cfg->tools.shell.timeout_seconds : 30; + + char cmd[4096]; + snprintf(cmd, + sizeof(cmd), + "cd %s && timeout %ds sh -lc %s 2>&1", + cwd, + timeout_s, + command->valuestring); + + FILE* fp = popen(cmd, "r"); + cJSON_Delete(args); + if (!fp) return json_error("failed to execute command"); + + int max_bytes = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536; + char* output = (char*)calloc((size_t)max_bytes + 1U, 1U); + if (!output) { + pclose(fp); + return json_error("allocation failure"); + } + + size_t used = 0; + while (!feof(fp) && used < (size_t)max_bytes) { + size_t n = fread(output + used, 1, (size_t)max_bytes - used, fp); + used += n; + if (n == 0) break; + } + + int status = pclose(fp); + + cJSON* out = cJSON_CreateObject(); + if (!out) { + free(output); + return NULL; + } + + cJSON_AddBoolToObject(out, "success", status == 0 ? 1 : 0); + cJSON_AddNumberToObject(out, "exit_status", status); + cJSON_AddStringToObject(out, "output", output); + free(output); + + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} + +char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json) { + if (!tool_name) return json_error("missing tool name"); + + if (strcmp(tool_name, "nostr_post") == 0) { + return execute_nostr_post(args_json); + } + if (strcmp(tool_name, "nostr_query") == 0) { + return execute_nostr_query(args_json); + } + if (strcmp(tool_name, "shell_exec") == 0) { + return execute_shell_exec(ctx, args_json); + } + + return json_error("unknown tool"); +} diff --git a/src/tools.h b/src/tools.h new file mode 100644 index 0000000..d2cf4f3 --- /dev/null +++ b/src/tools.h @@ -0,0 +1,15 @@ +#ifndef DIDACTYL_TOOLS_H +#define DIDACTYL_TOOLS_H + +#include "config.h" + +typedef struct { + didactyl_config_t* cfg; +} tools_context_t; + +int tools_init(tools_context_t* ctx, didactyl_config_t* cfg); +void tools_cleanup(tools_context_t* ctx); +char* tools_build_openai_schema_json(const tools_context_t* ctx); +char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json); + +#endif \ No newline at end of file