Compare commits

...
2 Commits
15 changed files with 2138 additions and 19 deletions
+1 -1
View File
@@ -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 \
+1
View File
@@ -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 = \
+16 -4
View File
@@ -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.21
## Current Status — v0.0.23
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.21 — Add full skill_* tool family with schema, execution, dispatch, and README updates
> Last release update: v0.0.23 — Add tool_list runtime tool introspection (name/description/schema) and validate via CLI test mode
- 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
+393
View File
@@ -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
+203
View File
@@ -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
View File
@@ -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;
+8
View File
@@ -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,
+55
View File
@@ -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;
@@ -581,6 +625,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 +730,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;
+9
View File
@@ -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,6 +89,7 @@ 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;
} didactyl_config_t;
+131 -1
View File
@@ -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;
@@ -22,13 +26,53 @@ static void signal_handler(int signum) {
static void print_usage(const char* prog) {
fprintf(stderr,
"Usage: %s [-h|--help] [-v|--version] [--config <path>] [--debug <0-5>]\n",
"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], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
@@ -41,6 +85,11 @@ int main(int argc, char** argv) {
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 {
print_usage(argv[0]);
return 1;
@@ -64,6 +113,68 @@ int main(int argc, char** argv) {
return 1;
}
if (dump_schemas || test_tool_name) {
if (nostr_handler_init(&cfg) != 0) {
fprintf(stderr, "Failed to initialize nostr handler\n");
config_free(&cfg);
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);
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);
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);
nostr_cleanup();
return exit_code;
}
if (llm_init(&cfg.llm) != 0) {
fprintf(stderr, "Failed to initialize llm client\n");
config_free(&cfg);
@@ -97,6 +208,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)");
@@ -124,6 +251,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;
@@ -139,6 +267,7 @@ int main(int argc, char** argv) {
while (g_running) {
(void)nostr_handler_poll(100);
(void)trigger_manager_poll(&trigger_manager);
struct timespec ts = {0, 10 * 1000 * 1000};
nanosleep(&ts, NULL);
}
@@ -148,6 +277,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
View File
@@ -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 21
#define DIDACTYL_VERSION "v0.0.21"
#define DIDACTYL_VERSION_PATCH 23
#define DIDACTYL_VERSION "v0.0.23"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+534 -11
View File
@@ -11,10 +11,12 @@
#include <ctype.h>
#include <time.h>
#include <unistd.h>
#include <sys/wait.h>
#include "cjson/cJSON.h"
#include "main.h"
#include "nostr_handler.h"
#include "trigger_manager.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
static char* json_error(const char* msg) {
@@ -263,6 +265,40 @@ static char* trim_copy(const char* start, size_t len) {
return out;
}
static char* shell_quote_single(const char* in) {
if (!in) return NULL;
size_t len = strlen(in);
size_t extra = 2U;
for (size_t i = 0; i < len; i++) {
if (in[i] == '\'') {
extra += 4U;
} else {
extra += 1U;
}
}
char* out = (char*)malloc(extra + 1U);
if (!out) return NULL;
size_t j = 0;
out[j++] = '\'';
for (size_t i = 0; i < len; i++) {
if (in[i] == '\'') {
out[j++] = '\'';
out[j++] = '\\';
out[j++] = '\'';
out[j++] = '\'';
} else {
out[j++] = in[i];
}
}
out[j++] = '\'';
out[j] = '\0';
return out;
}
static char* first_markdown_h1(const char* content) {
if (!content) return NULL;
@@ -505,6 +541,7 @@ int tools_init(tools_context_t* ctx, didactyl_config_t* cfg) {
if (!ctx || !cfg) return -1;
memset(ctx, 0, sizeof(*ctx));
ctx->cfg = cfg;
ctx->trigger_manager = NULL;
return 0;
}
@@ -1605,6 +1642,71 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON_AddItemToObject(t26, "function", t26_fn);
cJSON_AddItemToArray(tools, t26);
cJSON* t27 = cJSON_CreateObject();
cJSON* t27_fn = cJSON_CreateObject();
cJSON* t27_params = cJSON_CreateObject();
cJSON* t27_props = cJSON_CreateObject();
cJSON_AddStringToObject(t27, "type", "function");
cJSON_AddStringToObject(t27_fn, "name", "trigger_list");
cJSON_AddStringToObject(t27_fn, "description", "List active triggered skills and their runtime status");
cJSON_AddStringToObject(t27_params, "type", "object");
cJSON_AddItemToObject(t27_params, "properties", t27_props);
cJSON_AddItemToObject(t27_fn, "parameters", t27_params);
cJSON_AddItemToObject(t27, "function", t27_fn);
cJSON_AddItemToArray(tools, t27);
cJSON* t28 = cJSON_CreateObject();
cJSON* t28_fn = cJSON_CreateObject();
cJSON* t28_params = cJSON_CreateObject();
cJSON* t28_props = cJSON_CreateObject();
cJSON* t28_required = cJSON_CreateArray();
cJSON_AddStringToObject(t28, "type", "function");
cJSON_AddStringToObject(t28_fn, "name", "nostr_file_md_to_longform_post");
cJSON_AddStringToObject(t28_fn, "description", "Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename");
cJSON_AddStringToObject(t28_params, "type", "object");
cJSON_AddItemToObject(t28_params, "properties", t28_props);
cJSON_AddItemToObject(t28_params, "required", t28_required);
cJSON* p_lf_file = cJSON_CreateObject();
cJSON_AddStringToObject(p_lf_file, "type", "string");
cJSON_AddItemToObject(t28_props, "file", p_lf_file);
cJSON* p_lf_title = cJSON_CreateObject();
cJSON_AddStringToObject(p_lf_title, "type", "string");
cJSON_AddItemToObject(t28_props, "title", p_lf_title);
cJSON* p_lf_image = cJSON_CreateObject();
cJSON_AddStringToObject(p_lf_image, "type", "string");
cJSON_AddItemToObject(t28_props, "image", p_lf_image);
cJSON* p_lf_summary = cJSON_CreateObject();
cJSON_AddStringToObject(p_lf_summary, "type", "string");
cJSON_AddItemToObject(t28_props, "summary", p_lf_summary);
cJSON_AddItemToArray(t28_required, cJSON_CreateString("file"));
cJSON_AddItemToObject(t28_fn, "parameters", t28_params);
cJSON_AddItemToObject(t28, "function", t28_fn);
cJSON_AddItemToArray(tools, t28);
cJSON* t29 = cJSON_CreateObject();
cJSON* t29_fn = cJSON_CreateObject();
cJSON* t29_params = cJSON_CreateObject();
cJSON* t29_props = cJSON_CreateObject();
cJSON_AddStringToObject(t29, "type", "function");
cJSON_AddStringToObject(t29_fn, "name", "tool_list");
cJSON_AddStringToObject(t29_fn, "description", "List available tools with name, description, and JSON parameter schema");
cJSON_AddStringToObject(t29_params, "type", "object");
cJSON_AddItemToObject(t29_params, "properties", t29_props);
cJSON_AddItemToObject(t29_fn, "parameters", t29_params);
cJSON_AddItemToObject(t29, "function", t29_fn);
cJSON_AddItemToArray(tools, t29);
char* out = cJSON_PrintUnformatted(tools);
cJSON_Delete(tools);
return out;
@@ -1764,6 +1866,23 @@ static char* read_entire_file(const char* file_path, size_t* out_bytes) {
return buf;
}
static char* basename_lowercase_dup(const char* path) {
if (!path || path[0] == '\0') return NULL;
const char* base = strrchr(path, '/');
base = base ? (base + 1) : path;
if (base[0] == '\0') return NULL;
char* out = strdup(base);
if (!out) return NULL;
for (size_t i = 0; out[i] != '\0'; i++) {
out[i] = (char)tolower((unsigned char)out[i]);
}
return out;
}
static char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
@@ -1892,6 +2011,160 @@ static char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_js
return json;
}
static char* execute_nostr_file_md_to_longform_post(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON* file = cJSON_GetObjectItemCaseSensitive(args, "file");
cJSON* title = cJSON_GetObjectItemCaseSensitive(args, "title");
cJSON* image = cJSON_GetObjectItemCaseSensitive(args, "image");
cJSON* summary = cJSON_GetObjectItemCaseSensitive(args, "summary");
if (!file || !cJSON_IsString(file) || !file->valuestring || file->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error("nostr_file_md_to_longform_post requires string file");
}
if (title && !cJSON_IsString(title)) {
cJSON_Delete(args);
return json_error("nostr_file_md_to_longform_post title must be string when provided");
}
if (image && !cJSON_IsString(image)) {
cJSON_Delete(args);
return json_error("nostr_file_md_to_longform_post image must be string when provided");
}
if (summary && !cJSON_IsString(summary)) {
cJSON_Delete(args);
return json_error("nostr_file_md_to_longform_post summary must be string when provided");
}
char file_path[PATH_MAX];
if (build_tool_path(ctx, file->valuestring, file_path, sizeof(file_path)) != 0) {
cJSON_Delete(args);
return json_error("failed to resolve markdown file path");
}
size_t bytes_read = 0;
char* content = read_entire_file(file_path, &bytes_read);
if (!content) {
cJSON_Delete(args);
return json_error("failed to read markdown file");
}
char* d_tag = basename_lowercase_dup(file->valuestring);
if (!d_tag || d_tag[0] == '\0') {
free(d_tag);
free(content);
cJSON_Delete(args);
return json_error("failed to derive d tag from filename");
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
free(d_tag);
free(content);
cJSON_Delete(args);
return json_error("failed to create tags");
}
if (add_string_tag(tags, "d", d_tag) != 0) {
free(d_tag);
cJSON_Delete(tags);
free(content);
cJSON_Delete(args);
return json_error("failed to set d tag");
}
if (title && title->valuestring && title->valuestring[0] != '\0') {
if (add_string_tag(tags, "title", title->valuestring) != 0) {
free(d_tag);
cJSON_Delete(tags);
free(content);
cJSON_Delete(args);
return json_error("failed to set title tag");
}
}
if (image && image->valuestring && image->valuestring[0] != '\0') {
if (add_string_tag(tags, "image", image->valuestring) != 0) {
free(d_tag);
cJSON_Delete(tags);
free(content);
cJSON_Delete(args);
return json_error("failed to set image tag");
}
}
if (summary && summary->valuestring && summary->valuestring[0] != '\0') {
if (add_string_tag(tags, "summary", summary->valuestring) != 0) {
free(d_tag);
cJSON_Delete(tags);
free(content);
cJSON_Delete(args);
return json_error("failed to set summary tag");
}
}
ensure_nip23_metadata_tags(30023, content, &tags);
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(30023, content, tags, &publish_result);
cJSON_Delete(tags);
free(content);
cJSON_Delete(args);
if (rc != 0) {
free(d_tag);
nostr_handler_publish_result_free(&publish_result);
return json_error("nostr_file_md_to_longform_post failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
free(d_tag);
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "nostr_file_md_to_longform_post published");
cJSON_AddStringToObject(out, "path", file_path);
cJSON_AddStringToObject(out, "d_tag", d_tag);
cJSON_AddNumberToObject(out, "bytes_read", (double)bytes_read);
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
if (publish_result.note_uri[0] != '\0') {
cJSON_AddStringToObject(out, "note_uri", publish_result.note_uri);
}
if (publish_result.naddr_uri[0] != '\0') {
cJSON_AddStringToObject(out, "naddr_uri", publish_result.naddr_uri);
}
cJSON* relays = cJSON_CreateArray();
if (!relays) {
free(d_tag);
nostr_handler_publish_result_free(&publish_result);
cJSON_Delete(out);
return NULL;
}
for (int i = 0; i < publish_result.relay_count; i++) {
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
}
cJSON_AddItemToObject(out, "relays", relays);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(d_tag);
nostr_handler_publish_result_free(&publish_result);
return json;
}
static char* execute_nostr_delete(const char* args_json) {
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
@@ -2762,6 +3035,10 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
cJSON* scope = cJSON_GetObjectItemCaseSensitive(args, "scope");
cJSON* description = cJSON_GetObjectItemCaseSensitive(args, "description");
cJSON* auto_adopt = cJSON_GetObjectItemCaseSensitive(args, "auto_adopt");
cJSON* trigger = cJSON_GetObjectItemCaseSensitive(args, "trigger");
cJSON* filter = cJSON_GetObjectItemCaseSensitive(args, "filter");
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(args, "enabled");
if (!slug || !cJSON_IsString(slug) || !slug->valuestring ||
!content || !cJSON_IsString(content) || !content->valuestring) {
@@ -2781,6 +3058,23 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
return json_error("skill_create description must be string when provided");
}
if (trigger && (!cJSON_IsString(trigger) || !trigger->valuestring)) {
cJSON_Delete(args);
return json_error("skill_create trigger must be string when provided");
}
if (filter && (!cJSON_IsString(filter) || !filter->valuestring)) {
cJSON_Delete(args);
return json_error("skill_create filter must be string when provided");
}
if (action && (!cJSON_IsString(action) || !action->valuestring)) {
cJSON_Delete(args);
return json_error("skill_create action must be string when provided");
}
if (enabled && !cJSON_IsBool(enabled)) {
cJSON_Delete(args);
return json_error("skill_create enabled must be boolean when provided");
}
const char* scope_str = (scope && scope->valuestring && scope->valuestring[0] != '\0') ? scope->valuestring : "public";
int kind = 0;
if (strcmp(scope_str, "public") == 0) {
@@ -2816,6 +3110,38 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
}
}
const char* trigger_str = (trigger && trigger->valuestring && trigger->valuestring[0] != '\0')
? trigger->valuestring
: NULL;
const char* filter_str = (filter && filter->valuestring && filter->valuestring[0] != '\0')
? filter->valuestring
: NULL;
const char* action_str = (action && action->valuestring && action->valuestring[0] != '\0')
? action->valuestring
: "llm";
int enabled_int = (!enabled || cJSON_IsTrue(enabled)) ? 1 : 0;
if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error("skill_create trigger must be nostr-subscription when provided");
}
if ((trigger_str && !filter_str) || (!trigger_str && filter_str)) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error("skill_create trigger and filter must both be provided together");
}
if (trigger_str) {
if (add_string_tag(tags, "trigger", trigger_str) != 0 ||
add_string_tag(tags, "filter", filter_str) != 0 ||
add_string_tag(tags, "action", action_str) != 0 ||
add_string_tag(tags, "enabled", enabled_int ? "true" : "false") != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error("failed to add trigger tags");
}
}
nostr_publish_result_t skill_publish;
memset(&skill_publish, 0, sizeof(skill_publish));
@@ -2865,6 +3191,21 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
}
}
int trigger_registered = 0;
if (trigger_str && ctx->trigger_manager && enabled_int) {
trigger_action_type_t at = (strcmp(action_str, "template") == 0)
? TRIGGER_ACTION_TEMPLATE
: TRIGGER_ACTION_LLM;
if (trigger_manager_add(ctx->trigger_manager,
slug->valuestring,
content->valuestring,
filter_str,
at,
enabled_int) == 0) {
trigger_registered = 1;
}
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(args);
@@ -2881,6 +3222,7 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
cJSON_AddBoolToObject(out, "auto_adopt", do_auto_adopt ? 1 : 0);
cJSON_AddBoolToObject(out, "adopted", adopted ? 1 : 0);
cJSON_AddBoolToObject(out, "already_adopted", already_adopted ? 1 : 0);
cJSON_AddBoolToObject(out, "trigger_registered", trigger_registered ? 1 : 0);
if (adoption_publish.event_id[0] != '\0') {
cJSON_AddStringToObject(out, "adoption_event_id", adoption_publish.event_id);
@@ -2939,7 +3281,7 @@ static char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", 200);
cJSON_AddNumberToObject(filter, "limit", 300);
char* events_json = nostr_handler_query_json(filter, 8000);
cJSON_Delete(filter);
@@ -2954,6 +3296,39 @@ static char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
return json_error("skill_list returned invalid JSON");
}
int fallback_used = 0;
if (cJSON_GetArraySize(events) == 0) {
cJSON_Delete(events);
events = NULL;
cJSON* fallback_filter = cJSON_CreateObject();
cJSON* fallback_kinds = cJSON_CreateArray();
if (!fallback_filter || !fallback_kinds) {
cJSON_Delete(fallback_filter);
cJSON_Delete(fallback_kinds);
return json_error("failed to create fallback skill_list filter");
}
if (include_public) cJSON_AddItemToArray(fallback_kinds, cJSON_CreateNumber(31123));
if (include_private) cJSON_AddItemToArray(fallback_kinds, cJSON_CreateNumber(31124));
cJSON_AddItemToObject(fallback_filter, "kinds", fallback_kinds);
cJSON_AddNumberToObject(fallback_filter, "limit", 600);
char* fallback_json = nostr_handler_query_json(fallback_filter, 8000);
cJSON_Delete(fallback_filter);
if (!fallback_json) {
return json_error("skill_list fallback query failed");
}
events = cJSON_Parse(fallback_json);
free(fallback_json);
if (!events || !cJSON_IsArray(events)) {
cJSON_Delete(events);
return json_error("skill_list fallback returned invalid JSON");
}
fallback_used = 1;
}
cJSON* out = cJSON_CreateObject();
cJSON* skills = cJSON_CreateArray();
if (!out || !skills) {
@@ -2966,11 +3341,22 @@ static char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
int event_count = cJSON_GetArraySize(events);
for (int i = 0; i < event_count; i++) {
cJSON* ev = cJSON_GetArrayItem(events, i);
if (!ev || !cJSON_IsObject(ev)) continue;
if (fallback_used) {
cJSON* ev_pubkey = cJSON_GetObjectItemCaseSensitive(ev, "pubkey");
if (!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
strcmp(ev_pubkey->valuestring, ctx->cfg->keys.public_key_hex) != 0) {
continue;
}
}
cJSON* summary = extract_skill_summary(ev);
if (summary) cJSON_AddItemToArray(skills, summary);
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddBoolToObject(out, "fallback_used", fallback_used ? 1 : 0);
cJSON_AddItemToObject(out, "skills", skills);
cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(skills));
@@ -3138,6 +3524,10 @@ static char* execute_skill_remove(tools_context_t* ctx, const char* args_json) {
}
}
if (ctx->trigger_manager) {
(void)trigger_manager_remove(ctx->trigger_manager, slug->valuestring);
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(tuple);
@@ -3604,11 +3994,11 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
if (!ctx->cfg->tools.shell.enabled) return json_error("shell tool disabled");
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON* command = cJSON_GetObjectItemCaseSensitive(args, "command");
if (!command || !cJSON_IsString(command) || !command->valuestring) {
if (!command || !cJSON_IsString(command) || !command->valuestring || command->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error("shell_exec requires string command");
}
@@ -3618,16 +4008,47 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
: ".";
int timeout_s = ctx->cfg->tools.shell.timeout_seconds > 0 ? ctx->cfg->tools.shell.timeout_seconds : 30;
char cmd[4096];
char* quoted_cwd = shell_quote_single(cwd);
char* quoted_cmd = shell_quote_single(command->valuestring);
cJSON_Delete(args);
if (!quoted_cwd || !quoted_cmd) {
free(quoted_cwd);
free(quoted_cmd);
return json_error("allocation failure");
}
int needed = snprintf(NULL,
0,
"cd %s && timeout %ds sh -lc %s 2>&1",
quoted_cwd,
timeout_s,
quoted_cmd);
if (needed <= 0) {
free(quoted_cwd);
free(quoted_cmd);
return json_error("failed to build shell command");
}
char* cmd = (char*)malloc((size_t)needed + 1U);
if (!cmd) {
free(quoted_cwd);
free(quoted_cmd);
return json_error("allocation failure");
}
snprintf(cmd,
sizeof(cmd),
(size_t)needed + 1U,
"cd %s && timeout %ds sh -lc %s 2>&1",
cwd,
quoted_cwd,
timeout_s,
command->valuestring);
quoted_cmd);
free(quoted_cwd);
free(quoted_cmd);
FILE* fp = popen(cmd, "r");
cJSON_Delete(args);
free(cmd);
if (!fp) return json_error("failed to execute command");
int max_bytes = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
@@ -3644,7 +4065,15 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
if (n == 0) break;
}
int status = pclose(fp);
int raw_status = pclose(fp);
int exit_status = raw_status;
if (raw_status != -1) {
if (WIFEXITED(raw_status)) {
exit_status = WEXITSTATUS(raw_status);
} else if (WIFSIGNALED(raw_status)) {
exit_status = 128 + WTERMSIG(raw_status);
}
}
cJSON* out = cJSON_CreateObject();
if (!out) {
@@ -3652,8 +4081,8 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
return NULL;
}
cJSON_AddBoolToObject(out, "success", status == 0 ? 1 : 0);
cJSON_AddNumberToObject(out, "exit_status", status);
cJSON_AddBoolToObject(out, "success", exit_status == 0 ? 1 : 0);
cJSON_AddNumberToObject(out, "exit_status", exit_status);
cJSON_AddStringToObject(out, "output", output);
free(output);
@@ -3762,6 +4191,91 @@ static char* execute_file_write(tools_context_t* ctx, const char* args_json) {
return json;
}
static char* execute_tool_list(tools_context_t* ctx, const char* args_json) {
(void)args_json;
if (!ctx) {
return json_error("tool context unavailable");
}
char* schema_json = tools_build_openai_schema_json(ctx);
if (!schema_json) {
return json_error("failed to build tool schemas");
}
cJSON* schema_arr = cJSON_Parse(schema_json);
free(schema_json);
if (!schema_arr || !cJSON_IsArray(schema_arr)) {
cJSON_Delete(schema_arr);
return json_error("failed to parse tool schemas");
}
cJSON* out = cJSON_CreateObject();
cJSON* tools = cJSON_CreateArray();
if (!out || !tools) {
cJSON_Delete(schema_arr);
cJSON_Delete(out);
cJSON_Delete(tools);
return json_error("out of memory");
}
cJSON* item = NULL;
cJSON_ArrayForEach(item, schema_arr) {
cJSON* fn = cJSON_GetObjectItemCaseSensitive(item, "function");
if (!fn || !cJSON_IsObject(fn)) {
continue;
}
cJSON* name = cJSON_GetObjectItemCaseSensitive(fn, "name");
if (!name || !cJSON_IsString(name) || !name->valuestring) {
continue;
}
cJSON* row = cJSON_CreateObject();
if (!row) {
continue;
}
cJSON_AddStringToObject(row, "name", name->valuestring);
cJSON* description = cJSON_GetObjectItemCaseSensitive(fn, "description");
if (description && cJSON_IsString(description) && description->valuestring) {
cJSON_AddStringToObject(row, "description", description->valuestring);
} else {
cJSON_AddStringToObject(row, "description", "");
}
cJSON* parameters = cJSON_GetObjectItemCaseSensitive(fn, "parameters");
if (parameters) {
cJSON_AddItemToObject(row, "parameters", cJSON_Duplicate(parameters, 1));
} else {
cJSON_AddItemToObject(row, "parameters", cJSON_CreateObject());
}
cJSON_AddItemToArray(tools, row);
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddNumberToObject(out, "count", (double)cJSON_GetArraySize(tools));
cJSON_AddItemToObject(out, "tools", tools);
char* result = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
cJSON_Delete(schema_arr);
return result;
}
static char* execute_trigger_list(tools_context_t* ctx, const char* args_json) {
(void)args_json;
if (!ctx || !ctx->trigger_manager) {
return json_error("trigger manager unavailable");
}
char* status = trigger_manager_status_json(ctx->trigger_manager);
if (!status) {
return json_error("failed to build trigger status");
}
return status;
}
char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json) {
if (!tool_name) return json_error("missing tool name");
@@ -3840,10 +4354,19 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
if (strcmp(tool_name, "skill_search") == 0) {
return execute_skill_search(args_json);
}
if (strcmp(tool_name, "trigger_list") == 0) {
return execute_trigger_list(ctx, args_json);
}
if (strcmp(tool_name, "tool_list") == 0) {
return execute_tool_list(ctx, args_json);
}
if (strcmp(tool_name, "nostr_post_readme") == 0) {
return execute_nostr_post_readme(ctx, args_json);
}
if (strcmp(tool_name, "nostr_file_md_to_longform_post") == 0) {
return execute_nostr_file_md_to_longform_post(ctx, args_json);
}
return json_error("unknown tool");
}
+3
View File
@@ -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);
+649
View File
@@ -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");
}
+58
View File
@@ -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