Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a400f1582 | ||
|
|
a2d3f840c7 | ||
|
|
7f31e4ceb7 | ||
|
|
052c11863f | ||
|
|
a446f25400 | ||
|
|
fea0fdf5c9 | ||
|
|
a798f2c345 | ||
|
|
66b4ebee79 | ||
|
|
5673efeb94 |
@@ -94,7 +94,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
-I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core \
|
||||
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
|
||||
src/main.c src/config.c src/context.c src/llm.c \
|
||||
src/nostr_handler.c src/agent.c src/tools.c src/debug.c \
|
||||
src/nostr_handler.c src/agent.c src/tools.c src/trigger_manager.c src/debug.c \
|
||||
-o /build/didactyl_static \
|
||||
nostr_core_lib/libnostr_core_x64.a \
|
||||
-lsecp256k1 \
|
||||
|
||||
@@ -12,6 +12,7 @@ SRCS = \
|
||||
$(SRC_DIR)/nostr_handler.c \
|
||||
$(SRC_DIR)/agent.c \
|
||||
$(SRC_DIR)/tools.c \
|
||||
$(SRC_DIR)/trigger_manager.c \
|
||||
$(SRC_DIR)/debug.c
|
||||
|
||||
INCLUDES = \
|
||||
|
||||
@@ -51,11 +51,11 @@ Agents learn capabilities through skills — Nostr events that any agent can di
|
||||
|
||||
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.16
|
||||
## Current Status — v0.0.25
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.16 — Add deterministic nostr_post_readme tool to publish full README with d tag readme.md
|
||||
> Last release update: v0.0.25 — Add model_get/model_set/model_list tools with persisted LLM config updates and model discovery
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
@@ -179,8 +179,20 @@ Relays are sourced exclusively from startup kind `10002` `r` tags.
|
||||
Options:
|
||||
|
||||
```
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
|
||||
./didactyl_static_x86_64 --dump-schemas # print tool JSON schemas and exit
|
||||
./didactyl_static_x86_64 --test-tool <name> <args_json> # run one tool directly and print JSON result
|
||||
```
|
||||
|
||||
CLI debugger notes:
|
||||
|
||||
- `--test-tool` initializes Nostr, waits for at least one relay connection (up to 15s), then executes the selected tool.
|
||||
- Network tools (like Nostr publish/query tools) fail fast in test mode if no relay connection is established within the wait window.
|
||||
- Example:
|
||||
|
||||
```bash
|
||||
./didactyl_static_x86_64 --config ./config.json --test-tool nostr_file_md_to_longform_post '{"file":"docs/TOOLS_AND_SKILLS.md","title":"TOOLS_AND_SKILLS"}'
|
||||
```
|
||||
|
||||
### Talk to it
|
||||
@@ -276,15 +288,43 @@ Every serialized LLM context payload is appended to [`context.log`](context.log)
|
||||
|
||||
## Tooling Interface
|
||||
|
||||
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:72):
|
||||
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:881):
|
||||
|
||||
- `nostr_post`
|
||||
- `nostr_query`
|
||||
- `shell_exec`
|
||||
- `file_read`
|
||||
- `file_write`
|
||||
- Nostr publish/query:
|
||||
- `nostr_post`
|
||||
- `nostr_post_readme`
|
||||
- `nostr_query`
|
||||
- Nostr interaction and moderation:
|
||||
- `nostr_delete`
|
||||
- `nostr_react`
|
||||
- `nostr_profile_get`
|
||||
- `nostr_relay_status`
|
||||
- `nostr_relay_info`
|
||||
- `nostr_nip05_lookup`
|
||||
- Nostr encode/decode + encryption/DM:
|
||||
- `nostr_encode`
|
||||
- `nostr_decode`
|
||||
- `nostr_encrypt`
|
||||
- `nostr_decrypt`
|
||||
- `nostr_dm_send`
|
||||
- `nostr_dm_send_nip17`
|
||||
- Nostr list management:
|
||||
- `nostr_list_manage`
|
||||
- Skill management:
|
||||
- `skill_create`
|
||||
- `skill_list`
|
||||
- `skill_adopt`
|
||||
- `skill_remove`
|
||||
- `skill_search`
|
||||
- Local/host tools:
|
||||
- `shell_exec`
|
||||
- `file_read`
|
||||
- `file_write`
|
||||
- `http_fetch`
|
||||
- Agent metadata:
|
||||
- `my_version`
|
||||
|
||||
Execution entrypoint: [`tools_execute()`](src/tools.c:434).
|
||||
Execution entrypoint: [`tools_execute()`](src/tools.c:3765).
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# Crash Fixes Reference
|
||||
|
||||
This document catalogues crashes that have been diagnosed and fixed in Didactyl.
|
||||
Use it as a reference when investigating future crashes — the symptoms, root causes,
|
||||
and investigation techniques described here may save significant debugging time.
|
||||
|
||||
---
|
||||
|
||||
## 1. Silent process exit on relay disconnect — SIGPIPE (v0.0.24)
|
||||
|
||||
**Date**: 2026-03-02
|
||||
**Version**: v0.0.23 → fixed in v0.0.24
|
||||
**Severity**: Critical — process terminates without any log output
|
||||
**Files changed**: `src/main.c`
|
||||
|
||||
### Symptoms
|
||||
|
||||
- The process exits cleanly to the shell prompt with **no error message**.
|
||||
- The `[didactyl] shutting down` log line does **not** appear.
|
||||
- No coredump is generated.
|
||||
- The last log lines show all relays transitioning from `connected → disconnected`
|
||||
and then immediately `disconnected → connected`.
|
||||
- The crash typically follows a period of network instability or DNS resolution
|
||||
failure — for example, an LLM HTTP request failing with
|
||||
`curl=Could not resolve hostname`.
|
||||
|
||||
### Root cause
|
||||
|
||||
`SIGPIPE` was never handled. The default OS action for `SIGPIPE` is to terminate
|
||||
the process immediately — no signal handler runs, no cleanup occurs, no coredump
|
||||
is written.
|
||||
|
||||
The TLS write path in `nostr_core_lib` uses `SSL_write()`, which internally calls
|
||||
`write()` on the underlying socket. When a `wss://` relay drops its TCP connection
|
||||
and the relay pool attempts to write to that socket, the kernel delivers `SIGPIPE`.
|
||||
|
||||
The plain-TCP path for `ws://` connections was already protected by using
|
||||
`MSG_NOSIGNAL` on `send()` calls, but the TLS path had no equivalent protection.
|
||||
|
||||
### How it was diagnosed
|
||||
|
||||
1. **No crash message or coredump** ruled out `SIGSEGV`, `SIGABRT`, and OOM.
|
||||
2. **No shutdown log** ruled out a graceful exit via the signal handler.
|
||||
3. `dmesg` and `journalctl` showed no OOM-kill or segfault entries.
|
||||
4. Searching the entire codebase for `SIGPIPE` or `SIG_IGN` returned zero results.
|
||||
5. The websocket TLS transport was confirmed to use `SSL_write()` without
|
||||
`MSG_NOSIGNAL` or any per-thread signal mask.
|
||||
6. The timeline matched: relay disconnects occurred, the next poll cycle attempted
|
||||
a write to a dead TLS socket, and the process vanished.
|
||||
|
||||
### Fix
|
||||
|
||||
Added `signal(SIGPIPE, SIG_IGN)` in `main()` alongside the existing `SIGINT` and
|
||||
`SIGTERM` handlers:
|
||||
|
||||
```c
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
signal(SIGPIPE, SIG_IGN); /* ← added */
|
||||
```
|
||||
|
||||
This causes `SSL_write()` and any other write to a broken pipe to return `-1` with
|
||||
`errno = EPIPE` instead of killing the process. The relay pool and libcurl already
|
||||
handle write errors gracefully.
|
||||
|
||||
### How to recognise this class of bug in the future
|
||||
|
||||
- Process disappears without any log output or coredump.
|
||||
- Happens after network disruption or relay disconnects.
|
||||
- `ulimit -c` may be 0, but even with unlimited core size, `SIGPIPE` does not
|
||||
produce a coredump by default.
|
||||
- Any new network transport layer added to the project should be audited for
|
||||
`SIGPIPE` protection.
|
||||
|
||||
---
|
||||
|
||||
## 2. Use-after-free in `execute_nostr_list_manage` — SIGSEGV (v0.0.24)
|
||||
|
||||
**Date**: 2026-03-01 (coredump), fixed 2026-03-02
|
||||
**Version**: v0.0.23 → fixed in v0.0.24
|
||||
**Severity**: Critical — segmentation fault during tool execution
|
||||
**Files changed**: `src/tools.c`
|
||||
|
||||
### Symptoms
|
||||
|
||||
- `SIGSEGV` crash during execution of the `nostr_list_manage` tool.
|
||||
- Coredump shows the crash at `src/tools.c:29` inside `json_error()`, but with
|
||||
heavily corrupted stack frames — the real crash site is in
|
||||
`execute_nostr_list_manage()`.
|
||||
- The corrupted stack is characteristic of heap corruption from use-after-free.
|
||||
|
||||
### Root cause
|
||||
|
||||
In `execute_nostr_list_manage()`, the `action` pointer was obtained from the
|
||||
`args` cJSON tree:
|
||||
|
||||
```c
|
||||
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
|
||||
```
|
||||
|
||||
Later, `args` was freed:
|
||||
|
||||
```c
|
||||
cJSON_Delete(args); /* frees the entire tree including action */
|
||||
```
|
||||
|
||||
But `action->valuestring` was still accessed afterwards:
|
||||
|
||||
```c
|
||||
cJSON_AddStringToObject(out, "action", action->valuestring); /* dangling pointer */
|
||||
```
|
||||
|
||||
After `cJSON_Delete(args)`, the `action` pointer is dangling. Accessing
|
||||
`action->valuestring` reads freed heap memory, which may contain arbitrary data
|
||||
or may have been reallocated for another purpose.
|
||||
|
||||
### How it was diagnosed
|
||||
|
||||
1. The coredump was extracted from systemd-coredump storage:
|
||||
```
|
||||
zstd -d /var/lib/systemd/coredump/core.didactyl_static.*.zst -o /tmp/core.bin
|
||||
gdb ./didactyl_static_x86_64_debug /tmp/core.bin -batch -ex "bt"
|
||||
```
|
||||
2. The backtrace showed `execute_nostr_list_manage` with corrupted frames.
|
||||
3. Code review of the function identified the `cJSON_Delete(args)` call occurring
|
||||
before the last use of `action->valuestring`.
|
||||
|
||||
### Fix
|
||||
|
||||
Deferred `cJSON_Delete(args)` until after `action->valuestring` has been consumed
|
||||
by `cJSON_AddStringToObject()`. The free now occurs immediately after the last use:
|
||||
|
||||
```c
|
||||
cJSON_AddStringToObject(out, "action", action->valuestring);
|
||||
cJSON_Delete(args); /* ← moved here, after last use of action */
|
||||
```
|
||||
|
||||
All early-return error paths before this point also received their own
|
||||
`cJSON_Delete(args)` call to prevent leaks.
|
||||
|
||||
### How to recognise this class of bug in the future
|
||||
|
||||
- `SIGSEGV` with corrupted or nonsensical stack frames.
|
||||
- Crash location reported by GDB does not match the actual buggy code — the
|
||||
corruption happened earlier.
|
||||
- Any function that calls `cJSON_Delete()` on a parent object should be audited
|
||||
to ensure no child pointers are used afterwards.
|
||||
- Pattern to watch for:
|
||||
```c
|
||||
cJSON* child = cJSON_GetObjectItemCaseSensitive(parent, "key");
|
||||
/* ... */
|
||||
cJSON_Delete(parent);
|
||||
/* ... */
|
||||
use(child->valuestring); /* BUG: child is dangling */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DM subscription not receiving incoming kind 4 events — INVESTIGATION (v0.0.24)
|
||||
|
||||
**Date**: 2026-03-02
|
||||
**Version**: v0.0.24
|
||||
**Severity**: High — agent does not respond to incoming DMs
|
||||
**Status**: Under investigation — diagnostic logging added
|
||||
**Files changed**: `src/nostr_handler.c`
|
||||
|
||||
### Symptoms
|
||||
|
||||
- The agent starts up normally, connects to relays, and can **send** kind 4 DMs.
|
||||
- Incoming kind 4 messages posted to the same relays are never processed.
|
||||
- No error messages appear in the log — the agent simply sits idle after sending
|
||||
its startup DM.
|
||||
- The last log lines show successful outbound DM publishing but no inbound event
|
||||
processing.
|
||||
|
||||
### Possible root causes (under investigation)
|
||||
|
||||
1. **Events never reach `on_event()` callback** — The relay pool library only
|
||||
calls `on_event()` when it receives an `EVENT` message matching the
|
||||
subscription ID. If the subscription was silently closed, errored, or not
|
||||
re-established after a relay reconnect, no events would be delivered. The
|
||||
relay could also be rejecting the subscription filter.
|
||||
|
||||
2. **`since` filter timing mismatch** — `g_start_time` is set to `time(NULL)`
|
||||
during `nostr_handler_init()` (early in startup), but the DM subscription
|
||||
is created much later (after admin context subscription, startup event
|
||||
reconciliation, and startup DM sending). If the sender's clock is behind
|
||||
the server's clock, their `created_at` timestamp would be before
|
||||
`g_start_time` and the relay would filter them out.
|
||||
|
||||
3. **Sender tier filtering** — If the sender's pubkey doesn't match the admin
|
||||
pubkey and isn't in the WoT contact list, the message is classified as
|
||||
`DIDACTYL_SENDER_STRANGER` and silently dropped (only a DEBUG_LOG at
|
||||
level 4).
|
||||
|
||||
4. **Signature verification failure** — If `verify_signatures` is enabled and
|
||||
the event has an invalid signature, it is dropped with only a WARN log.
|
||||
|
||||
5. **Pool-level deduplication** — The relay pool's `is_event_seen()` cache is
|
||||
shared across all subscriptions. If an event ID was somehow marked as seen
|
||||
by another subscription, it would be silently dropped.
|
||||
|
||||
### Diagnostic logging added
|
||||
|
||||
TRACE-level (level 5 / `--debug 5`) logs were added at every decision point
|
||||
in the `on_event()` callback and the `nostr_handler_subscribe_dms()` setup:
|
||||
|
||||
| Log message prefix | What it tells you |
|
||||
|---|---|
|
||||
| `DEBUG on_event ENTRY` | Callback was called — events are reaching didactyl |
|
||||
| `DEBUG on_event NULL guard` | Event, config, or callback pointer is NULL |
|
||||
| `DEBUG on_event: missing required fields` | Event JSON is malformed |
|
||||
| `DEBUG on_event: kind=N id=... from=...` | Event kind, ID, and sender pubkey |
|
||||
| `DEBUG on_event: ignoring non-kind4` | Event is not kind 4 |
|
||||
| `DEBUG on_event: no p-tag found` | Kind 4 event has no `p` tag |
|
||||
| `DEBUG on_event: p-tag mismatch` | `p` tag doesn't match agent's pubkey |
|
||||
| `DEBUG on_event: sender=... tier=N` | Sender tier classification |
|
||||
| `DEBUG on_eose called` | EOSE received from relays |
|
||||
| `DEBUG DM subscription filter` | Full subscription filter JSON |
|
||||
| `DEBUG DM subscription g_start_time=...` | `since` timestamp and time delta |
|
||||
| `DEBUG DM subscription sub=...` | Subscription pointer (confirms creation) |
|
||||
|
||||
### How to use the diagnostic logs
|
||||
|
||||
1. Build with `./build_static.sh --debug`
|
||||
2. Run with `--debug 5` to enable TRACE output
|
||||
3. Send a kind 4 DM to the agent
|
||||
4. Check the output:
|
||||
- **No `on_event ENTRY` lines** → problem is at the relay/subscription level
|
||||
- **`on_event ENTRY` appears but processing stops** → the specific drop-point
|
||||
log identifies the exact filter that rejected the event
|
||||
5. Check the `DM subscription filter` log to verify the `since` timestamp and
|
||||
`#p` tag are correct
|
||||
|
||||
### How to recognise this class of bug in the future
|
||||
|
||||
- Agent can send but not receive — asymmetric connectivity.
|
||||
- No error messages in the log — silent event filtering.
|
||||
- The subscription filter (`since`, `#p`, `kinds`) may not match what the
|
||||
sender is actually publishing.
|
||||
- Clock skew between sender and receiver can cause `since` filter mismatches.
|
||||
- Relay reconnections may not automatically re-subscribe.
|
||||
|
||||
---
|
||||
|
||||
## General debugging checklist
|
||||
|
||||
When investigating a crash in Didactyl, work through these steps:
|
||||
|
||||
1. **Check for coredumps**: `ls /var/lib/systemd/coredump/ | grep didactyl`
|
||||
2. **Check dmesg/journalctl**: `dmesg | grep -i 'didactyl\|oom\|segfault'`
|
||||
3. **Check for the shutdown log line**: If `[didactyl] shutting down` is missing,
|
||||
the process was killed by a signal that bypassed the handler.
|
||||
4. **Check ulimit**: `ulimit -c` — if 0, coredumps are disabled.
|
||||
5. **Extract and analyse coredumps**:
|
||||
```bash
|
||||
zstd -d /var/lib/systemd/coredump/core.didactyl*.zst -o /tmp/core.bin
|
||||
gdb ./didactyl_static_x86_64_debug /tmp/core.bin -batch -ex "bt full"
|
||||
```
|
||||
6. **Common silent killers**:
|
||||
- `SIGPIPE` — process writes to a broken socket/pipe
|
||||
- `SIGKILL` — OOM killer or external kill
|
||||
- `SIGBUS` — memory-mapped file issues
|
||||
7. **Common crash causes**:
|
||||
- Use-after-free on cJSON child pointers after parent deletion
|
||||
- Buffer overflows in fixed-size stack buffers
|
||||
- NULL pointer dereference on failed allocations
|
||||
- Re-entrancy in callbacks during internal `nostr_relay_pool_poll()` calls
|
||||
@@ -0,0 +1,393 @@
|
||||
# Didactyl — Tools & Skills
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl is a **Nostr-first sovereign AI agent**. It receives commands via encrypted DMs, reasons about them with an LLM, and takes actions through **tools**. It stores learned behaviors as **skills** — Nostr events that define reusable capabilities. Skills can optionally carry **triggers** — Nostr subscription filters that activate the skill automatically when matching events arrive.
|
||||
|
||||
This document describes the complete tools and skills architecture: what they are, how they work, and how they compose into a dynamic, self-modifying agent.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
Tools are the agent's hands. They are hardcoded C functions that the LLM can invoke during a conversation to take actions in the world.
|
||||
|
||||
### How Tools Work
|
||||
|
||||
1. Admin sends a DM to didactyl
|
||||
2. The agent builds an LLM request with the message, context, and a JSON schema of all available tools
|
||||
3. The LLM decides whether to call a tool or respond directly
|
||||
4. If a tool is called, didactyl executes it and feeds the result back to the LLM
|
||||
5. The loop repeats until the LLM produces a final text response
|
||||
6. The response is sent back as a DM
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin
|
||||
participant Agent as Didactyl Agent Loop
|
||||
participant LLM as LLM API
|
||||
participant Tools as Tool Registry
|
||||
|
||||
Admin->>Agent: Encrypted DM
|
||||
Agent->>LLM: messages + tool schemas
|
||||
|
||||
loop Until final answer
|
||||
LLM->>Agent: tool_call request
|
||||
Agent->>Tools: dispatch tool
|
||||
Tools->>Agent: result JSON
|
||||
Agent->>LLM: tool result + continue
|
||||
end
|
||||
|
||||
LLM->>Agent: final text response
|
||||
Agent->>Admin: Encrypted DM reply
|
||||
```
|
||||
|
||||
### Tool Categories
|
||||
|
||||
#### Nostr Core Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_post` | Publish any kind event to relays |
|
||||
| `nostr_query` | Query relays with filters, return matching events |
|
||||
| `nostr_dm` | Send a DM via NIP-04 |
|
||||
| `nostr_dm_nip17` | Send a DM via NIP-17 gift wrap |
|
||||
| `nostr_profile` | Update the agent's kind 0 metadata |
|
||||
| `nostr_list_manage` | Add/remove items from replaceable list events |
|
||||
| `nostr_relay_status` | Get connection status of all relays |
|
||||
| `nostr_relay_info` | Get NIP-11 relay information document |
|
||||
|
||||
#### Identity Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_resolve_identifier` | Resolve NIP-05, npub, nprofile, or note identifiers |
|
||||
| `nostr_verify_nip05` | Verify a NIP-05 identifier |
|
||||
|
||||
#### Skill Management Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `skill_create` | Create or update a skill definition |
|
||||
| `skill_list` | List the agent's published skills |
|
||||
| `skill_adopt` | Add a skill to the adoption list |
|
||||
| `skill_remove` | Remove a skill from the adoption list |
|
||||
| `skill_search` | Search for skills across the Web of Trust |
|
||||
|
||||
#### System Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `shell_exec` | Execute a shell command with sandboxing |
|
||||
| `http_request` | Make an HTTP request |
|
||||
| `get_time` | Get the current UTC time |
|
||||
|
||||
### Security Model
|
||||
|
||||
Tools are gated by sender tier:
|
||||
|
||||
| Tier | Identity | Tools | Response |
|
||||
|------|----------|-------|----------|
|
||||
| **ADMIN** | Configured admin pubkey | All tools | Full LLM with context |
|
||||
| **WOT** | In admin's kind 3 contact list | None | Chat-only LLM |
|
||||
| **STRANGER** | Anyone else | None | Configurable static response |
|
||||
|
||||
---
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are the agent's learned behaviors. They are **Nostr events** — stored on relays, portable, shareable, and discoverable by other agents.
|
||||
|
||||
### Skill Events
|
||||
|
||||
| Kind | Purpose | Replaceable? |
|
||||
|---|---|---|
|
||||
| `31123` | Public skill definition | Yes, by d-tag |
|
||||
| `31124` | Private skill definition | Yes, by d-tag |
|
||||
| `10123` | Skill adoption list | Yes, single per pubkey |
|
||||
|
||||
A skill event looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "When asked to summarize a thread, query the root event and all replies, then produce a concise summary with key points and sentiment.",
|
||||
"tags": [
|
||||
["d", "summarize-thread"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Summarize a Nostr thread given a root event ID"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Skill Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CREATE[Admin asks didactyl to create a skill] --> PUBLISH[skill_create publishes kind 31123/31124]
|
||||
PUBLISH --> ADOPT[Auto-adopted into kind 10123 list]
|
||||
ADOPT --> AVAILABLE[Skill available for use]
|
||||
AVAILABLE --> DISCOVER[Other agents can discover via skill_search]
|
||||
DISCOVER --> ADOPT_OTHER[Other agents can skill_adopt]
|
||||
```
|
||||
|
||||
### How Skills Are Used Today
|
||||
|
||||
Currently, skills are **passive knowledge**. They exist on Nostr and are loaded into the LLM context when relevant. The admin might say "use your summarize-thread skill" and the LLM retrieves and follows the skill's instructions.
|
||||
|
||||
---
|
||||
|
||||
## Triggered Skills — The Activation System
|
||||
|
||||
This is where skills become **active**. A triggered skill is a skill with a Nostr subscription filter attached. When matching events arrive on the relay, didactyl wakes up and executes the skill automatically — no admin DM required.
|
||||
|
||||
### Anatomy of a Triggered Skill
|
||||
|
||||
A triggered skill extends the standard skill event with trigger-related tags:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31124,
|
||||
"content": "DM admin: '{author_display_name} just posted: {content_preview}'",
|
||||
"tags": [
|
||||
["d", "watch-jack"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["description", "Notify admin when @jack posts a note"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"authors\":[\"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2\"],\"kinds\":[1]}"],
|
||||
["action", "template"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type. Currently: `nostr-subscription` |
|
||||
| `filter` | Yes | JSON-encoded Nostr subscription filter |
|
||||
| `action` | No | Action type: `template` or `llm`. Default: `llm` |
|
||||
| `enabled` | No | Whether the trigger is active. Default: `true` |
|
||||
|
||||
### Action Types
|
||||
|
||||
#### Template Actions
|
||||
|
||||
The skill content is a string template with placeholders that get interpolated from the triggering event:
|
||||
|
||||
```
|
||||
DM admin: '{author_display_name} posted: {content_preview}'
|
||||
```
|
||||
|
||||
Available placeholders:
|
||||
|
||||
| Placeholder | Source |
|
||||
|---|---|
|
||||
| `{event_id}` | Triggering event ID hex |
|
||||
| `{pubkey}` | Author pubkey hex |
|
||||
| `{author_display_name}` | Resolved display name, falls back to truncated pubkey |
|
||||
| `{kind}` | Event kind number |
|
||||
| `{content}` | Full event content |
|
||||
| `{content_preview}` | First 280 characters of content |
|
||||
| `{created_at}` | Unix timestamp |
|
||||
| `{relay_url}` | Relay the event arrived from |
|
||||
|
||||
Template actions execute **without LLM involvement** — they are fast, cheap, and deterministic. The interpolated string is then acted upon based on a simple action prefix:
|
||||
|
||||
- `DM admin: ...` — send a DM to the admin
|
||||
- `DM <pubkey>: ...` — send a DM to a specific pubkey
|
||||
- `POST: ...` — publish as a kind 1 note
|
||||
- `LOG: ...` — write to debug log only
|
||||
|
||||
#### LLM-Mediated Actions
|
||||
|
||||
The skill content is a prompt. The triggering event is injected as context, and the LLM decides what to do:
|
||||
|
||||
```
|
||||
You received a note from a watched author. Analyze the note content.
|
||||
If it mentions Bitcoin or Lightning, summarize it and DM the admin.
|
||||
If it's a repost or low-effort content, ignore it silently.
|
||||
Use your tools to take action.
|
||||
```
|
||||
|
||||
LLM-mediated actions go through the full agent loop — the LLM can call tools, reason about the event, and produce complex multi-step responses.
|
||||
|
||||
### Trigger Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Creation
|
||||
ADMIN_CMD[Admin: 'Warn me when @jack posts'] --> LLM_REASON[LLM resolves pubkey + builds skill]
|
||||
LLM_REASON --> SKILL_CREATE[skill_create with trigger tags]
|
||||
SKILL_CREATE --> PUBLISHED[Skill published to Nostr]
|
||||
end
|
||||
|
||||
subgraph Activation
|
||||
STARTUP[Didactyl starts up] --> LOAD_SKILLS[Load adopted skills from kind 10123]
|
||||
LOAD_SKILLS --> FIND_TRIGGERS[Find skills with trigger tags]
|
||||
FIND_TRIGGERS --> SUBSCRIBE[Create Nostr subscriptions for each filter]
|
||||
end
|
||||
|
||||
subgraph Execution
|
||||
EVENT_IN[Matching event arrives] --> LOOKUP[Find associated skill]
|
||||
LOOKUP --> CHECK_TYPE{Action type?}
|
||||
CHECK_TYPE -->|template| INTERPOLATE[Interpolate placeholders]
|
||||
CHECK_TYPE -->|llm| LLM_LOOP[Run agent loop with event as context]
|
||||
INTERPOLATE --> EXECUTE_TPL[Execute action prefix]
|
||||
LLM_LOOP --> EXECUTE_LLM[LLM uses tools to respond]
|
||||
end
|
||||
|
||||
PUBLISHED --> LOAD_SKILLS
|
||||
```
|
||||
|
||||
### Dynamic Subscription Management
|
||||
|
||||
Didactyl manages its trigger subscriptions dynamically:
|
||||
|
||||
1. **On startup**: Load all adopted skills, find triggered ones, create subscriptions
|
||||
2. **On skill_create with trigger**: Immediately create a new subscription (no restart needed)
|
||||
3. **On skill_remove with trigger**: Tear down the associated subscription
|
||||
4. **On skill update**: Tear down old subscription, create new one if trigger changed
|
||||
|
||||
This requires a **trigger manager** component that:
|
||||
- Maintains a registry of active trigger subscriptions
|
||||
- Maps subscription callbacks back to their source skills
|
||||
- Handles subscription lifecycle (create, update, destroy)
|
||||
- Enforces limits on concurrent triggers
|
||||
|
||||
### Limits and Safety
|
||||
|
||||
| Limit | Default | Description |
|
||||
|---|---|---|
|
||||
| Max concurrent triggers | 16 | Prevents resource exhaustion from too many subscriptions |
|
||||
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution from high-volume filters |
|
||||
| LLM action rate limit | 10/min | Prevents runaway LLM costs from triggered skills |
|
||||
| Template action rate limit | 60/min | Prevents DM spam from template actions |
|
||||
|
||||
---
|
||||
|
||||
## How It All Fits Together
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Activation Sources
|
||||
DM_IN[DM from Admin/WoT]
|
||||
TRIGGER_EVENT[Nostr event matching a trigger filter]
|
||||
end
|
||||
|
||||
subgraph Agent Core
|
||||
DISPATCHER{Dispatcher}
|
||||
AGENT_LOOP[Agent Loop - LLM + Tools]
|
||||
TEMPLATE_ENGINE[Template Engine]
|
||||
end
|
||||
|
||||
subgraph Nostr
|
||||
RELAYS[Relays]
|
||||
SKILLS_STORE[Skills - kind 31123/31124]
|
||||
ADOPTION[Adoption List - kind 10123]
|
||||
end
|
||||
|
||||
subgraph Actions
|
||||
DM_OUT[Send DM]
|
||||
POST[Publish Note]
|
||||
TOOL_EXEC[Execute Tool]
|
||||
end
|
||||
|
||||
DM_IN --> DISPATCHER
|
||||
TRIGGER_EVENT --> DISPATCHER
|
||||
|
||||
DISPATCHER -->|DM message| AGENT_LOOP
|
||||
DISPATCHER -->|template trigger| TEMPLATE_ENGINE
|
||||
DISPATCHER -->|llm trigger| AGENT_LOOP
|
||||
|
||||
AGENT_LOOP --> TOOL_EXEC
|
||||
AGENT_LOOP --> DM_OUT
|
||||
TEMPLATE_ENGINE --> DM_OUT
|
||||
TEMPLATE_ENGINE --> POST
|
||||
|
||||
TOOL_EXEC -->|skill_create| SKILLS_STORE
|
||||
TOOL_EXEC -->|skill_adopt| ADOPTION
|
||||
TOOL_EXEC -->|nostr_post| RELAYS
|
||||
TOOL_EXEC -->|nostr_dm| DM_OUT
|
||||
```
|
||||
|
||||
### The Activation Flow
|
||||
|
||||
Today, didactyl has one activation source: **DMs**. With triggered skills, it gains a second: **any Nostr event matching a trigger filter**.
|
||||
|
||||
Both paths converge at the dispatcher, which routes to either:
|
||||
- The **agent loop** (for DMs and LLM-mediated triggers)
|
||||
- The **template engine** (for template triggers — fast path, no LLM)
|
||||
|
||||
### Self-Modification
|
||||
|
||||
The most powerful aspect: **didactyl can create its own triggers**. The admin says "watch for mentions of me on Nostr" and the LLM:
|
||||
|
||||
1. Resolves the admin's pubkey
|
||||
2. Crafts a Nostr filter: `{"#p": ["<admin_pubkey>"], "kinds": [1]}`
|
||||
3. Writes a skill with trigger tags via `skill_create`
|
||||
4. The trigger manager picks it up and creates the subscription
|
||||
5. From now on, didactyl monitors mentions autonomously
|
||||
|
||||
The admin can later say "stop watching for mentions" and didactyl removes the skill, tearing down the subscription.
|
||||
|
||||
---
|
||||
|
||||
## Storage — Everything on Nostr
|
||||
|
||||
All state lives on Nostr:
|
||||
|
||||
| Data | Storage |
|
||||
|---|---|
|
||||
| Agent identity | Kind 0 profile event |
|
||||
| Agent relay list | Kind 10002 event |
|
||||
| Agent contact list | Kind 3 event |
|
||||
| Skills | Kind 31123/31124 events |
|
||||
| Adopted skills | Kind 10123 event |
|
||||
| Trigger definitions | Tags on skill events |
|
||||
| Conversation history | Kind 4 DM events on relays |
|
||||
| Agent soul/personality | Startup event content in config |
|
||||
|
||||
The only local state is `config.json` (keys, relay URLs, LLM config) and the runtime in-memory state (active subscriptions, LLM context).
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
### Trigger Types Beyond Nostr Subscriptions
|
||||
|
||||
The `trigger` tag is designed to be extensible:
|
||||
|
||||
| Trigger Type | Description |
|
||||
|---|---|
|
||||
| `nostr-subscription` | Match events via Nostr filter (implemented first) |
|
||||
| `cron` | Time-based triggers — "every day at 9am, post a GM" |
|
||||
| `webhook` | HTTP webhook triggers — external systems wake didactyl |
|
||||
| `chain` | Output of one skill triggers another skill |
|
||||
|
||||
### Skill Composition
|
||||
|
||||
Skills could reference other skills, building complex behaviors from simple primitives:
|
||||
|
||||
```
|
||||
When triggered, run skill 'translate-to-english' on the note content,
|
||||
then run skill 'sentiment-analysis' on the translation,
|
||||
then DM admin with the result if sentiment is negative.
|
||||
```
|
||||
|
||||
### Agent-to-Agent Skill Sharing
|
||||
|
||||
Since skills are Nostr events, agents can:
|
||||
- Discover skills published by other agents via `skill_search`
|
||||
- Adopt skills from other agents via `skill_adopt`
|
||||
- Share triggered skill patterns across a network of agents
|
||||
|
||||
### Trigger Marketplace
|
||||
|
||||
With kind 10123 adoption lists being public, a natural marketplace emerges:
|
||||
- Agents publish useful triggered skills
|
||||
- Other agents discover and adopt them
|
||||
- Popular triggers rise to the top via adoption count
|
||||
@@ -0,0 +1,596 @@
|
||||
# Implementation Plan: Nostr Tools
|
||||
|
||||
## Current Tool Inventory (v0.0.19)
|
||||
|
||||
| # | Tool | Kind/NIP | Status |
|
||||
|---|------|----------|--------|
|
||||
| 1 | `nostr_post` | Any kind | ✅ Shipped |
|
||||
| 2 | `nostr_post_readme` | 30023 / NIP-23 | ✅ Shipped |
|
||||
| 3 | `nostr_query` | Filter-based | ✅ Shipped |
|
||||
| 4 | `nostr_delete` | 5 / NIP-09 | ✅ Shipped |
|
||||
| 5 | `nostr_react` | 7 / NIP-25 | ✅ Shipped |
|
||||
| 6 | `nostr_profile_get` | 0 query | ✅ Shipped |
|
||||
| 7 | `nostr_relay_status` | Pool stats | ✅ Shipped |
|
||||
| 8 | `shell_exec` | OS | ✅ Shipped |
|
||||
| 9 | `file_read` | OS | ✅ Shipped |
|
||||
| 10 | `file_write` | OS | ✅ Shipped |
|
||||
|
||||
---
|
||||
|
||||
## Pattern for All New Tools
|
||||
|
||||
Every tool follows the same three-step pattern in [`src/tools.c`](../src/tools.c):
|
||||
|
||||
1. **Schema** — Add OpenAI function schema block in [`tools_build_openai_schema_json()`](../src/tools.c:511)
|
||||
2. **Executor** — Implement `static char* execute_<name>()` function
|
||||
3. **Dispatch** — Add `strcmp` case in [`tools_execute()`](../src/tools.c:1620)
|
||||
|
||||
Use the shared [`parse_tool_args_json()`](../src/tools.c:527) helper for hardened argument parsing.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 Tools — Medium Value, Moderate Implementation
|
||||
|
||||
### Tool 11: `nostr_nip05_lookup` — DNS Identity Verification (NIP-05)
|
||||
|
||||
#### Purpose
|
||||
Verify `user@domain.com` NIP-05 identifiers. Useful for trust decisions and profile enrichment.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_nip05_lookup",
|
||||
"description": "Verify or look up a NIP-05 DNS identifier and return the associated pubkey and relays",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"description": "NIP-05 identifier, e.g. user@domain.com"
|
||||
},
|
||||
"pubkey": {
|
||||
"type": "string",
|
||||
"description": "Optional 64-char hex pubkey to verify against the identifier"
|
||||
}
|
||||
},
|
||||
"required": ["identifier"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_nip05_lookup()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "identifier" (required string, must contain @)
|
||||
3. Extract "pubkey" (optional 64-char hex)
|
||||
4. If pubkey provided:
|
||||
- Call nostr_nip05_verify(identifier, pubkey, &relays, &relay_count, 10)
|
||||
- Return { success, verified: true/false, pubkey, relays }
|
||||
5. If no pubkey:
|
||||
- Call nostr_nip05_lookup(identifier, pubkey_out, &relays, &relay_count, 10)
|
||||
- Return { success, pubkey: pubkey_out, relays }
|
||||
6. Free relay array after serializing
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_nip05_lookup()`](../nostr_core_lib/nostr_core/nip005.h:13)
|
||||
- [`nostr_nip05_verify()`](../nostr_core_lib/nostr_core/nip005.h:15)
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
#### Notes
|
||||
- Involves HTTP network I/O (fetches `/.well-known/nostr.json` from the domain)
|
||||
- May block for up to `timeout_seconds` — use 10s default
|
||||
- Must free the `relays` array returned by the library
|
||||
|
||||
---
|
||||
|
||||
### Tool 12: `nostr_encode` — Bech32 Entity Encoding (NIP-19/NIP-21)
|
||||
|
||||
#### Purpose
|
||||
Encode hex pubkeys, event IDs, or addressable coordinates into shareable `npub`, `note`, `nprofile`, `nevent`, `naddr` URIs.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_encode",
|
||||
"description": "Encode a Nostr entity into a bech32 URI: npub, note, nprofile, nevent, or naddr",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Entity type: npub, note, nprofile, nevent, or naddr"
|
||||
},
|
||||
"hex": {
|
||||
"type": "string",
|
||||
"description": "64-char hex value: pubkey for npub/nprofile, event_id for note/nevent"
|
||||
},
|
||||
"relays": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional relay hints for nprofile, nevent, naddr"
|
||||
},
|
||||
"kind": {
|
||||
"type": "integer",
|
||||
"description": "Kind number, required for naddr"
|
||||
},
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"description": "d-tag identifier, required for naddr"
|
||||
}
|
||||
},
|
||||
"required": ["type", "hex"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_encode()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "type" (required: npub|note|nprofile|nevent|naddr)
|
||||
3. Extract "hex" (required, 64-char hex)
|
||||
4. Convert hex to 32-byte array via nostr_hex_to_bytes()
|
||||
5. Switch on type:
|
||||
- "npub": nostr_build_uri_npub(bytes, output, sizeof output)
|
||||
- "note": nostr_build_uri_note(bytes, output, sizeof output)
|
||||
- "nprofile": nostr_build_uri_nprofile(bytes, relays, relay_count, output, sizeof output)
|
||||
- "nevent": nostr_build_uri_nevent(bytes, relays, relay_count, NULL, -1, 0, output, sizeof output)
|
||||
- "naddr": nostr_build_uri_naddr(identifier, bytes, kind, relays, relay_count, output, sizeof output)
|
||||
6. Return { success, type, uri: output }
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_build_uri_npub()`](../nostr_core_lib/nostr_core/nip021.h:66)
|
||||
- [`nostr_build_uri_note()`](../nostr_core_lib/nostr_core/nip021.h:68)
|
||||
- [`nostr_build_uri_nprofile()`](../nostr_core_lib/nostr_core/nip021.h:69)
|
||||
- [`nostr_build_uri_nevent()`](../nostr_core_lib/nostr_core/nip021.h:71)
|
||||
- [`nostr_build_uri_naddr()`](../nostr_core_lib/nostr_core/nip021.h:74)
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 13: `nostr_decode` — Bech32 Entity Decoding (NIP-19/NIP-21)
|
||||
|
||||
#### Purpose
|
||||
Decode `npub`, `note`, `nprofile`, `nevent`, `naddr` URIs back into hex values and metadata.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_decode",
|
||||
"description": "Decode a bech32 Nostr URI into its components: type, hex, relays, kind, identifier",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uri": {
|
||||
"type": "string",
|
||||
"description": "Bech32 URI to decode, e.g. npub1..., note1..., nostr:npub1..."
|
||||
}
|
||||
},
|
||||
"required": ["uri"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_decode()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "uri" (required string)
|
||||
3. Call nostr_parse_uri(uri, &result)
|
||||
4. Switch on result.type:
|
||||
- NPUB: convert result.data.pubkey to hex, return { type: "npub", pubkey: hex }
|
||||
- NOTE: convert result.data.event_id to hex, return { type: "note", event_id: hex }
|
||||
- NPROFILE: pubkey hex + relays array
|
||||
- NEVENT: event_id hex + relays + optional author/kind
|
||||
- NADDR: identifier + pubkey hex + kind + relays
|
||||
5. Call nostr_uri_result_free(&result)
|
||||
6. Return JSON
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_parse_uri()`](../nostr_core_lib/nostr_core/nip021.h:63)
|
||||
- [`nostr_uri_result_free()`](../nostr_core_lib/nostr_core/nip021.h:78)
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 14: `nostr_dm_send` — Send DM as Tool Call
|
||||
|
||||
#### Purpose
|
||||
Expose DM sending as a tool so the agent can proactively message people (notify admin, reach out to contacts).
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_dm_send",
|
||||
"description": "Send an encrypted direct message to a Nostr user (NIP-04)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recipient_pubkey": {
|
||||
"type": "string",
|
||||
"description": "64-char hex pubkey of the recipient"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Plaintext message to send"
|
||||
}
|
||||
},
|
||||
"required": ["recipient_pubkey", "message"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_dm_send()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "recipient_pubkey" (required, 64-char hex)
|
||||
3. Extract "message" (required, non-empty string)
|
||||
4. Call nostr_handler_send_dm(recipient_pubkey, message)
|
||||
5. Return { success: true/false, recipient_pubkey, message_length }
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_handler_send_dm()`](../src/nostr_handler.h:33) — already exists
|
||||
|
||||
#### Security Note
|
||||
- Should be admin-tier only in practice. The tool itself doesn't enforce tier, but the agent's system prompt and skill definitions should restrict usage.
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 15: `nostr_relay_info` — Relay Information Document (NIP-11)
|
||||
|
||||
#### Purpose
|
||||
Inspect relay capabilities, supported NIPs, limitations, PoW requirements before publishing.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_relay_info",
|
||||
"description": "Fetch the NIP-11 relay information document for a relay URL",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"relay_url": {
|
||||
"type": "string",
|
||||
"description": "WebSocket relay URL, e.g. wss://relay.example.com"
|
||||
}
|
||||
},
|
||||
"required": ["relay_url"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation
|
||||
|
||||
This requires a new helper in [`nostr_handler.c`](../src/nostr_handler.c) since NIP-11 fetching needs an HTTP GET request with `Accept: application/nostr+json` header.
|
||||
|
||||
##### New function: `nostr_handler_relay_info_json()`
|
||||
|
||||
```
|
||||
1. Convert ws:// or wss:// URL to http:// or https://
|
||||
2. Use libcurl to HTTP GET with header "Accept: application/nostr+json"
|
||||
3. Return the raw JSON response string
|
||||
```
|
||||
|
||||
##### `execute_nostr_relay_info()` in tools.c:
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "relay_url" (required string, must start with ws:// or wss://)
|
||||
3. Call nostr_handler_relay_info_json(relay_url)
|
||||
4. Parse returned JSON
|
||||
5. Return { success, relay_url, info: { name, description, pubkey, supported_nips, limitations, ... } }
|
||||
```
|
||||
|
||||
#### Files Modified
|
||||
- `src/nostr_handler.h`: Add `nostr_handler_relay_info_json()` declaration
|
||||
- `src/nostr_handler.c`: Implement using libcurl (already linked)
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 Tools — High Value, More Complex
|
||||
|
||||
### Tool 16: `nostr_dm_send_nip17` — Private DMs via Gift Wrap (NIP-17/NIP-59)
|
||||
|
||||
#### Purpose
|
||||
Send modern, metadata-private DMs using the NIP-17 gift wrap protocol instead of legacy NIP-04.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_dm_send_nip17",
|
||||
"description": "Send a private direct message using NIP-17 gift wrap protocol for metadata privacy",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recipient_pubkey": {
|
||||
"type": "string",
|
||||
"description": "64-char hex pubkey of the recipient"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Plaintext message to send"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Optional conversation subject"
|
||||
}
|
||||
},
|
||||
"required": ["recipient_pubkey", "message"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation
|
||||
|
||||
This is a multi-step crypto pipeline. The library handles the heavy lifting but we need to orchestrate:
|
||||
|
||||
##### New function: `nostr_handler_send_dm_nip17()` in nostr_handler.c
|
||||
|
||||
```
|
||||
1. Query recipient's kind 10050 relay list (DM relays)
|
||||
- Build filter: { kinds: [10050], authors: [recipient_pubkey], limit: 1 }
|
||||
- Call nostr_relay_pool_query_sync()
|
||||
- Extract relay URLs via nostr_nip17_extract_dm_relays()
|
||||
- Fall back to our own relays if no 10050 found
|
||||
2. Create kind 14 chat event:
|
||||
- nostr_nip17_create_chat_event(message, &recipient_pubkey, 1, subject, NULL, NULL, our_pubkey)
|
||||
3. Gift wrap and send:
|
||||
- nostr_nip17_send_dm(chat_event, &recipient_pubkey, 1, our_private_key, gift_wraps, max_wraps, 0)
|
||||
4. Publish each gift wrap to recipient's DM relays
|
||||
5. Return success/failure + event metadata
|
||||
```
|
||||
|
||||
##### `execute_nostr_dm_send_nip17()` in tools.c:
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract recipient_pubkey, message, optional subject
|
||||
3. Call nostr_handler_send_dm_nip17(recipient_pubkey, message, subject)
|
||||
4. Return { success, recipient_pubkey, protocol: "nip17" }
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_nip17_create_chat_event()`](../nostr_core_lib/nostr_core/nip017.h:28)
|
||||
- [`nostr_nip17_send_dm()`](../nostr_core_lib/nostr_core/nip017.h:100)
|
||||
- [`nostr_nip17_extract_dm_relays()`](../nostr_core_lib/nostr_core/nip017.h)
|
||||
- [`nostr_nip59_create_gift_wrap()`](../nostr_core_lib/nostr_core/nip059.h:50)
|
||||
|
||||
#### Files Modified
|
||||
- `src/nostr_handler.h`: Add `nostr_handler_send_dm_nip17()` declaration
|
||||
- `src/nostr_handler.c`: Implement the full NIP-17 send pipeline
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 17: `nostr_encrypt` — NIP-44 Encryption
|
||||
|
||||
#### Purpose
|
||||
Encrypt arbitrary content for a specific recipient using modern NIP-44 encryption.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_encrypt",
|
||||
"description": "Encrypt plaintext for a recipient using NIP-44 ChaCha20+HMAC encryption",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recipient_pubkey": {
|
||||
"type": "string",
|
||||
"description": "64-char hex pubkey of the recipient"
|
||||
},
|
||||
"plaintext": {
|
||||
"type": "string",
|
||||
"description": "Text to encrypt"
|
||||
}
|
||||
},
|
||||
"required": ["recipient_pubkey", "plaintext"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_encrypt()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract recipient_pubkey (64-char hex), plaintext (non-empty string)
|
||||
3. Convert recipient_pubkey hex to 32-byte array
|
||||
4. Allocate output buffer (plaintext length * 2 + 256 for base64 overhead)
|
||||
5. Call nostr_nip44_encrypt(our_private_key, recipient_pubkey_bytes, plaintext, output, output_size)
|
||||
6. Return { success, recipient_pubkey, ciphertext: output }
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_nip44_encrypt()`](../nostr_core_lib/nostr_core/nip044.h:28)
|
||||
|
||||
#### Security Note
|
||||
- Admin-tier only. Uses the agent's private key for ECDH shared secret.
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 18: `nostr_decrypt` — NIP-44 Decryption
|
||||
|
||||
#### Purpose
|
||||
Decrypt NIP-44 encrypted content from a known sender.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_decrypt",
|
||||
"description": "Decrypt NIP-44 encrypted content from a sender",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sender_pubkey": {
|
||||
"type": "string",
|
||||
"description": "64-char hex pubkey of the sender"
|
||||
},
|
||||
"ciphertext": {
|
||||
"type": "string",
|
||||
"description": "Base64-encoded NIP-44 encrypted payload"
|
||||
}
|
||||
},
|
||||
"required": ["sender_pubkey", "ciphertext"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_decrypt()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract sender_pubkey (64-char hex), ciphertext (non-empty string)
|
||||
3. Convert sender_pubkey hex to 32-byte array
|
||||
4. Allocate output buffer
|
||||
5. Call nostr_nip44_decrypt(our_private_key, sender_pubkey_bytes, ciphertext, output, output_size)
|
||||
6. Return { success, sender_pubkey, plaintext: output }
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_nip44_decrypt()`](../nostr_core_lib/nostr_core/nip044.h:62)
|
||||
|
||||
#### Security Note
|
||||
- Admin-tier only.
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 19: `nostr_list_manage` — List Management (NIP-51)
|
||||
|
||||
#### Purpose
|
||||
Manage mute lists (kind 10000), bookmarks (kind 10003), follow sets, relay sets. Lets the agent curate its social graph.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_list_manage",
|
||||
"description": "Add or remove items from a Nostr list: mute list, bookmarks, relay sets, etc. (NIP-51)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list_kind": {
|
||||
"type": "integer",
|
||||
"description": "Kind number of the list: 10000=mute, 10001=pins, 10003=bookmarks, 3=follows, 10002=relays"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Action: add or remove"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"description": "Array of tag tuples to add/remove, e.g. [[p, pubkey], [e, event_id]]"
|
||||
}
|
||||
},
|
||||
"required": ["list_kind", "action", "items"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation
|
||||
|
||||
This is a read-modify-write pattern for replaceable events:
|
||||
|
||||
##### `execute_nostr_list_manage()`:
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract list_kind (integer), action ("add"|"remove"), items (array of tag arrays)
|
||||
3. Query existing list:
|
||||
- Build filter: { kinds: [list_kind], authors: [our_pubkey], limit: 1 }
|
||||
- Call nostr_handler_query_json(filter, 8000)
|
||||
4. Parse existing event tags (or start with empty array if none found)
|
||||
5. If action == "add":
|
||||
- For each item in items: append to tags if not already present
|
||||
6. If action == "remove":
|
||||
- For each item in items: remove matching tag from tags
|
||||
7. Publish updated event:
|
||||
- Call nostr_handler_publish_kind_event(list_kind, existing_content_or_empty, updated_tags, &result)
|
||||
8. Return { success, list_kind, action, items_affected, event_id }
|
||||
```
|
||||
|
||||
#### Notes
|
||||
- Replaceable events (kind 10000-19999) automatically replace previous versions
|
||||
- Kind 3 (follows) is also replaceable
|
||||
- Content field may contain NIP-44 encrypted private items — preserve it unchanged
|
||||
- Must deduplicate tags when adding
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[nostr_nip05_lookup] --> B[nostr_encode]
|
||||
B --> C[nostr_decode]
|
||||
C --> D[nostr_dm_send]
|
||||
D --> E[nostr_relay_info]
|
||||
E --> F[nostr_encrypt]
|
||||
F --> G[nostr_decrypt]
|
||||
G --> H[nostr_dm_send_nip17]
|
||||
H --> I[nostr_list_manage]
|
||||
|
||||
style A fill:#FFFFE0
|
||||
style B fill:#FFFFE0
|
||||
style C fill:#FFFFE0
|
||||
style D fill:#FFFFE0
|
||||
style E fill:#FFFFE0
|
||||
style F fill:#FFD700
|
||||
style G fill:#FFD700
|
||||
style H fill:#FFD700
|
||||
style I fill:#FFD700
|
||||
```
|
||||
|
||||
Yellow = Tier 2 (moderate), Gold = Tier 3 (complex).
|
||||
|
||||
### Recommended batching:
|
||||
|
||||
**Batch A — Pure computation, no new handler functions:**
|
||||
1. `nostr_encode` — pure NIP-21 encoding
|
||||
2. `nostr_decode` — pure NIP-21 decoding
|
||||
3. `nostr_dm_send` — thin wrapper around existing `nostr_handler_send_dm()`
|
||||
|
||||
**Batch B — Network I/O tools:**
|
||||
4. `nostr_nip05_lookup` — HTTP fetch via library
|
||||
5. `nostr_relay_info` — HTTP fetch via new handler function + libcurl
|
||||
|
||||
**Batch C — Crypto tools:**
|
||||
6. `nostr_encrypt` — NIP-44 encrypt
|
||||
7. `nostr_decrypt` — NIP-44 decrypt
|
||||
|
||||
**Batch D — Complex orchestration:**
|
||||
8. `nostr_dm_send_nip17` — multi-step NIP-17/59 pipeline, new handler function
|
||||
9. `nostr_list_manage` — read-modify-write replaceable events
|
||||
|
||||
## Build & Validate
|
||||
|
||||
After each batch:
|
||||
1. Run `./build_static.sh` to verify compilation
|
||||
2. Run `./increment_and_push.sh` with descriptive message
|
||||
@@ -0,0 +1,337 @@
|
||||
# Skill Tools — Architecture Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add a family of five skill-management tools to Didactyl so the agent can **create, list, adopt, remove, and discover** skills at runtime — all through the existing LLM tool-calling loop.
|
||||
|
||||
Skills are Nostr events. The tools are thin orchestration wrappers over the existing `nostr_handler_publish_kind_event()` and `nostr_handler_query_json()` primitives.
|
||||
|
||||
---
|
||||
|
||||
## Nostr Kind Reference
|
||||
|
||||
| Kind | Purpose | Replaceable? | Key tag |
|
||||
|---|---|---|---|
|
||||
| `31123` | Public skill definition | Yes (d-tag) | `d=<slug>` |
|
||||
| `31124` | Private skill definition | Yes (d-tag) | `d=<slug>` |
|
||||
| `10123` | Public skill adoption list | Yes (replaceable) | `a` refs to 31123 events |
|
||||
|
||||
All skill events carry these standard tags:
|
||||
- `["d", "<slug>"]` — unique identifier within the author's pubkey
|
||||
- `["app", "didactyl"]` — app namespace
|
||||
- `["scope", "public"]` or `["scope", "private"]`
|
||||
|
||||
---
|
||||
|
||||
## Tool Family
|
||||
|
||||
### 1. `skill_create`
|
||||
|
||||
**Purpose:** Create or update a skill definition and optionally auto-adopt it.
|
||||
|
||||
**OpenAI schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_create",
|
||||
"description": "Create or update a skill definition (kind 31123 public / 31124 private) and optionally auto-adopt it",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
|
||||
"content": { "type": "string", "description": "Skill body — markdown instructions or structured JSON" },
|
||||
"scope": { "type": "string", "description": "public (kind 31123) or private (kind 31124). Default: public" },
|
||||
"description": { "type": "string", "description": "Short one-line description for the skill" },
|
||||
"auto_adopt": { "type": "boolean", "description": "Automatically add to adoption list (kind 10123). Default: true" }
|
||||
},
|
||||
"required": ["slug", "content"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_create`):**
|
||||
|
||||
1. Validate `slug` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
|
||||
2. Determine kind: `31123` if scope is `"public"` or absent; `31124` if `"private"`
|
||||
3. Build tags array:
|
||||
- `["d", slug]`
|
||||
- `["app", "didactyl"]`
|
||||
- `["scope", scope]`
|
||||
- `["description", description]` if provided
|
||||
4. Call `nostr_handler_publish_kind_event(kind, content, tags, &result)`
|
||||
5. If `auto_adopt` is true (default), update the kind `10123` adoption list:
|
||||
- Query existing `10123` event for own pubkey (same pattern as `execute_nostr_list_manage`)
|
||||
- Add `["a", "31123:<own_pubkey>:<slug>"]` tag if not already present
|
||||
- Republish the updated `10123` event
|
||||
6. Return JSON with `success`, `event_id`, `naddr_uri`, `slug`, `adopted`
|
||||
|
||||
**Key design decisions:**
|
||||
- Auto-adopt defaults to `true` — creating a skill you don't adopt is unusual
|
||||
- Private skills (31124) are NOT added to the public adoption list (10123)
|
||||
- Republishing with the same slug replaces the previous version (replaceable event)
|
||||
|
||||
---
|
||||
|
||||
### 2. `skill_list`
|
||||
|
||||
**Purpose:** List the agent's own published skills.
|
||||
|
||||
**OpenAI schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_list",
|
||||
"description": "List skills published by this agent, optionally filtered by scope",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": { "type": "string", "description": "Filter by public or private. Omit for both." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_list`):**
|
||||
|
||||
1. Build filter based on scope:
|
||||
- Both: `{"kinds": [31123, 31124], "authors": [own_pubkey]}`
|
||||
- Public only: `{"kinds": [31123], "authors": [own_pubkey]}`
|
||||
- Private only: `{"kinds": [31124], "authors": [own_pubkey]}`
|
||||
2. Call `nostr_handler_query_json(filter, 8000)`
|
||||
3. Parse results, extract for each event:
|
||||
- `slug` (from d-tag)
|
||||
- `kind`
|
||||
- `scope` (from scope tag)
|
||||
- `description` (from description tag, if present)
|
||||
- `created_at` timestamp
|
||||
- `content` preview (first 200 chars)
|
||||
4. Return JSON array of skill summaries
|
||||
|
||||
---
|
||||
|
||||
### 3. `skill_adopt`
|
||||
|
||||
**Purpose:** Adopt a skill published by another author (or self) into the agent's adoption list.
|
||||
|
||||
**OpenAI schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_adopt",
|
||||
"description": "Add a skill to the agent's public adoption list (kind 10123)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author" },
|
||||
"slug": { "type": "string", "description": "Skill slug (d-tag value)" },
|
||||
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
|
||||
},
|
||||
"required": ["pubkey", "slug"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_adopt`):**
|
||||
|
||||
1. Validate pubkey (64-char hex) and slug (non-empty)
|
||||
2. Default kind to 31123 if not provided
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
|
||||
4. Query existing kind `10123` event for own pubkey
|
||||
5. Check if `["a", "<kind>:<pubkey>:<slug>"]` already exists — if so, return success with `already_adopted: true`
|
||||
6. Add the tag, republish `10123`
|
||||
7. Return JSON with `success`, `adopted_address`, `event_id`
|
||||
|
||||
---
|
||||
|
||||
### 4. `skill_remove`
|
||||
|
||||
**Purpose:** Remove a skill from the agent's adoption list.
|
||||
|
||||
**OpenAI schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_remove",
|
||||
"description": "Remove a skill from the agent's public adoption list (kind 10123)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author. Defaults to own pubkey." },
|
||||
"slug": { "type": "string", "description": "Skill slug to remove" },
|
||||
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
|
||||
},
|
||||
"required": ["slug"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_remove`):**
|
||||
|
||||
1. Default pubkey to own pubkey if not provided
|
||||
2. Default kind to 31123
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
|
||||
4. Query existing kind `10123` event for own pubkey
|
||||
5. Find and remove matching `["a", ...]` tag
|
||||
6. Republish `10123`
|
||||
7. Return JSON with `success`, `removed_address`, `event_id`
|
||||
|
||||
---
|
||||
|
||||
### 5. `skill_search`
|
||||
|
||||
**Purpose:** Search for skills across the agent's Web of Trust.
|
||||
|
||||
**OpenAI schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_search",
|
||||
"description": "Search for skills adopted by Web of Trust contacts, or query public skill definitions",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Optional keyword to filter skill slugs or descriptions" },
|
||||
"pubkey": { "type": "string", "description": "Search skills by a specific author pubkey" },
|
||||
"popular": { "type": "boolean", "description": "If true, query WoT adoption lists to find most-adopted skills" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_search`):**
|
||||
|
||||
1. If `popular` is true:
|
||||
- Query `{"kinds": [10123]}` from relays (with reasonable limit)
|
||||
- Parse all `a`-tags from results
|
||||
- Count occurrences of each `a`-tag address
|
||||
- Sort by adoption count descending
|
||||
- Return top N skill addresses with counts
|
||||
2. If `pubkey` is provided:
|
||||
- Query `{"kinds": [31123], "authors": [pubkey]}`
|
||||
- Return skill summaries
|
||||
3. If `query` is provided (keyword search):
|
||||
- Query `{"kinds": [31123]}` with limit
|
||||
- Filter results client-side by matching `query` against slug, description tag, or content
|
||||
- Return matching skill summaries
|
||||
4. Default (no params): return own adopted skills from `10123`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
### Shared helper: adoption list update
|
||||
|
||||
Since `skill_create`, `skill_adopt`, and `skill_remove` all modify the kind `10123` list, extract a shared helper:
|
||||
|
||||
```c
|
||||
// Fetch current 10123 event, return duplicated tags array (or empty array if none exists)
|
||||
static cJSON* fetch_adoption_list_tags(tools_context_t* ctx);
|
||||
|
||||
// Publish updated 10123 event with new tags
|
||||
static int publish_adoption_list(tools_context_t* ctx, cJSON* tags, nostr_publish_result_t* result);
|
||||
```
|
||||
|
||||
This is essentially the same pattern already used in `execute_nostr_list_manage()` but specialized for kind `10123`.
|
||||
|
||||
### Shared helper: skill summary extraction
|
||||
|
||||
```c
|
||||
// Extract slug, kind, scope, description, created_at from a skill event JSON
|
||||
static cJSON* extract_skill_summary(cJSON* event);
|
||||
```
|
||||
|
||||
### Flow diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
SC[skill_create] --> PUB[nostr_handler_publish_kind_event]
|
||||
SC --> ADOPT_HELPER[update adoption list helper]
|
||||
|
||||
SL[skill_list] --> QUERY[nostr_handler_query_json]
|
||||
SL --> SUMMARY[extract_skill_summary]
|
||||
|
||||
SA[skill_adopt] --> ADOPT_HELPER
|
||||
SR[skill_remove] --> ADOPT_HELPER
|
||||
|
||||
SS[skill_search] --> QUERY
|
||||
SS --> SUMMARY
|
||||
|
||||
ADOPT_HELPER --> QUERY
|
||||
ADOPT_HELPER --> PUB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changes Required
|
||||
|
||||
### `src/tools.c`
|
||||
|
||||
1. **Schema registration** — Add 5 new tool definitions in `tools_build_openai_schema_json()` (t22–t26)
|
||||
2. **Execution functions** — Add 5 new `execute_skill_*()` static functions
|
||||
3. **Dispatch** — Add 5 new `strcmp` branches in `tools_execute()`
|
||||
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_slug()`
|
||||
|
||||
### `README.md`
|
||||
|
||||
1. Add skill tools to the Tooling Interface section under a new "Skill management" category
|
||||
|
||||
### No changes needed to:
|
||||
- `src/tools.h` — the `tools_context_t` already has `cfg` which provides `keys.public_key_hex`
|
||||
- `src/nostr_handler.h` — all needed APIs already exist
|
||||
- `src/config.h` — no new config fields needed
|
||||
|
||||
---
|
||||
|
||||
## Slug Validation Rules
|
||||
|
||||
A valid skill slug must:
|
||||
- Be 1–64 characters
|
||||
- Contain only lowercase letters, digits, and hyphens
|
||||
- Not start or end with a hyphen
|
||||
- Not contain consecutive hyphens
|
||||
|
||||
```c
|
||||
static int validate_skill_slug(const char* slug) {
|
||||
if (!slug || slug[0] == '\0' || strlen(slug) > 64) return 0;
|
||||
if (slug[0] == '-') return 0;
|
||||
int prev_dash = 0;
|
||||
for (size_t i = 0; slug[i]; i++) {
|
||||
char c = slug[i];
|
||||
if (c == '-') {
|
||||
if (prev_dash) return 0;
|
||||
prev_dash = 1;
|
||||
} else if (islower(c) || isdigit(c)) {
|
||||
prev_dash = 0;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (slug[strlen(slug) - 1] == '-') return 0;
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Shared helpers** — `validate_skill_slug`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
|
||||
2. **`skill_create`** — most important tool, enables the agent to author skills
|
||||
3. **`skill_list`** — lets the agent see what it has published
|
||||
4. **`skill_adopt`** — adopt skills from other authors
|
||||
5. **`skill_remove`** — remove skills from adoption list
|
||||
6. **`skill_search`** — discover skills across WoT
|
||||
7. **Schema registration** — add all 5 tools to `tools_build_openai_schema_json()`
|
||||
8. **Dispatch wiring** — add all 5 to `tools_execute()`
|
||||
9. **README update** — document the new tools
|
||||
10. **Build and test** — verify compilation and basic tool execution
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Admin-only**: Skill tools inherit the existing ADMIN tier restriction — only the admin can trigger tool calls
|
||||
- **Slug validation**: Prevents injection of malformed d-tags
|
||||
- **No arbitrary kind**: `skill_create` only publishes kind 31123 or 31124, not arbitrary kinds
|
||||
- **Adoption list integrity**: The helpers always fetch-then-update to avoid clobbering existing adoption entries
|
||||
- **Content size**: No explicit limit on skill content size — relies on relay limits and LLM context window constraints
|
||||
@@ -0,0 +1,139 @@
|
||||
# Didactyl Tool Testing Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Testing agent-mediated tools is different from unit testing pure functions because the execution path spans multiple boundaries:
|
||||
|
||||
```
|
||||
LLM schema interpretation → JSON argument generation → argument parsing → business logic → Nostr/network I/O → JSON response → LLM interpretation
|
||||
```
|
||||
|
||||
This plan defines three testing layers, ordered by implementation priority.
|
||||
|
||||
---
|
||||
|
||||
## Layer 1: Direct Tool Execution
|
||||
|
||||
Call `tools_execute()` directly with known JSON arguments and assert the JSON response structure.
|
||||
|
||||
### Implementation
|
||||
|
||||
Add a `--test-tool <name> <args_json>` CLI flag to `src/main.c` that:
|
||||
1. Initializes config and nostr_handler (for network-dependent tools)
|
||||
2. Calls `tools_execute(&ctx, name, args_json)`
|
||||
3. Prints the raw JSON result to stdout
|
||||
4. Exits with 0 if `success: true`, 1 otherwise
|
||||
|
||||
### Pure Computation Tools (no network needed)
|
||||
|
||||
| Tool | Test Command | Expected |
|
||||
|------|-------------|----------|
|
||||
| `nostr_encode` | `--test-tool nostr_encode '{"type":"npub","hex":"<64-char-hex>"}'` | `success: true`, uri starts with `nostr:npub1` |
|
||||
| `nostr_decode` | `--test-tool nostr_decode '{"uri":"npub1..."}'` | `success: true`, pubkey is 64-char hex |
|
||||
| `nostr_encrypt` | `--test-tool nostr_encrypt '{"recipient_pubkey":"<hex>","plaintext":"hello"}'` | `success: true`, ciphertext is base64 |
|
||||
| `nostr_decrypt` | `--test-tool nostr_decrypt '{"sender_pubkey":"<hex>","ciphertext":"<from encrypt>"}'` | `success: true`, plaintext is "hello" |
|
||||
|
||||
### Network-Dependent Tools (need relay or HTTP)
|
||||
|
||||
| Tool | Test Command | Expected |
|
||||
|------|-------------|----------|
|
||||
| `nostr_nip05_lookup` | `--test-tool nostr_nip05_lookup '{"identifier":"_@laantungir.com"}'` | `success: true`, pubkey returned |
|
||||
| `nostr_relay_info` | `--test-tool nostr_relay_info '{"relay_url":"wss://relay.damus.io"}'` | `success: true`, info.basic.name present |
|
||||
| `nostr_relay_status` | `--test-tool nostr_relay_status '{}'` | `success: true`, relay_count > 0 |
|
||||
| `nostr_dm_send` | `--test-tool nostr_dm_send '{"recipient_pubkey":"<hex>","message":"test"}'` | `success: true` |
|
||||
| `nostr_post` | `--test-tool nostr_post '{"kind":1,"content":"test"}'` | `success: true`, event_id present |
|
||||
| `nostr_delete` | `--test-tool nostr_delete '{"event_ids":["<64-char-hex>"]}'` | `success: true` |
|
||||
| `nostr_react` | `--test-tool nostr_react '{"event_id":"<hex>","event_pubkey":"<hex>"}'` | `success: true` |
|
||||
| `nostr_profile_get` | `--test-tool nostr_profile_get '{"pubkey":"<hex>"}'` | `success: true`, found: true/false |
|
||||
| `nostr_dm_send_nip17` | `--test-tool nostr_dm_send_nip17 '{"recipient_pubkey":"<hex>","message":"test"}'` | `success: true` |
|
||||
| `nostr_list_manage` | `--test-tool nostr_list_manage '{"list_kind":10000,"action":"add","items":[["p","<hex>"]]}'` | `success: true` |
|
||||
|
||||
### Error Case Tests
|
||||
|
||||
Each tool should also be tested with:
|
||||
- Empty args: `'{}'` — should return descriptive error
|
||||
- Missing required fields — should return specific error message
|
||||
- Invalid hex strings — should reject gracefully
|
||||
- Double-encoded JSON string args — should parse correctly (regression for the `invalid arguments JSON` bug)
|
||||
|
||||
---
|
||||
|
||||
## Layer 2: Schema-Parse Fidelity
|
||||
|
||||
Validates that the OpenAI function schema matches what executors actually accept.
|
||||
|
||||
### Implementation
|
||||
|
||||
A Python script (`tests/validate_schemas.py`) that:
|
||||
1. Runs `didactyl_static --dump-schemas` (new flag) to get the tool schema JSON
|
||||
2. For each tool in the schema:
|
||||
- Generates a minimal valid argument payload from `required` + `properties`
|
||||
- Runs `didactyl_static --test-tool <name> '<generated_json>'`
|
||||
- Asserts exit code 0 or expected network error
|
||||
3. Reports mismatches between schema and executor expectations
|
||||
|
||||
### What This Catches
|
||||
|
||||
- Schema says `required: ["hex"]` but executor checks for `"pubkey"` — mismatch
|
||||
- Schema says `type: "integer"` but executor reads it as string
|
||||
- Schema advertises parameters the executor ignores
|
||||
- Missing required parameters in schema that executor demands
|
||||
|
||||
---
|
||||
|
||||
## Layer 3: Agent Integration Testing (Live DM)
|
||||
|
||||
The most natural test — DM the running agent and verify it uses tools correctly.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Local relay running at `ws://127.0.0.1:7777`
|
||||
- Didactyl running with valid config
|
||||
- A separate Nostr client (or script) to send/receive DMs
|
||||
|
||||
### Test Prompts
|
||||
|
||||
| # | Tool | DM Prompt | Assert in Response |
|
||||
|---|------|-----------|-------------------|
|
||||
| 1 | `nostr_encode` | "Encode this pubkey as npub: `<64-char hex>`" | Contains `nostr:npub1` |
|
||||
| 2 | `nostr_decode` | "Decode this npub: `npub1...`" | Contains the hex pubkey |
|
||||
| 3 | `nostr_dm_send` | "Send a DM to `<your-own-pubkey>` saying 'hello test'" | Confirms sent; you receive it |
|
||||
| 4 | `nostr_encrypt` | "Encrypt 'secret message' for `<pubkey>`" | Contains base64 ciphertext |
|
||||
| 5 | `nostr_decrypt` | "Decrypt this NIP-44 payload: `<ciphertext from #4>`" | Contains 'secret message' |
|
||||
| 6 | `nostr_nip05_lookup` | "Look up `_@laantungir.com`" | Returns a pubkey |
|
||||
| 7 | `nostr_relay_info` | "Get NIP-11 info for `wss://relay.damus.io`" | Returns relay name and supported NIPs |
|
||||
| 8 | `nostr_relay_status` | "Show me relay connection status" | Lists connected relays with stats |
|
||||
| 9 | `nostr_react` | "React with 🤙 to event `<id>` from `<pubkey>`" | Confirms kind 7 published |
|
||||
| 10 | `nostr_delete` | "Delete event `<id>`" | Confirms kind 5 published |
|
||||
| 11 | `nostr_profile_get` | "Look up the profile for `<pubkey>`" | Returns name/about/picture |
|
||||
| 12 | `nostr_post` | "Post a kind 1 note saying 'tool test'" | Confirms published with event_id |
|
||||
| 13 | `nostr_list_manage` | "Add `<pubkey>` to my mute list" | Confirms kind 10000 published |
|
||||
| 14 | `nostr_dm_send_nip17` | "Send a private NIP-17 DM to `<pubkey>` saying 'gift wrap test'" | Confirms gift wrap sent |
|
||||
| 15 | `nostr_post_readme` | "Publish the README to Nostr" | Confirms kind 30023 with d=readme.md |
|
||||
|
||||
### Verification Methods
|
||||
|
||||
- **stdout logs**: Watch `[didactyl] executing tool call: <name>` in terminal
|
||||
- **context.log**: Full LLM conversation including tool calls and results
|
||||
- **Relay inspection**: Query the local relay for published events
|
||||
- **DM receipt**: For DM tools, verify the message arrives at the recipient
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **Immediate (no code changes)**: Run Layer 3 tests by DMing the live agent
|
||||
2. **Next sprint**: Add `--test-tool` CLI flag for Layer 1
|
||||
3. **Later**: Add `--dump-schemas` flag and Python schema validator for Layer 2
|
||||
4. **CI integration**: Wrap Layer 1 pure-computation tests in a shell script that runs after `build_static.sh`
|
||||
|
||||
---
|
||||
|
||||
## Local Relay Setup for Testing
|
||||
|
||||
A local relay like `strfry` or `nostr-rs-relay` at `ws://127.0.0.1:7777` provides:
|
||||
- All published events are observable and queryable
|
||||
- No rate limits or content policies
|
||||
- NIP-42 auth testing in isolation
|
||||
- Gift-wrapped NIP-17 DMs stay in your test environment
|
||||
- Database can be wiped between test runs
|
||||
@@ -0,0 +1,203 @@
|
||||
# Triggered Skills — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the existing skill system so that skills can carry Nostr subscription triggers. When matching events arrive, didactyl executes the skill automatically — either via template interpolation (fast, no LLM) or LLM-mediated reasoning (full agent loop).
|
||||
|
||||
See [docs/TOOLS_AND_SKILLS.md](../docs/TOOLS_AND_SKILLS.md) for the full architecture.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Trigger Manager Module
|
||||
|
||||
Create `src/trigger_manager.c` and `src/trigger_manager.h` — the core component that manages dynamic Nostr subscriptions tied to skills.
|
||||
|
||||
**Data structures:**
|
||||
|
||||
```c
|
||||
#define TRIGGER_MAX_ACTIVE 16
|
||||
#define TRIGGER_COOLDOWN_SECONDS 60
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[65];
|
||||
char skill_content[4096]; // the action template or LLM prompt
|
||||
int action_type; // 0 = llm, 1 = template
|
||||
char filter_json[2048]; // the Nostr subscription filter
|
||||
int enabled;
|
||||
time_t last_fired;
|
||||
nostr_pool_subscription_t* subscription;
|
||||
} active_trigger_t;
|
||||
|
||||
typedef struct {
|
||||
active_trigger_t triggers[TRIGGER_MAX_ACTIVE];
|
||||
int count;
|
||||
didactyl_config_t* cfg;
|
||||
pthread_mutex_t mutex;
|
||||
} trigger_manager_t;
|
||||
```
|
||||
|
||||
**API:**
|
||||
|
||||
```c
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
|
||||
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_slug,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_slug,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr);
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr);
|
||||
```
|
||||
|
||||
### 2. Template Engine
|
||||
|
||||
Create a simple template interpolation engine in `src/trigger_manager.c` (or a separate `src/template.c` if it grows).
|
||||
|
||||
**Functionality:**
|
||||
- Parse placeholders like `{content}`, `{pubkey}`, `{author_display_name}` from a template string
|
||||
- Extract values from a triggering Nostr event (cJSON object)
|
||||
- Produce an interpolated output string
|
||||
- Parse action prefixes: `DM admin:`, `DM <pubkey>:`, `POST:`, `LOG:`
|
||||
|
||||
### 3. Trigger Event Callback
|
||||
|
||||
When a subscribed event arrives:
|
||||
|
||||
```c
|
||||
static void on_trigger_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
active_trigger_t* trigger = (active_trigger_t*)user_data;
|
||||
|
||||
// Check cooldown
|
||||
if (time(NULL) - trigger->last_fired < TRIGGER_COOLDOWN_SECONDS) return;
|
||||
trigger->last_fired = time(NULL);
|
||||
|
||||
if (trigger->action_type == 1) {
|
||||
// Template: interpolate and execute
|
||||
char* output = template_interpolate(trigger->skill_content, event);
|
||||
template_execute_action(output); // parse prefix, DM/POST/LOG
|
||||
free(output);
|
||||
} else {
|
||||
// LLM: build context and run agent loop
|
||||
trigger_run_llm_action(trigger, event, relay_url);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Skill Loading on Startup
|
||||
|
||||
In `main.c`, after `agent_init()` and skill adoption list is available:
|
||||
|
||||
1. Query own kind 10123 adoption list
|
||||
2. For each adopted skill address, query the skill event
|
||||
3. Check for `trigger` tag — if present, extract `filter`, `action`, `enabled` tags
|
||||
4. Register with trigger manager
|
||||
5. Trigger manager creates Nostr subscriptions
|
||||
|
||||
### 5. Extend skill_create for Live Trigger Registration
|
||||
|
||||
When `skill_create` is called with trigger tags:
|
||||
1. Publish the skill event as normal
|
||||
2. If trigger tags are present, also register with the trigger manager immediately
|
||||
3. No restart required — the subscription goes live right away
|
||||
|
||||
When `skill_remove` is called for a triggered skill:
|
||||
1. Remove from adoption list as normal
|
||||
2. Also unregister from trigger manager, tearing down the subscription
|
||||
|
||||
### 6. Extend Agent for Trigger-Initiated Conversations
|
||||
|
||||
The agent currently only handles DM-initiated conversations via `agent_on_message()`. Add a new entry point:
|
||||
|
||||
```c
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
```
|
||||
|
||||
This builds an LLM conversation with:
|
||||
- System context (soul)
|
||||
- A system message explaining this is a triggered skill execution
|
||||
- The skill content as instructions
|
||||
- The triggering event as user context
|
||||
- Full tool access (same as admin tier)
|
||||
|
||||
The LLM response actions (DMs, posts, etc.) are executed via tools as normal.
|
||||
|
||||
### 7. New Tool: trigger_list
|
||||
|
||||
Add a tool so the LLM can inspect active triggers:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "trigger_list",
|
||||
"description": "List all active triggered skills with their filters and status",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Config Extension
|
||||
|
||||
Add trigger-related limits to config:
|
||||
|
||||
```json
|
||||
{
|
||||
"triggers": {
|
||||
"enabled": true,
|
||||
"max_active": 16,
|
||||
"cooldown_seconds": 60,
|
||||
"llm_rate_limit_per_minute": 10,
|
||||
"template_rate_limit_per_minute": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Integration into Main Loop
|
||||
|
||||
The trigger manager subscriptions are serviced by the same `nostr_handler_poll()` call in the main loop — no changes needed to the poll loop itself, since all subscriptions share the relay pool.
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/trigger_manager.c` | **NEW** — trigger manager, template engine, event callbacks |
|
||||
| `src/trigger_manager.h` | **NEW** — trigger manager API |
|
||||
| `src/main.c` | Add trigger_manager_init, trigger_manager_load_from_skills after agent_init |
|
||||
| `src/agent.c` | Add agent_on_trigger entry point for LLM-mediated trigger actions |
|
||||
| `src/agent.h` | Declare agent_on_trigger |
|
||||
| `src/tools.c` | Extend skill_create/skill_remove to register/unregister triggers; add trigger_list tool |
|
||||
| `src/config.h` | Add triggers_config_t struct |
|
||||
| `src/config.c` | Parse triggers config section |
|
||||
| `docs/TOOLS_AND_SKILLS.md` | Already written — full architecture reference |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. `trigger_manager.h` / `trigger_manager.c` — core module with data structures and API stubs
|
||||
2. Template engine — interpolation and action prefix parsing
|
||||
3. Trigger event callback — on_trigger_event with cooldown
|
||||
4. Startup loading — query adoption list, find triggered skills, create subscriptions
|
||||
5. agent_on_trigger — LLM-mediated trigger execution path
|
||||
6. Live registration — extend skill_create/skill_remove for immediate trigger management
|
||||
7. trigger_list tool — LLM visibility into active triggers
|
||||
8. Config parsing — triggers section with limits
|
||||
9. Rate limiting — enforce LLM and template rate limits
|
||||
10. Testing — manual trigger creation and verification
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing `nostr_handler_query_json()` for loading skills
|
||||
- Existing `nostr_relay_pool_subscribe()` for creating trigger subscriptions
|
||||
- Existing `agent_on_message()` pattern for the LLM-mediated path
|
||||
- Existing `skill_create` / `skill_remove` tools for lifecycle hooks
|
||||
+75
@@ -19,6 +19,7 @@
|
||||
static didactyl_config_t* g_cfg = NULL;
|
||||
static char* g_system_context = NULL;
|
||||
static tools_context_t g_tools_ctx;
|
||||
static struct trigger_manager* g_trigger_manager = NULL;
|
||||
|
||||
#define AGENT_DEBOUNCE_WINDOW_SECONDS 5
|
||||
#define AGENT_DEBOUNCE_CACHE_SIZE 256
|
||||
@@ -488,6 +489,79 @@ int agent_init(didactyl_config_t* config, const char* system_context) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void agent_set_trigger_manager(struct trigger_manager* trigger_manager) {
|
||||
g_trigger_manager = trigger_manager;
|
||||
g_tools_ctx.trigger_manager = trigger_manager;
|
||||
}
|
||||
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url) {
|
||||
if (!g_cfg || !g_system_context || !skill_slug || !skill_content || !triggering_event) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* event_json = cJSON_PrintUnformatted(triggering_event);
|
||||
if (!event_json) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char* relay = (relay_url && relay_url[0] != '\0') ? relay_url : "unknown";
|
||||
|
||||
const char* trigger_prefix =
|
||||
"Triggered-skill execution context:\n"
|
||||
"- You are executing a skill because a Nostr event matched its trigger filter.\n"
|
||||
"- Execute the skill instructions below against the triggering event.\n"
|
||||
"- Keep output concise and actionable for the administrator.\n"
|
||||
"- If no action is needed, explicitly say why.\n\n"
|
||||
"Skill slug: ";
|
||||
|
||||
size_t system_len = strlen(g_system_context) + 2 + strlen(trigger_prefix) + strlen(skill_slug) +
|
||||
strlen("\nRelay: ") + strlen(relay) + strlen("\n\nSkill instructions:\n") +
|
||||
strlen(skill_content) + 1U;
|
||||
char* system_prompt = (char*)malloc(system_len);
|
||||
if (!system_prompt) {
|
||||
free(event_json);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(system_prompt,
|
||||
system_len,
|
||||
"%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s",
|
||||
g_system_context,
|
||||
trigger_prefix,
|
||||
skill_slug,
|
||||
relay,
|
||||
skill_content);
|
||||
|
||||
size_t user_len = strlen("Triggering event JSON:\n") + strlen(event_json) + 1U;
|
||||
char* user_prompt = (char*)malloc(user_len);
|
||||
if (!user_prompt) {
|
||||
free(system_prompt);
|
||||
free(event_json);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(user_prompt, user_len, "Triggering event JSON:\n%s", event_json);
|
||||
free(event_json);
|
||||
|
||||
append_context_log(g_cfg->admin.pubkey, "llm_trigger", user_prompt);
|
||||
|
||||
char* response = llm_chat(system_prompt, user_prompt);
|
||||
free(system_prompt);
|
||||
free(user_prompt);
|
||||
|
||||
if (!response) {
|
||||
const char* fallback = "Triggered skill execution failed: LLM unavailable.";
|
||||
(void)nostr_handler_send_dm(g_cfg->admin.pubkey, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
(void)nostr_handler_send_dm(g_cfg->admin.pubkey, response);
|
||||
free(response);
|
||||
}
|
||||
|
||||
void agent_on_message(const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
@@ -669,6 +743,7 @@ void agent_cleanup(void) {
|
||||
free(g_system_context);
|
||||
g_system_context = NULL;
|
||||
g_cfg = NULL;
|
||||
g_trigger_manager = NULL;
|
||||
memset(g_seen_msgs, 0, sizeof(g_seen_msgs));
|
||||
g_seen_msgs_count = 0;
|
||||
g_seen_msgs_next = 0;
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
int agent_init(didactyl_config_t* config, const char* system_context);
|
||||
void agent_set_trigger_manager(struct trigger_manager* trigger_manager);
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
void agent_on_message(const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
|
||||
@@ -271,6 +271,50 @@ static int parse_admin_context_config(cJSON* root, didactyl_config_t* config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_triggers_config(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* triggers = cJSON_GetObjectItemCaseSensitive(root, "triggers");
|
||||
if (!triggers || !cJSON_IsObject(triggers)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(triggers, "enabled");
|
||||
cJSON* max_active = cJSON_GetObjectItemCaseSensitive(triggers, "max_active");
|
||||
cJSON* cooldown_seconds = cJSON_GetObjectItemCaseSensitive(triggers, "cooldown_seconds");
|
||||
cJSON* llm_rate_limit = cJSON_GetObjectItemCaseSensitive(triggers, "llm_rate_limit_per_minute");
|
||||
cJSON* template_rate_limit = cJSON_GetObjectItemCaseSensitive(triggers, "template_rate_limit_per_minute");
|
||||
|
||||
if (enabled && cJSON_IsBool(enabled)) {
|
||||
config->triggers.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
|
||||
}
|
||||
if (max_active && cJSON_IsNumber(max_active)) {
|
||||
config->triggers.max_active = (int)max_active->valuedouble;
|
||||
}
|
||||
if (cooldown_seconds && cJSON_IsNumber(cooldown_seconds)) {
|
||||
config->triggers.cooldown_seconds = (int)cooldown_seconds->valuedouble;
|
||||
}
|
||||
if (llm_rate_limit && cJSON_IsNumber(llm_rate_limit)) {
|
||||
config->triggers.llm_rate_limit_per_minute = (int)llm_rate_limit->valuedouble;
|
||||
}
|
||||
if (template_rate_limit && cJSON_IsNumber(template_rate_limit)) {
|
||||
config->triggers.template_rate_limit_per_minute = (int)template_rate_limit->valuedouble;
|
||||
}
|
||||
|
||||
if (config->triggers.max_active < 1) {
|
||||
config->triggers.max_active = 1;
|
||||
}
|
||||
if (config->triggers.cooldown_seconds < 0) {
|
||||
config->triggers.cooldown_seconds = 0;
|
||||
}
|
||||
if (config->triggers.llm_rate_limit_per_minute < 1) {
|
||||
config->triggers.llm_rate_limit_per_minute = 1;
|
||||
}
|
||||
if (config->triggers.template_rate_limit_per_minute < 1) {
|
||||
config->triggers.template_rate_limit_per_minute = 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static cJSON* find_tag_value_string(cJSON* tags, const char* tag_key) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !tag_key) {
|
||||
return NULL;
|
||||
@@ -558,6 +602,7 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config_set_error("unknown configuration error");
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
snprintf(config->config_path, sizeof(config->config_path), "%s", path);
|
||||
config->tools.enabled = 1;
|
||||
config->tools.max_turns = 8;
|
||||
config->tools.shell.enabled = 1;
|
||||
@@ -581,6 +626,12 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config->admin_context.track_kind_1 = 1;
|
||||
config->admin_context.kind_1_limit = 10;
|
||||
|
||||
config->triggers.enabled = 1;
|
||||
config->triggers.max_active = 16;
|
||||
config->triggers.cooldown_seconds = 60;
|
||||
config->triggers.llm_rate_limit_per_minute = 10;
|
||||
config->triggers.template_rate_limit_per_minute = 60;
|
||||
|
||||
char* json_buf = NULL;
|
||||
size_t json_len = 0;
|
||||
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
|
||||
@@ -680,6 +731,11 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_triggers_config(root, config) != 0) {
|
||||
config_set_error("invalid triggers configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
|
||||
config_set_error("keys.nsec must be valid nsec1... or 64-char hex private key");
|
||||
goto cleanup;
|
||||
|
||||
@@ -72,6 +72,14 @@ typedef struct {
|
||||
int kind_1_limit;
|
||||
} admin_context_config_t;
|
||||
|
||||
typedef struct {
|
||||
int enabled;
|
||||
int max_active;
|
||||
int cooldown_seconds;
|
||||
int llm_rate_limit_per_minute;
|
||||
int template_rate_limit_per_minute;
|
||||
} triggers_config_t;
|
||||
|
||||
typedef struct {
|
||||
agent_keys_t keys;
|
||||
admin_config_t admin;
|
||||
@@ -81,8 +89,10 @@ typedef struct {
|
||||
tools_config_t tools;
|
||||
security_config_t security;
|
||||
admin_context_config_t admin_context;
|
||||
triggers_config_t triggers;
|
||||
startup_event_t* startup_events;
|
||||
int startup_event_count;
|
||||
char config_path[OW_MAX_URL_LEN];
|
||||
} didactyl_config_t;
|
||||
|
||||
int config_load(const char* path, didactyl_config_t* config);
|
||||
|
||||
@@ -64,15 +64,13 @@ static const char* detect_ca_bundle_path(void) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static char* perform_chat_request(const char* body) {
|
||||
static char* perform_http_request(const char* url, const char* body, int is_post) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl || !body) {
|
||||
if (!curl || !url) {
|
||||
if (curl) curl_easy_cleanup(curl);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char url[OW_MAX_URL_LEN + 64];
|
||||
snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url);
|
||||
|
||||
response_buffer_t rb = {0};
|
||||
struct curl_slist* headers = NULL;
|
||||
@@ -83,8 +81,11 @@ static char* perform_chat_request(const char* body) {
|
||||
headers = curl_slist_append(headers, auth_header);
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPGET, is_post ? 0L : 1L);
|
||||
if (is_post) {
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : "{}");
|
||||
}
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
|
||||
@@ -132,6 +133,12 @@ static char* perform_chat_request(const char* body) {
|
||||
return rb.data;
|
||||
}
|
||||
|
||||
static char* perform_chat_request(const char* body) {
|
||||
char url[OW_MAX_URL_LEN + 64];
|
||||
snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url);
|
||||
return perform_http_request(url, body, 1);
|
||||
}
|
||||
|
||||
static char* build_request_json(const char* system_prompt, const char* user_message) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
@@ -388,6 +395,36 @@ void llm_response_free(llm_response_t* response) {
|
||||
memset(response, 0, sizeof(*response));
|
||||
}
|
||||
|
||||
int llm_get_config(llm_config_t* out_config) {
|
||||
if (!g_initialized || !out_config) {
|
||||
return -1;
|
||||
}
|
||||
*out_config = g_cfg;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int llm_set_config(const llm_config_t* config) {
|
||||
if (!g_initialized || !config) {
|
||||
return -1;
|
||||
}
|
||||
g_cfg = *config;
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* llm_list_models_json(const char* base_url_override) {
|
||||
if (!g_initialized) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const char* base_url = (base_url_override && base_url_override[0] != '\0')
|
||||
? base_url_override
|
||||
: g_cfg.base_url;
|
||||
|
||||
char url[OW_MAX_URL_LEN + 32];
|
||||
snprintf(url, sizeof(url), "%s/models", base_url);
|
||||
return perform_http_request(url, NULL, 0);
|
||||
}
|
||||
|
||||
void llm_cleanup(void) {
|
||||
if (!g_initialized) {
|
||||
return;
|
||||
|
||||
@@ -26,6 +26,9 @@ int llm_chat_with_tools_messages(const char* messages_json,
|
||||
const char* tool_choice,
|
||||
llm_response_t* out_response);
|
||||
void llm_response_free(llm_response_t* response);
|
||||
int llm_get_config(llm_config_t* out_config);
|
||||
int llm_set_config(const llm_config_t* config);
|
||||
char* llm_list_models_json(const char* base_url_override);
|
||||
void llm_cleanup(void);
|
||||
|
||||
#endif
|
||||
+156
-2
@@ -4,6 +4,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "main.h"
|
||||
@@ -12,6 +13,9 @@
|
||||
#include "llm.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "debug.h"
|
||||
#include "trigger_manager.h"
|
||||
#include "tools.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
@@ -20,17 +24,74 @@ static void signal_handler(int signum) {
|
||||
g_running = 0;
|
||||
}
|
||||
|
||||
static void print_usage(const char* prog) {
|
||||
fprintf(stderr,
|
||||
"Usage: %s [-h|--help] [-v|--version] [--config <path>] [--debug <0-5>] [--dump-schemas] [--test-tool <name> <args_json>]\n",
|
||||
prog ? prog : "didactyl");
|
||||
}
|
||||
|
||||
static int tool_result_success(const char* json) {
|
||||
if (!json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_Parse(json);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
|
||||
int ok = (success && cJSON_IsBool(success) && cJSON_IsTrue(success)) ? 1 : 0;
|
||||
cJSON_Delete(root);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static int wait_for_connected_relays(int timeout_ms) {
|
||||
if (timeout_ms <= 0) {
|
||||
return nostr_handler_connected_relay_count() > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
const int step_ms = 100;
|
||||
int elapsed_ms = 0;
|
||||
|
||||
while (elapsed_ms < timeout_ms) {
|
||||
if (nostr_handler_connected_relay_count() > 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
(void)nostr_handler_poll(step_ms);
|
||||
elapsed_ms += step_ms;
|
||||
}
|
||||
|
||||
return nostr_handler_connected_relay_count() > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char* config_path = "./config.json";
|
||||
int debug_level = DEBUG_LEVEL_TRACE;
|
||||
int dump_schemas = 0;
|
||||
const char* test_tool_name = NULL;
|
||||
const char* test_tool_args = "{}";
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) {
|
||||
if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
||||
print_usage(argv[0]);
|
||||
return 0;
|
||||
} else if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--version") == 0) {
|
||||
printf("%s %s\n", DIDACTYL_NAME, DIDACTYL_VERSION);
|
||||
return 0;
|
||||
} else if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) {
|
||||
config_path = argv[++i];
|
||||
} else if (strcmp(argv[i], "--debug") == 0 && i + 1 < argc) {
|
||||
debug_level = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--dump-schemas") == 0) {
|
||||
dump_schemas = 1;
|
||||
} else if (strcmp(argv[i], "--test-tool") == 0 && i + 2 < argc) {
|
||||
test_tool_name = argv[++i];
|
||||
test_tool_args = argv[++i];
|
||||
} else {
|
||||
fprintf(stderr, "Usage: %s [--config <path>] [--debug <0-5>]\n", argv[0]);
|
||||
print_usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +113,79 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (dump_schemas || test_tool_name) {
|
||||
if (llm_init(&cfg.llm) != 0) {
|
||||
fprintf(stderr, "Failed to initialize llm client\n");
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (nostr_handler_init(&cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize nostr handler\n");
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
tools_context_t tools_ctx;
|
||||
if (tools_init(&tools_ctx, &cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize tools context\n");
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
int exit_code = 0;
|
||||
|
||||
if (test_tool_name) {
|
||||
const int timeout_ms = 15000;
|
||||
if (!wait_for_connected_relays(timeout_ms)) {
|
||||
fprintf(stderr,
|
||||
"No relay connection established within %d ms; refusing to run --test-tool '%s'\n",
|
||||
timeout_ms,
|
||||
test_tool_name);
|
||||
tools_cleanup(&tools_ctx);
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (dump_schemas) {
|
||||
char* schemas = tools_build_openai_schema_json(&tools_ctx);
|
||||
if (!schemas) {
|
||||
fprintf(stderr, "Failed to build tool schemas\n");
|
||||
exit_code = 1;
|
||||
} else {
|
||||
printf("%s\n", schemas);
|
||||
free(schemas);
|
||||
}
|
||||
} else {
|
||||
char* result = tools_execute(&tools_ctx, test_tool_name, test_tool_args);
|
||||
if (!result) {
|
||||
fprintf(stderr, "Failed to execute tool\n");
|
||||
exit_code = 1;
|
||||
} else {
|
||||
printf("%s\n", result);
|
||||
exit_code = tool_result_success(result) ? 0 : 1;
|
||||
free(result);
|
||||
}
|
||||
}
|
||||
|
||||
tools_cleanup(&tools_ctx);
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return exit_code;
|
||||
}
|
||||
|
||||
if (llm_init(&cfg.llm) != 0) {
|
||||
fprintf(stderr, "Failed to initialize llm client\n");
|
||||
config_free(&cfg);
|
||||
@@ -85,6 +219,22 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
trigger_manager_t trigger_manager;
|
||||
if (trigger_manager_init(&trigger_manager, &cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize trigger manager\n");
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
agent_set_trigger_manager(&trigger_manager);
|
||||
|
||||
if (trigger_manager_load_from_skills(&trigger_manager) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: trigger manager load from skills failed (continuing)");
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe admin context begin");
|
||||
if (nostr_handler_subscribe_admin_context() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: subscribe admin context failed (continuing)");
|
||||
@@ -112,6 +262,7 @@ int main(int argc, char** argv) {
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
trigger_manager_cleanup(&trigger_manager);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
@@ -121,12 +272,14 @@ int main(int argc, char** argv) {
|
||||
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
DEBUG_INFO("[didactyl] entering main poll loop");
|
||||
DEBUG_INFO("[didactyl] running with pubkey %s", cfg.keys.public_key_hex);
|
||||
|
||||
while (g_running) {
|
||||
(void)nostr_handler_poll(100);
|
||||
(void)trigger_manager_poll(&trigger_manager);
|
||||
struct timespec ts = {0, 10 * 1000 * 1000};
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
@@ -136,6 +289,7 @@ int main(int argc, char** argv) {
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
trigger_manager_cleanup(&trigger_manager);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
|
||||
|
||||
+2
-2
@@ -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 16
|
||||
#define DIDACTYL_VERSION "v0.0.16"
|
||||
#define DIDACTYL_VERSION_PATCH 25
|
||||
#define DIDACTYL_VERSION "v0.0.25"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
+385
-1
@@ -12,6 +12,9 @@
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "debug.h"
|
||||
|
||||
#define NIP17_MAX_RELAYS 32
|
||||
#define NIP17_MAX_GIFT_WRAPS 8
|
||||
|
||||
static didactyl_config_t* g_cfg = NULL;
|
||||
static nostr_relay_pool_t* g_pool = NULL;
|
||||
static dm_callback_t g_dm_callback = NULL;
|
||||
@@ -426,7 +429,13 @@ static void trace_plaintext_dm(const char* prefix, const char* plaintext) {
|
||||
static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
(void)user_data;
|
||||
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event ENTRY from %s (event=%p g_cfg=%p g_dm_callback=%s)",
|
||||
relay_url ? relay_url : "NULL", (void*)event, (void*)g_cfg,
|
||||
g_dm_callback ? "set" : "NULL");
|
||||
|
||||
if (!event || !g_cfg || !g_dm_callback) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event NULL guard: event=%p g_cfg=%p g_dm_callback=%s",
|
||||
(void*)event, (void*)g_cfg, g_dm_callback ? "set" : "NULL");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -444,6 +453,8 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
|
||||
if (!kind || !pubkey || !content || !tags ||
|
||||
!cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) || !cJSON_IsString(content)) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: missing required fields (kind=%p pubkey=%p content=%p tags=%p)",
|
||||
(void*)kind, (void*)pubkey, (void*)content, (void*)tags);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,16 +462,27 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
? id->valuestring
|
||||
: NULL;
|
||||
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: kind=%d id=%.16s... from=%.16s... via %s",
|
||||
(int)kind->valuedouble,
|
||||
event_id_hex ? event_id_hex : "<no-id>",
|
||||
pubkey->valuestring ? pubkey->valuestring : "<no-pk>",
|
||||
relay_url ? relay_url : "unknown");
|
||||
|
||||
if ((int)kind->valuedouble != 4) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring non-kind4 (kind=%d)", (int)kind->valuedouble);
|
||||
return;
|
||||
}
|
||||
|
||||
char recipient_pubkey_hex[65] = {0};
|
||||
if (extract_first_p_tag(tags, recipient_pubkey_hex) != 0) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: no p-tag found in kind4 event %.16s...",
|
||||
event_id_hex ? event_id_hex : "<no-id>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(recipient_pubkey_hex, g_cfg->keys.public_key_hex) != 0) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: p-tag mismatch (got=%.16s... want=%.16s...)",
|
||||
recipient_pubkey_hex, g_cfg->keys.public_key_hex);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -471,6 +493,9 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
tier = DIDACTYL_SENDER_WOT;
|
||||
}
|
||||
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_event: sender=%.16s... tier=%d (admin=%.16s...)",
|
||||
pubkey->valuestring, (int)tier, g_cfg->admin.pubkey);
|
||||
|
||||
if (tier == DIDACTYL_SENDER_STRANGER) {
|
||||
if (!g_cfg->security.stranger.enabled) {
|
||||
DEBUG_LOG("[didactyl] ignored DM from stranger %.16s... via %s",
|
||||
@@ -528,8 +553,8 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
|
||||
static void on_eose(cJSON** events, int event_count, void* user_data) {
|
||||
(void)events;
|
||||
(void)event_count;
|
||||
(void)user_data;
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_eose called: event_count=%d", event_count);
|
||||
}
|
||||
|
||||
static void free_admin_context_locked(void) {
|
||||
@@ -864,6 +889,14 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
|
||||
cJSON_AddNumberToObject(filter, "since", (double)g_start_time);
|
||||
cJSON_AddNumberToObject(filter, "limit", 100);
|
||||
|
||||
{
|
||||
char* filter_str = cJSON_PrintUnformatted(filter);
|
||||
DEBUG_TRACE("[didactyl] DEBUG DM subscription filter: %s", filter_str ? filter_str : "<null>");
|
||||
DEBUG_TRACE("[didactyl] DEBUG DM subscription g_start_time=%ld now=%ld delta=%ld relay_count=%d",
|
||||
(long)g_start_time, (long)time(NULL), (long)(time(NULL) - g_start_time), g_cfg->relay_count);
|
||||
free(filter_str);
|
||||
}
|
||||
|
||||
nostr_pool_subscription_t* sub = nostr_relay_pool_subscribe(
|
||||
g_pool,
|
||||
(const char**)g_cfg->relays,
|
||||
@@ -885,6 +918,7 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] DM subscription active for pubkey %.16s...", g_cfg->keys.public_key_hex);
|
||||
DEBUG_TRACE("[didactyl] DEBUG DM subscription sub=%p close_on_eose=0 dedup=1", (void*)sub);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1295,6 +1329,356 @@ int nostr_handler_connected_relay_count(void) {
|
||||
return connected;
|
||||
}
|
||||
|
||||
char* nostr_handler_relay_status_json(void) {
|
||||
if (!g_pool) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char** relay_urls = NULL;
|
||||
nostr_pool_relay_status_t* statuses = NULL;
|
||||
int relay_count = nostr_relay_pool_list_relays(g_pool, &relay_urls, &statuses);
|
||||
if (relay_count < 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* relays = cJSON_CreateArray();
|
||||
if (!root || !relays) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(relays);
|
||||
if (relay_urls) {
|
||||
for (int i = 0; i < relay_count; i++) {
|
||||
free(relay_urls[i]);
|
||||
}
|
||||
free(relay_urls);
|
||||
}
|
||||
free(statuses);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int connected_count = 0;
|
||||
for (int i = 0; i < relay_count; i++) {
|
||||
cJSON* relay = cJSON_CreateObject();
|
||||
if (!relay) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const char* url = (relay_urls && relay_urls[i]) ? relay_urls[i] : "";
|
||||
nostr_pool_relay_status_t st = statuses ? statuses[i] : NOSTR_POOL_RELAY_DISCONNECTED;
|
||||
if (st == NOSTR_POOL_RELAY_CONNECTED) {
|
||||
connected_count++;
|
||||
}
|
||||
|
||||
const nostr_relay_stats_t* stats = nostr_relay_pool_get_relay_stats(g_pool, url);
|
||||
|
||||
cJSON_AddStringToObject(relay, "url", url);
|
||||
cJSON_AddStringToObject(relay, "status", relay_status_str(st));
|
||||
|
||||
if (stats) {
|
||||
cJSON_AddNumberToObject(relay, "events_received", stats->events_received);
|
||||
cJSON_AddNumberToObject(relay, "events_published", stats->events_published);
|
||||
cJSON_AddNumberToObject(relay, "events_published_ok", stats->events_published_ok);
|
||||
cJSON_AddNumberToObject(relay, "events_published_failed", stats->events_published_failed);
|
||||
cJSON_AddNumberToObject(relay, "ping_latency_current", stats->ping_latency_current);
|
||||
cJSON_AddNumberToObject(relay, "ping_latency_avg", stats->ping_latency_avg);
|
||||
cJSON_AddNumberToObject(relay, "query_latency_avg", stats->query_latency_avg);
|
||||
cJSON_AddNumberToObject(relay, "publish_latency_avg", stats->publish_latency_avg);
|
||||
cJSON_AddNumberToObject(relay, "connection_uptime_start", (double)stats->connection_uptime_start);
|
||||
cJSON_AddNumberToObject(relay, "last_event_time", (double)stats->last_event_time);
|
||||
}
|
||||
|
||||
const char* last_pub_err = nostr_relay_pool_get_relay_last_publish_error(g_pool, url);
|
||||
const char* last_conn_err = nostr_relay_pool_get_relay_last_connection_error(g_pool, url);
|
||||
if (last_pub_err && last_pub_err[0] != '\0') {
|
||||
cJSON_AddStringToObject(relay, "last_publish_error", last_pub_err);
|
||||
}
|
||||
if (last_conn_err && last_conn_err[0] != '\0') {
|
||||
cJSON_AddStringToObject(relay, "last_connection_error", last_conn_err);
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(relays, relay);
|
||||
}
|
||||
|
||||
cJSON_AddNumberToObject(root, "relay_count", relay_count);
|
||||
cJSON_AddNumberToObject(root, "connected_count", connected_count);
|
||||
cJSON_AddItemToObject(root, "relays", relays);
|
||||
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
|
||||
if (relay_urls) {
|
||||
for (int i = 0; i < relay_count; i++) {
|
||||
free(relay_urls[i]);
|
||||
}
|
||||
free(relay_urls);
|
||||
}
|
||||
free(statuses);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
char* nostr_handler_relay_info_json(const char* relay_url) {
|
||||
if (!relay_url || relay_url[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
nostr_relay_info_t* info = NULL;
|
||||
if (nostr_nip11_fetch_relay_info(relay_url, &info, 10) != NOSTR_SUCCESS || !info) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
nostr_nip11_relay_info_free(info);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* basic = cJSON_CreateObject();
|
||||
if (!basic) {
|
||||
cJSON_Delete(root);
|
||||
nostr_nip11_relay_info_free(info);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (info->basic.name) cJSON_AddStringToObject(basic, "name", info->basic.name);
|
||||
if (info->basic.description) cJSON_AddStringToObject(basic, "description", info->basic.description);
|
||||
if (info->basic.pubkey) cJSON_AddStringToObject(basic, "pubkey", info->basic.pubkey);
|
||||
if (info->basic.contact) cJSON_AddStringToObject(basic, "contact", info->basic.contact);
|
||||
if (info->basic.software) cJSON_AddStringToObject(basic, "software", info->basic.software);
|
||||
if (info->basic.version) cJSON_AddStringToObject(basic, "version", info->basic.version);
|
||||
|
||||
cJSON* supported_nips = cJSON_CreateArray();
|
||||
if (!supported_nips) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(basic);
|
||||
nostr_nip11_relay_info_free(info);
|
||||
return NULL;
|
||||
}
|
||||
for (size_t i = 0; i < info->basic.supported_nips_count; i++) {
|
||||
cJSON_AddItemToArray(supported_nips, cJSON_CreateNumber(info->basic.supported_nips[i]));
|
||||
}
|
||||
cJSON_AddItemToObject(basic, "supported_nips", supported_nips);
|
||||
cJSON_AddItemToObject(root, "basic", basic);
|
||||
|
||||
if (info->has_limitations) {
|
||||
cJSON* limitations = cJSON_CreateObject();
|
||||
if (!limitations) {
|
||||
cJSON_Delete(root);
|
||||
nostr_nip11_relay_info_free(info);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddNumberToObject(limitations, "max_message_length", info->limitations.max_message_length);
|
||||
cJSON_AddNumberToObject(limitations, "max_subscriptions", info->limitations.max_subscriptions);
|
||||
cJSON_AddNumberToObject(limitations, "max_filters", info->limitations.max_filters);
|
||||
cJSON_AddNumberToObject(limitations, "max_limit", info->limitations.max_limit);
|
||||
cJSON_AddNumberToObject(limitations, "max_subid_length", info->limitations.max_subid_length);
|
||||
cJSON_AddNumberToObject(limitations, "min_prefix", info->limitations.min_prefix);
|
||||
cJSON_AddNumberToObject(limitations, "max_event_tags", info->limitations.max_event_tags);
|
||||
cJSON_AddNumberToObject(limitations, "max_content_length", info->limitations.max_content_length);
|
||||
cJSON_AddNumberToObject(limitations, "min_pow_difficulty", info->limitations.min_pow_difficulty);
|
||||
cJSON_AddNumberToObject(limitations, "auth_required", info->limitations.auth_required);
|
||||
cJSON_AddNumberToObject(limitations, "payment_required", info->limitations.payment_required);
|
||||
cJSON_AddNumberToObject(limitations, "restricted_writes", info->limitations.restricted_writes);
|
||||
cJSON_AddNumberToObject(limitations, "created_at_lower_limit", (double)info->limitations.created_at_lower_limit);
|
||||
cJSON_AddNumberToObject(limitations, "created_at_upper_limit", (double)info->limitations.created_at_upper_limit);
|
||||
cJSON_AddItemToObject(root, "limitations", limitations);
|
||||
}
|
||||
|
||||
if (info->has_content_limitations) {
|
||||
cJSON* countries = cJSON_CreateArray();
|
||||
if (!countries) {
|
||||
cJSON_Delete(root);
|
||||
nostr_nip11_relay_info_free(info);
|
||||
return NULL;
|
||||
}
|
||||
for (size_t i = 0; i < info->content_limitations.relay_countries_count; i++) {
|
||||
if (info->content_limitations.relay_countries[i]) {
|
||||
cJSON_AddItemToArray(countries, cJSON_CreateString(info->content_limitations.relay_countries[i]));
|
||||
}
|
||||
}
|
||||
cJSON_AddItemToObject(root, "relay_countries", countries);
|
||||
}
|
||||
|
||||
if (info->has_community_preferences) {
|
||||
cJSON* prefs = cJSON_CreateObject();
|
||||
if (!prefs) {
|
||||
cJSON_Delete(root);
|
||||
nostr_nip11_relay_info_free(info);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* language_tags = cJSON_CreateArray();
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
if (!language_tags || !tags) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(prefs);
|
||||
cJSON_Delete(language_tags);
|
||||
cJSON_Delete(tags);
|
||||
nostr_nip11_relay_info_free(info);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < info->community_preferences.language_tags_count; i++) {
|
||||
if (info->community_preferences.language_tags[i]) {
|
||||
cJSON_AddItemToArray(language_tags, cJSON_CreateString(info->community_preferences.language_tags[i]));
|
||||
}
|
||||
}
|
||||
for (size_t i = 0; i < info->community_preferences.tags_count; i++) {
|
||||
if (info->community_preferences.tags[i]) {
|
||||
cJSON_AddItemToArray(tags, cJSON_CreateString(info->community_preferences.tags[i]));
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(prefs, "language_tags", language_tags);
|
||||
cJSON_AddItemToObject(prefs, "tags", tags);
|
||||
if (info->community_preferences.posting_policy) {
|
||||
cJSON_AddStringToObject(prefs, "posting_policy", info->community_preferences.posting_policy);
|
||||
}
|
||||
cJSON_AddItemToObject(root, "community_preferences", prefs);
|
||||
}
|
||||
|
||||
if (info->has_icon && info->icon.icon) {
|
||||
cJSON_AddStringToObject(root, "icon", info->icon.icon);
|
||||
}
|
||||
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
nostr_nip11_relay_info_free(info);
|
||||
return out;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !authors) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10050));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(recipient_pubkey_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
|
||||
int event_count = 0;
|
||||
cJSON** events = nostr_relay_pool_query_sync(
|
||||
g_pool,
|
||||
(const char**)g_cfg->relays,
|
||||
g_cfg->relay_count,
|
||||
filter,
|
||||
&event_count,
|
||||
5000);
|
||||
|
||||
cJSON_Delete(filter);
|
||||
|
||||
char* extracted_dm_relays[NIP17_MAX_RELAYS] = {0};
|
||||
int extracted_count = 0;
|
||||
if (events && event_count > 0 && events[0]) {
|
||||
int extracted = nostr_nip17_extract_dm_relays(events[0], extracted_dm_relays, NIP17_MAX_RELAYS);
|
||||
if (extracted > 0) {
|
||||
extracted_count = extracted;
|
||||
}
|
||||
}
|
||||
|
||||
if (events) {
|
||||
for (int i = 0; i < event_count; i++) {
|
||||
cJSON_Delete(events[i]);
|
||||
}
|
||||
free(events);
|
||||
}
|
||||
|
||||
const char* target_relays[NIP17_MAX_RELAYS] = {0};
|
||||
int target_count = 0;
|
||||
|
||||
if (extracted_count > 0) {
|
||||
for (int i = 0; i < g_cfg->relay_count && target_count < NIP17_MAX_RELAYS; i++) {
|
||||
if (nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]) != NOSTR_POOL_RELAY_CONNECTED) {
|
||||
continue;
|
||||
}
|
||||
for (int j = 0; j < extracted_count; j++) {
|
||||
if (extracted_dm_relays[j] && strcmp(g_cfg->relays[i], extracted_dm_relays[j]) == 0) {
|
||||
target_relays[target_count++] = g_cfg->relays[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (target_count == 0) {
|
||||
for (int i = 0; i < g_cfg->relay_count && target_count < NIP17_MAX_RELAYS; i++) {
|
||||
if (nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]) == NOSTR_POOL_RELAY_CONNECTED) {
|
||||
target_relays[target_count++] = g_cfg->relays[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < extracted_count; i++) {
|
||||
free(extracted_dm_relays[i]);
|
||||
}
|
||||
|
||||
if (target_count <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* recipients[1] = { recipient_pubkey_hex };
|
||||
cJSON* chat_event = nostr_nip17_create_chat_event(
|
||||
message,
|
||||
recipients,
|
||||
1,
|
||||
(subject && subject[0] != '\0') ? subject : NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
g_cfg->keys.public_key_hex);
|
||||
if (!chat_event) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* gift_wraps[NIP17_MAX_GIFT_WRAPS] = {0};
|
||||
int gift_count = nostr_nip17_send_dm(chat_event,
|
||||
recipients,
|
||||
1,
|
||||
g_cfg->keys.private_key,
|
||||
gift_wraps,
|
||||
NIP17_MAX_GIFT_WRAPS,
|
||||
0);
|
||||
cJSON_Delete(chat_event);
|
||||
if (gift_count <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int any_sent = 0;
|
||||
for (int i = 0; i < gift_count; i++) {
|
||||
if (!gift_wraps[i]) {
|
||||
continue;
|
||||
}
|
||||
int sent = nostr_relay_pool_publish_async(g_pool,
|
||||
target_relays,
|
||||
target_count,
|
||||
gift_wraps[i],
|
||||
NULL,
|
||||
NULL);
|
||||
if (sent > 0) {
|
||||
any_sent = 1;
|
||||
}
|
||||
cJSON_Delete(gift_wraps[i]);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
(void)nostr_relay_pool_poll(g_pool, 100);
|
||||
}
|
||||
|
||||
return any_sent ? 0 : -1;
|
||||
}
|
||||
|
||||
char* nostr_handler_get_admin_kind0_context(void) {
|
||||
if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_0) {
|
||||
return NULL;
|
||||
|
||||
@@ -43,6 +43,9 @@ char* nostr_handler_get_admin_kind0_context(void);
|
||||
char* nostr_handler_get_admin_kind10002_context(void);
|
||||
char* nostr_handler_get_admin_kind1_notes_context(void);
|
||||
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);
|
||||
int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject);
|
||||
void nostr_handler_cleanup(void);
|
||||
|
||||
#endif
|
||||
+3603
-19
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,11 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
typedef struct {
|
||||
didactyl_config_t* cfg;
|
||||
struct trigger_manager* trigger_manager;
|
||||
} tools_context_t;
|
||||
|
||||
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg);
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "trigger_manager.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "agent.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "debug.h"
|
||||
#include "nostr_handler.h"
|
||||
|
||||
static int clamp_enabled(int enabled) {
|
||||
return enabled ? 1 : 0;
|
||||
}
|
||||
|
||||
static int ensure_capacity(trigger_manager_t* mgr, int needed) {
|
||||
if (!mgr || needed <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (mgr->capacity >= needed) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int new_cap = mgr->capacity > 0 ? mgr->capacity : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
while (new_cap < needed) {
|
||||
if (new_cap > 1024) {
|
||||
return -1;
|
||||
}
|
||||
new_cap *= 2;
|
||||
}
|
||||
|
||||
active_trigger_t* grown = (active_trigger_t*)realloc(mgr->triggers, (size_t)new_cap * sizeof(active_trigger_t));
|
||||
if (!grown) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (new_cap > mgr->capacity) {
|
||||
memset(&grown[mgr->capacity], 0, (size_t)(new_cap - mgr->capacity) * sizeof(active_trigger_t));
|
||||
}
|
||||
|
||||
mgr->triggers = grown;
|
||||
mgr->capacity = new_cap;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int find_trigger_index_locked(trigger_manager_t* mgr, const char* skill_slug) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
if (strncmp(mgr->triggers[i].skill_slug, skill_slug, TRIGGER_SKILL_SLUG_MAX) == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static cJSON* find_tag_value_string(cJSON* tags, const char* key) {
|
||||
if (!tags || !key || !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* k = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* v = cJSON_GetArrayItem(tag, 1);
|
||||
if (k && v && cJSON_IsString(k) && cJSON_IsString(v) &&
|
||||
k->valuestring && v->valuestring && strcmp(k->valuestring, key) == 0) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65], char out_slug[65]) {
|
||||
if (!addr || !out_kind || !out_pubkey || !out_slug) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* p1 = strchr(addr, ':');
|
||||
if (!p1) return -1;
|
||||
const char* p2 = strchr(p1 + 1, ':');
|
||||
if (!p2) return -1;
|
||||
|
||||
char kind_buf[16] = {0};
|
||||
size_t kind_len = (size_t)(p1 - addr);
|
||||
if (kind_len == 0 || kind_len >= sizeof(kind_buf)) return -1;
|
||||
memcpy(kind_buf, addr, kind_len);
|
||||
|
||||
int kind = atoi(kind_buf);
|
||||
if (kind != 31123 && kind != 31124) return -1;
|
||||
|
||||
size_t pub_len = (size_t)(p2 - (p1 + 1));
|
||||
if (pub_len != 64U) return -1;
|
||||
memcpy(out_pubkey, p1 + 1, 64U);
|
||||
out_pubkey[64] = '\0';
|
||||
|
||||
size_t slug_len = strlen(p2 + 1);
|
||||
if (slug_len == 0 || slug_len >= 65U) return -1;
|
||||
memcpy(out_slug, p2 + 1, slug_len + 1U);
|
||||
|
||||
*out_kind = kind;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* build_template_output(const active_trigger_t* t, cJSON* event, const char* relay_url) {
|
||||
(void)relay_url;
|
||||
|
||||
if (!t || !event) return NULL;
|
||||
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
|
||||
const char* content_s = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
|
||||
const char* pubkey_s = (pubkey && cJSON_IsString(pubkey) && pubkey->valuestring) ? pubkey->valuestring : "unknown";
|
||||
const char* id_s = (id && cJSON_IsString(id) && id->valuestring) ? id->valuestring : "";
|
||||
int kind_i = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : -1;
|
||||
|
||||
const char* tmpl = t->skill_content;
|
||||
if (!tmpl || tmpl[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char out[TRIGGER_SKILL_CONTENT_MAX + 256];
|
||||
snprintf(out,
|
||||
sizeof(out),
|
||||
"%s\n\n[event id=%s kind=%d pubkey=%s]\n%s",
|
||||
tmpl,
|
||||
id_s,
|
||||
kind_i,
|
||||
pubkey_s,
|
||||
content_s);
|
||||
|
||||
return strdup(out);
|
||||
}
|
||||
|
||||
static void execute_template_action(trigger_manager_t* mgr,
|
||||
const active_trigger_t* t,
|
||||
cJSON* event,
|
||||
const char* relay_url) {
|
||||
if (!mgr || !mgr->cfg || !t || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* rendered = build_template_output(t, event, relay_url);
|
||||
if (!rendered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strncmp(rendered, "DM admin:", 9) == 0) {
|
||||
const char* body = rendered + 9;
|
||||
while (*body == ' ') body++;
|
||||
(void)nostr_handler_send_dm(mgr->cfg->admin.pubkey, body);
|
||||
} else if (strncmp(rendered, "POST:", 5) == 0) {
|
||||
const char* body = rendered + 5;
|
||||
while (*body == ' ') body++;
|
||||
(void)nostr_handler_publish_kind_event(1, body, NULL, NULL);
|
||||
} else if (strncmp(rendered, "LOG:", 4) == 0) {
|
||||
const char* body = rendered + 4;
|
||||
while (*body == ' ') body++;
|
||||
DEBUG_INFO("[didactyl] trigger template log (%s): %s", t->skill_slug, body);
|
||||
} else {
|
||||
(void)nostr_handler_send_dm(mgr->cfg->admin.pubkey, rendered);
|
||||
}
|
||||
|
||||
free(rendered);
|
||||
}
|
||||
|
||||
static void execute_llm_action(const active_trigger_t* t, cJSON* event, const char* relay_url) {
|
||||
if (!t || !event) {
|
||||
return;
|
||||
}
|
||||
agent_on_trigger(t->skill_slug, t->skill_content, event, relay_url);
|
||||
}
|
||||
|
||||
static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* event, const char* relay_url) {
|
||||
active_trigger_t* t = &mgr->triggers[index];
|
||||
if (!t->enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
|
||||
time_t created_ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : 0;
|
||||
if (created_ts > 0 && created_ts <= t->last_seen_created_at) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
int cooldown = mgr->cfg->triggers.cooldown_seconds;
|
||||
if (cooldown < 0) cooldown = 0;
|
||||
if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
t->last_fired = now;
|
||||
if (created_ts > t->last_seen_created_at) {
|
||||
t->last_seen_created_at = created_ts;
|
||||
}
|
||||
|
||||
active_trigger_t trigger_copy = *t;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
|
||||
execute_template_action(mgr, &trigger_copy, event, relay_url);
|
||||
} else {
|
||||
execute_llm_action(&trigger_copy, event, relay_url);
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg) {
|
||||
if (!mgr || !cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
mgr->cfg = cfg;
|
||||
|
||||
if (pthread_mutex_init(&mgr->mutex, NULL) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int initial_cap = cfg->triggers.max_active > 0 ? cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
mgr->capacity = initial_cap;
|
||||
mgr->triggers = (active_trigger_t*)calloc((size_t)mgr->capacity, sizeof(active_trigger_t));
|
||||
if (!mgr->triggers) {
|
||||
pthread_mutex_destroy(&mgr->mutex);
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
return -1;
|
||||
}
|
||||
|
||||
mgr->last_poll_at = time(NULL);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager initialized (capacity=%d)", mgr->capacity);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* adopt_filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!adopt_filter || !kinds || !authors) {
|
||||
cJSON_Delete(adopt_filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10123));
|
||||
cJSON_AddItemToObject(adopt_filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(mgr->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(adopt_filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(adopt_filter, "limit", 1);
|
||||
|
||||
char* adoption_json = nostr_handler_query_json(adopt_filter, 8000);
|
||||
cJSON_Delete(adopt_filter);
|
||||
if (!adoption_json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* adoption_events = cJSON_Parse(adoption_json);
|
||||
free(adoption_json);
|
||||
if (!adoption_events || !cJSON_IsArray(adoption_events) || cJSON_GetArraySize(adoption_events) <= 0) {
|
||||
cJSON_Delete(adoption_events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* list_event = cJSON_GetArrayItem(adoption_events, 0);
|
||||
cJSON* list_tags = list_event ? cJSON_GetObjectItemCaseSensitive(list_event, "tags") : NULL;
|
||||
if (!list_tags || !cJSON_IsArray(list_tags)) {
|
||||
cJSON_Delete(adoption_events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int loaded = 0;
|
||||
int tn = cJSON_GetArraySize(list_tags);
|
||||
for (int i = 0; i < tn; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(list_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) || !cJSON_IsString(val) ||
|
||||
!key->valuestring || !val->valuestring || strcmp(key->valuestring, "a") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int kind = 0;
|
||||
char pubkey[65] = {0};
|
||||
char slug[65] = {0};
|
||||
if (parse_address_tag(val->valuestring, &kind, pubkey, slug) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_filter = cJSON_CreateObject();
|
||||
cJSON* sk_kinds = cJSON_CreateArray();
|
||||
cJSON* sk_authors = cJSON_CreateArray();
|
||||
cJSON* d_values = cJSON_CreateArray();
|
||||
if (!skill_filter || !sk_kinds || !sk_authors || !d_values) {
|
||||
cJSON_Delete(skill_filter);
|
||||
cJSON_Delete(sk_kinds);
|
||||
cJSON_Delete(sk_authors);
|
||||
cJSON_Delete(d_values);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(sk_kinds, cJSON_CreateNumber(kind));
|
||||
cJSON_AddItemToObject(skill_filter, "kinds", sk_kinds);
|
||||
cJSON_AddItemToArray(sk_authors, cJSON_CreateString(pubkey));
|
||||
cJSON_AddItemToObject(skill_filter, "authors", sk_authors);
|
||||
cJSON_AddItemToArray(d_values, cJSON_CreateString(slug));
|
||||
cJSON_AddItemToObject(skill_filter, "#d", d_values);
|
||||
cJSON_AddNumberToObject(skill_filter, "limit", 1);
|
||||
|
||||
char* skill_json = nostr_handler_query_json(skill_filter, 8000);
|
||||
cJSON_Delete(skill_filter);
|
||||
if (!skill_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_events = cJSON_Parse(skill_json);
|
||||
free(skill_json);
|
||||
if (!skill_events || !cJSON_IsArray(skill_events) || cJSON_GetArraySize(skill_events) <= 0) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_event = cJSON_GetArrayItem(skill_events, 0);
|
||||
cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL;
|
||||
cJSON* tags = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "tags") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring || !tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* trigger = find_tag_value_string(tags, "trigger");
|
||||
cJSON* filter = find_tag_value_string(tags, "filter");
|
||||
cJSON* action = find_tag_value_string(tags, "action");
|
||||
cJSON* enabled = find_tag_value_string(tags, "enabled");
|
||||
|
||||
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
|
||||
const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL;
|
||||
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
|
||||
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
|
||||
|
||||
if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') {
|
||||
trigger_action_type_t at = (strcmp(action_s, "template") == 0) ? TRIGGER_ACTION_TEMPLATE : TRIGGER_ACTION_LLM;
|
||||
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
|
||||
if (trigger_manager_add(mgr, slug, content->valuestring, filter_s, at, is_enabled) == 0) {
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(skill_events);
|
||||
}
|
||||
|
||||
cJSON_Delete(adoption_events);
|
||||
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from skills", loaded);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (action_type != TRIGGER_ACTION_LLM && action_type != TRIGGER_ACTION_TEMPLATE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int existing = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (existing >= 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_update(mgr, skill_slug, content, filter_json, action_type, enabled);
|
||||
}
|
||||
|
||||
int max_active = mgr->cfg ? mgr->cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
if (max_active < 1) max_active = TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
if (mgr->count >= max_active) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger add rejected: max_active reached (%d)", max_active);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (ensure_capacity(mgr, mgr->count + 1) != 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
active_trigger_t* t = &mgr->triggers[mgr->count];
|
||||
memset(t, 0, sizeof(*t));
|
||||
snprintf(t->skill_slug, sizeof(t->skill_slug), "%s", skill_slug);
|
||||
snprintf(t->skill_content, sizeof(t->skill_content), "%s", content);
|
||||
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
|
||||
t->action_type = action_type;
|
||||
t->enabled = clamp_enabled(enabled);
|
||||
t->last_fired = 0;
|
||||
t->last_seen_created_at = 0;
|
||||
|
||||
mgr->count++;
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger added slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (idx < mgr->count - 1) {
|
||||
memmove(&mgr->triggers[idx],
|
||||
&mgr->triggers[idx + 1],
|
||||
(size_t)(mgr->count - idx - 1) * sizeof(active_trigger_t));
|
||||
}
|
||||
|
||||
mgr->count--;
|
||||
memset(&mgr->triggers[mgr->count], 0, sizeof(active_trigger_t));
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger removed slug=%s", skill_slug);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_update(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (action_type != TRIGGER_ACTION_LLM && action_type != TRIGGER_ACTION_TEMPLATE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_add(mgr, skill_slug, content, filter_json, action_type, enabled);
|
||||
}
|
||||
|
||||
active_trigger_t* t = &mgr->triggers[idx];
|
||||
snprintf(t->skill_content, sizeof(t->skill_content), "%s", content);
|
||||
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
|
||||
t->action_type = action_type;
|
||||
t->enabled = clamp_enabled(enabled);
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger updated slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int active = 0;
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
if (mgr->triggers[i].enabled) {
|
||||
active++;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg || !mgr->cfg->triggers.enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 2) {
|
||||
return 0;
|
||||
}
|
||||
mgr->last_poll_at = now;
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int count = mgr->count;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
active_trigger_t snapshot = mgr->triggers[i];
|
||||
if (!snapshot.enabled || snapshot.filter_json[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* filter = cJSON_Parse(snapshot.filter_json);
|
||||
if (!filter || !cJSON_IsObject(filter)) {
|
||||
cJSON_Delete(filter);
|
||||
continue;
|
||||
}
|
||||
|
||||
time_t since = snapshot.last_seen_created_at > 0 ? snapshot.last_seen_created_at + 1 : now - 30;
|
||||
if (since < 0) since = 0;
|
||||
cJSON_AddNumberToObject(filter, "since", (double)since);
|
||||
cJSON_AddNumberToObject(filter, "limit", 8);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 4000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* events = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!events || !cJSON_IsArray(events)) {
|
||||
cJSON_Delete(events);
|
||||
continue;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int e = 0; e < n; e++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(events, e);
|
||||
if (!ev || !cJSON_IsObject(ev)) continue;
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, snapshot.skill_slug);
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(void)maybe_fire_trigger_locked(mgr, idx, ev, NULL);
|
||||
}
|
||||
|
||||
cJSON_Delete(events);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
cJSON* err = cJSON_CreateObject();
|
||||
if (!err) {
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddBoolToObject(err, "success", 0);
|
||||
cJSON_AddStringToObject(err, "error", "trigger manager unavailable");
|
||||
char* out = cJSON_PrintUnformatted(err);
|
||||
cJSON_Delete(err);
|
||||
return out;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* arr = cJSON_CreateArray();
|
||||
if (!root || !arr) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(arr);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddNumberToObject(root, "count", mgr->count);
|
||||
|
||||
int active = 0;
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (t->enabled) {
|
||||
active++;
|
||||
}
|
||||
|
||||
cJSON* item = cJSON_CreateObject();
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(item, "skill_slug", t->skill_slug);
|
||||
cJSON_AddStringToObject(item, "filter_json", t->filter_json);
|
||||
cJSON_AddStringToObject(item, "action", t->action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm");
|
||||
cJSON_AddBoolToObject(item, "enabled", t->enabled ? 1 : 0);
|
||||
cJSON_AddNumberToObject(item, "last_fired", (double)t->last_fired);
|
||||
cJSON_AddNumberToObject(item, "last_seen_created_at", (double)t->last_seen_created_at);
|
||||
cJSON_AddItemToArray(arr, item);
|
||||
}
|
||||
|
||||
cJSON_AddNumberToObject(root, "active", active);
|
||||
cJSON_AddItemToObject(root, "triggers", arr);
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
free(mgr->triggers);
|
||||
mgr->triggers = NULL;
|
||||
mgr->count = 0;
|
||||
mgr->capacity = 0;
|
||||
mgr->cfg = NULL;
|
||||
mgr->last_poll_at = 0;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
pthread_mutex_destroy(&mgr->mutex);
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager cleaned up");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#ifndef DIDACTYL_TRIGGER_MANAGER_H
|
||||
#define DIDACTYL_TRIGGER_MANAGER_H
|
||||
|
||||
#include <pthread.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#define TRIGGER_DEFAULT_MAX_ACTIVE 16
|
||||
#define TRIGGER_SKILL_SLUG_MAX 65
|
||||
#define TRIGGER_SKILL_CONTENT_MAX 4096
|
||||
#define TRIGGER_FILTER_JSON_MAX 2048
|
||||
|
||||
typedef enum {
|
||||
TRIGGER_ACTION_LLM = 0,
|
||||
TRIGGER_ACTION_TEMPLATE = 1
|
||||
} trigger_action_type_t;
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[TRIGGER_SKILL_SLUG_MAX];
|
||||
char skill_content[TRIGGER_SKILL_CONTENT_MAX];
|
||||
char filter_json[TRIGGER_FILTER_JSON_MAX];
|
||||
trigger_action_type_t action_type;
|
||||
int enabled;
|
||||
time_t last_fired;
|
||||
time_t last_seen_created_at;
|
||||
} active_trigger_t;
|
||||
|
||||
typedef struct trigger_manager {
|
||||
active_trigger_t* triggers;
|
||||
int count;
|
||||
int capacity;
|
||||
didactyl_config_t* cfg;
|
||||
pthread_mutex_t mutex;
|
||||
time_t last_poll_at;
|
||||
} trigger_manager_t;
|
||||
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_update(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
int trigger_manager_poll(trigger_manager_t* mgr);
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr);
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr);
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user