v0.0.61 - feat: add webhook cron and chain trigger types

This commit is contained in:
Your Name
2026-03-09 20:58:52 -04:00
parent 4336cc77c0
commit 1d66a3a97e
11 changed files with 1440 additions and 34 deletions
+2 -2
View File
@@ -53,11 +53,11 @@ Skills support context modes (`inject`, `full`, `override`) and per-skill LLM fa
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.60
## Current Status — v0.0.61
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.60Remove forced final trigger DM, add skill_edit tool, and complete d_tag terminology migration
> Last release update: v0.0.61feat: add webhook cron and chain trigger types
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
+39
View File
@@ -190,6 +190,45 @@ Returns the context broken into labeled, individually-sized parts. Useful for un
---
### POST /api/trigger/:d_tag
Fires a webhook-triggered skill by `d_tag`.
**Path params:**
| Param | Required | Description |
|---|---|---|
| `d_tag` | yes | Skill `d` tag of a skill configured with `trigger=webhook` |
**Request body:**
Optional JSON payload; if present it is passed into the synthetic trigger event as `payload`.
```json
{
"source": "external-cron",
"note": "run maintenance sweep"
}
```
**Response:**
```json
{
"success": true,
"d_tag": "maintenance-sweep",
"fired": true
}
```
**Notes:**
- Returns `404` if no skill exists for `d_tag` or no active trigger is registered.
- Returns `400` if the matched skill is not a `webhook` trigger or is disabled.
- Trigger execution uses the same trigger cooldown policy as other trigger types.
---
### POST /api/prompt/run-simple
Submit a system prompt and user message for a simple LLM call with no tools. Useful for quick prompt iteration.
+52 -8
View File
@@ -204,14 +204,14 @@ sequenceDiagram
## Triggered Skills
A triggered skill has a Nostr subscription filter attached. When matching events arrive, the skill executes automatically.
A triggered skill has a trigger source attached. Didactyl supports `nostr-subscription`, `webhook`, `cron`, and `chain` trigger types.
### Trigger Tags
| Tag | Required | Description |
|---|---|---|
| `trigger` | Yes | Trigger type: `nostr-subscription` |
| `filter` | Yes | JSON-encoded Nostr subscription filter |
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` |
| `filter` | Yes | Type-specific filter (Nostr JSON filter, webhook placeholder payload, cron expression, or source skill `d` tag) |
| `action` | No | `template` or `llm` (default: `llm`) |
| `enabled` | No | Whether active (default: `true`) |
@@ -251,6 +251,44 @@ The skill content defines the execution context. The triggering event is availab
}
```
### Trigger Types
#### `nostr-subscription`
`filter` is a JSON-encoded Nostr subscription filter.
```json
["trigger", "nostr-subscription"],
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"]
```
#### `webhook`
`filter` is required for schema compatibility and can be a simple placeholder like `{}`; webhook firing happens via HTTP.
```json
["trigger", "webhook"],
["filter", "{}"]
```
#### `cron`
`filter` is a standard 5-field cron expression: `minute hour day-of-month month day-of-week`.
```json
["trigger", "cron"],
["filter", "0 * * * *"]
```
#### `chain`
`filter` is the source skill `d` tag to chain from.
```json
["trigger", "chain"],
["filter", "source-skill-d-tag"]
```
### Trigger Lifecycle
```mermaid
@@ -264,14 +302,23 @@ flowchart TD
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]
FIND_TRIGGERS --> REGISTER[Register trigger by type]
REGISTER --> NOSTR_SUB[nostr-subscription: create Nostr subscriptions]
REGISTER --> CRON_REG[cron: keep expression for poll loop]
REGISTER --> WEBHOOK_REG[webhook: route via /api/trigger/:d_tag]
REGISTER --> CHAIN_REG[chain: wait for source skill completion]
end
subgraph Execution
EVENT_IN[Matching event arrives] --> LOOKUP[Find associated skill]
EVENT_IN[Matching Nostr event] --> LOOKUP[Find associated skill]
WEBHOOK_IN[POST /api/trigger/:d_tag] --> LOOKUP
CRON_TICK[cron poll match] --> LOOKUP
LOOKUP --> CHECK_TYPE{Action type?}
CHECK_TYPE -->|template| INTERPOLATE[Interpolate + execute prefix]
CHECK_TYPE -->|llm| RESOLVE[Resolve LLM + assemble context + run]
RESOLVE --> CHAIN_CHECK[Check chain triggers]
INTERPOLATE --> CHAIN_CHECK
CHAIN_CHECK --> CHAIN_FIRE[Fire matching chain triggers]
end
PUBLISHED --> LOAD_SKILLS
@@ -351,9 +398,6 @@ A spelling checker runs with no soul — purely functional, minimal context, che
| Extension | Description |
|---|---|
| `cron` triggers | Time-based triggers |
| `webhook` triggers | HTTP webhook triggers |
| `chain` triggers | Output of one skill triggers another |
| Skill composition | Pipeline multiple skills |
| Agent-to-agent sharing | Discover and adopt skills across agents |
| Trigger marketplace | Popular triggers rise via adoption count |
+689
View File
@@ -0,0 +1,689 @@
# Plan: Implement Webhook, Cron, and Chain Trigger Types
## Overview
Add three new trigger types to Didactyl's trigger system alongside the existing `nostr-subscription` type. Implementation order: webhook → cron → chain, followed by documentation updates.
## Current Architecture
```mermaid
flowchart LR
subgraph Current Flow
NS[Nostr Event] --> SUB[Subscription Callback]
SUB --> MF[maybe_fire_trigger_locked]
MF --> EA{Action Type?}
EA -->|template| TA[execute_template_action]
EA -->|llm| LA[execute_llm_action]
LA --> AOT[agent_on_trigger]
end
```
All triggers are implicitly `nostr-subscription` — there is no type discriminator field in `active_trigger_t`.
## Target Architecture
```mermaid
flowchart TD
subgraph Trigger Sources
NS[Nostr Subscription]
WH[Webhook HTTP POST]
CR[Cron Timer]
CH[Chain - Post-Execution]
end
subgraph Trigger Manager
NS --> MF[maybe_fire_trigger]
WH --> MF
CR --> MF
CH --> MF
end
subgraph Dispatch
MF --> EA{Action Type?}
EA -->|template| TA[execute_template_action]
EA -->|llm| LA[execute_llm_action]
LA --> AOT[agent_on_trigger]
AOT -->|on completion| CHK[Check chain triggers]
CHK -->|match| CH
end
```
---
## Phase 1: Foundation — Type System and API Changes
### 1.1 Add trigger type enum to `trigger_manager.h`
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:15)
Add a new enum after the existing `trigger_action_type_t` at line 18:
```c
typedef enum {
TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0,
TRIGGER_TYPE_WEBHOOK,
TRIGGER_TYPE_CRON,
TRIGGER_TYPE_CHAIN
} trigger_type_t;
```
### 1.2 Extend `active_trigger_t` struct
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:20)
Add three new fields to the struct after [`enabled`](src/trigger_manager.h:25):
```c
trigger_type_t trigger_type; // discriminator: which trigger source
time_t last_cron_fire; // cron: last time this trigger fired
char cron_expr[64]; // cron: parsed 5-field cron expression
```
The struct currently has `subscription` and `subscription_ctx` fields which are only relevant for `nostr-subscription` — they'll remain but be NULL for other types.
### 1.3 Add trigger type string conversion helpers
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:57) — add declarations:
```c
trigger_type_t trigger_type_from_string(const char *s);
const char* trigger_type_to_string(trigger_type_t t);
```
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:20) — add implementations after `clamp_enabled()`:
```c
trigger_type_t trigger_type_from_string(const char *s) {
if (!s) return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
if (strcmp(s, "webhook") == 0) return TRIGGER_TYPE_WEBHOOK;
if (strcmp(s, "cron") == 0) return TRIGGER_TYPE_CRON;
if (strcmp(s, "chain") == 0) return TRIGGER_TYPE_CHAIN;
return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
}
const char* trigger_type_to_string(trigger_type_t t) {
switch (t) {
case TRIGGER_TYPE_WEBHOOK: return "webhook";
case TRIGGER_TYPE_CRON: return "cron";
case TRIGGER_TYPE_CHAIN: return "chain";
default: return "nostr-subscription";
}
}
```
### 1.4 Update `trigger_manager_add()` signature
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:44) and [`src/trigger_manager.c`](src/trigger_manager.c:569)
Add `const char* trigger_type_str` parameter:
```c
int trigger_manager_add(trigger_manager_t* mgr,
const char* skill_d_tag,
const char* content,
const char* filter_json,
trigger_action_type_t action_type,
const char* trigger_type_str, // NEW
int enabled);
```
Inside the function body at [line 604](src/trigger_manager.c:604), after `memset(t, 0, sizeof(*t))` and field assignments:
```c
t->trigger_type = trigger_type_from_string(trigger_type_str);
// For cron triggers, copy the filter as the cron expression
if (t->trigger_type == TRIGGER_TYPE_CRON) {
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
t->last_cron_fire = 0;
}
// Only create Nostr subscription for nostr-subscription type
if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
if (register_trigger_subscription_locked(mgr, t) != 0) {
pthread_mutex_unlock(&mgr->mutex);
DEBUG_WARN("[didactyl] trigger add rejected: subscription failed d_tag=%s", skill_d_tag);
memset(t, 0, sizeof(*t));
return -1;
}
}
```
### 1.5 Update `trigger_manager_update()` signature
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:51) and [`src/trigger_manager.c`](src/trigger_manager.c:661)
Same pattern — add `const char* trigger_type_str` parameter. Inside the body at [line 683](src/trigger_manager.c:683):
```c
t->trigger_type = trigger_type_from_string(trigger_type_str);
if (t->trigger_type == TRIGGER_TYPE_CRON) {
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
}
// Only (re)subscribe for nostr-subscription type
if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
if (register_trigger_subscription_locked(mgr, t) != 0) { ... }
} else {
// Close any existing subscription if type changed
close_trigger_subscription_locked(t);
}
```
### 1.6 Update all call sites of `trigger_manager_add()` and `trigger_manager_update()`
There are 5 call sites that need the new `trigger_type_str` parameter:
| Call Site | File | Line | Current `trigger_type_str` value |
|---|---|---|---|
| `trigger_manager_load_from_skills()` | [`src/trigger_manager.c`](src/trigger_manager.c:485) | 485 | `trigger_s` — already extracted from tags |
| `trigger_manager_load_from_startup_events()` | [`src/trigger_manager.c`](src/trigger_manager.c:555) | 555 | `trigger_s` — already extracted from tags |
| `execute_skill_create()` | [`src/tools.c`](src/tools.c:3678) | 3678 | `trigger_str` — already available |
| `execute_skill_edit()` update call | [`src/tools.c`](src/tools.c:4213) | 4213 | `merged_trigger` — already available |
| `execute_skill_edit()` add fallback | [`src/tools.c`](src/tools.c:4219) | 4219 | `merged_trigger` — already available |
| `trigger_manager_add()``trigger_manager_update()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:588) | 588 | Pass through from caller |
| `trigger_manager_update()``trigger_manager_add()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:680) | 680 | Pass through from caller |
### 1.7 Update loading functions to accept all trigger types
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:482)
In `trigger_manager_load_from_skills()`, change the filter at line 482 from:
```c
if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') {
```
To:
```c
if (trigger_s && filter_s && filter_s[0] != '\0' &&
(strcmp(trigger_s, "nostr-subscription") == 0 ||
strcmp(trigger_s, "webhook") == 0 ||
strcmp(trigger_s, "cron") == 0 ||
strcmp(trigger_s, "chain") == 0)) {
```
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:547)
Same change in `trigger_manager_load_from_startup_events()` at line 547.
Note: For `webhook` type, `filter` can be empty/unused since webhooks are triggered by HTTP POST, not by a filter match. Consider allowing empty filter for webhook type. However, keeping the existing requirement that filter must be non-empty is simpler — webhook skills can use `filter: "{}"` as a placeholder.
### 1.8 Update `trigger_manager_status_json()`
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:761)
After the existing `cJSON_AddStringToObject(item, "filter_json", ...)` at line 762, add:
```c
cJSON_AddStringToObject(item, "type", trigger_type_to_string(t->trigger_type));
```
### 1.9 Update `tools.c` validation
**File:** [`src/tools.c`](src/tools.c:3603)
In `execute_skill_create()`, change line 3603 from:
```c
if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) {
```
To:
```c
if (trigger_str &&
strcmp(trigger_str, "nostr-subscription") != 0 &&
strcmp(trigger_str, "webhook") != 0 &&
strcmp(trigger_str, "cron") != 0 &&
strcmp(trigger_str, "chain") != 0) {
```
Update the error message to list valid types.
**File:** [`src/tools.c`](src/tools.c:4177)
In `execute_skill_edit()`, change line 4177 from:
```c
if (strcmp(merged_trigger, "nostr-subscription") != 0) {
```
To the same multi-type check.
Also update the `trigger_manager_add()` and `trigger_manager_update()` calls at [lines 3678](src/tools.c:3678) and [4213-4224](src/tools.c:4213) to pass the trigger type string.
---
## Phase 2: Webhook Trigger
### 2.1 Add `POST /api/trigger/:d_tag` route handler
**File:** [`src/http_api.c`](src/http_api.c:1154)
Add a new handler function before `http_handler()`:
```c
static void handle_trigger_webhook(struct mg_connection* c, struct mg_http_message* hm, const char* d_tag) {
if (!g_api_ctx.trigger_manager) {
reply_error(c, 503, "trigger manager unavailable");
return;
}
// Look up the trigger by d_tag
// Need a new function: trigger_manager_find_by_d_tag() that returns a copy
active_trigger_t trigger_copy;
if (trigger_manager_find(g_api_ctx.trigger_manager, d_tag, &trigger_copy) != 0) {
reply_error(c, 404, "no trigger found for d_tag");
return;
}
if (trigger_copy.trigger_type != TRIGGER_TYPE_WEBHOOK) {
reply_error(c, 400, "trigger is not a webhook type");
return;
}
if (!trigger_copy.enabled) {
reply_error(c, 400, "trigger is disabled");
return;
}
// Parse optional JSON body as the webhook payload
cJSON* payload = parse_body_json(hm);
if (!payload) {
reply_error(c, 400, "invalid JSON body");
return;
}
// Build synthetic triggering event
cJSON* event = cJSON_CreateObject();
cJSON_AddStringToObject(event, "type", "webhook");
cJSON_AddStringToObject(event, "d_tag", d_tag);
cJSON_AddNumberToObject(event, "created_at", (double)time(NULL));
cJSON_AddItemToObject(event, "payload", payload);
// Fire the trigger - dispatch based on action type
if (trigger_copy.action_type == TRIGGER_ACTION_LLM) {
agent_on_trigger(trigger_copy.skill_d_tag,
trigger_copy.skill_content,
event,
"webhook");
}
// Template actions could also be supported here
cJSON_Delete(event);
// Return success immediately
cJSON* root = cJSON_CreateObject();
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddStringToObject(root, "d_tag", d_tag);
cJSON_AddStringToObject(root, "status", "fired");
reply_json(c, 200, root);
cJSON_Delete(root);
}
```
### 2.2 Add route to `http_handler()`
**File:** [`src/http_api.c`](src/http_api.c:1201)
Before the 404 fallthrough at line 1206, add:
```c
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/trigger/*"), NULL)) {
// Extract d_tag from URI: /api/trigger/{d_tag}
struct mg_str uri = hm->uri;
const char* prefix = "/api/trigger/";
size_t prefix_len = strlen(prefix);
if (uri.len > prefix_len) {
char d_tag[TRIGGER_SKILL_D_TAG_MAX];
size_t tag_len = uri.len - prefix_len;
if (tag_len >= sizeof(d_tag)) tag_len = sizeof(d_tag) - 1;
memcpy(d_tag, uri.buf + prefix_len, tag_len);
d_tag[tag_len] = '\0';
handle_trigger_webhook(c, hm, d_tag);
return;
}
reply_error(c, 400, "missing d_tag in trigger URL");
return;
}
```
### 2.3 Add `trigger_manager_find()` function
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:50) and [`src/trigger_manager.c`](src/trigger_manager.c:631)
New function to look up a trigger by d_tag and return a copy:
```c
// Declaration
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out);
// Implementation
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out) {
if (!mgr || !skill_d_tag || !out) return -1;
pthread_mutex_lock(&mgr->mutex);
int idx = find_trigger_index_locked(mgr, skill_d_tag);
if (idx < 0) {
pthread_mutex_unlock(&mgr->mutex);
return -1;
}
*out = mgr->triggers[idx];
// Clear pointer fields in copy to prevent double-free
out->subscription = NULL;
out->subscription_ctx = NULL;
pthread_mutex_unlock(&mgr->mutex);
return 0;
}
```
### 2.4 Webhook cooldown
The webhook handler should respect the same cooldown as other triggers. Either:
- Call `maybe_fire_trigger_locked()` from the webhook handler (requires refactoring to expose it), or
- Add cooldown checking in the webhook handler using `trigger_copy.last_fired` and updating it via a new `trigger_manager_mark_fired()` function
Recommended: Add a `trigger_manager_fire()` function that encapsulates the cooldown check and dispatch, usable by both the Nostr subscription callback and the webhook handler. This avoids duplicating cooldown logic.
```c
int trigger_manager_fire(trigger_manager_t* mgr, const char* skill_d_tag, cJSON* event, const char* source);
```
---
## Phase 3: Cron Trigger
### 3.1 Cron expression parser
**File:** [`src/trigger_manager.c`](src/trigger_manager.c)
Add a minimal 5-field cron expression matcher. The cron expression format is: `minute hour day-of-month month day-of-week`
```c
// Returns 1 if the cron expression matches the given time, 0 otherwise
static int cron_matches(const char* expr, const struct tm* tm);
// Helper: check if a single field matches a value
// Supports: *, specific number, comma-separated list, ranges with -, step with /
static int cron_field_matches(const char* field, int value, int min, int max);
```
The parser needs to handle:
- `*` — match any
- `5` — match exact value
- `1,15` — match list
- `1-5` — match range
- `*/15` — match step
- `1-5/2` — match range with step
This is ~80-100 lines of C. Keep it simple — no named days/months, no special strings like `@hourly`.
### 3.2 Implement `trigger_manager_poll()` for cron
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:718)
Replace the no-op `trigger_manager_poll()` with cron checking logic:
```c
int trigger_manager_poll(trigger_manager_t* mgr) {
if (!mgr) return 0;
time_t now = time(NULL);
// Only check once per minute (cron resolution is 1 minute)
if (now - mgr->last_poll_at < 60) return 0;
mgr->last_poll_at = now;
struct tm tm_now;
localtime_r(&now, &tm_now);
pthread_mutex_lock(&mgr->mutex);
for (int i = 0; i < mgr->count; i++) {
active_trigger_t* t = &mgr->triggers[i];
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CRON) continue;
if (t->cron_expr[0] == '\0') continue;
if (!cron_matches(t->cron_expr, &tm_now)) continue;
// Prevent double-fire within same minute
struct tm tm_last;
localtime_r(&t->last_cron_fire, &tm_last);
if (t->last_cron_fire > 0 &&
tm_last.tm_min == tm_now.tm_min &&
tm_last.tm_hour == tm_now.tm_hour &&
tm_last.tm_mday == tm_now.tm_mday) {
continue;
}
t->last_cron_fire = now;
t->last_fired = now;
// Copy trigger data before unlocking
active_trigger_t trigger_copy = *t;
trigger_copy.subscription = NULL;
trigger_copy.subscription_ctx = NULL;
pthread_mutex_unlock(&mgr->mutex);
// Build synthetic cron event
cJSON* event = cJSON_CreateObject();
cJSON_AddStringToObject(event, "type", "cron");
cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag);
cJSON_AddNumberToObject(event, "created_at", (double)now);
cJSON_AddStringToObject(event, "cron_expr", trigger_copy.cron_expr);
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
execute_template_action(mgr, &trigger_copy, event, "cron");
} else {
execute_llm_action(&trigger_copy, event, "cron");
}
cJSON_Delete(event);
// Re-lock and continue scanning
pthread_mutex_lock(&mgr->mutex);
}
pthread_mutex_unlock(&mgr->mutex);
return 0;
}
```
### 3.3 Cron expression in skill tags
For cron triggers, the `filter` tag contains the cron expression instead of a JSON filter:
- `["trigger", "cron"]`
- `["filter", "0 * * * *"]` — fires every hour at minute 0
- `["filter", "*/5 * * * *"]` — fires every 5 minutes
This reuses the existing `filter` tag semantics — the filter meaning depends on the trigger type.
---
## Phase 4: Chain Trigger
### 4.1 Post-execution hook in `agent_on_trigger()`
**File:** [`src/agent.c`](src/agent.c:1913)
After the tool loop completes at [line 2041](src/agent.c:2041), before cleanup, add a chain trigger check:
```c
// After the tool loop, check for chain triggers
// Need access to trigger_manager — use agent_get_trigger_manager() or global
trigger_manager_fire_chains(g_trigger_manager, skill_d_tag, messages);
```
This requires:
1. The agent needs access to the trigger manager — it already has this via [`agent_set_trigger_manager()`](src/agent.h:12)
2. A new function `trigger_manager_fire_chains()` that scans for chain triggers whose filter matches the completed skill's d_tag
### 4.2 Add `trigger_manager_fire_chains()` function
**File:** [`src/trigger_manager.c`](src/trigger_manager.c)
```c
void trigger_manager_fire_chains(trigger_manager_t* mgr,
const char* source_d_tag,
cJSON* source_output) {
if (!mgr || !source_d_tag) return;
pthread_mutex_lock(&mgr->mutex);
for (int i = 0; i < mgr->count; i++) {
active_trigger_t* t = &mgr->triggers[i];
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CHAIN) continue;
// For chain triggers, filter_json contains the source skill d_tag
if (strcmp(t->filter_json, source_d_tag) != 0) continue;
// Cooldown check
time_t now = time(NULL);
int cooldown = mgr->cfg->triggers.cooldown_seconds;
if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) continue;
t->last_fired = now;
active_trigger_t trigger_copy = *t;
trigger_copy.subscription = NULL;
trigger_copy.subscription_ctx = NULL;
pthread_mutex_unlock(&mgr->mutex);
// Build synthetic chain event
cJSON* event = cJSON_CreateObject();
cJSON_AddStringToObject(event, "type", "chain");
cJSON_AddStringToObject(event, "source_d_tag", source_d_tag);
cJSON_AddNumberToObject(event, "created_at", (double)now);
if (source_output) {
cJSON* output_copy = cJSON_Duplicate(source_output, 1);
if (output_copy) {
cJSON_AddItemToObject(event, "source_output", output_copy);
}
}
if (trigger_copy.action_type == TRIGGER_ACTION_LLM) {
execute_llm_action(&trigger_copy, event, "chain");
}
// Note: template actions could also be supported
cJSON_Delete(event);
pthread_mutex_lock(&mgr->mutex);
}
pthread_mutex_unlock(&mgr->mutex);
}
```
### 4.3 Chain trigger recursion protection
To prevent infinite chain loops (A → B → A → B → ...), add a chain depth counter:
- Add a `static __thread int chain_depth = 0;` in `trigger_manager_fire_chains()`
- Increment before firing, decrement after
- Refuse to fire if depth exceeds a limit (e.g., 5)
### 4.4 Chain trigger skill tags
For chain triggers, the `filter` tag contains the source skill's d_tag:
- `["trigger", "chain"]`
- `["filter", "data-fetcher"]` — fires after `data-fetcher` skill completes
### 4.5 What to pass as chain context
The chain trigger's synthetic event should include the source skill's final LLM response. This requires capturing the last assistant message from the tool loop in `agent_on_trigger()`. Currently the function doesn't return any output — it just runs and exits.
**Approach:** After the tool loop at [line 2041](src/agent.c:2041), extract the last assistant message from `messages` array and pass it to `trigger_manager_fire_chains()`.
---
## Phase 5: Documentation
### 5.1 Update `docs/SKILLS.md`
**File:** [`docs/SKILLS.md`](docs/SKILLS.md:209)
Expand the Trigger Tags table to show all four types and their filter semantics:
| Tag | Required | Description |
|---|---|---|
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` |
| `filter` | Yes | Type-dependent: JSON filter, empty/unused, cron expression, or source d_tag |
| `action` | No | `template` or `llm` — default: `llm` |
| `enabled` | No | Whether active — default: `true` |
Add sections for each new trigger type with examples.
Update the Future Extensions table to mark webhook, cron, and chain as implemented.
### 5.2 Update `docs/API.md`
**File:** [`docs/API.md`](docs/API.md:49)
Add documentation for the new webhook endpoint:
```
### POST /api/trigger/:d_tag
Fire a webhook trigger by skill d_tag.
**URL Parameters:**
- `d_tag` — The skill's d_tag identifier
**Request Body:** Optional JSON payload passed as context to the skill
**Response:**
{
"success": true,
"d_tag": "my-webhook-skill",
"status": "fired"
}
```
---
## Phase 6: Build and Push
1. Run `make -j` and fix any compilation warnings/errors
2. Test webhook with: `curl -X POST http://localhost:8484/api/trigger/test-skill -d '{"message":"hello"}'`
3. Push with `./increment_and_push.sh "feat: add webhook, cron, and chain trigger types"`
---
## Files Modified Summary
| File | Changes |
|---|---|
| [`src/trigger_manager.h`](src/trigger_manager.h) | Add `trigger_type_t` enum, extend `active_trigger_t`, update function signatures, add new declarations |
| [`src/trigger_manager.c`](src/trigger_manager.c) | Type conversion helpers, update load/add/update functions, cron parser, poll implementation, chain fire function, find function |
| [`src/tools.c`](src/tools.c) | Update `execute_skill_create()` and `execute_skill_edit()` validation and call sites |
| [`src/http_api.c`](src/http_api.c) | Add webhook route handler and route entry |
| [`src/agent.c`](src/agent.c) | Add chain trigger post-execution hook in `agent_on_trigger()` |
| [`src/agent.h`](src/agent.h) | No changes needed — `agent_on_trigger()` signature unchanged |
| [`docs/SKILLS.md`](docs/SKILLS.md) | Document all three new trigger types |
| [`docs/API.md`](docs/API.md) | Document webhook endpoint |
---
## Implementation Checklist
- [ ] Add `trigger_type_t` enum to `trigger_manager.h`
- [ ] Extend `active_trigger_t` struct with `trigger_type`, `last_cron_fire`, `cron_expr`
- [ ] Add `trigger_type_from_string()` and `trigger_type_to_string()` declarations and implementations
- [ ] Add `trigger_manager_find()` declaration and implementation
- [ ] Add `trigger_manager_fire_chains()` declaration and implementation
- [ ] Update `trigger_manager_add()` signature with `trigger_type_str` parameter
- [ ] Update `trigger_manager_update()` signature with `trigger_type_str` parameter
- [ ] Update `trigger_manager_add()` body: set trigger_type, conditional subscription registration
- [ ] Update `trigger_manager_update()` body: set trigger_type, conditional subscription
- [ ] Update `trigger_manager_load_from_skills()` to accept all 4 trigger types
- [ ] Update `trigger_manager_load_from_startup_events()` to accept all 4 trigger types
- [ ] Update `trigger_manager_status_json()` to include type field
- [ ] Update `execute_skill_create()` validation at line 3603
- [ ] Update `execute_skill_create()` `trigger_manager_add()` call at line 3678
- [ ] Update `execute_skill_edit()` validation at line 4177
- [ ] Update `execute_skill_edit()` `trigger_manager_update()` and `trigger_manager_add()` calls at lines 4213-4224
- [ ] Add `handle_trigger_webhook()` handler to `http_api.c`
- [ ] Add webhook route to `http_handler()` in `http_api.c`
- [ ] Implement `cron_field_matches()` and `cron_matches()` in `trigger_manager.c`
- [ ] Implement cron polling in `trigger_manager_poll()`
- [ ] Add chain trigger post-execution hook in `agent_on_trigger()`
- [ ] Add chain depth recursion protection
- [ ] Update `docs/SKILLS.md` with all three new trigger types
- [ ] Update `docs/API.md` with webhook endpoint
- [ ] Build with `make -j` and verify clean compilation
- [ ] Push with `./increment_and_push.sh`
+8
View File
@@ -13,6 +13,7 @@
#include "llm.h"
#include "nostr_handler.h"
#include "trigger_manager.h"
#include "tools.h"
#include "prompt_template.h"
#include "cjson/cJSON.h"
@@ -2040,6 +2041,13 @@ void agent_on_trigger(const char* skill_d_tag,
llm_response_free(&resp);
}
if (g_trigger_manager) {
(void)trigger_manager_fire_chains(g_trigger_manager,
skill_d_tag,
triggering_event,
relay_url && relay_url[0] != '\0' ? relay_url : "trigger");
}
cJSON_Delete(messages);
free(tools_json);
}
+183
View File
@@ -5,6 +5,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "agent.h"
#include "llm.h"
@@ -80,6 +81,179 @@ static int estimate_tokens_from_chars(int chars) {
return (chars + 3) / 4;
}
static int uri_extract_after_prefix(const struct mg_str* uri,
const char* prefix,
char* out,
size_t out_sz) {
if (!uri || !prefix || !out || out_sz == 0) {
return -1;
}
out[0] = '\0';
size_t prefix_len = strlen(prefix);
if (uri->len <= prefix_len || strncmp(uri->buf, prefix, prefix_len) != 0) {
return -1;
}
size_t rem = uri->len - prefix_len;
if (rem == 0 || rem >= out_sz) {
return -1;
}
memcpy(out, uri->buf + prefix_len, rem);
out[rem] = '\0';
return 0;
}
static cJSON* find_skill_event_by_d_tag(const char* d_tag) {
if (!d_tag || d_tag[0] == '\0') {
return NULL;
}
char* events_json = nostr_handler_get_self_skill_events_json();
if (!events_json) {
return NULL;
}
cJSON* events = cJSON_Parse(events_json);
free(events_json);
if (!events || !cJSON_IsArray(events)) {
cJSON_Delete(events);
return NULL;
}
cJSON* found = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON* ev = cJSON_GetArrayItem(events, i);
cJSON* tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL;
if (!tags || !cJSON_IsArray(tags)) {
continue;
}
int tn = cJSON_GetArraySize(tags);
for (int ti = 0; ti < tn; ti++) {
cJSON* tag = cJSON_GetArrayItem(tags, ti);
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) {
continue;
}
if (strcmp(key->valuestring, "d") == 0 && strcmp(val->valuestring, d_tag) == 0) {
found = cJSON_Duplicate(ev, 1);
break;
}
}
if (found) {
break;
}
}
cJSON_Delete(events);
return found;
}
static const char* find_tag_string_local(cJSON* tags, const char* key) {
if (!tags || !cJSON_IsArray(tags) || !key || key[0] == '\0') {
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) {
continue;
}
if (strcmp(k->valuestring, key) == 0) {
return v->valuestring;
}
}
return NULL;
}
static void handle_trigger_webhook(struct mg_connection* c,
const struct mg_http_message* hm,
const char* d_tag) {
if (!g_api_ctx.trigger_manager) {
reply_error(c, 503, "trigger manager unavailable");
return;
}
cJSON* skill_ev = find_skill_event_by_d_tag(d_tag);
if (!skill_ev) {
reply_error(c, 404, "skill not found by d_tag");
return;
}
cJSON* tags = cJSON_GetObjectItemCaseSensitive(skill_ev, "tags");
const char* trigger_type = find_tag_string_local(tags, "trigger");
if (!trigger_type || strcmp(trigger_type, "webhook") != 0) {
cJSON_Delete(skill_ev);
reply_error(c, 400, "skill trigger type is not webhook");
return;
}
const char* enabled = find_tag_string_local(tags, "enabled");
if (enabled && (strcmp(enabled, "false") == 0 || strcmp(enabled, "0") == 0)) {
cJSON_Delete(skill_ev);
reply_error(c, 400, "webhook trigger is disabled");
return;
}
cJSON* payload = parse_body_json(hm);
if (!payload || !cJSON_IsObject(payload)) {
cJSON_Delete(payload);
cJSON_Delete(skill_ev);
reply_error(c, 400, "invalid JSON body");
return;
}
cJSON* event = cJSON_CreateObject();
if (!event) {
cJSON_Delete(payload);
cJSON_Delete(skill_ev);
reply_error(c, 500, "oom");
return;
}
cJSON_AddStringToObject(event, "type", "webhook");
cJSON_AddStringToObject(event, "skill_d_tag", d_tag);
cJSON_AddNumberToObject(event, "created_at", (double)time(NULL));
cJSON_AddItemToObject(event, "payload", payload);
int fired = trigger_manager_fire(g_api_ctx.trigger_manager, d_tag, event, "webhook");
cJSON_Delete(event);
cJSON_Delete(skill_ev);
if (fired < 0) {
reply_error(c, 404, "active trigger not found for d_tag");
return;
}
cJSON* out = cJSON_CreateObject();
if (!out) {
reply_error(c, 500, "oom");
return;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "d_tag", d_tag);
cJSON_AddBoolToObject(out, "fired", fired > 0 ? 1 : 0);
reply_json(c, 200, out);
cJSON_Delete(out);
}
static char* maybe_model_override_begin(cJSON* body, llm_config_t* out_old_cfg, int* out_overridden) {
if (!body || !out_old_cfg || !out_overridden) return NULL;
*out_overridden = 0;
@@ -1202,6 +1376,15 @@ static void http_handler(struct mg_connection* c, int ev, void* ev_data) {
handle_prompt_compare(c, hm);
return;
}
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/trigger/*"), NULL)) {
char d_tag[TRIGGER_SKILL_D_TAG_MAX] = {0};
if (uri_extract_after_prefix(&hm->uri, "/api/trigger/", d_tag, sizeof(d_tag)) != 0) {
reply_error(c, 400, "missing or invalid d_tag");
return;
}
handle_trigger_webhook(c, hm, d_tag);
return;
}
reply_error(c, 404, "not found");
}
+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 60
#define DIDACTYL_VERSION "v0.0.60"
#define DIDACTYL_VERSION_PATCH 61
#define DIDACTYL_VERSION "v0.0.61"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+7 -1
View File
@@ -488,8 +488,13 @@ static void register_trigger_from_self_skill_event(cJSON* event) {
const char* action = find_tag_value_local(tags, "action");
const char* enabled = find_tag_value_local(tags, "enabled");
int trigger_supported = trigger &&
(strcmp(trigger, "nostr-subscription") == 0 ||
strcmp(trigger, "webhook") == 0 ||
strcmp(trigger, "cron") == 0 ||
strcmp(trigger, "chain") == 0);
if (!d_tag || d_tag[0] == '\0' ||
!trigger || strcmp(trigger, "nostr-subscription") != 0 ||
!trigger_supported ||
!filter || filter[0] == '\0') {
return;
}
@@ -504,6 +509,7 @@ static void register_trigger_from_self_skill_event(cJSON* event) {
content->valuestring,
filter,
action_type,
trigger,
is_enabled);
if (rc == 0) {
DEBUG_INFO("[didactyl] live self-skill trigger registered d_tag=%s action=%s enabled=%d",
+14 -4
View File
@@ -3600,10 +3600,14 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
: "llm";
int enabled_int = (!enabled || cJSON_IsTrue(enabled)) ? 1 : 0;
if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) {
if (trigger_str &&
strcmp(trigger_str, "nostr-subscription") != 0 &&
strcmp(trigger_str, "webhook") != 0 &&
strcmp(trigger_str, "cron") != 0 &&
strcmp(trigger_str, "chain") != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error("skill_create trigger must be nostr-subscription when provided");
return json_error("skill_create trigger must be one of: nostr-subscription, webhook, cron, chain");
}
if ((trigger_str && !filter_str) || (!trigger_str && filter_str)) {
cJSON_Delete(tags);
@@ -3680,6 +3684,7 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
content->valuestring,
filter_str,
at,
trigger_str,
enabled_int) == 0) {
trigger_registered = 1;
}
@@ -4174,11 +4179,14 @@ static char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
remove_tag_key_all(tags_out, "enabled");
if (merged_trigger && merged_filter) {
if (strcmp(merged_trigger, "nostr-subscription") != 0) {
if (strcmp(merged_trigger, "nostr-subscription") != 0 &&
strcmp(merged_trigger, "webhook") != 0 &&
strcmp(merged_trigger, "cron") != 0 &&
strcmp(merged_trigger, "chain") != 0) {
cJSON_Delete(args);
cJSON_Delete(events);
cJSON_Delete(tags_out);
return json_error("skill_edit trigger must be nostr-subscription");
return json_error("skill_edit trigger must be one of: nostr-subscription, webhook, cron, chain");
}
if (add_string_tag(tags_out, "trigger", merged_trigger) != 0 ||
add_string_tag(tags_out, "filter", merged_filter) != 0 ||
@@ -4215,12 +4223,14 @@ static char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
out_content,
merged_filter,
at,
merged_trigger,
merged_enabled) == 0 ||
trigger_manager_add(ctx->trigger_manager,
d->valuestring,
out_content,
merged_filter,
at,
merged_trigger,
merged_enabled) == 0) {
trigger_registered = 1;
}
+419 -17
View File
@@ -6,6 +6,7 @@
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <ctype.h>
#include "agent.h"
#include "cjson/cJSON.h"
@@ -21,6 +22,216 @@ static int clamp_enabled(int enabled) {
return enabled ? 1 : 0;
}
trigger_type_t trigger_type_from_string(const char* s) {
if (!s) {
return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
}
if (strcmp(s, "webhook") == 0) {
return TRIGGER_TYPE_WEBHOOK;
}
if (strcmp(s, "cron") == 0) {
return TRIGGER_TYPE_CRON;
}
if (strcmp(s, "chain") == 0) {
return TRIGGER_TYPE_CHAIN;
}
return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
}
const char* trigger_type_to_string(trigger_type_t t) {
switch (t) {
case TRIGGER_TYPE_WEBHOOK:
return "webhook";
case TRIGGER_TYPE_CRON:
return "cron";
case TRIGGER_TYPE_CHAIN:
return "chain";
case TRIGGER_TYPE_NOSTR_SUBSCRIPTION:
default:
return "nostr-subscription";
}
}
static int is_int_in_csv_list(const char* text, int value) {
if (!text || text[0] == '\0') {
return 0;
}
const char* p = text;
while (*p) {
while (*p == ' ') p++;
char* endptr = NULL;
long n = strtol(p, &endptr, 10);
if (endptr == p) {
return 0;
}
if ((int)n == value) {
return 1;
}
p = endptr;
while (*p == ' ') p++;
if (*p == ',') {
p++;
continue;
}
if (*p == '\0') {
break;
}
return 0;
}
return 0;
}
static int cron_field_token_matches(const char* token, int value, int min_v, int max_v) {
if (!token || token[0] == '\0') {
return 0;
}
if (strcmp(token, "*") == 0) {
return 1;
}
const char* slash = strchr(token, '/');
int step = 0;
char base[64];
if (slash) {
size_t base_len = (size_t)(slash - token);
if (base_len == 0 || base_len >= sizeof(base)) {
return 0;
}
memcpy(base, token, base_len);
base[base_len] = '\0';
char* endptr = NULL;
long step_l = strtol(slash + 1, &endptr, 10);
if (endptr == slash + 1 || *endptr != '\0' || step_l <= 0 || step_l > 1024) {
return 0;
}
step = (int)step_l;
} else {
snprintf(base, sizeof(base), "%s", token);
}
int range_start = min_v;
int range_end = max_v;
if (strcmp(base, "*") == 0) {
range_start = min_v;
range_end = max_v;
} else {
const char* dash = strchr(base, '-');
if (dash) {
char left[32];
char right[32];
size_t left_len = (size_t)(dash - base);
size_t right_len = strlen(dash + 1);
if (left_len == 0 || right_len == 0 || left_len >= sizeof(left) || right_len >= sizeof(right)) {
return 0;
}
memcpy(left, base, left_len);
left[left_len] = '\0';
memcpy(right, dash + 1, right_len + 1U);
char* e1 = NULL;
char* e2 = NULL;
long a = strtol(left, &e1, 10);
long b = strtol(right, &e2, 10);
if (!e1 || *e1 != '\0' || !e2 || *e2 != '\0') {
return 0;
}
range_start = (int)a;
range_end = (int)b;
} else if (strchr(base, ',')) {
if (!is_int_in_csv_list(base, value)) {
return 0;
}
if (step <= 1) {
return 1;
}
return ((value - min_v) % step) == 0;
} else {
char* e = NULL;
long n = strtol(base, &e, 10);
if (!e || *e != '\0') {
return 0;
}
range_start = (int)n;
range_end = (int)n;
}
}
if (range_start < min_v || range_end > max_v || range_start > range_end) {
return 0;
}
if (value < range_start || value > range_end) {
return 0;
}
if (step > 1 && ((value - range_start) % step) != 0) {
return 0;
}
return 1;
}
static int cron_field_matches(const char* field, int value, int min_v, int max_v) {
if (!field || field[0] == '\0') {
return 0;
}
char tmp[128];
snprintf(tmp, sizeof(tmp), "%s", field);
char* saveptr = NULL;
char* token = strtok_r(tmp, ",", &saveptr);
while (token) {
while (*token == ' ') token++;
if (cron_field_token_matches(token, value, min_v, max_v)) {
return 1;
}
token = strtok_r(NULL, ",", &saveptr);
}
return 0;
}
static int cron_matches_now(const char* expr, time_t now_ts) {
if (!expr || expr[0] == '\0') {
return 0;
}
char buf[TRIGGER_FILTER_JSON_MAX];
snprintf(buf, sizeof(buf), "%s", expr);
char* fields[5] = {0};
int nf = 0;
char* saveptr = NULL;
char* tok = strtok_r(buf, " \t", &saveptr);
while (tok && nf < 5) {
fields[nf++] = tok;
tok = strtok_r(NULL, " \t", &saveptr);
}
if (nf != 5 || tok != NULL) {
return 0;
}
struct tm tm_now;
localtime_r(&now_ts, &tm_now);
int minute = tm_now.tm_min;
int hour = tm_now.tm_hour;
int mday = tm_now.tm_mday;
int month = tm_now.tm_mon + 1;
int wday = tm_now.tm_wday;
if (!cron_field_matches(fields[0], minute, 0, 59)) return 0;
if (!cron_field_matches(fields[1], hour, 0, 23)) return 0;
if (!cron_field_matches(fields[2], mday, 1, 31)) return 0;
if (!cron_field_matches(fields[3], month, 1, 12)) return 0;
if (!cron_field_matches(fields[4], wday, 0, 6)) return 0;
return 1;
}
static int ensure_capacity(trigger_manager_t* mgr, int needed) {
if (!mgr || needed <= 0) {
return -1;
@@ -253,15 +464,7 @@ static void on_trigger_subscription_event(cJSON* event, const char* relay_url, v
return;
}
trigger_manager_t* mgr = ctx->mgr;
pthread_mutex_lock(&mgr->mutex);
int idx = find_trigger_index_locked(mgr, ctx->skill_d_tag);
if (idx >= 0) {
(void)maybe_fire_trigger_locked(mgr, idx, event, relay_url);
}
pthread_mutex_unlock(&mgr->mutex);
(void)trigger_manager_fire(ctx->mgr, ctx->skill_d_tag, event, relay_url);
}
static int register_trigger_subscription_locked(trigger_manager_t* mgr, active_trigger_t* t) {
@@ -271,6 +474,10 @@ static int register_trigger_subscription_locked(trigger_manager_t* mgr, active_t
close_trigger_subscription_locked(t);
if (t->trigger_type != TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
return 0;
}
if (!t->enabled || t->filter_json[0] == '\0') {
return 0;
}
@@ -479,10 +686,15 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
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') {
int trigger_supported = trigger_s &&
(strcmp(trigger_s, "nostr-subscription") == 0 ||
strcmp(trigger_s, "webhook") == 0 ||
strcmp(trigger_s, "cron") == 0 ||
strcmp(trigger_s, "chain") == 0);
if (trigger_supported && 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, d_tag, content->valuestring, filter_s, at, is_enabled) == 0) {
if (trigger_manager_add(mgr, d_tag, content->valuestring, filter_s, at, trigger_s, is_enabled) == 0) {
loaded++;
}
}
@@ -544,7 +756,12 @@ int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) {
continue;
}
if (!trigger_s || strcmp(trigger_s, "nostr-subscription") != 0 || !filter_s || filter_s[0] == '\0') {
int trigger_supported = trigger_s &&
(strcmp(trigger_s, "nostr-subscription") == 0 ||
strcmp(trigger_s, "webhook") == 0 ||
strcmp(trigger_s, "cron") == 0 ||
strcmp(trigger_s, "chain") == 0);
if (!trigger_supported || !filter_s || filter_s[0] == '\0') {
cJSON_Delete(tags);
continue;
}
@@ -552,7 +769,7 @@ int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) {
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, d_tag, ev->content, filter_s, at, is_enabled) == 0) {
if (trigger_manager_add(mgr, d_tag, ev->content, filter_s, at, trigger_s, is_enabled) == 0) {
loaded++;
DEBUG_INFO("[didactyl] startup trigger registered d_tag=%s action=%s enabled=%d", d_tag, at == TRIGGER_ACTION_TEMPLATE ? "template" : "llm", is_enabled);
} else {
@@ -571,6 +788,7 @@ int trigger_manager_add(trigger_manager_t* mgr,
const char* content,
const char* filter_json,
trigger_action_type_t action_type,
const char* trigger_type_str,
int enabled) {
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) {
return -1;
@@ -585,7 +803,7 @@ int trigger_manager_add(trigger_manager_t* mgr,
int existing = find_trigger_index_locked(mgr, skill_d_tag);
if (existing >= 0) {
pthread_mutex_unlock(&mgr->mutex);
return trigger_manager_update(mgr, skill_d_tag, content, filter_json, action_type, enabled);
return trigger_manager_update(mgr, skill_d_tag, content, filter_json, action_type, trigger_type_str, enabled);
}
int max_active = mgr->cfg ? mgr->cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
@@ -608,8 +826,13 @@ int trigger_manager_add(trigger_manager_t* mgr,
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
t->action_type = action_type;
t->enabled = clamp_enabled(enabled);
t->trigger_type = trigger_type_from_string(trigger_type_str);
t->last_fired = 0;
t->last_seen_created_at = 0;
t->last_cron_fire = 0;
if (t->trigger_type == TRIGGER_TYPE_CRON) {
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
}
t->subscription = NULL;
t->subscription_ctx = NULL;
@@ -658,11 +881,124 @@ int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag) {
return 0;
}
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out) {
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !out) {
return -1;
}
pthread_mutex_lock(&mgr->mutex);
int idx = find_trigger_index_locked(mgr, skill_d_tag);
if (idx < 0) {
pthread_mutex_unlock(&mgr->mutex);
return -1;
}
*out = mgr->triggers[idx];
out->subscription = NULL;
out->subscription_ctx = NULL;
pthread_mutex_unlock(&mgr->mutex);
return 0;
}
int trigger_manager_fire(trigger_manager_t* mgr,
const char* skill_d_tag,
cJSON* event,
const char* source_label) {
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !event) {
return -1;
}
pthread_mutex_lock(&mgr->mutex);
int idx = find_trigger_index_locked(mgr, skill_d_tag);
if (idx < 0) {
pthread_mutex_unlock(&mgr->mutex);
return -1;
}
int fired = maybe_fire_trigger_locked(mgr, idx, event, source_label);
pthread_mutex_unlock(&mgr->mutex);
return fired;
}
int trigger_manager_fire_chains(trigger_manager_t* mgr,
const char* source_skill_d_tag,
cJSON* source_event,
const char* source_label) {
if (!mgr || !source_skill_d_tag || source_skill_d_tag[0] == '\0') {
return -1;
}
static __thread int s_chain_depth = 0;
if (s_chain_depth >= 5) {
DEBUG_WARN("[didactyl] chain trigger depth limit reached for source=%s", source_skill_d_tag);
return 0;
}
int fired_count = 0;
s_chain_depth++;
pthread_mutex_lock(&mgr->mutex);
int count_snapshot = mgr->count;
for (int i = 0; i < count_snapshot; i++) {
active_trigger_t* t = &mgr->triggers[i];
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CHAIN) {
continue;
}
if (t->filter_json[0] == '\0' || strcmp(t->filter_json, source_skill_d_tag) != 0) {
continue;
}
time_t now = time(NULL);
int cooldown = mgr->cfg ? mgr->cfg->triggers.cooldown_seconds : 0;
if (cooldown < 0) cooldown = 0;
if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) {
continue;
}
t->last_fired = now;
active_trigger_t trigger_copy = *t;
trigger_copy.subscription = NULL;
trigger_copy.subscription_ctx = NULL;
pthread_mutex_unlock(&mgr->mutex);
cJSON* event = cJSON_CreateObject();
if (event) {
cJSON_AddStringToObject(event, "type", "chain");
cJSON_AddStringToObject(event, "source_d_tag", source_skill_d_tag);
cJSON_AddStringToObject(event, "source_label", source_label ? source_label : "chain");
cJSON_AddNumberToObject(event, "created_at", (double)now);
if (source_event) {
cJSON* dup = cJSON_Duplicate(source_event, 1);
if (dup) {
cJSON_AddItemToObject(event, "source_event", dup);
}
}
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
execute_template_action(mgr, &trigger_copy, event, "chain");
} else {
execute_llm_action(&trigger_copy, event, "chain");
}
cJSON_Delete(event);
fired_count++;
}
pthread_mutex_lock(&mgr->mutex);
}
pthread_mutex_unlock(&mgr->mutex);
s_chain_depth--;
return fired_count;
}
int trigger_manager_update(trigger_manager_t* mgr,
const char* skill_d_tag,
const char* content,
const char* filter_json,
trigger_action_type_t action_type,
const char* trigger_type_str,
int enabled) {
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) {
return -1;
@@ -677,7 +1013,7 @@ int trigger_manager_update(trigger_manager_t* mgr,
int idx = find_trigger_index_locked(mgr, skill_d_tag);
if (idx < 0) {
pthread_mutex_unlock(&mgr->mutex);
return trigger_manager_add(mgr, skill_d_tag, content, filter_json, action_type, enabled);
return trigger_manager_add(mgr, skill_d_tag, content, filter_json, action_type, trigger_type_str, enabled);
}
active_trigger_t* t = &mgr->triggers[idx];
@@ -685,6 +1021,12 @@ int trigger_manager_update(trigger_manager_t* mgr,
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
t->action_type = action_type;
t->enabled = clamp_enabled(enabled);
t->trigger_type = trigger_type_from_string(trigger_type_str);
if (t->trigger_type == TRIGGER_TYPE_CRON) {
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
} else {
t->cron_expr[0] = '\0';
}
if (register_trigger_subscription_locked(mgr, t) != 0) {
pthread_mutex_unlock(&mgr->mutex);
@@ -716,8 +1058,67 @@ int trigger_manager_active_count(trigger_manager_t* mgr) {
}
int trigger_manager_poll(trigger_manager_t* mgr) {
(void)mgr;
return 0;
if (!mgr) {
return -1;
}
time_t now = time(NULL);
if (now <= 0) {
return 0;
}
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 30) {
return 0;
}
mgr->last_poll_at = now;
int fired = 0;
pthread_mutex_lock(&mgr->mutex);
int count_snapshot = mgr->count;
for (int i = 0; i < count_snapshot; i++) {
active_trigger_t* t = &mgr->triggers[i];
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CRON) {
continue;
}
const char* expr = (t->cron_expr[0] != '\0') ? t->cron_expr : t->filter_json;
if (!cron_matches_now(expr, now)) {
continue;
}
if (t->last_cron_fire > 0 && (now - t->last_cron_fire) < 50) {
continue;
}
t->last_cron_fire = now;
active_trigger_t trigger_copy = *t;
trigger_copy.subscription = NULL;
trigger_copy.subscription_ctx = NULL;
pthread_mutex_unlock(&mgr->mutex);
cJSON* event = cJSON_CreateObject();
if (event) {
cJSON_AddStringToObject(event, "type", "cron");
cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag);
cJSON_AddStringToObject(event, "cron_expr", expr);
cJSON_AddNumberToObject(event, "created_at", (double)now);
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
execute_template_action(mgr, &trigger_copy, event, "cron");
} else {
execute_llm_action(&trigger_copy, event, "cron");
}
cJSON_Delete(event);
fired++;
}
pthread_mutex_lock(&mgr->mutex);
}
pthread_mutex_unlock(&mgr->mutex);
return fired;
}
char* trigger_manager_status_json(trigger_manager_t* mgr) {
@@ -760,6 +1161,7 @@ char* trigger_manager_status_json(trigger_manager_t* mgr) {
cJSON_AddStringToObject(item, "skill_d_tag", t->skill_d_tag);
cJSON_AddStringToObject(item, "filter_json", t->filter_json);
cJSON_AddStringToObject(item, "type", trigger_type_to_string(t->trigger_type));
cJSON_AddStringToObject(item, "action", t->action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm");
cJSON_AddBoolToObject(item, "enabled", t->enabled ? 1 : 0);
cJSON_AddBoolToObject(item, "subscribed", t->subscription ? 1 : 0);
+25
View File
@@ -5,6 +5,7 @@
#include <time.h>
#include "config.h"
#include "cjson/cJSON.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#define TRIGGER_DEFAULT_MAX_ACTIVE 16
@@ -17,14 +18,24 @@ typedef enum {
TRIGGER_ACTION_TEMPLATE = 1
} trigger_action_type_t;
typedef enum {
TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0,
TRIGGER_TYPE_WEBHOOK = 1,
TRIGGER_TYPE_CRON = 2,
TRIGGER_TYPE_CHAIN = 3
} trigger_type_t;
typedef struct {
char skill_d_tag[TRIGGER_SKILL_D_TAG_MAX];
char skill_content[TRIGGER_SKILL_CONTENT_MAX];
char filter_json[TRIGGER_FILTER_JSON_MAX];
trigger_action_type_t action_type;
int enabled;
trigger_type_t trigger_type;
time_t last_fired;
time_t last_seen_created_at;
time_t last_cron_fire;
char cron_expr[64];
nostr_pool_subscription_t* subscription;
void* subscription_ctx;
} active_trigger_t;
@@ -41,19 +52,33 @@ typedef struct trigger_manager {
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_load_from_startup_events(trigger_manager_t* mgr);
trigger_type_t trigger_type_from_string(const char* s);
const char* trigger_type_to_string(trigger_type_t t);
int trigger_manager_add(trigger_manager_t* mgr,
const char* skill_d_tag,
const char* content,
const char* filter_json,
trigger_action_type_t action_type,
const char* trigger_type_str,
int enabled);
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag);
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out);
int trigger_manager_update(trigger_manager_t* mgr,
const char* skill_d_tag,
const char* content,
const char* filter_json,
trigger_action_type_t action_type,
const char* trigger_type_str,
int enabled);
int trigger_manager_fire(trigger_manager_t* mgr,
const char* skill_d_tag,
cJSON* event,
const char* source_label);
int trigger_manager_fire_chains(trigger_manager_t* mgr,
const char* source_skill_d_tag,
cJSON* source_event,
const char* source_label);
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);