From 0919406bb8a5a7cb30296b248aeeb70a3b142257 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 17 Mar 2026 10:30:22 -0400 Subject: [PATCH] v0.0.73 - Refined Nostr handler and tool dispatch/schema updates for relay and my-events flows --- Dockerfile.alpine-musl | 2 +- Makefile | 1 + README.md | 4 +- context_template.recovered.md | 56 ++ plans/server_installation_guide.md | 459 +++++++++++++++ src/agent.c | 75 ++- src/default_events.h | 113 ++++ src/http_api.c | 75 ++- src/main.c | 8 +- src/main.h | 4 +- src/nostr_handler.c | 897 +++++++++++++++++++++++------ src/nostr_handler.h | 3 + src/setup_wizard.c | 658 ++++++++++++++------- src/tools/tool_nostr_my_events.c | 212 +++++++ src/tools/tool_nostr_relay.c | 102 ++++ src/tools/tools_dispatch.c | 9 + src/tools/tools_internal.h | 3 + src/tools/tools_schema.c | 78 +++ 18 files changed, 2362 insertions(+), 397 deletions(-) create mode 100644 context_template.recovered.md create mode 100644 plans/server_installation_guide.md create mode 100644 src/default_events.h create mode 100644 src/tools/tool_nostr_my_events.c diff --git a/Dockerfile.alpine-musl b/Dockerfile.alpine-musl index c16190e..9854f46 100644 --- a/Dockerfile.alpine-musl +++ b/Dockerfile.alpine-musl @@ -97,7 +97,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \ src/main.c src/config.c src/context.c src/llm.c \ src/nostr_handler.c src/agent.c src/tools/tools_common.c src/tools/tools_schema.c src/tools/tools_dispatch.c \ src/tools/tool_agent.c src/tools/tool_meta.c src/tools/tool_model.c \ - src/tools/tool_nostr_query.c src/tools/tool_nostr_identity.c src/tools/tool_nostr_social.c \ + src/tools/tool_nostr_query.c src/tools/tool_nostr_my_events.c src/tools/tool_nostr_identity.c src/tools/tool_nostr_social.c \ src/tools/tool_nostr_relay.c src/tools/tool_nostr_dm.c src/tools/tool_admin.c \ src/tools/tool_task.c src/tools/tool_nostr_list.c src/tools/tool_local.c \ src/tools/tool_skill.c src/tools/tool_nostr_post.c src/tools/tool_memory.c src/tools/tool_config.c src/trigger_manager.c \ diff --git a/Makefile b/Makefile index a3ffb97..b09946b 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ SRCS = \ $(SRC_DIR)/tools/tool_meta.c \ $(SRC_DIR)/tools/tool_model.c \ $(SRC_DIR)/tools/tool_nostr_query.c \ + $(SRC_DIR)/tools/tool_nostr_my_events.c \ $(SRC_DIR)/tools/tool_nostr_identity.c \ $(SRC_DIR)/tools/tool_nostr_social.c \ $(SRC_DIR)/tools/tool_nostr_relay.c \ diff --git a/README.md b/README.md index f79c8c0..6acd716 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers. -## Current Status — v0.0.72 +## Current Status — v0.0.73 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.0.72 — Refined wizard framing and menu hotkey display; diagnosed hardcoded Didactyl fallback causing name mismatch +> Last release update: v0.0.73 — Refined Nostr handler and tool dispatch/schema updates for relay and my-events flows - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/context_template.recovered.md b/context_template.recovered.md new file mode 100644 index 0000000..11c708a --- /dev/null +++ b/context_template.recovered.md @@ -0,0 +1,56 @@ +# Context Template + +```yaml +- section: admin_identity + role: system + content: | + ## Administrator Identity (source: config.admin.pubkey) + + This is your administrator! Admin pubkey (hex): {{admin_pubkey}} + +- section: admin_profile + role: system + content: | + ## Administrator Kind 0 Profile (source: nostr kind 0) + + Administrator kind 0 profile content (JSON): {{admin_kind0_json}} + provider: + anthropic: | + + {{admin_kind0_json}} + + +- section: admin_relay_list + role: system + content: | + ## Administrator Relay List (source: nostr kind 10002) + + Administrator kind 10002 relay-list content (JSON): {{admin_kind10002_json}} + +- section: startup_events + role: system + content: | + ## Startup Events Memory (source: config.startup_events) + + Startup events memory (kinds/content/tags): {{startup_events_json}} + +- section: adopted_skills + role: system + content: | + {{adopted_skills_content}} + +- section: agent_tasks + role: system + content: | + {{tasks_content}} + +- section: dm_history + role: expand + limit: 12 + +- section: admin_notes + role: system + content: | + ## Administrator Recent Notes (source: nostr kind 1) + + {{admin_notes_content}} \ No newline at end of file diff --git a/plans/server_installation_guide.md b/plans/server_installation_guide.md new file mode 100644 index 0000000..61e7f48 --- /dev/null +++ b/plans/server_installation_guide.md @@ -0,0 +1,459 @@ +# Didactyl Server Installation Guide — systemd + Dedicated User Model + +## Overview + +This document describes how to install Didactyl on a Linux server as a dedicated system user, managed by systemd, with scoped sudo privileges for server maintenance tasks. + +The mental model: **Didactyl is a person on your server.** It gets its own home directory, its own login identity, and explicit permission to manage the services you delegate to it — nothing more. + +## Architecture + +```mermaid +graph TD + subgraph Internet + RELAYS[Nostr Relays
wss://relay.damus.io
wss://relay.primal.net] + LLM_API[LLM Provider API
OpenAI / PPQ / Ollama] + end + + subgraph Server + subgraph "systemd" + SVC[didactyl.service
User=didactyl
Group=didactyl] + end + + subgraph "/home/didactyl/" + BIN[didactyl binary] + CFG[genesis.jsonc
mode 600] + LOGS[context.log.md
didactyl.log] + end + + subgraph "Privilege Boundary" + SUDOERS[/etc/sudoers.d/didactyl
Whitelisted commands only] + end + + subgraph "Unprivileged Commands" + MON[free / df / uptime
ps / cat /proc/*
ss -tlnp / top] + end + + subgraph "Privileged Commands via sudo" + SYSD[systemctl status/restart/start/stop
journalctl
daemon-reload] + end + end + + ADMIN[Administrator
via Nostr DM] -->|NIP-04 encrypted| RELAYS + RELAYS <-->|WebSocket| SVC + SVC --> BIN + BIN <-->|HTTPS| LLM_API + BIN -->|popen as didactyl| MON + BIN -->|sudo via popen| SUDOERS + SUDOERS --> SYSD + + style CFG fill:#f66,stroke:#333,color:#fff + style SUDOERS fill:#ff9,stroke:#333 + style SVC fill:#9f9,stroke:#333 +``` + +## Security Model + +### Privilege Tiers + +Didactyl enforces three privilege tiers for inbound Nostr messages (see `src/config.h` security_config_t): + +| Tier | Who | Can use tools? | Can chat? | +|------|-----|---------------|-----------| +| **ADMIN** | Your npub (config `admin.pubkey`) | ✅ Yes | ✅ Yes | +| **WoT** | Contacts in your follow list | ❌ No (configurable) | ✅ Yes | +| **Stranger** | Everyone else | ❌ No | Canned response or ignored | + +Only the ADMIN tier can trigger `local_shell_exec` — the tool that runs commands on the server. + +### OS-Level Privilege Separation + +The `local_shell_exec` tool in `src/tools/tool_local.c` calls `popen()` directly. Whatever the process user can do, the agent can do. This is why we: + +1. Run as a **dedicated unprivileged user** (`didactyl`) +2. Grant **specific sudo permissions** via `/etc/sudoers.d/didactyl` +3. Use **systemd sandboxing** directives to limit filesystem access + +### What the Agent Cannot Do + +With the configuration described in this guide: + +- ❌ Write anywhere outside `/home/didactyl/` +- ❌ Run arbitrary root commands not in the sudoers whitelist +- ❌ Install or remove packages (unless explicitly whitelisted) +- ❌ Modify system configuration files in `/etc/` +- ❌ Access other users' home directories (with proper home directory permissions) +- ❌ Accept tool commands from anyone except the ADMIN pubkey + +--- + +## Installation Steps + +### Prerequisites + +- A Linux server with systemd (Debian/Ubuntu, RHEL/Fedora, Arch, etc.) +- Network access to Nostr relays and your LLM provider +- The Didactyl static binary (download from releases or build with `./build_static.sh`) +- A Nostr keypair for the agent (nsec) +- Your admin Nostr keypair (npub) +- An LLM API key + +### Step 1: Create the Didactyl User + +```bash +# Create a regular user with a home directory and bash shell +sudo useradd -m -s /bin/bash -c "Didactyl Nostr Agent" didactyl +``` + +**Why bash and not nologin?** The `local_shell_exec` tool runs commands via `sh -lc` (login shell). A real shell ensures PATH, locale, and environment are properly initialized. The agent needs to "log in" to do its job. + +**Why a real home directory?** The agent needs a workspace for: +- Its binary and config +- Log files (`context.log.md`, `didactyl.log`) +- Any files it creates during shell operations +- The `working_directory` for the shell tool + +### Step 2: Install the Binary and Config + +```bash +# Copy the static binary +sudo cp didactyl_static_x86_64 /home/didactyl/didactyl +sudo chmod 755 /home/didactyl/didactyl + +# Copy and configure genesis.jsonc +sudo cp genesis.jsonc /home/didactyl/genesis.jsonc + +# CRITICAL: Lock down permissions on the config file (contains nsec private key) +sudo chmod 600 /home/didactyl/genesis.jsonc + +# Set ownership +sudo chown -R didactyl:didactyl /home/didactyl/ + +# Protect the home directory from other users +sudo chmod 750 /home/didactyl/ +``` + +### Step 3: Configure genesis.jsonc + +Edit `/home/didactyl/genesis.jsonc` with the agent's identity, your admin pubkey, LLM credentials, and the shell working directory: + +```jsonc +{ + "key": { + "nsec": "nsec1..." // Agent's private key + }, + + "admin": { + "pubkey": "npub1..." // YOUR public key + }, + + "dm_protocol": "nip04", + + "llm": { + "provider": "openai", + "api_key": "sk-...", + "model": "gpt-4o-mini", + "base_url": "https://api.openai.com/v1", + "max_tokens": 512, + "temperature": 0.7 + }, + + "tools": { + "enabled": true, + "max_turns": 8, + "shell": { + "enabled": true, + "timeout_seconds": 60, // systemctl can be slow + "max_output_bytes": 131072, // journalctl output can be large + "working_directory": "/home/didactyl" + } + }, + + "api": { + "enabled": true, + "port": 8484, + "bind_address": "127.0.0.1" // Localhost only — do NOT expose + }, + + "startup_events": [ + { + "kind": 10002, + "content": "", + "tags": [ + ["r", "wss://relay.damus.io"], + ["r", "wss://relay.primal.net"] + ] + } + ] +} +``` + +**Key settings for server maintenance:** + +| Setting | Value | Why | +|---------|-------|-----| +| `shell.timeout_seconds` | `60` | `systemctl` and `journalctl` can take time | +| `shell.max_output_bytes` | `131072` (128KB) | Log output can be verbose | +| `shell.working_directory` | `/home/didactyl` | Agent's home — safe default CWD | +| `api.bind_address` | `127.0.0.1` | Never expose the admin API to the network | + +### Step 4: Configure sudo Privileges + +```bash +sudo visudo -f /etc/sudoers.d/didactyl +``` + +Add the following (adjust to your needs): + +```sudoers +# /etc/sudoers.d/didactyl +# Didactyl agent — scoped system maintenance privileges + +# Service management +didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl status * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl start * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl disable * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl is-active * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl is-enabled * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl list-units * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl daemon-reload + +# Log inspection +didactyl ALL=(ALL) NOPASSWD: /usr/bin/journalctl * + +# Network diagnostics (if needed) +didactyl ALL=(ALL) NOPASSWD: /usr/sbin/lsof * +``` + +**Commands that do NOT need sudo** (the didactyl user can run these directly): + +- `free -h` — memory usage +- `df -h` — disk usage +- `uptime` — load averages +- `top -bn1` — process snapshot +- `ps aux` — process list +- `cat /proc/cpuinfo` — CPU info +- `cat /proc/meminfo` — memory info +- `ss -tlnp` — listening ports (as unprivileged user, shows own processes) +- `uname -a` — kernel info +- `w` — who is logged in + +### Step 5: Create the systemd Unit File + +```bash +sudo tee /etc/systemd/system/didactyl.service << 'EOF' +[Unit] +Description=Didactyl Sovereign Nostr Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=didactyl +Group=didactyl +WorkingDirectory=/home/didactyl + +ExecStart=/home/didactyl/didactyl --config /home/didactyl/genesis.jsonc --debug 3 + +# --- Privilege & Sandbox --- +# Must be false — sudo needs to escalate privileges +NoNewPrivileges=false + +# Protect filesystem: read-only except for agent home +ProtectSystem=strict +ReadWritePaths=/home/didactyl + +# Must be false — the agent's home IS its workspace +ProtectHome=false + +# Isolate /tmp +PrivateTmp=yes + +# --- Environment --- +Environment=SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + +# --- Restart Policy --- +Restart=on-failure +RestartSec=10 + +# --- Logging --- +StandardOutput=journal +StandardError=journal +SyslogIdentifier=didactyl + +[Install] +WantedBy=multi-user.target +EOF +``` + +**Important systemd notes:** + +| Directive | Value | Reason | +|-----------|-------|--------| +| `NoNewPrivileges` | `false` | Required for `sudo` to work from `popen()` | +| `ProtectSystem` | `strict` | Makes `/usr`, `/boot`, `/etc` read-only | +| `ReadWritePaths` | `/home/didactyl` | Whitelist the agent's home for writes | +| `ProtectHome` | `false` | The agent lives in `/home/` — can't protect it from itself | +| `PrivateTmp` | `yes` | Isolates `/tmp` so other processes can't snoop | +| `SSL_CERT_FILE` | path to CA bundle | Required for TLS connections to relays and LLM API | + +### Step 6: Enable and Start + +```bash +# Reload systemd to pick up the new unit +sudo systemctl daemon-reload + +# Enable auto-start on boot +sudo systemctl enable didactyl + +# Start the agent +sudo systemctl start didactyl + +# Verify it's running +sudo systemctl status didactyl + +# Watch logs in real-time +sudo journalctl -u didactyl -f +``` + +### Step 7: Verify the Agent is Working + +1. **Check systemd status:** + ```bash + sudo systemctl status didactyl + ``` + Should show `active (running)`. + +2. **Check logs for relay connections:** + ```bash + sudo journalctl -u didactyl --no-pager -n 50 + ``` + Look for relay connection messages and "EOSE" (End of Stored Events). + +3. **Send a test DM via Nostr:** + From your admin Nostr client, send an encrypted DM to the agent's npub: + ``` + What is the server uptime? + ``` + The agent should call `local_shell_exec` with `uptime` and reply with the result. + +4. **Test sudo access:** + ``` + What services are running? Use systemctl list-units --type=service --state=running + ``` + +5. **Check the local API (from the server itself):** + ```bash + curl http://127.0.0.1:8484/api/context + ``` + +--- + +## Updating the Agent + +To update Didactyl to a new version: + +```bash +# Stop the service +sudo systemctl stop didactyl + +# Replace the binary +sudo cp didactyl_static_x86_64_new /home/didactyl/didactyl +sudo chown didactyl:didactyl /home/didactyl/didactyl +sudo chmod 755 /home/didactyl/didactyl + +# Start the service +sudo systemctl start didactyl +``` + +The agent's identity, skills, and memory live on Nostr — replacing the binary doesn't lose any state. + +--- + +## Expanding Privileges + +The sudoers file is the single control point for what the agent can do with elevated privileges. To grant additional capabilities: + +```bash +sudo visudo -f /etc/sudoers.d/didactyl +``` + +**Examples of additional privileges you might add:** + +```sudoers +# Package management (careful!) +didactyl ALL=(ALL) NOPASSWD: /usr/bin/apt update +didactyl ALL=(ALL) NOPASSWD: /usr/bin/apt install * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/apt upgrade -y + +# Docker management +didactyl ALL=(ALL) NOPASSWD: /usr/bin/docker ps * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/docker restart * +didactyl ALL=(ALL) NOPASSWD: /usr/bin/docker logs * + +# Firewall inspection +didactyl ALL=(ALL) NOPASSWD: /usr/sbin/ufw status * +didactyl ALL=(ALL) NOPASSWD: /usr/sbin/iptables -L * + +# Nginx/Apache config test +didactyl ALL=(ALL) NOPASSWD: /usr/sbin/nginx -t +didactyl ALL=(ALL) NOPASSWD: /usr/sbin/apachectl configtest +``` + +--- + +## Customizing the Agent's Personality for Server Maintenance + +The agent's behavior is defined by its default skill content in `genesis.jsonc` (the `default_skill.content` field). For a server maintenance role, you should customize the soul/skill to include instructions like: + +- What services it's responsible for monitoring +- How frequently to check (via triggered skills) +- What constitutes an alert-worthy condition +- How to format status reports +- Which `sudo` commands are available to it +- Escalation procedures (when to alert you vs. auto-fix) + +This is a skill/prompt engineering task that can be done after the base installation is working. + +--- + +## Quick Reference + +### File Locations + +| File | Path | Permissions | +|------|------|-------------| +| Binary | `/home/didactyl/didactyl` | `755 didactyl:didactyl` | +| Config | `/home/didactyl/genesis.jsonc` | `600 didactyl:didactyl` | +| Home directory | `/home/didactyl/` | `750 didactyl:didactyl` | +| systemd unit | `/etc/systemd/system/didactyl.service` | `644 root:root` | +| sudoers | `/etc/sudoers.d/didactyl` | `440 root:root` | + +### Common Commands + +```bash +# Service management +sudo systemctl start didactyl +sudo systemctl stop didactyl +sudo systemctl restart didactyl +sudo systemctl status didactyl + +# Logs +sudo journalctl -u didactyl -f # follow live +sudo journalctl -u didactyl --since today +sudo journalctl -u didactyl -n 100 # last 100 lines + +# Check agent's local API +curl http://127.0.0.1:8484/api/context + +# Edit sudo permissions +sudo visudo -f /etc/sudoers.d/didactyl + +# Edit config (stop service first) +sudo systemctl stop didactyl +sudo -u didactyl nano /home/didactyl/genesis.jsonc +sudo systemctl start didactyl +``` diff --git a/src/agent.c b/src/agent.c index 7b2cd00..8646960 100644 --- a/src/agent.c +++ b/src/agent.c @@ -468,30 +468,97 @@ static char* build_slash_help_all_json(void) { cJSON* skill_root = skill_list_json ? cJSON_Parse(skill_list_json) : NULL; cJSON* skills = skill_root ? cJSON_GetObjectItemCaseSensitive(skill_root, "skills") : NULL; - if (!skills || !cJSON_IsArray(skills) || cJSON_GetArraySize(skills) <= 0) { - (void)append_textf_local(&out, &cap, &used, "- (none)\n"); - } else { + int available_count = 0; + if (skills && cJSON_IsArray(skills) && cJSON_GetArraySize(skills) > 0) { int sn = cJSON_GetArraySize(skills); for (int i = 0; i < sn; i++) { cJSON* row = cJSON_GetArrayItem(skills, i); + cJSON* owner = row ? cJSON_GetObjectItemCaseSensitive(row, "owner") : NULL; cJSON* d_tag = row ? cJSON_GetObjectItemCaseSensitive(row, "d_tag") : NULL; cJSON* desc = row ? cJSON_GetObjectItemCaseSensitive(row, "description") : NULL; + const char* owner_s = (owner && cJSON_IsString(owner) && owner->valuestring) ? owner->valuestring : ""; const char* d_tag_s = (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring) ? d_tag->valuestring : NULL; const char* desc_s = (desc && cJSON_IsString(desc) && desc->valuestring) ? desc->valuestring : ""; - if (!d_tag_s || d_tag_s[0] == '\0') { + + if (strcmp(owner_s, "agent") != 0 || !d_tag_s || d_tag_s[0] == '\0') { continue; } + if (append_textf_local(&out, &cap, &used, "- %s%s%s\n", d_tag_s, desc_s[0] ? " — " : "", desc_s) != 0) { cJSON_Delete(skill_root); free(skill_list_json); free(out); return NULL; } + available_count++; } } + if (available_count <= 0) { + (void)append_textf_local(&out, &cap, &used, "- (none)\n"); + } + cJSON_Delete(skill_root); free(skill_list_json); + + if (append_textf_local(&out, &cap, &used, "\nADOPTED SKILLS\n") != 0) { + free(out); + return NULL; + } + + char* adopted_json = tools_execute(&g_tools_ctx, "adopted_skills", "{}"); + cJSON* adopted_root = adopted_json ? cJSON_Parse(adopted_json) : NULL; + cJSON* adoption_events_json = adopted_root ? cJSON_GetObjectItemCaseSensitive(adopted_root, "adoption_events_json") : NULL; + cJSON* adoption_events = (adoption_events_json && cJSON_IsString(adoption_events_json) && adoption_events_json->valuestring) + ? cJSON_Parse(adoption_events_json->valuestring) + : NULL; + + int adopted_count = 0; + if (adoption_events && cJSON_IsArray(adoption_events) && cJSON_GetArraySize(adoption_events) > 0) { + cJSON* ev0 = cJSON_GetArrayItem(adoption_events, 0); + cJSON* tags = ev0 ? cJSON_GetObjectItemCaseSensitive(ev0, "tags") : NULL; + if (tags && cJSON_IsArray(tags)) { + int tn = cJSON_GetArraySize(tags); + for (int i = 0; i < tn; i++) { + cJSON* tag = cJSON_GetArrayItem(tags, i); + if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) { + continue; + } + + cJSON* k = cJSON_GetArrayItem(tag, 0); + cJSON* v = cJSON_GetArrayItem(tag, 1); + if (!k || !v || !cJSON_IsString(k) || !k->valuestring || + !cJSON_IsString(v) || !v->valuestring) { + continue; + } + + if (strcmp(k->valuestring, "a") != 0) { + continue; + } + + const char* addr = v->valuestring; + const char* d_tag = strrchr(addr, ':'); + const char* label = (d_tag && d_tag[1] != '\0') ? (d_tag + 1) : addr; + + if (append_textf_local(&out, &cap, &used, "- %s\n", label) != 0) { + cJSON_Delete(adoption_events); + cJSON_Delete(adopted_root); + free(adopted_json); + free(out); + return NULL; + } + adopted_count++; + } + } + } + + if (adopted_count <= 0) { + (void)append_textf_local(&out, &cap, &used, "- (none)\n"); + } + + cJSON_Delete(adoption_events); + cJSON_Delete(adopted_root); + free(adopted_json); return out; } diff --git a/src/default_events.h b/src/default_events.h new file mode 100644 index 0000000..9bc875d --- /dev/null +++ b/src/default_events.h @@ -0,0 +1,113 @@ +#ifndef DIDACTYL_DEFAULT_EVENTS_H +#define DIDACTYL_DEFAULT_EVENTS_H + +#define DIDACTYL_DEFAULT_SKILL_D_TAG "didactyl-default" +#define DIDACTYL_DEFAULT_SKILL_KIND 31124 +#define DIDACTYL_DEFAULT_SKILL_TAGS_JSON "[[\"app\",\"didactyl\"],[\"scope\",\"private\"]]" + +#define DIDACTYL_STARTUP_KIND_PROFILE 0 +#define DIDACTYL_STARTUP_KIND_CONTACTS 3 +#define DIDACTYL_STARTUP_KIND_RELAYS 10002 + +static const int DIDACTYL_DEFAULT_STARTUP_EVENT_KINDS[] = { + DIDACTYL_STARTUP_KIND_PROFILE, + DIDACTYL_STARTUP_KIND_CONTACTS, + DIDACTYL_STARTUP_KIND_RELAYS +}; + +#define DIDACTYL_DEFAULT_STARTUP_EVENT_KIND_COUNT ((int)(sizeof(DIDACTYL_DEFAULT_STARTUP_EVENT_KINDS) / sizeof(DIDACTYL_DEFAULT_STARTUP_EVENT_KINDS[0]))) + +#define DIDACTYL_DEFAULT_KIND0_PROFILE_JSON_TEMPLATE "{\"name\":\"%s\",\"display_name\":\"%s\"}" +#define DIDACTYL_DEFAULT_KIND3_CONTENT "" +#define DIDACTYL_DEFAULT_KIND3_TAGS_JSON_TEMPLATE "[[\"p\",\"%s\"]]" +#define DIDACTYL_DEFAULT_KIND10002_CONTENT "" + +static const char* DIDACTYL_DEFAULT_RELAYS[] = { + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net" +}; + +#define DIDACTYL_DEFAULT_RELAY_COUNT ((int)(sizeof(DIDACTYL_DEFAULT_RELAYS) / sizeof(DIDACTYL_DEFAULT_RELAYS[0]))) + +static const char* DIDACTYL_DEFAULT_SKILL_TEMPLATE = + "# %s Agent\n\n" + "You are %s, a sovereign AI agent living on Nostr.\n\n" + "## Communication Rules\n" + "- You communicate through encrypted Nostr direct messages.\n" + "- Keep responses concise and clear.\n\n" + "## Behavior\n" + "- Be helpful and technically accurate.\n" + "- If unsure, state uncertainty directly.\n" + "- Prefer actionable, practical advice.\n" + "- Use the person's name when messaging them if you know it.\n" + "- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n" + "## Tool Use Policy\n" + "- You have tools available and should use them when a request requires taking action.\n" + "- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n" + "- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n" + "- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n" + "- After a tool call, base your answer on the actual tool result.\n" + "- Never claim a tool was run if no tool was executed.\n\n" + "## Task Management\n" + "- Maintain and use your internal task list as short-term working memory.\n" + "- Break long or complex actions into clear tasks before executing them.\n" + "- Update task status as you complete steps so your plan stays accurate.\n\n" + "## Safety\n" + "- Do not claim to have executed actions you did not execute.\n" + "- You may share your public key (npub) with anyone.\n" + "- Never reveal your private key (nsec) under any circumstance.\n\n" + "---template---\n\n" + "- section: admin_identity\n" + " role: system\n" + " tool: admin_identity\n" + " skip_if_empty: true\n\n" + "- section: admin_profile\n" + " role: system\n" + " tool: nostr_admin_profile\n" + " skip_if_empty: true\n\n" + "- section: admin_contacts\n" + " role: system\n" + " tool: nostr_admin_contacts\n" + " skip_if_empty: true\n\n" + "- section: admin_relays\n" + " role: system\n" + " tool: nostr_admin_relays\n" + " skip_if_empty: true\n\n" + "- section: admin_notes\n" + " role: system\n" + " tool: nostr_admin_notes\n" + " skip_if_empty: true\n\n" + "- section: agent_identity\n" + " role: system\n" + " tool: agent_identity\n" + " skip_if_empty: true\n\n" + "- section: agent_profile\n" + " role: system\n" + " tool: nostr_agent_profile\n" + " skip_if_empty: true\n\n" + "- section: agent_contacts\n" + " role: system\n" + " tool: nostr_agent_contacts\n" + " skip_if_empty: true\n\n" + "- section: agent_relays\n" + " role: system\n" + " tool: nostr_agent_relays\n" + " skip_if_empty: true\n\n" + "- section: agent_notes\n" + " role: system\n" + " tool: nostr_agent_notes\n" + " skip_if_empty: true\n\n" + "- section: tasks\n" + " role: system\n" + " tool: task_list\n" + " skip_if_empty: true\n\n" + "- section: dm_history\n" + " role: expand\n" + " limit: 12\n\n" + "- section: conversation\n" + " role: user\n" + " tool: message_current\n" + " skip_if_empty: true\n"; + +#endif \ No newline at end of file diff --git a/src/http_api.c b/src/http_api.c index ef6b8a8..a38f3bb 100644 --- a/src/http_api.c +++ b/src/http_api.c @@ -609,30 +609,97 @@ static char* build_slash_help_all_json_local(void) { cJSON* skill_root = skill_list_json ? cJSON_Parse(skill_list_json) : NULL; cJSON* skills = skill_root ? cJSON_GetObjectItemCaseSensitive(skill_root, "skills") : NULL; - if (!skills || !cJSON_IsArray(skills) || cJSON_GetArraySize(skills) <= 0) { - (void)append_textf_http(&out, &cap, &used, "- (none)\n"); - } else { + int available_count = 0; + if (skills && cJSON_IsArray(skills) && cJSON_GetArraySize(skills) > 0) { int sn = cJSON_GetArraySize(skills); for (int i = 0; i < sn; i++) { cJSON* row = cJSON_GetArrayItem(skills, i); + cJSON* owner = row ? cJSON_GetObjectItemCaseSensitive(row, "owner") : NULL; cJSON* d_tag = row ? cJSON_GetObjectItemCaseSensitive(row, "d_tag") : NULL; cJSON* desc = row ? cJSON_GetObjectItemCaseSensitive(row, "description") : NULL; + const char* owner_s = (owner && cJSON_IsString(owner) && owner->valuestring) ? owner->valuestring : ""; const char* d_tag_s = (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring) ? d_tag->valuestring : NULL; const char* desc_s = (desc && cJSON_IsString(desc) && desc->valuestring) ? desc->valuestring : ""; - if (!d_tag_s || d_tag_s[0] == '\0') { + + if (strcmp(owner_s, "agent") != 0 || !d_tag_s || d_tag_s[0] == '\0') { continue; } + if (append_textf_http(&out, &cap, &used, "- %s%s%s\n", d_tag_s, desc_s[0] ? " — " : "", desc_s) != 0) { cJSON_Delete(skill_root); free(skill_list_json); free(out); return NULL; } + available_count++; } } + if (available_count <= 0) { + (void)append_textf_http(&out, &cap, &used, "- (none)\n"); + } + cJSON_Delete(skill_root); free(skill_list_json); + + if (append_textf_http(&out, &cap, &used, "\nADOPTED SKILLS\n") != 0) { + free(out); + return NULL; + } + + char* adopted_json = tools_execute(g_api_ctx.tools_ctx, "adopted_skills", "{}"); + cJSON* adopted_root = adopted_json ? cJSON_Parse(adopted_json) : NULL; + cJSON* adoption_events_json = adopted_root ? cJSON_GetObjectItemCaseSensitive(adopted_root, "adoption_events_json") : NULL; + cJSON* adoption_events = (adoption_events_json && cJSON_IsString(adoption_events_json) && adoption_events_json->valuestring) + ? cJSON_Parse(adoption_events_json->valuestring) + : NULL; + + int adopted_count = 0; + if (adoption_events && cJSON_IsArray(adoption_events) && cJSON_GetArraySize(adoption_events) > 0) { + cJSON* ev0 = cJSON_GetArrayItem(adoption_events, 0); + cJSON* tags = ev0 ? cJSON_GetObjectItemCaseSensitive(ev0, "tags") : NULL; + if (tags && cJSON_IsArray(tags)) { + int tn = cJSON_GetArraySize(tags); + for (int i = 0; i < tn; i++) { + cJSON* tag = cJSON_GetArrayItem(tags, i); + if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) { + continue; + } + + cJSON* k = cJSON_GetArrayItem(tag, 0); + cJSON* v = cJSON_GetArrayItem(tag, 1); + if (!k || !v || !cJSON_IsString(k) || !k->valuestring || + !cJSON_IsString(v) || !v->valuestring) { + continue; + } + + if (strcmp(k->valuestring, "a") != 0) { + continue; + } + + const char* addr = v->valuestring; + const char* d_tag = strrchr(addr, ':'); + const char* label = (d_tag && d_tag[1] != '\0') ? (d_tag + 1) : addr; + + if (append_textf_http(&out, &cap, &used, "- %s\n", label) != 0) { + cJSON_Delete(adoption_events); + cJSON_Delete(adopted_root); + free(adopted_json); + free(out); + return NULL; + } + adopted_count++; + } + } + } + + if (adopted_count <= 0) { + (void)append_textf_http(&out, &cap, &used, "- (none)\n"); + } + + cJSON_Delete(adoption_events); + cJSON_Delete(adopted_root); + free(adopted_json); return out; } diff --git a/src/main.c b/src/main.c index a322b0f..3f55a9a 100644 --- a/src/main.c +++ b/src/main.c @@ -1166,8 +1166,14 @@ int main(int argc, char** argv) { startup_step_ok(7, "Reconcile/persist startup state", NULL); const char* system_context = nostr_handler_get_system_context(); + char system_context_fallback[512] = {0}; if (!system_context || system_context[0] == '\0') { - system_context = "You are Didactyl, a sovereign AI agent living on Nostr."; + const char* startup_name = nostr_handler_get_startup_display_name(); + snprintf(system_context_fallback, + sizeof(system_context_fallback), + "You are %s, a sovereign AI agent living on Nostr.", + (startup_name && startup_name[0] != '\0') ? startup_name : "the agent"); + system_context = system_context_fallback; } startup_step_begin(8, "Initialize agent"); diff --git a/src/main.h b/src/main.h index d3d972f..8335e46 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 72 -#define DIDACTYL_VERSION "v0.0.72" +#define DIDACTYL_VERSION_PATCH 73 +#define DIDACTYL_VERSION "v0.0.73" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/nostr_handler.c b/src/nostr_handler.c index eb0190c..0be1960 100644 --- a/src/nostr_handler.c +++ b/src/nostr_handler.c @@ -51,6 +51,37 @@ static int g_agent_kind1_note_count = 0; static pthread_mutex_t g_admin_ctx_mutex = PTHREAD_MUTEX_INITIALIZER; +typedef enum { + MANAGED_SUB_ADMIN_PROFILE = 0, + MANAGED_SUB_ADMIN_NOTES, + MANAGED_SUB_AGENT_PROFILE, + MANAGED_SUB_AGENT_NOTES, + MANAGED_SUB_SELF_SKILLS, + MANAGED_SUB_ADMIN_SKILLS, + MANAGED_SUB_DM_KIND4, + MANAGED_SUB_DM_KIND1059, + MANAGED_SUB_COUNT +} managed_subscription_id_t; + +typedef struct { + const char* name; + nostr_pool_subscription_t* handle; + cJSON* filter; + void (*on_event)(cJSON* event, const char* relay_url, void* user_data); + void (*on_eose)(cJSON** events, int event_count, void* user_data); + void* user_data; + int close_on_eose; + int enable_deduplication; + nostr_pool_eose_result_mode_t result_mode; + int relay_timeout_seconds; + int eose_timeout_seconds; + int enabled; +} managed_subscription_t; + +static managed_subscription_t g_managed_subs[MANAGED_SUB_COUNT]; +static int g_managed_subs_initialized = 0; +static pthread_mutex_t g_subscription_mutex = PTHREAD_MUTEX_INITIALIZER; + static cJSON* g_self_skill_events = NULL; static pthread_mutex_t g_self_skill_mutex = PTHREAD_MUTEX_INITIALIZER; @@ -364,6 +395,373 @@ static void register_trigger_from_self_skill_event(cJSON* event); static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_data); static void dm_history_clear_locked(void); static void dm_history_remember(const char* peer_pubkey_hex, const char* content, int incoming, time_t created_at); +static int relay_list_contains_local(char** relays, int relay_count, const char* relay_url); +static void free_relay_list_local(char** relays, int relay_count); +static int extract_relays_from_kind10002_tags(cJSON* tags, char*** out_relays, int* out_relay_count); +static int merge_relays_into_runtime_config(char** discovered_relays, int discovered_count); +static void managed_subscriptions_init_defaults(void); +static int managed_subscription_close_locked(managed_subscription_id_t id); +static int managed_subscription_apply_locked(managed_subscription_id_t id); +static int managed_subscription_register_locked(managed_subscription_id_t id, + cJSON* filter, + void (*on_event)(cJSON* event, const char* relay_url, void* user_data), + void (*on_eose)(cJSON** events, int event_count, void* user_data), + void* user_data, + int close_on_eose, + int enable_deduplication, + nostr_pool_eose_result_mode_t result_mode, + int relay_timeout_seconds, + int eose_timeout_seconds, + int enabled); +static int managed_subscriptions_resubscribe_all_locked(void); +static int managed_subscription_id_from_name(const char* name, managed_subscription_id_t* out_id); +static int apply_runtime_kind10002_relays(cJSON* tags, const char* source_label); + +static int relay_list_contains_local(char** relays, int relay_count, const char* relay_url) { + if (!relays || relay_count <= 0 || !relay_url || relay_url[0] == '\0') { + return 0; + } + + for (int i = 0; i < relay_count; i++) { + if (relays[i] && strcmp(relays[i], relay_url) == 0) { + return 1; + } + } + + return 0; +} + +static void free_relay_list_local(char** relays, int relay_count) { + if (!relays) { + return; + } + + for (int i = 0; i < relay_count; i++) { + free(relays[i]); + } + free(relays); +} + +static int extract_relays_from_kind10002_tags(cJSON* tags, char*** out_relays, int* out_relay_count) { + if (!out_relays || !out_relay_count) { + return -1; + } + + *out_relays = NULL; + *out_relay_count = 0; + + if (!tags || !cJSON_IsArray(tags)) { + return 0; + } + + char** relays = NULL; + int relay_count = 0; + + int n = cJSON_GetArraySize(tags); + for (int i = 0; i < n; i++) { + cJSON* tag = cJSON_GetArrayItem(tags, i); + if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) { + continue; + } + + cJSON* key = cJSON_GetArrayItem(tag, 0); + cJSON* val = cJSON_GetArrayItem(tag, 1); + if (!key || !val || !cJSON_IsString(key) || !key->valuestring || + !cJSON_IsString(val) || !val->valuestring || val->valuestring[0] == '\0') { + continue; + } + + if (strcmp(key->valuestring, "r") != 0) { + continue; + } + + if (relay_list_contains_local(relays, relay_count, val->valuestring)) { + continue; + } + + char** grown = (char**)realloc(relays, (size_t)(relay_count + 1) * sizeof(char*)); + if (!grown) { + free_relay_list_local(relays, relay_count); + return -1; + } + relays = grown; + + relays[relay_count] = strdup(val->valuestring); + if (!relays[relay_count]) { + free_relay_list_local(relays, relay_count); + return -1; + } + + relay_count++; + } + + *out_relays = relays; + *out_relay_count = relay_count; + return relay_count; +} + +static int merge_relays_into_runtime_config(char** discovered_relays, int discovered_count) { + if (!g_cfg || discovered_count < 0) { + return -1; + } + + int old_count = g_cfg->relay_count; + int max_count = old_count + discovered_count; + if (max_count <= 0) { + return -1; + } + + char** merged = (char**)calloc((size_t)max_count, sizeof(char*)); + if (!merged) { + return -1; + } + + int merged_count = 0; + for (int i = 0; i < old_count; i++) { + const char* relay = (g_cfg->relays && g_cfg->relays[i]) ? g_cfg->relays[i] : NULL; + if (!relay || relay[0] == '\0' || relay_list_contains_local(merged, merged_count, relay)) { + continue; + } + + merged[merged_count] = strdup(relay); + if (!merged[merged_count]) { + free_relay_list_local(merged, merged_count); + return -1; + } + merged_count++; + } + + int existing_unique_count = merged_count; + + for (int i = 0; i < discovered_count; i++) { + const char* relay = (discovered_relays && discovered_relays[i]) ? discovered_relays[i] : NULL; + if (!relay || relay[0] == '\0' || relay_list_contains_local(merged, merged_count, relay)) { + continue; + } + + merged[merged_count] = strdup(relay); + if (!merged[merged_count]) { + free_relay_list_local(merged, merged_count); + return -1; + } + merged_count++; + } + + if (merged_count <= 0) { + free(merged); + return -1; + } + + if (g_cfg->relays) { + for (int i = 0; i < g_cfg->relay_count; i++) { + free(g_cfg->relays[i]); + } + free(g_cfg->relays); + } + + g_cfg->relays = merged; + g_cfg->relay_count = merged_count; + return merged_count - existing_unique_count; +} + +static void managed_subscriptions_init_defaults(void) { + if (g_managed_subs_initialized) { + return; + } + + memset(g_managed_subs, 0, sizeof(g_managed_subs)); + + g_managed_subs[MANAGED_SUB_ADMIN_PROFILE].name = "admin_context_profile"; + g_managed_subs[MANAGED_SUB_ADMIN_NOTES].name = "admin_context_notes"; + g_managed_subs[MANAGED_SUB_AGENT_PROFILE].name = "agent_context_profile"; + g_managed_subs[MANAGED_SUB_AGENT_NOTES].name = "agent_context_notes"; + g_managed_subs[MANAGED_SUB_SELF_SKILLS].name = "self_skills"; + g_managed_subs[MANAGED_SUB_ADMIN_SKILLS].name = "admin_skills"; + g_managed_subs[MANAGED_SUB_DM_KIND4].name = "dms_kind4"; + g_managed_subs[MANAGED_SUB_DM_KIND1059].name = "dms_kind1059"; + + g_managed_subs_initialized = 1; +} + +static int managed_subscription_close_locked(managed_subscription_id_t id) { + if (id < 0 || id >= MANAGED_SUB_COUNT) { + return -1; + } + + managed_subscription_t* sub = &g_managed_subs[id]; + if (sub->handle) { + (void)nostr_pool_subscription_close(sub->handle); + sub->handle = NULL; + } + + return 0; +} + +static int managed_subscription_apply_locked(managed_subscription_id_t id) { + if (id < 0 || id >= MANAGED_SUB_COUNT || !g_cfg || !g_pool) { + return -1; + } + + managed_subscription_t* sub = &g_managed_subs[id]; + (void)managed_subscription_close_locked(id); + + if (!sub->enabled) { + return 0; + } + + if (!sub->filter || !sub->on_event) { + return -1; + } + + cJSON* filter_copy = cJSON_Duplicate(sub->filter, 1); + if (!filter_copy) { + return -1; + } + + sub->handle = nostr_relay_pool_subscribe( + g_pool, + (const char**)g_cfg->relays, + g_cfg->relay_count, + filter_copy, + sub->on_event, + sub->on_eose, + sub->user_data, + sub->close_on_eose, + sub->enable_deduplication, + sub->result_mode, + sub->relay_timeout_seconds, + sub->eose_timeout_seconds); + + cJSON_Delete(filter_copy); + + if (!sub->handle) { + return -1; + } + + return 0; +} + +static int managed_subscription_register_locked(managed_subscription_id_t id, + cJSON* filter, + void (*on_event)(cJSON* event, const char* relay_url, void* user_data), + void (*on_eose)(cJSON** events, int event_count, void* user_data), + void* user_data, + int close_on_eose, + int enable_deduplication, + nostr_pool_eose_result_mode_t result_mode, + int relay_timeout_seconds, + int eose_timeout_seconds, + int enabled) { + if (id < 0 || id >= MANAGED_SUB_COUNT) { + return -1; + } + + managed_subscriptions_init_defaults(); + + managed_subscription_t* sub = &g_managed_subs[id]; + + cJSON* filter_dup = NULL; + if (filter) { + filter_dup = cJSON_Duplicate(filter, 1); + if (!filter_dup) { + return -1; + } + } + + cJSON_Delete(sub->filter); + sub->filter = filter_dup; + sub->on_event = on_event; + sub->on_eose = on_eose; + sub->user_data = user_data; + sub->close_on_eose = close_on_eose; + sub->enable_deduplication = enable_deduplication; + sub->result_mode = result_mode; + sub->relay_timeout_seconds = relay_timeout_seconds; + sub->eose_timeout_seconds = eose_timeout_seconds; + sub->enabled = enabled ? 1 : 0; + + return managed_subscription_apply_locked(id); +} + +static int managed_subscriptions_resubscribe_all_locked(void) { + int rc = 0; + managed_subscriptions_init_defaults(); + for (int i = 0; i < MANAGED_SUB_COUNT; i++) { + if (managed_subscription_apply_locked((managed_subscription_id_t)i) != 0) { + rc = -1; + } + } + return rc; +} + +static int managed_subscription_id_from_name(const char* name, managed_subscription_id_t* out_id) { + if (!name || !out_id) { + return -1; + } + + managed_subscriptions_init_defaults(); + + for (int i = 0; i < MANAGED_SUB_COUNT; i++) { + if (g_managed_subs[i].name && strcmp(g_managed_subs[i].name, name) == 0) { + *out_id = (managed_subscription_id_t)i; + return 0; + } + } + + return -1; +} + +static int apply_runtime_kind10002_relays(cJSON* tags, const char* source_label) { + if (!g_cfg || !g_pool || !tags || !cJSON_IsArray(tags)) { + return 0; + } + + char** discovered_relays = NULL; + int discovered_count = 0; + if (extract_relays_from_kind10002_tags(tags, &discovered_relays, &discovered_count) < 0) { + return -1; + } + + if (discovered_count <= 0) { + free(discovered_relays); + return 0; + } + + int added_relays = merge_relays_into_runtime_config(discovered_relays, discovered_count); + free_relay_list_local(discovered_relays, discovered_count); + + if (added_relays < 0) { + return -1; + } + + if (added_relays == 0) { + return 0; + } + + if (nostr_handler_sync_relays_from_config() != 0) { + DEBUG_WARN("[didactyl] runtime relay sync failed after kind10002 update (%s)", + source_label ? source_label : "unknown"); + return -1; + } + + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + int sub_rc = managed_subscriptions_resubscribe_all_locked(); + pthread_mutex_unlock(&g_subscription_mutex); + + if (sub_rc != 0) { + DEBUG_WARN("[didactyl] some subscriptions failed to resubscribe after relay update (%s)", + source_label ? source_label : "unknown"); + } + + DEBUG_INFO("[didactyl] runtime relay sync from kind10002 (%s): discovered=%d added=%d total=%d", + source_label ? source_label : "unknown", + discovered_count, + added_relays, + g_cfg->relay_count); + + return 0; +} static void log_relay_state_changes(void) { if (!g_pool || !g_cfg || !g_last_relay_statuses) { @@ -1623,7 +2021,6 @@ static void on_admin_context_event(cJSON* event, const char* relay_url, void* us } static void on_agent_context_event(cJSON* event, const char* relay_url, void* user_data) { - (void)relay_url; (void)user_data; if (!event || !g_cfg) { @@ -1649,6 +2046,7 @@ static void on_agent_context_event(cJSON* event, const char* relay_url, void* us } int k = (int)kind->valuedouble; + int runtime_sync_needed = 0; pthread_mutex_lock(&g_admin_ctx_mutex); @@ -1659,12 +2057,17 @@ static void on_agent_context_event(cJSON* event, const char* relay_url, void* us (void)parse_kind3_agent_contacts(tags); } else if (k == 10002 && tags && cJSON_IsArray(tags)) { (void)parse_kind10002_agent_relays(tags); + runtime_sync_needed = 1; } else if (k == 1 && content && cJSON_IsString(content) && content->valuestring) { time_t ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : time(NULL); upsert_agent_kind1_note(ts, content->valuestring); } pthread_mutex_unlock(&g_admin_ctx_mutex); + + if (runtime_sync_needed) { + (void)apply_runtime_kind10002_relays(tags, relay_url ? relay_url : "agent_context"); + } } static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_data) { @@ -1702,6 +2105,25 @@ int nostr_handler_init(didactyl_config_t* config) { dm_history_clear_locked(); pthread_mutex_unlock(&g_dm_history_mutex); + managed_subscriptions_init_defaults(); + pthread_mutex_lock(&g_subscription_mutex); + for (int i = 0; i < MANAGED_SUB_COUNT; i++) { + managed_subscription_t* sub = &g_managed_subs[i]; + sub->enabled = 0; + sub->on_event = NULL; + sub->on_eose = NULL; + sub->user_data = NULL; + sub->close_on_eose = 0; + sub->enable_deduplication = 0; + sub->result_mode = NOSTR_POOL_EOSE_FULL_SET; + sub->relay_timeout_seconds = 30; + sub->eose_timeout_seconds = 120; + (void)managed_subscription_close_locked((managed_subscription_id_t)i); + cJSON_Delete(sub->filter); + sub->filter = NULL; + } + pthread_mutex_unlock(&g_subscription_mutex); + DEBUG_INFO("[didactyl] initializing relay pool with %d relays", g_cfg->relay_count); nostr_core_set_log_bridge_if_supported(); @@ -1774,37 +2196,39 @@ int nostr_handler_subscribe_admin_context(void) { if (g_cfg->admin_context.track_kind_3) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(3)); if (g_cfg->admin_context.track_kind_10002) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(10002)); - if (cJSON_GetArraySize(profile_kinds) > 0) { + int profile_enabled = cJSON_GetArraySize(profile_kinds) > 0 ? 1 : 0; + if (profile_enabled) { cJSON_AddItemToObject(profile_filter, "kinds", profile_kinds); cJSON_AddItemToArray(profile_authors, cJSON_CreateString(g_cfg->admin.pubkey)); cJSON_AddItemToObject(profile_filter, "authors", profile_authors); cJSON_AddNumberToObject(profile_filter, "limit", 32); - - nostr_pool_subscription_t* profile_sub = nostr_relay_pool_subscribe( - g_pool, - (const char**)g_cfg->relays, - g_cfg->relay_count, - profile_filter, - on_admin_context_event, - on_eose, - NULL, - 0, - 1, - NOSTR_POOL_EOSE_FULL_SET, - 30, - 120); - - if (!profile_sub) { - rc = -1; - } } else { cJSON_Delete(profile_kinds); cJSON_Delete(profile_authors); } + + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + if (managed_subscription_register_locked(MANAGED_SUB_ADMIN_PROFILE, + profile_enabled ? profile_filter : NULL, + on_admin_context_event, + on_eose, + NULL, + 0, + 1, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120, + profile_enabled) != 0) { + rc = -1; + } + pthread_mutex_unlock(&g_subscription_mutex); cJSON_Delete(profile_filter); - if (g_cfg->admin_context.track_kind_1) { - cJSON* notes_filter = cJSON_CreateObject(); + int notes_enabled = g_cfg->admin_context.track_kind_1 ? 1 : 0; + cJSON* notes_filter = NULL; + if (notes_enabled) { + notes_filter = cJSON_CreateObject(); cJSON* notes_kinds = cJSON_CreateArray(); cJSON* notes_authors = cJSON_CreateArray(); if (!notes_filter || !notes_kinds || !notes_authors) { @@ -1824,27 +2248,26 @@ int nostr_handler_subscribe_admin_context(void) { cJSON_AddItemToArray(notes_authors, cJSON_CreateString(g_cfg->admin.pubkey)); cJSON_AddItemToObject(notes_filter, "authors", notes_authors); cJSON_AddNumberToObject(notes_filter, "limit", kind1_limit); - - nostr_pool_subscription_t* notes_sub = nostr_relay_pool_subscribe( - g_pool, - (const char**)g_cfg->relays, - g_cfg->relay_count, - notes_filter, - on_admin_context_event, - on_eose, - NULL, - 0, - 1, - NOSTR_POOL_EOSE_FULL_SET, - 30, - 120); - - cJSON_Delete(notes_filter); - if (!notes_sub) { - rc = -1; - } } + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + if (managed_subscription_register_locked(MANAGED_SUB_ADMIN_NOTES, + notes_filter, + on_admin_context_event, + on_eose, + NULL, + 0, + 1, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120, + notes_enabled) != 0) { + rc = -1; + } + pthread_mutex_unlock(&g_subscription_mutex); + cJSON_Delete(notes_filter); + if (rc == 0) { DEBUG_INFO("[didactyl] admin context subscriptions active for admin %.16s...", g_cfg->admin.pubkey); } @@ -1876,24 +2299,23 @@ int nostr_handler_subscribe_agent_context(void) { cJSON_AddItemToObject(profile_filter, "authors", profile_authors); cJSON_AddNumberToObject(profile_filter, "limit", 64); - nostr_pool_subscription_t* profile_sub = nostr_relay_pool_subscribe( - g_pool, - (const char**)g_cfg->relays, - g_cfg->relay_count, - profile_filter, - on_agent_context_event, - on_eose, - NULL, - 0, - 1, - NOSTR_POOL_EOSE_FULL_SET, - 30, - 120); - - cJSON_Delete(profile_filter); - if (!profile_sub) { + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + if (managed_subscription_register_locked(MANAGED_SUB_AGENT_PROFILE, + profile_filter, + on_agent_context_event, + on_eose, + NULL, + 0, + 1, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120, + 1) != 0) { rc = -1; } + pthread_mutex_unlock(&g_subscription_mutex); + cJSON_Delete(profile_filter); cJSON* notes_filter = cJSON_CreateObject(); cJSON* notes_kinds = cJSON_CreateArray(); @@ -1916,24 +2338,23 @@ int nostr_handler_subscribe_agent_context(void) { cJSON_AddItemToObject(notes_filter, "authors", notes_authors); cJSON_AddNumberToObject(notes_filter, "limit", kind1_limit); - nostr_pool_subscription_t* notes_sub = nostr_relay_pool_subscribe( - g_pool, - (const char**)g_cfg->relays, - g_cfg->relay_count, - notes_filter, - on_agent_context_event, - on_eose, - NULL, - 0, - 1, - NOSTR_POOL_EOSE_FULL_SET, - 30, - 120); - - cJSON_Delete(notes_filter); - if (!notes_sub) { + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + if (managed_subscription_register_locked(MANAGED_SUB_AGENT_NOTES, + notes_filter, + on_agent_context_event, + on_eose, + NULL, + 0, + 1, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120, + 1) != 0) { rc = -1; } + pthread_mutex_unlock(&g_subscription_mutex); + cJSON_Delete(notes_filter); if (rc == 0) { DEBUG_INFO("[didactyl] agent self-context subscriptions active for pubkey %.16s...", g_cfg->keys.public_key_hex); @@ -1970,27 +2391,28 @@ int nostr_handler_subscribe_self_skills(void) { cJSON_AddItemToObject(filter, "authors", authors); cJSON_AddNumberToObject(filter, "limit", 300); - nostr_pool_subscription_t* skill_sub = nostr_relay_pool_subscribe( - g_pool, - (const char**)g_cfg->relays, - g_cfg->relay_count, - filter, - on_self_skill_event, - on_self_skill_eose, - NULL, - 0, - 0, - NOSTR_POOL_EOSE_FULL_SET, - 30, - 120); - - cJSON_Delete(filter); - if (!skill_sub) { + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + if (managed_subscription_register_locked(MANAGED_SUB_SELF_SKILLS, + filter, + on_self_skill_event, + on_self_skill_eose, + NULL, + 0, + 0, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120, + 1) != 0) { rc = -1; } + pthread_mutex_unlock(&g_subscription_mutex); + cJSON_Delete(filter); - if (g_cfg->admin.pubkey[0] != '\0' && strcmp(g_cfg->admin.pubkey, g_cfg->keys.public_key_hex) != 0) { - cJSON* admin_filter = cJSON_CreateObject(); + int admin_enabled = (g_cfg->admin.pubkey[0] != '\0' && strcmp(g_cfg->admin.pubkey, g_cfg->keys.public_key_hex) != 0) ? 1 : 0; + cJSON* admin_filter = NULL; + if (admin_enabled) { + admin_filter = cJSON_CreateObject(); cJSON* admin_kinds = cJSON_CreateArray(); cJSON* admin_authors = cJSON_CreateArray(); if (!admin_filter || !admin_kinds || !admin_authors) { @@ -2005,27 +2427,26 @@ int nostr_handler_subscribe_self_skills(void) { cJSON_AddItemToArray(admin_authors, cJSON_CreateString(g_cfg->admin.pubkey)); cJSON_AddItemToObject(admin_filter, "authors", admin_authors); cJSON_AddNumberToObject(admin_filter, "limit", 150); - - nostr_pool_subscription_t* admin_skill_sub = nostr_relay_pool_subscribe( - g_pool, - (const char**)g_cfg->relays, - g_cfg->relay_count, - admin_filter, - on_self_skill_event, - on_eose, - NULL, - 0, - 0, - NOSTR_POOL_EOSE_FULL_SET, - 30, - 120); - - cJSON_Delete(admin_filter); - if (!admin_skill_sub) { - rc = -1; - } } + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + if (managed_subscription_register_locked(MANAGED_SUB_ADMIN_SKILLS, + admin_filter, + on_self_skill_event, + on_eose, + NULL, + 0, + 0, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120, + admin_enabled) != 0) { + rc = -1; + } + pthread_mutex_unlock(&g_subscription_mutex); + cJSON_Delete(admin_filter); + if (rc == 0) { DEBUG_INFO("[didactyl] self/admin skill subscriptions active (self=%.16s..., admin=%.16s...)", g_cfg->keys.public_key_hex, @@ -2050,12 +2471,16 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) { return -1; } - int subscribed_any = 0; + int rc = 0; + cJSON* filter4 = NULL; if (need_kind4) { - cJSON* filter4 = cJSON_CreateObject(); - cJSON* kinds4 = cJSON_CreateArray(); - cJSON* p_values4 = cJSON_CreateArray(); + cJSON* kinds4 = NULL; + cJSON* p_values4 = NULL; + + filter4 = cJSON_CreateObject(); + kinds4 = cJSON_CreateArray(); + p_values4 = cJSON_CreateArray(); if (!filter4 || !kinds4 || !p_values4) { cJSON_Delete(filter4); cJSON_Delete(kinds4); @@ -2069,44 +2494,18 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) { cJSON_AddItemToObject(filter4, "#p", p_values4); cJSON_AddNumberToObject(filter4, "since", (double)g_start_time); cJSON_AddNumberToObject(filter4, "limit", 100); - - { - char* filter_str = cJSON_PrintUnformatted(filter4); - DEBUG_TRACE("[didactyl] DEBUG DM subscription filter kind4: %s", filter_str ? filter_str : ""); - free(filter_str); - } - - nostr_pool_subscription_t* sub4 = nostr_relay_pool_subscribe( - g_pool, - (const char**)g_cfg->relays, - g_cfg->relay_count, - filter4, - on_event, - on_eose, - NULL, - 0, - 0, - NOSTR_POOL_EOSE_FULL_SET, - 30, - 120); - - cJSON_Delete(filter4); - if (!sub4) { - fprintf(stderr, "[didactyl] kind4 DM subscription failed\n"); - return -1; - } - - subscribed_any = 1; - DEBUG_TRACE("[didactyl] DEBUG kind4 DM subscription sub=%p", (void*)sub4); } + cJSON* filter1059 = NULL; if (need_kind1059) { - time_t since_1059 = g_start_time; + cJSON* kinds1059 = NULL; + cJSON* p_values1059 = NULL; - cJSON* filter1059 = cJSON_CreateObject(); - cJSON* kinds1059 = cJSON_CreateArray(); - cJSON* p_values1059 = cJSON_CreateArray(); + filter1059 = cJSON_CreateObject(); + kinds1059 = cJSON_CreateArray(); + p_values1059 = cJSON_CreateArray(); if (!filter1059 || !kinds1059 || !p_values1059) { + cJSON_Delete(filter4); cJSON_Delete(filter1059); cJSON_Delete(kinds1059); cJSON_Delete(p_values1059); @@ -2117,41 +2516,46 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) { cJSON_AddItemToObject(filter1059, "kinds", kinds1059); cJSON_AddItemToArray(p_values1059, cJSON_CreateString(g_cfg->keys.public_key_hex)); cJSON_AddItemToObject(filter1059, "#p", p_values1059); - cJSON_AddNumberToObject(filter1059, "since", (double)since_1059); + cJSON_AddNumberToObject(filter1059, "since", (double)g_start_time); cJSON_AddNumberToObject(filter1059, "limit", 400); - - { - char* filter_str = cJSON_PrintUnformatted(filter1059); - DEBUG_TRACE("[didactyl] DEBUG DM subscription filter kind1059: %s", filter_str ? filter_str : ""); - DEBUG_TRACE("[didactyl] DEBUG kind1059 since=%ld start=%ld", (long)since_1059, (long)g_start_time); - free(filter_str); - } - - nostr_pool_subscription_t* sub1059 = nostr_relay_pool_subscribe( - g_pool, - (const char**)g_cfg->relays, - g_cfg->relay_count, - filter1059, - on_event, - on_eose, - NULL, - 0, - 0, - NOSTR_POOL_EOSE_FULL_SET, - 30, - 120); - - cJSON_Delete(filter1059); - if (!sub1059) { - fprintf(stderr, "[didactyl] kind1059 DM subscription failed\n"); - return -1; - } - - subscribed_any = 1; - DEBUG_TRACE("[didactyl] DEBUG kind1059 DM subscription sub=%p", (void*)sub1059); } - if (!subscribed_any) { + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + + if (managed_subscription_register_locked(MANAGED_SUB_DM_KIND4, + need_kind4 ? filter4 : NULL, + on_event, + on_eose, + NULL, + 0, + 0, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120, + need_kind4) != 0) { + rc = -1; + } + + if (managed_subscription_register_locked(MANAGED_SUB_DM_KIND1059, + need_kind1059 ? filter1059 : NULL, + on_event, + on_eose, + NULL, + 0, + 0, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120, + need_kind1059) != 0) { + rc = -1; + } + + pthread_mutex_unlock(&g_subscription_mutex); + cJSON_Delete(filter4); + cJSON_Delete(filter1059); + + if (rc != 0) { return -1; } @@ -2553,7 +2957,12 @@ int nostr_handler_reconcile_startup_events(void) { } if (!g_system_context) { - g_system_context = strdup("You are Didactyl, a sovereign AI agent living on Nostr."); + char default_ctx[512]; + snprintf(default_ctx, + sizeof(default_ctx), + "You are %s, a sovereign AI agent living on Nostr.", + g_startup_display_name[0] != '\0' ? g_startup_display_name : "the agent"); + g_system_context = strdup(default_ctx); } for (int relay_index = 0; relay_index < g_cfg->relay_count; relay_index++) { @@ -2748,6 +3157,33 @@ char* nostr_handler_get_self_skill_events_json(void) { return json ? json : strdup("[]"); } +int nostr_handler_is_event_in_self_cache(const char* event_id_hex) { + if (!event_id_hex || strlen(event_id_hex) != 64U) { + return 0; + } + + int found = 0; + + pthread_mutex_lock(&g_self_skill_mutex); + if (g_self_skill_events && cJSON_IsArray(g_self_skill_events)) { + int n = cJSON_GetArraySize(g_self_skill_events); + for (int i = 0; i < n; i++) { + cJSON* ev = cJSON_GetArrayItem(g_self_skill_events, i); + cJSON* id = ev ? cJSON_GetObjectItemCaseSensitive(ev, "id") : NULL; + if (!id || !cJSON_IsString(id) || !id->valuestring) { + continue; + } + if (strcmp(id->valuestring, event_id_hex) == 0) { + found = 1; + break; + } + } + } + pthread_mutex_unlock(&g_self_skill_mutex); + + return found; +} + const char* nostr_handler_get_startup_display_name(void) { return g_startup_display_name; } @@ -2986,6 +3422,94 @@ char* nostr_handler_relay_info_json(const char* relay_url) { return out; } +char* nostr_handler_subscription_status_json(void) { + cJSON* root = cJSON_CreateObject(); + cJSON* subs = cJSON_CreateArray(); + if (!root || !subs) { + cJSON_Delete(root); + cJSON_Delete(subs); + return NULL; + } + + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + + for (int i = 0; i < MANAGED_SUB_COUNT; i++) { + managed_subscription_t* sub = &g_managed_subs[i]; + + cJSON* item = cJSON_CreateObject(); + if (!item) { + continue; + } + + cJSON_AddStringToObject(item, "name", sub->name ? sub->name : ""); + cJSON_AddBoolToObject(item, "enabled", sub->enabled ? 1 : 0); + cJSON_AddBoolToObject(item, "active", sub->handle ? 1 : 0); + cJSON_AddBoolToObject(item, "has_filter", sub->filter ? 1 : 0); + cJSON_AddNumberToObject(item, "close_on_eose", sub->close_on_eose); + cJSON_AddNumberToObject(item, "enable_deduplication", sub->enable_deduplication); + cJSON_AddNumberToObject(item, "relay_timeout_seconds", sub->relay_timeout_seconds); + cJSON_AddNumberToObject(item, "eose_timeout_seconds", sub->eose_timeout_seconds); + + if (sub->filter) { + cJSON* filter_dup = cJSON_Duplicate(sub->filter, 1); + if (filter_dup) { + cJSON_AddItemToObject(item, "filter", filter_dup); + } + } + + cJSON_AddItemToArray(subs, item); + } + + pthread_mutex_unlock(&g_subscription_mutex); + + cJSON_AddNumberToObject(root, "count", MANAGED_SUB_COUNT); + cJSON_AddItemToObject(root, "subscriptions", subs); + + char* out = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + return out; +} + +int nostr_handler_subscription_set_json(const char* name, cJSON* filter, int enabled, int enabled_set) { + if (!name || name[0] == '\0') { + return -1; + } + + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + + managed_subscription_id_t id = MANAGED_SUB_COUNT; + if (managed_subscription_id_from_name(name, &id) != 0) { + pthread_mutex_unlock(&g_subscription_mutex); + return -1; + } + + managed_subscription_t* sub = &g_managed_subs[id]; + + cJSON* filter_to_use = sub->filter; + if (filter) { + filter_to_use = filter; + } + + int enabled_to_use = enabled_set ? (enabled ? 1 : 0) : sub->enabled; + + int rc = managed_subscription_register_locked(id, + filter_to_use, + sub->on_event, + sub->on_eose, + sub->user_data, + sub->close_on_eose, + sub->enable_deduplication, + sub->result_mode, + sub->relay_timeout_seconds, + sub->eose_timeout_seconds, + enabled_to_use); + + pthread_mutex_unlock(&g_subscription_mutex); + return rc; +} + int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject) { if (!g_cfg || !g_pool || !recipient_pubkey_hex || !message || recipient_pubkey_hex[0] == '\0' || message[0] == '\0') { return -1; @@ -3368,6 +3892,19 @@ int nostr_handler_poll(int timeout_ms) { } void nostr_handler_cleanup(void) { + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + for (int i = 0; i < MANAGED_SUB_COUNT; i++) { + (void)managed_subscription_close_locked((managed_subscription_id_t)i); + cJSON_Delete(g_managed_subs[i].filter); + g_managed_subs[i].filter = NULL; + g_managed_subs[i].enabled = 0; + g_managed_subs[i].on_event = NULL; + g_managed_subs[i].on_eose = NULL; + g_managed_subs[i].user_data = NULL; + } + pthread_mutex_unlock(&g_subscription_mutex); + if (g_pool) { nostr_relay_pool_destroy(g_pool); } diff --git a/src/nostr_handler.h b/src/nostr_handler.h index ef48595..5d89562 100644 --- a/src/nostr_handler.h +++ b/src/nostr_handler.h @@ -63,6 +63,7 @@ void nostr_handler_refresh_relay_statuses(void); int nostr_handler_sync_relays_from_config(void); const char* nostr_handler_get_system_context(void); char* nostr_handler_get_self_skill_events_json(void); +int nostr_handler_is_event_in_self_cache(const char* event_id_hex); const char* nostr_handler_get_startup_display_name(void); int nostr_handler_connected_relay_count(void); char* nostr_handler_get_admin_kind0_context(void); @@ -76,6 +77,8 @@ char* nostr_handler_get_agent_kind1_notes_context(void); int nostr_handler_is_wot_contact(const char* pubkey_hex); char* nostr_handler_relay_status_json(void); char* nostr_handler_relay_info_json(const char* relay_url); +char* nostr_handler_subscription_status_json(void); +int nostr_handler_subscription_set_json(const char* name, cJSON* filter, int enabled, int enabled_set); int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject); char* nostr_handler_get_dm_history_json(const char* peer_pubkey_hex, int limit); void nostr_handler_cleanup(void); diff --git a/src/setup_wizard.c b/src/setup_wizard.c index a41deef..31324a2 100644 --- a/src/setup_wizard.c +++ b/src/setup_wizard.c @@ -11,11 +11,16 @@ #include #include #include +#include +#include +#include +#include #include "cjson/cJSON.h" #include "llm.h" #include "main.h" #include "nostr_handler.h" +#include "default_events.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h" #define ANSI_UNDERLINE "\x1b[4m" @@ -321,20 +326,15 @@ static void free_relays_only(didactyl_config_t* cfg) { static int set_default_relays(didactyl_config_t* cfg) { if (!cfg) return -1; - static const char* defaults[] = { - "wss://relay.damus.io", - "wss://nos.lol", - "wss://relay.primal.net" - }; free_relays_only(cfg); - cfg->relay_count = (int)(sizeof(defaults) / sizeof(defaults[0])); + cfg->relay_count = DIDACTYL_DEFAULT_RELAY_COUNT; cfg->relays = (char**)calloc((size_t)cfg->relay_count, sizeof(char*)); if (!cfg->relays) { cfg->relay_count = 0; return -1; } for (int i = 0; i < cfg->relay_count; i++) { - cfg->relays[i] = strdup(defaults[i]); + cfg->relays[i] = strdup(DIDACTYL_DEFAULT_RELAYS[i]); if (!cfg->relays[i]) { free_relays_only(cfg); return -1; @@ -369,131 +369,161 @@ static int build_kind10002_tags_json(const didactyl_config_t* cfg, char** out_ta return 0; } -static int sync_startup_kind0_event(didactyl_config_t* cfg, const char* name) { - if (!cfg || !name || name[0] == '\0') return -1; - - cJSON* profile = cJSON_CreateObject(); - if (!profile) return -1; - cJSON_AddStringToObject(profile, "name", name); - cJSON_AddStringToObject(profile, "display_name", name); - - char* content_json = cJSON_PrintUnformatted(profile); - cJSON_Delete(profile); - if (!content_json) return -1; - - if (!cfg->startup_events || cfg->startup_event_count <= 0) { - cfg->startup_events = (startup_event_t*)calloc(1U, sizeof(startup_event_t)); - if (!cfg->startup_events) { - free(content_json); - return -1; - } - cfg->startup_event_count = 1; - cfg->startup_events[0].kind = 0; - cfg->startup_events[0].content = content_json; - cfg->startup_events[0].tags_json = strdup("[]"); - if (!cfg->startup_events[0].tags_json) { - free(cfg->startup_events[0].content); - free(cfg->startup_events); - cfg->startup_events = NULL; - cfg->startup_event_count = 0; - return -1; - } - return 0; +static int upsert_startup_event(didactyl_config_t* cfg, int kind, const char* content, const char* tags_json) { + if (!cfg || !content || !tags_json) { + return -1; } for (int i = 0; i < cfg->startup_event_count; i++) { - if (cfg->startup_events[i].kind == 0) { - free(cfg->startup_events[i].content); - cfg->startup_events[i].content = content_json; - if (!cfg->startup_events[i].tags_json) { - cfg->startup_events[i].tags_json = strdup("[]"); - if (!cfg->startup_events[i].tags_json) { - return -1; - } - } - return 0; + startup_event_t* se = &cfg->startup_events[i]; + if (se->kind != kind) { + continue; } + + char* content_dup = strdup(content); + char* tags_dup = strdup(tags_json); + if (!content_dup || !tags_dup) { + free(content_dup); + free(tags_dup); + return -1; + } + + free(se->content); + free(se->tags_json); + se->content = content_dup; + se->tags_json = tags_dup; + return 0; } startup_event_t* grown = (startup_event_t*)realloc(cfg->startup_events, (size_t)(cfg->startup_event_count + 1) * sizeof(startup_event_t)); if (!grown) { - free(content_json); + return -1; + } + cfg->startup_events = grown; + + startup_event_t* se = &cfg->startup_events[cfg->startup_event_count]; + memset(se, 0, sizeof(*se)); + se->kind = kind; + se->content = strdup(content); + se->tags_json = strdup(tags_json); + if (!se->content || !se->tags_json) { + free(se->content); + free(se->tags_json); + se->content = NULL; + se->tags_json = NULL; return -1; } - cfg->startup_events = grown; - startup_event_t* se = &cfg->startup_events[cfg->startup_event_count]; - memset(se, 0, sizeof(*se)); - se->kind = 0; - se->content = content_json; - se->tags_json = strdup("[]"); - if (!se->tags_json) { - free(se->content); - se->content = NULL; - return -1; - } cfg->startup_event_count++; return 0; } -static int sync_startup_kind10002_event(didactyl_config_t* cfg) { - if (!cfg) return -1; - - char* tags_json = NULL; - if (build_kind10002_tags_json(cfg, &tags_json) != 0) { +static int apply_default_startup_events(didactyl_config_t* cfg, const char* agent_name) { + if (!cfg || !agent_name || agent_name[0] == '\0' || cfg->admin.pubkey[0] == '\0') { return -1; } - if (!cfg->startup_events || cfg->startup_event_count <= 0) { - cfg->startup_events = (startup_event_t*)calloc(1U, sizeof(startup_event_t)); - if (!cfg->startup_events) { - free(tags_json); - return -1; - } - cfg->startup_event_count = 1; - cfg->startup_events[0].kind = 10002; - cfg->startup_events[0].content = strdup(""); - cfg->startup_events[0].tags_json = tags_json; - if (!cfg->startup_events[0].content) { - free(cfg->startup_events[0].tags_json); - free(cfg->startup_events); - cfg->startup_events = NULL; - cfg->startup_event_count = 0; - return -1; - } - return 0; - } + for (int i = 0; i < DIDACTYL_DEFAULT_STARTUP_EVENT_KIND_COUNT; i++) { + int kind = DIDACTYL_DEFAULT_STARTUP_EVENT_KINDS[i]; - for (int i = 0; i < cfg->startup_event_count; i++) { - if (cfg->startup_events[i].kind == 10002) { - free(cfg->startup_events[i].tags_json); - cfg->startup_events[i].tags_json = tags_json; - if (!cfg->startup_events[i].content) { - cfg->startup_events[i].content = strdup(""); + if (kind == DIDACTYL_STARTUP_KIND_PROFILE) { + int needed = snprintf(NULL, + 0, + DIDACTYL_DEFAULT_KIND0_PROFILE_JSON_TEMPLATE, + agent_name, + agent_name); + if (needed <= 0) { + return -1; } - return 0; + char* content_json = (char*)malloc((size_t)needed + 1U); + if (!content_json) { + return -1; + } + snprintf(content_json, + (size_t)needed + 1U, + DIDACTYL_DEFAULT_KIND0_PROFILE_JSON_TEMPLATE, + agent_name, + agent_name); + + int rc = upsert_startup_event(cfg, kind, content_json, "[]"); + free(content_json); + if (rc != 0) { + return -1; + } + continue; + } + + if (kind == DIDACTYL_STARTUP_KIND_CONTACTS) { + int needed = snprintf(NULL, 0, DIDACTYL_DEFAULT_KIND3_TAGS_JSON_TEMPLATE, cfg->admin.pubkey); + if (needed <= 0) { + return -1; + } + char* tags_json = (char*)malloc((size_t)needed + 1U); + if (!tags_json) { + return -1; + } + snprintf(tags_json, (size_t)needed + 1U, DIDACTYL_DEFAULT_KIND3_TAGS_JSON_TEMPLATE, cfg->admin.pubkey); + + int rc = upsert_startup_event(cfg, kind, DIDACTYL_DEFAULT_KIND3_CONTENT, tags_json); + free(tags_json); + if (rc != 0) { + return -1; + } + continue; + } + + if (kind == DIDACTYL_STARTUP_KIND_RELAYS) { + char* tags_json = NULL; + if (build_kind10002_tags_json(cfg, &tags_json) != 0 || !tags_json) { + free(tags_json); + return -1; + } + + int rc = upsert_startup_event(cfg, kind, DIDACTYL_DEFAULT_KIND10002_CONTENT, tags_json); + free(tags_json); + if (rc != 0) { + return -1; + } + continue; } } - startup_event_t* grown = (startup_event_t*)realloc(cfg->startup_events, - (size_t)(cfg->startup_event_count + 1) * sizeof(startup_event_t)); - if (!grown) { - free(tags_json); + return 0; +} + +static int configure_default_skill_for_agent(didactyl_config_t* cfg, const char* agent_name) { + if (!cfg || !agent_name || agent_name[0] == '\0') { return -1; } - cfg->startup_events = grown; - startup_event_t* se = &cfg->startup_events[cfg->startup_event_count]; - memset(se, 0, sizeof(*se)); - se->kind = 10002; - se->content = strdup(""); - se->tags_json = tags_json; - if (!se->content) { - free(se->tags_json); - se->tags_json = NULL; + + free(cfg->default_skill.content); + cfg->default_skill.content = NULL; + free(cfg->default_skill.tags_json); + cfg->default_skill.tags_json = NULL; + + cfg->default_skill.kind = DIDACTYL_DEFAULT_SKILL_KIND; + snprintf(cfg->default_skill.d_tag, sizeof(cfg->default_skill.d_tag), "%s", DIDACTYL_DEFAULT_SKILL_D_TAG); + + int needed = snprintf(NULL, 0, DIDACTYL_DEFAULT_SKILL_TEMPLATE, agent_name, agent_name); + if (needed <= 0) { return -1; } - cfg->startup_event_count++; + + cfg->default_skill.content = (char*)malloc((size_t)needed + 1U); + if (!cfg->default_skill.content) { + return -1; + } + + snprintf(cfg->default_skill.content, (size_t)needed + 1U, DIDACTYL_DEFAULT_SKILL_TEMPLATE, agent_name, agent_name); + + cfg->default_skill.tags_json = strdup(DIDACTYL_DEFAULT_SKILL_TAGS_JSON); + if (!cfg->default_skill.tags_json) { + free(cfg->default_skill.content); + cfg->default_skill.content = NULL; + return -1; + } + return 0; } @@ -1010,7 +1040,7 @@ static int prompt_model_selection(const cJSON* models, static int prompt_admin_pubkey(didactyl_config_t* cfg) { char input[WIZARD_LINE_MAX]; for (;;) { - render_wizard_page_header("Step 2 of 7", "New Agent Setup -- Administrator"); + render_wizard_page_header("Step 3 of 7", "New Agent Setup -- Administrator"); if (read_line_prompt(" Enter the admin's Nostr public key (npub1... or hex):\n> ", input, sizeof(input)) != 0) { return -1; } @@ -1028,7 +1058,7 @@ static int prompt_admin_pubkey(didactyl_config_t* cfg) { static int prompt_llm_config(didactyl_config_t* cfg) { for (;;) { - render_wizard_page_header("Step 4 of 7", "New Agent Setup -- LLM Provider"); + render_wizard_page_header("Step 5 of 7", "New Agent Setup -- LLM Provider"); print_option('1', " ppq.ai"); print_option('2', " Routstr"); print_option('3', " OpenAI-compatible"); @@ -1119,25 +1149,7 @@ static int prompt_llm_config(didactyl_config_t* cfg) { cJSON_Delete(models); int max_tokens = cfg->llm.max_tokens > 0 ? cfg->llm.max_tokens : 512; - char max_tokens_s[32] = {0}; - char prompt[256]; - snprintf(prompt, sizeof(prompt), " Max Tokens [%d]: ", max_tokens); - if (read_line_prompt(prompt, max_tokens_s, sizeof(max_tokens_s)) != 0) return -1; - if (line_is_quit(max_tokens_s)) return -1; - if (max_tokens_s[0] != '\0') { - int mt = atoi(max_tokens_s); - if (mt > 0) max_tokens = mt; - } - double temperature = cfg->llm.temperature > 0.0 ? cfg->llm.temperature : 0.7; - char temperature_s[32] = {0}; - snprintf(prompt, sizeof(prompt), " Temperature [%.1f]: ", temperature); - if (read_line_prompt(prompt, temperature_s, sizeof(temperature_s)) != 0) return -1; - if (line_is_quit(temperature_s)) return -1; - if (temperature_s[0] != '\0') { - double t = atof(temperature_s); - if (t >= 0.0 && t <= 2.0) temperature = t; - } snprintf(cfg->llm.provider, sizeof(cfg->llm.provider), "%s", provider); snprintf(cfg->llm.base_url, sizeof(cfg->llm.base_url), "%s", base_url); @@ -1171,7 +1183,7 @@ static int prompt_relay_configuration(didactyl_config_t* cfg) { char input[WIZARD_LINE_MAX]; for (;;) { - render_wizard_page_header("Step 5 of 7", "New Agent Setup -- Relay Configuration"); + render_wizard_page_header("Step 6 of 7", "New Agent Setup -- Relay Configuration"); fprintf(stderr, " Current relays:\n"); for (int i = 0; i < cfg->relay_count; i++) { fprintf(stderr, " %d. %s\n", i + 1, cfg->relays[i] ? cfg->relays[i] : ""); @@ -1223,53 +1235,308 @@ static int prompt_relay_configuration(didactyl_config_t* cfg) { } } -static int write_genesis_file(const didactyl_config_t* cfg, const char* path, int include_nsec) { - if (!cfg || !path || path[0] == '\0') return -1; +static int ensure_dir_exists_local(const char* path) { + if (!path || path[0] == '\0') return -1; + if (mkdir(path, 0755) == 0 || errno == EEXIST) return 0; + return -1; +} - FILE* fp = fopen(path, "w"); - if (!fp) return -1; - - fprintf(fp, "{\n"); - fprintf(fp, " \"key\": {\n"); - if (include_nsec) { - fprintf(fp, " \"nsec\": \"%s\"\n", cfg->keys.nsec); +static int run_command_local(char* const argv[]) { + pid_t pid = fork(); + if (pid < 0) return -1; + if (pid == 0) { + execvp(argv[0], argv); + _exit(127); } - fprintf(fp, " },\n"); - fprintf(fp, " \"admin\": {\n"); - fprintf(fp, " \"pubkey\": \"%s\"\n", cfg->admin.pubkey); - fprintf(fp, " },\n"); - fprintf(fp, " \"dm_protocol\": \"nip04\",\n"); - fprintf(fp, " \"llm\": {\n"); - fprintf(fp, " \"provider\": \"%s\",\n", cfg->llm.provider[0] ? cfg->llm.provider : "openai"); - fprintf(fp, " \"api_key\": \"%s\",\n", cfg->llm.api_key); - fprintf(fp, " \"model\": \"%s\",\n", cfg->llm.model); - fprintf(fp, " \"base_url\": \"%s\",\n", cfg->llm.base_url); - fprintf(fp, " \"max_tokens\": %d,\n", cfg->llm.max_tokens > 0 ? cfg->llm.max_tokens : 512); - fprintf(fp, " \"temperature\": %.2f\n", cfg->llm.temperature > 0.0 ? cfg->llm.temperature : 0.7); - fprintf(fp, " },\n"); - fprintf(fp, " \"api\": {\n"); - fprintf(fp, " \"enabled\": true,\n"); - fprintf(fp, " \"port\": %d,\n", cfg->api.port > 0 ? cfg->api.port : 8484); - fprintf(fp, " \"bind_address\": \"%s\"\n", cfg->api.bind_address[0] ? cfg->api.bind_address : "127.0.0.1"); - fprintf(fp, " },\n"); - fprintf(fp, " \"startup_events\": [\n"); - fprintf(fp, " {\n"); - fprintf(fp, " \"kind\": 10002,\n"); - fprintf(fp, " \"content\": \"\",\n"); - fprintf(fp, " \"tags\": [\n"); - for (int i = 0; i < cfg->relay_count; i++) { - fprintf(fp, " [\"r\", \"%s\"]%s\n", cfg->relays[i], (i + 1 < cfg->relay_count) ? "," : ""); - } - fprintf(fp, " ]\n"); - fprintf(fp, " }\n"); - fprintf(fp, " ]\n"); - fprintf(fp, "}\n"); - fclose(fp); + int status = 0; + if (waitpid(pid, &status, 0) < 0) return -1; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return -1; return 0; } -static int maybe_prompt_write_path(char* path_out, size_t path_out_size) { +static int copy_file_local(const char* src, const char* dst) { + if (!src || !dst || src[0] == '\0' || dst[0] == '\0') return -1; + + FILE* in = fopen(src, "rb"); + if (!in) return -1; + + FILE* out = fopen(dst, "wb"); + if (!out) { + fclose(in); + return -1; + } + + char buf[16384]; + size_t n = 0; + while ((n = fread(buf, 1, sizeof(buf), in)) > 0) { + if (fwrite(buf, 1, n, out) != n) { + fclose(in); + fclose(out); + return -1; + } + } + + fclose(in); + fclose(out); + return 0; +} + +static void sanitize_service_user_from_name(const char* name, char* out, size_t out_size) { + if (!out || out_size == 0) return; + out[0] = '\0'; + + const char* src = (name && name[0] != '\0') ? name : "didactyl"; + size_t j = 0; + for (size_t i = 0; src[i] != '\0' && j + 1 < out_size; i++) { + char c = (char)tolower((unsigned char)src[i]); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + out[j++] = c; + } else if ((c == ' ' || c == '_' || c == '-') && j > 0 && out[j - 1] != '-') { + out[j++] = '-'; + } + if (j >= 31) break; + } + + while (j > 0 && out[j - 1] == '-') j--; + out[j] = '\0'; + + if (out[0] == '\0' || !(out[0] >= 'a' && out[0] <= 'z')) { + snprintf(out, out_size, "didactyl"); + } +} + + +static const char* detect_ca_bundle_path_local(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", + "/etc/ssl/cert.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem" + }; + 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 int write_sudoers_file(const char* service_user) { + if (!service_user || service_user[0] == '\0') return -1; + + char sudoers_path[PATH_MAX] = {0}; + snprintf(sudoers_path, sizeof(sudoers_path), "/etc/sudoers.d/%s", service_user); + + FILE* fp = fopen(sudoers_path, "w"); + if (!fp) { + fprintf(stderr, "%sFailed to write sudoers file at %s.%s\n", ANSI_RED, sudoers_path, ANSI_RESET); + return -1; + } + + fprintf(fp, + "# Didactyl agent — scoped system maintenance privileges\n" + "# Generated by didactyl setup wizard\n" + "\n" + "# Service management\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl status *\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart *\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl start *\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop *\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable *\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl disable *\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl is-active *\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl is-enabled *\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl list-units *\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl daemon-reload\n" + "\n" + "# Log inspection\n" + "%s ALL=(ALL) NOPASSWD: /usr/bin/journalctl *\n", + service_user, service_user, service_user, service_user, + service_user, service_user, service_user, service_user, + service_user, service_user, service_user); + fclose(fp); + + if (chmod(sudoers_path, 0440) != 0) { + fprintf(stderr, "%sFailed to chmod sudoers file at %s.%s\n", ANSI_RED, sudoers_path, ANSI_RESET); + return -1; + } + + return 0; +} + +static int install_system_service_with_dedicated_user(const didactyl_config_t* cfg, const char* agent_name) { + if (!cfg || cfg->keys.nsec[0] == '\0') return -1; + + if (geteuid() != 0) { + fprintf(stderr, "%sSystem install requires root (run with sudo).%s\n", ANSI_RED, ANSI_RESET); + return -1; + } + + /* --- Derive service username from agent name --- */ + char service_user[64] = {0}; + sanitize_service_user_from_name(agent_name, service_user, sizeof(service_user)); + + /* --- Create dedicated user if it doesn't exist --- */ + struct passwd* pw = getpwnam(service_user); + if (!pw) { + fprintf(stderr, " Creating system user '%s'...\n", service_user); + char comment[128] = {0}; + snprintf(comment, sizeof(comment), "Didactyl Nostr Agent (%s)", agent_name); + char* useradd_argv[] = {"useradd", "-m", "-s", "/bin/bash", "-c", comment, service_user, NULL}; + if (run_command_local(useradd_argv) != 0) { + fprintf(stderr, "%sFailed to create system user '%s'.%s\n", ANSI_RED, service_user, ANSI_RESET); + return -1; + } + pw = getpwnam(service_user); + if (!pw) { + fprintf(stderr, "%sCreated user but failed to resolve passwd entry.%s\n", ANSI_RED, ANSI_RESET); + return -1; + } + } else { + fprintf(stderr, " User '%s' already exists, reusing.\n", service_user); + } + + const char* home = (pw->pw_dir && pw->pw_dir[0] != '\0') ? pw->pw_dir : NULL; + if (!home) { + fprintf(stderr, "%sUnable to determine service user home directory.%s\n", ANSI_RED, ANSI_RESET); + return -1; + } + + if (ensure_dir_exists_local(home) != 0) { + fprintf(stderr, "%sUnable to create/access home directory: %s%s\n", ANSI_RED, home, ANSI_RESET); + return -1; + } + + /* --- Copy binary --- */ + char self_path[PATH_MAX] = {0}; + ssize_t self_len = readlink("/proc/self/exe", self_path, sizeof(self_path) - 1); + if (self_len <= 0) { + fprintf(stderr, "%sUnable to resolve running binary path.%s\n", ANSI_RED, ANSI_RESET); + return -1; + } + self_path[self_len] = '\0'; + + char bin_path[PATH_MAX] = {0}; + snprintf(bin_path, sizeof(bin_path), "%s/didactyl", home); + fprintf(stderr, " Copying binary to %s...\n", bin_path); + if (copy_file_local(self_path, bin_path) != 0) { + fprintf(stderr, "%sFailed to copy binary to %s.%s\n", ANSI_RED, bin_path, ANSI_RESET); + return -1; + } + if (chmod(bin_path, 0755) != 0) { + fprintf(stderr, "%sFailed to chmod binary at %s.%s\n", ANSI_RED, bin_path, ANSI_RESET); + return -1; + } + + /* --- Set ownership on home directory and installed binary --- */ + if (chown(home, pw->pw_uid, pw->pw_gid) != 0 || + chmod(home, 0750) != 0 || + chown(bin_path, pw->pw_uid, pw->pw_gid) != 0) { + fprintf(stderr, "%sFailed to set ownership/permissions for installed files.%s\n", ANSI_RED, ANSI_RESET); + return -1; + } + + /* --- Write sudoers file for scoped sudo privileges --- */ + fprintf(stderr, " Writing sudoers file for '%s'...\n", service_user); + if (write_sudoers_file(service_user) != 0) { + fprintf(stderr, "%sWarning: Failed to write sudoers file. Agent will not have sudo access.%s\n", + ANSI_YELLOW, ANSI_RESET); + /* Non-fatal — agent can still run without sudo */ + } + + /* --- Detect CA bundle for TLS --- */ + const char* ca_bundle = detect_ca_bundle_path_local(); + + /* --- Write systemd unit file --- */ + char service_name[96] = {0}; + snprintf(service_name, sizeof(service_name), "%s.service", service_user); + + char service_path[PATH_MAX] = {0}; + snprintf(service_path, sizeof(service_path), "/etc/systemd/system/%s", service_name); + fprintf(stderr, " Writing systemd unit to %s...\n", service_path); + FILE* svc_fp = fopen(service_path, "w"); + if (!svc_fp) { + fprintf(stderr, "%sFailed to write service file at %s.%s\n", ANSI_RED, service_path, ANSI_RESET); + return -1; + } + + fprintf(svc_fp, + "[Unit]\n" + "Description=Didactyl Nostr Agent (%s)\n" + "After=network-online.target\n" + "Wants=network-online.target\n\n" + "[Service]\n" + "Type=simple\n" + "User=%s\n" + "Group=%s\n" + "WorkingDirectory=%s\n" + "ExecStart=%s --nsec %s --debug 3\n", + service_user, + service_user, + service_user, + home, + bin_path, + cfg->keys.nsec); + + if (ca_bundle) { + fprintf(svc_fp, "Environment=SSL_CERT_FILE=%s\n", ca_bundle); + } + + fprintf(svc_fp, + "SyslogIdentifier=%s\n" + "NoNewPrivileges=false\n" + "ProtectSystem=strict\n" + "ReadWritePaths=%s\n" + "ProtectHome=false\n" + "PrivateTmp=yes\n" + "Restart=on-failure\n" + "RestartSec=10\n" + "StandardOutput=journal\n" + "StandardError=journal\n\n" + "[Install]\n" + "WantedBy=multi-user.target\n", + service_user, + home); + fclose(svc_fp); + + /* --- Enable and start the service --- */ + fprintf(stderr, " Enabling and starting %s...\n", service_name); + char* reload_argv[] = {"systemctl", "daemon-reload", NULL}; + char* enable_argv[] = {"systemctl", "enable", service_name, NULL}; + char* start_argv[] = {"systemctl", "start", service_name, NULL}; + + if (run_command_local(reload_argv) != 0 || + run_command_local(enable_argv) != 0 || + run_command_local(start_argv) != 0) { + fprintf(stderr, "%sFailed to enable/start systemd service %s.%s\n", ANSI_RED, service_name, ANSI_RESET); + fprintf(stderr, " You can manually start with:\n"); + fprintf(stderr, " sudo systemctl daemon-reload\n"); + fprintf(stderr, " sudo systemctl enable %s\n", service_name); + fprintf(stderr, " sudo systemctl start %s\n", service_name); + return -1; + } + + fprintf(stderr, "\n%sSystem service installed and started.%s\n", ANSI_YELLOW, ANSI_RESET); + fprintf(stderr, " Service: %s\n", service_name); + fprintf(stderr, " User: %s\n", service_user); + fprintf(stderr, " Home: %s\n", home); + fprintf(stderr, " Binary: %s\n", bin_path); + fprintf(stderr, " Nsec: embedded directly in ExecStart\n"); + fprintf(stderr, " Sudoers: /etc/sudoers.d/%s\n", service_user); + fprintf(stderr, " Unit: %s\n", service_path); + fprintf(stderr, "\n Useful commands:\n"); + fprintf(stderr, " sudo systemctl status %s\n", service_name); + fprintf(stderr, " sudo journalctl -u %s -f\n", service_name); + fprintf(stderr, " sudo systemctl restart %s\n", service_name); + return 0; +} + +static __attribute__((unused)) int maybe_prompt_write_path(char* path_out, size_t path_out_size) { if (!path_out || path_out_size == 0) return -1; char input[WIZARD_LINE_MAX]; if (read_line_prompt("Path [./genesis.jsonc]: ", input, sizeof(input)) != 0) return -1; @@ -1298,7 +1565,7 @@ static int maybe_prompt_write_path(char* path_out, size_t path_out_size) { static int new_agent_identity_step(didactyl_config_t* cfg) { for (;;) { - render_wizard_page_header("Step 1 of 7", "New Agent Setup -- Identity"); + render_wizard_page_header("Step 2 of 7", "New Agent Setup -- Identity"); print_option('g', "enerate a new Nostr keypair"); print_option('p', "rovide an existing nsec"); print_option('b', "ack"); @@ -1359,6 +1626,16 @@ static int new_agent_identity_step(didactyl_config_t* cfg) { } static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t genesis_path_out_size) { + (void)genesis_path_out; + (void)genesis_path_out_size; + char agent_name[OW_MAX_NAME_LEN] = {0}; + render_wizard_page_header("Step 1 of 7", "New Agent Setup -- Agent Profile"); + if (read_line_prompt(" Agent name [Didactyl]: ", agent_name, sizeof(agent_name)) != 0) return -1; + if (line_is_quit(agent_name)) return -1; + if (agent_name[0] == '\0') { + snprintf(agent_name, sizeof(agent_name), "%s", "Didactyl"); + } + int rc = new_agent_identity_step(cfg); if (rc != 0) return rc < 0 ? -1 : 1; @@ -1372,7 +1649,7 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t ANSI_YELLOW, ANSI_RESET); } else { - render_wizard_page_header("Step 3 of 7", "New Agent Setup -- Identity Validation"); + render_wizard_page_header("Step 4 of 7", "New Agent Setup -- Identity Validation"); fprintf(stderr, " Agent kind 10002: %s\n", exists ? "FOUND (may overwrite existing state)" : "Agent npub available for new setup"); if (admin_name_found) { fprintf(stderr, " Admin profile: %s\n", admin_name); @@ -1400,21 +1677,13 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t if (prompt_llm_config(cfg) != 0) return -1; } - char agent_name[OW_MAX_NAME_LEN] = {0}; - render_wizard_page_header("Step 6 of 7", "New Agent Setup -- Agent Profile"); - if (read_line_prompt(" Agent name [Didactyl]: ", agent_name, sizeof(agent_name)) != 0) return -1; - if (line_is_quit(agent_name)) return -1; - if (agent_name[0] == '\0') { - snprintf(agent_name, sizeof(agent_name), "%s", "Didactyl"); - } - - if (sync_startup_kind0_event(cfg, agent_name) != 0) { - fprintf(stderr, "%sFailed to prepare startup kind0 profile event.%s\n", ANSI_RED, ANSI_RESET); + if (configure_default_skill_for_agent(cfg, agent_name) != 0) { + fprintf(stderr, "%sFailed to prepare default skill event content.%s\n", ANSI_RED, ANSI_RESET); return -1; } - if (sync_startup_kind10002_event(cfg) != 0) { - fprintf(stderr, "%sFailed to prepare startup kind10002 event.%s\n", ANSI_RED, ANSI_RESET); + if (apply_default_startup_events(cfg, agent_name) != 0) { + fprintf(stderr, "%sFailed to prepare startup events from defaults.%s\n", ANSI_RED, ANSI_RESET); return -1; } @@ -1427,14 +1696,12 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t fprintf(stderr, " DM Protocol: nip04\n\n"); print_option('b', "oot the agent now"); - print_option('w', "rite genesis.jsonc (without nsec) and boot"); - print_option('i', "nclude nsec in genesis.jsonc and boot (security risk)"); - print_option('e', "xport genesis.jsonc (without nsec) and exit"); + print_option('i', "nstall dedicated-user systemd service and boot"); print_option('s', "tart over"); print_option('q', "uit"); - wizard_option_t opts[] = {{'b', ""}, {'w', ""}, {'i', ""}, {'e', ""}, {'s', ""}, {'q', ""}}; - char c = read_menu_choice(opts, 6); + wizard_option_t opts[] = {{'b', ""}, {'i', ""}, {'s', ""}, {'q', ""}}; + char c = read_menu_choice(opts, 4); if (c == 'q') return -1; if (c == 's') return 2; @@ -1445,26 +1712,11 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t return 0; } - if (c == 'w' || c == 'i' || c == 'e') { - char path[OW_MAX_URL_LEN] = {0}; - if (maybe_prompt_write_path(path, sizeof(path)) != 0) return -1; - int include_nsec = (c == 'i') ? 1 : 0; - if (include_nsec) { - fprintf(stderr, "%sWarning: this writes nsec to disk in plaintext.%s\n", ANSI_YELLOW, ANSI_RESET); - } - if (write_genesis_file(cfg, path, include_nsec) != 0) { - fprintf(stderr, "%sFailed to write genesis file: %s (%s)%s\n", - ANSI_RED, - path, - strerror(errno), - ANSI_RESET); + if (c == 'i') { + if (install_system_service_with_dedicated_user(cfg, agent_name) != 0) { + fprintf(stderr, "%sFailed to install dedicated-user system service.%s\n", ANSI_RED, ANSI_RESET); return -1; } - fprintf(stderr, "Wrote %s\n", path); - if (genesis_path_out && genesis_path_out_size > 0) { - snprintf(genesis_path_out, genesis_path_out_size, "%s", path); - } - if (c == 'e') return 1; fprintf(stderr, "%sStep 7 of 7 -- Booting new agent. Check your messages for initial greeting.%s\n", ANSI_YELLOW, ANSI_RESET); diff --git a/src/tools/tool_nostr_my_events.c b/src/tools/tool_nostr_my_events.c new file mode 100644 index 0000000..a131bcd --- /dev/null +++ b/src/tools/tool_nostr_my_events.c @@ -0,0 +1,212 @@ +#define _POSIX_C_SOURCE 200809L + +#include "tools_internal.h" + +#include +#include +#include + +#include "cjson/cJSON.h" +#include "../nostr_handler.h" + +static char* json_error_local(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 const char* find_d_tag_local(cJSON* tags) { + if (!tags || !cJSON_IsArray(tags)) { + return NULL; + } + + int n = cJSON_GetArraySize(tags); + for (int i = 0; i < n; i++) { + cJSON* tag = cJSON_GetArrayItem(tags, i); + if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) { + continue; + } + + cJSON* key = cJSON_GetArrayItem(tag, 0); + cJSON* value = cJSON_GetArrayItem(tag, 1); + if (!key || !value || !cJSON_IsString(key) || !cJSON_IsString(value) || + !key->valuestring || !value->valuestring) { + continue; + } + + if (strcmp(key->valuestring, "d") == 0) { + return value->valuestring; + } + } + + return NULL; +} + +char* execute_nostr_my_events(tools_context_t* ctx, const char* args_json) { + if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable"); + + cJSON* args = cJSON_Parse(args_json ? args_json : "{}"); + if (!args || !cJSON_IsObject(args)) { + cJSON_Delete(args); + return json_error_local("invalid arguments JSON"); + } + + int limit = 200; + int timeout_ms = 8000; + int kind_filter = -1; + + cJSON* limit_item = cJSON_GetObjectItemCaseSensitive(args, "limit"); + if (limit_item) { + if (!cJSON_IsNumber(limit_item)) { + cJSON_Delete(args); + return json_error_local("limit must be an integer"); + } + limit = (int)limit_item->valuedouble; + if (limit < 1) limit = 1; + if (limit > 2000) limit = 2000; + } + + cJSON* timeout_item = cJSON_GetObjectItemCaseSensitive(args, "timeout_ms"); + if (timeout_item) { + if (!cJSON_IsNumber(timeout_item)) { + cJSON_Delete(args); + return json_error_local("timeout_ms must be an integer"); + } + timeout_ms = (int)timeout_item->valuedouble; + if (timeout_ms < 1000) timeout_ms = 1000; + if (timeout_ms > 60000) timeout_ms = 60000; + } + + cJSON* kind_item = cJSON_GetObjectItemCaseSensitive(args, "kind"); + if (kind_item) { + if (!cJSON_IsNumber(kind_item)) { + cJSON_Delete(args); + return json_error_local("kind must be an integer when provided"); + } + kind_filter = (int)kind_item->valuedouble; + if (kind_filter < 0) { + cJSON_Delete(args); + return json_error_local("kind must be >= 0"); + } + } + + cJSON* filter = cJSON_CreateObject(); + cJSON* authors = cJSON_CreateArray(); + if (!filter || !authors) { + cJSON_Delete(filter); + cJSON_Delete(authors); + cJSON_Delete(args); + return json_error_local("failed to build filter"); + } + + cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex)); + cJSON_AddItemToObject(filter, "authors", authors); + if (kind_filter >= 0) { + cJSON* kinds = cJSON_CreateArray(); + if (!kinds) { + cJSON_Delete(filter); + cJSON_Delete(args); + return json_error_local("failed to build kind filter"); + } + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(kind_filter)); + cJSON_AddItemToObject(filter, "kinds", kinds); + } + cJSON_AddNumberToObject(filter, "limit", limit); + + char* events_json = nostr_handler_query_json(filter, timeout_ms); + cJSON_Delete(filter); + cJSON_Delete(args); + if (!events_json) { + return json_error_local("nostr query failed"); + } + + cJSON* events = cJSON_Parse(events_json); + free(events_json); + if (!events || !cJSON_IsArray(events)) { + cJSON_Delete(events); + return json_error_local("nostr query returned invalid JSON"); + } + + cJSON* summarized = cJSON_CreateArray(); + cJSON* seen_event_ids = cJSON_CreateObject(); + if (!summarized || !seen_event_ids) { + cJSON_Delete(events); + cJSON_Delete(summarized); + cJSON_Delete(seen_event_ids); + return json_error_local("allocation failure"); + } + + int n = cJSON_GetArraySize(events); + for (int i = 0; i < n; i++) { + cJSON* ev = cJSON_GetArrayItem(events, i); + if (!ev || !cJSON_IsObject(ev)) { + continue; + } + + cJSON* kind = cJSON_GetObjectItemCaseSensitive(ev, "kind"); + cJSON* id = cJSON_GetObjectItemCaseSensitive(ev, "id"); + cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at"); + cJSON* tags = cJSON_GetObjectItemCaseSensitive(ev, "tags"); + + if (!kind || !cJSON_IsNumber(kind) || + !id || !cJSON_IsString(id) || !id->valuestring || + !created_at || !cJSON_IsNumber(created_at)) { + continue; + } + + cJSON* already_seen = cJSON_GetObjectItemCaseSensitive(seen_event_ids, id->valuestring); + if (already_seen) { + continue; + } + cJSON_AddBoolToObject(seen_event_ids, id->valuestring, 1); + + cJSON* item = cJSON_CreateObject(); + if (!item) { + continue; + } + + cJSON_AddNumberToObject(item, "kind", (int)kind->valuedouble); + cJSON_AddStringToObject(item, "event_id", id->valuestring); + { + char ts_buf[32]; + long long ts = (long long)created_at->valuedouble; + snprintf(ts_buf, sizeof(ts_buf), "%lld", ts); + cJSON_AddStringToObject(item, "timestamp", ts_buf); + } + + const char* d_tag = find_d_tag_local(tags); + if (d_tag && d_tag[0] != '\0') { + cJSON_AddStringToObject(item, "d_tag", d_tag); + } else { + cJSON_AddNullToObject(item, "d_tag"); + } + + cJSON_AddBoolToObject(item, + "in_current_cache", + nostr_handler_is_event_in_self_cache(id->valuestring) ? 1 : 0); + + cJSON_AddItemToArray(summarized, item); + } + + cJSON* out = cJSON_CreateObject(); + if (!out) { + cJSON_Delete(events); + cJSON_Delete(summarized); + cJSON_Delete(seen_event_ids); + return json_error_local("allocation failure"); + } + + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(summarized)); + cJSON_AddItemToObject(out, "events", summarized); + + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + cJSON_Delete(events); + cJSON_Delete(seen_event_ids); + return json; +} diff --git a/src/tools/tool_nostr_relay.c b/src/tools/tool_nostr_relay.c index 7ca34a3..cafd1cc 100644 --- a/src/tools/tool_nostr_relay.c +++ b/src/tools/tool_nostr_relay.c @@ -92,3 +92,105 @@ char* execute_nostr_relay_info(const char* args_json) { cJSON_Delete(out); return json; } + +char* execute_nostr_subscription_status(const char* args_json) { + cJSON* args = parse_args_local(args_json); + if (!args) return json_error_local("invalid arguments JSON"); + cJSON_Delete(args); + + char* status_json = nostr_handler_subscription_status_json(); + if (!status_json) { + return json_error_local("nostr_subscription_status failed"); + } + + cJSON* status = cJSON_Parse(status_json); + free(status_json); + if (!status || !cJSON_IsObject(status)) { + cJSON_Delete(status); + return json_error_local("nostr_subscription_status returned invalid JSON"); + } + + cJSON* out = cJSON_CreateObject(); + if (!out) { + cJSON_Delete(status); + return NULL; + } + + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddItemToObject(out, "status", status); + + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} + +char* execute_nostr_subscription_set(const char* args_json) { + cJSON* args = parse_args_local(args_json); + if (!args) return json_error_local("invalid arguments JSON"); + + cJSON* name = cJSON_GetObjectItemCaseSensitive(args, "name"); + if (!name || !cJSON_IsString(name) || !name->valuestring || name->valuestring[0] == '\0') { + cJSON_Delete(args); + return json_error_local("nostr_subscription_set requires non-empty string name"); + } + + cJSON* enabled_item = cJSON_GetObjectItemCaseSensitive(args, "enabled"); + int enabled_set = 0; + int enabled = 0; + if (enabled_item) { + if (!cJSON_IsBool(enabled_item)) { + cJSON_Delete(args); + return json_error_local("nostr_subscription_set enabled must be boolean"); + } + enabled_set = 1; + enabled = cJSON_IsTrue(enabled_item) ? 1 : 0; + } + + cJSON* filter_item = cJSON_GetObjectItemCaseSensitive(args, "filter"); + cJSON* filter_dup = NULL; + if (filter_item) { + if (!cJSON_IsObject(filter_item)) { + cJSON_Delete(args); + return json_error_local("nostr_subscription_set filter must be an object"); + } + filter_dup = cJSON_Duplicate(filter_item, 1); + if (!filter_dup) { + cJSON_Delete(args); + return json_error_local("nostr_subscription_set filter copy failed"); + } + } + + int rc = nostr_handler_subscription_set_json(name->valuestring, filter_dup, enabled, enabled_set); + cJSON_Delete(filter_dup); + + char* status_json = nostr_handler_subscription_status_json(); + cJSON_Delete(args); + + if (rc != 0) { + free(status_json); + return json_error_local("nostr_subscription_set failed"); + } + + cJSON* status = status_json ? cJSON_Parse(status_json) : NULL; + free(status_json); + + cJSON* out = cJSON_CreateObject(); + if (!out) { + cJSON_Delete(status); + return NULL; + } + + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddStringToObject(out, "name", name->valuestring); + cJSON_AddBoolToObject(out, "updated", 1); + if (enabled_set) { + cJSON_AddBoolToObject(out, "enabled", enabled ? 1 : 0); + } + if (status) { + cJSON_AddItemToObject(out, "status", status); + } + + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} diff --git a/src/tools/tools_dispatch.c b/src/tools/tools_dispatch.c index 989ef39..1664445 100644 --- a/src/tools/tools_dispatch.c +++ b/src/tools/tools_dispatch.c @@ -66,6 +66,12 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch if (strcmp(tool_name, "nostr_relay_info") == 0) { return execute_nostr_relay_info(args_json); } + if (strcmp(tool_name, "nostr_subscription_status") == 0) { + return execute_nostr_subscription_status(args_json); + } + if (strcmp(tool_name, "nostr_subscription_set") == 0) { + return execute_nostr_subscription_set(args_json); + } if (strcmp(tool_name, "nostr_encrypt") == 0) { return execute_nostr_encrypt(ctx, args_json); } @@ -81,6 +87,9 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch if (strcmp(tool_name, "nostr_query") == 0) { return execute_nostr_query(args_json); } + if (strcmp(tool_name, "nostr_my_events") == 0) { + return execute_nostr_my_events(ctx, args_json); + } if (strcmp(tool_name, "agent_version") == 0) { return execute_agent_version(args_json); } diff --git a/src/tools/tools_internal.h b/src/tools/tools_internal.h index 5b19fcc..3f51306 100644 --- a/src/tools/tools_internal.h +++ b/src/tools/tools_internal.h @@ -21,6 +21,7 @@ char* execute_model_get(const char* args_json); char* execute_model_set(tools_context_t* ctx, const char* args_json); char* execute_model_list(const char* args_json); char* execute_nostr_query(const char* args_json); +char* execute_nostr_my_events(tools_context_t* ctx, const char* args_json); char* execute_nostr_pubkey(tools_context_t* ctx, const char* args_json); char* execute_nostr_npub(tools_context_t* ctx, const char* args_json); char* execute_nostr_encode(const char* args_json); @@ -30,6 +31,8 @@ char* execute_nostr_profile_get(const char* args_json); char* execute_nostr_nip05_lookup(const char* args_json); char* execute_nostr_relay_status(const char* args_json); char* execute_nostr_relay_info(const char* args_json); +char* execute_nostr_subscription_status(const char* args_json); +char* execute_nostr_subscription_set(const char* args_json); char* execute_nostr_dm_send(const char* args_json); char* execute_nostr_encrypt(tools_context_t* ctx, const char* args_json); char* execute_nostr_decrypt(tools_context_t* ctx, const char* args_json); diff --git a/src/tools/tools_schema.c b/src/tools/tools_schema.c index a449281..2e2bdd0 100644 --- a/src/tools/tools_schema.c +++ b/src/tools/tools_schema.c @@ -410,6 +410,54 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) { cJSON_AddItemToObject(t15, "function", t15_fn); cJSON_AddItemToArray(tools, t15); + cJSON* t15b = cJSON_CreateObject(); + cJSON* t15b_fn = cJSON_CreateObject(); + cJSON* t15b_params = cJSON_CreateObject(); + cJSON* t15b_props = cJSON_CreateObject(); + + cJSON_AddStringToObject(t15b, "type", "function"); + cJSON_AddStringToObject(t15b_fn, "name", "nostr_subscription_status"); + cJSON_AddStringToObject(t15b_fn, "description", "List currently managed runtime Nostr subscriptions and filters"); + cJSON_AddStringToObject(t15b_params, "type", "object"); + cJSON_AddItemToObject(t15b_params, "properties", t15b_props); + cJSON_AddItemToObject(t15b_fn, "parameters", t15b_params); + cJSON_AddItemToObject(t15b, "function", t15b_fn); + cJSON_AddItemToArray(tools, t15b); + + cJSON* t15c = cJSON_CreateObject(); + cJSON* t15c_fn = cJSON_CreateObject(); + cJSON* t15c_params = cJSON_CreateObject(); + cJSON* t15c_props = cJSON_CreateObject(); + cJSON* t15c_required = cJSON_CreateArray(); + + cJSON_AddStringToObject(t15c, "type", "function"); + cJSON_AddStringToObject(t15c_fn, "name", "nostr_subscription_set"); + cJSON_AddStringToObject(t15c_fn, "description", "Update one managed runtime subscription by name (toggle enabled and/or replace filter)"); + cJSON_AddStringToObject(t15c_params, "type", "object"); + cJSON_AddItemToObject(t15c_params, "properties", t15c_props); + cJSON_AddItemToObject(t15c_params, "required", t15c_required); + + cJSON* p_sub_name = cJSON_CreateObject(); + cJSON_AddStringToObject(p_sub_name, "type", "string"); + cJSON_AddStringToObject(p_sub_name, "description", "Subscription name (e.g. admin_context_profile, agent_context_notes, dms_kind4)"); + cJSON_AddItemToObject(t15c_props, "name", p_sub_name); + + cJSON* p_sub_enabled = cJSON_CreateObject(); + cJSON_AddStringToObject(p_sub_enabled, "type", "boolean"); + cJSON_AddStringToObject(p_sub_enabled, "description", "Optional: enable or disable this subscription"); + cJSON_AddItemToObject(t15c_props, "enabled", p_sub_enabled); + + cJSON* p_sub_filter = cJSON_CreateObject(); + cJSON_AddStringToObject(p_sub_filter, "type", "object"); + cJSON_AddStringToObject(p_sub_filter, "description", "Optional: replacement Nostr filter object"); + cJSON_AddItemToObject(t15c_props, "filter", p_sub_filter); + + cJSON_AddItemToArray(t15c_required, cJSON_CreateString("name")); + + cJSON_AddItemToObject(t15c_fn, "parameters", t15c_params); + cJSON_AddItemToObject(t15c, "function", t15c_fn); + cJSON_AddItemToArray(tools, t15c); + cJSON* t16 = cJSON_CreateObject(); cJSON* t16_fn = cJSON_CreateObject(); cJSON* t16_params = cJSON_CreateObject(); @@ -1393,6 +1441,36 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) { cJSON_AddItemToObject(t49, "function", t49_fn); cJSON_AddItemToArray(tools, t49); + cJSON* t50 = cJSON_CreateObject(); + cJSON* t50_fn = cJSON_CreateObject(); + cJSON* t50_params = cJSON_CreateObject(); + cJSON* t50_props = cJSON_CreateObject(); + + cJSON_AddStringToObject(t50, "type", "function"); + cJSON_AddStringToObject(t50_fn, "name", "nostr_my_events"); + cJSON_AddStringToObject(t50_fn, "description", "Query recent events authored by this agent and return kind, event_id, timestamp, d_tag, and cache presence"); + cJSON_AddStringToObject(t50_params, "type", "object"); + cJSON_AddItemToObject(t50_params, "properties", t50_props); + + cJSON* p_my_events_limit = cJSON_CreateObject(); + cJSON_AddStringToObject(p_my_events_limit, "type", "integer"); + cJSON_AddStringToObject(p_my_events_limit, "description", "Maximum number of authored events to query (1-2000, default 200)"); + cJSON_AddItemToObject(t50_props, "limit", p_my_events_limit); + + cJSON* p_my_events_kind = cJSON_CreateObject(); + cJSON_AddStringToObject(p_my_events_kind, "type", "integer"); + cJSON_AddStringToObject(p_my_events_kind, "description", "Optional specific kind to query; omit for all kinds"); + cJSON_AddItemToObject(t50_props, "kind", p_my_events_kind); + + cJSON* p_my_events_timeout = cJSON_CreateObject(); + cJSON_AddStringToObject(p_my_events_timeout, "type", "integer"); + cJSON_AddStringToObject(p_my_events_timeout, "description", "Query timeout in milliseconds (1000-60000, default 8000)"); + cJSON_AddItemToObject(t50_props, "timeout_ms", p_my_events_timeout); + + cJSON_AddItemToObject(t50_fn, "parameters", t50_params); + cJSON_AddItemToObject(t50, "function", t50_fn); + cJSON_AddItemToArray(tools, t50); + char* out = cJSON_PrintUnformatted(tools); cJSON_Delete(tools); return out;