Compare commits

...
2 Commits
13 changed files with 2066 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.62
**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.62fix: webhook trigger lookup uses trigger manager and add startup PoC triggers/tests
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
+607
View File
@@ -1,3 +1,610 @@
```text
Context Log - not seen by model
timestamp=2026-03-10 05:44:14
phase=direct_tool_exec
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
model=claude-opus-4.6
context_bytes=7997
approx_tokens=1999
```
slash=/help
result_json=AVAILABLE TOOLS
- admin_identity — Build admin identity context block from cached runtime metadata
- agent_identity — Build agent identity context block with pubkey and npub
- agent_version — Return current Didactyl version and metadata from build macros
- local_file_read — Read a local file as text from the configured working directory
- local_file_write — Write text content to a local file in the configured working directory
- local_http_fetch — Fetch HTTP(S) resources with optional method, headers, timeout, and body
- local_shell_exec — Execute a shell command and return stdout/stderr
- model_get — Get current active LLM runtime configuration (excluding API key)
- model_list — List available model IDs using provider OpenAI-compatible /models endpoint
- model_set — Update active LLM configuration and persist it to config.jsonc
- my_npub — Alias for nostr_npub: return this agent's pubkey encoded as npub bech32
- my_pubkey — Alias for nostr_pubkey: return this agent's pubkey in hex format
- nostr_admin_contacts — Build admin contacts context block from cached kind 3 contact list
- nostr_admin_notes — Build admin notes context block from cached kind 1 notes
- nostr_admin_profile — Build admin profile context block from cached kind 0 metadata
- nostr_admin_relays — Build admin relay context block from cached kind 10002 data
- nostr_decode — Decode a Nostr bech32/nostr: URI into components
- nostr_decrypt — Decrypt NIP-44 ciphertext from a sender
- nostr_delete — Request deletion of one or more previously published events (NIP-09 kind 5)
- nostr_dm_send — Send a NIP-04 encrypted DM
- nostr_dm_send_nip17 — Send a private DM using NIP-17 gift wrap protocol
- nostr_encode — Encode a Nostr entity into nostr: URI (npub, note, nprofile, nevent, naddr)
- nostr_encrypt — Encrypt plaintext using NIP-44 for a recipient
- nostr_file_md_to_longform_post — Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename
- nostr_list_manage — Add/remove tag tuples in replaceable list events (NIP-51 style)
- nostr_nip05_lookup — Look up or verify a NIP-05 identifier (user@domain)
- nostr_npub — Return this agent's pubkey encoded as npub bech32
- nostr_post — Publish a Nostr event to connected relays
- nostr_post_readme — Publish README.md as kind 30023 with deterministic d tag readme.md
- nostr_profile_get — Look up a Nostr profile (kind 0 metadata) by pubkey
- nostr_pubkey — Return this agent's pubkey in hex format
- nostr_query — Query events from relays using a Nostr filter
- nostr_react — React to a Nostr event with like/dislike/emoji (NIP-25 kind 7)
- nostr_relay_info — Fetch NIP-11 relay information document
- nostr_relay_status — Get connection status and statistics for all relays
- skill_adopt — Adopt a skill by adding its address to kind 10123 adoption list
- skill_create — Create or update a skill definition as kind 31123/31124 and optionally auto-adopt it
- skill_edit — Edit an existing self skill by d tag and republish it as kind 31123/31124
- skill_list — List this agent's published skills, optionally filtered by scope
- skill_remove — Remove a skill address from kind 10123 adoption list
- skill_search — Search public skills by query/author and optionally rank by adoption popularity
- task_list — Build current task list context block from tasks.json
- task_manage — Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)
- tool_list — List available tools with name, description, and JSON parameter schema
- trigger_list — List active triggered skills and their runtime status
AVAILABLE SKILLS
- long_form_note
- post_readme_to_nostr
- admin_ops
- webhook_source_poc — PoC webhook trigger skill
- cron_poc — PoC cron trigger skill
- chain_from_webhook_poc — PoC chain trigger skill
- cheerleader — Cheer on admin whenever they post a kind 1 note
result_markdown=AVAILABLE TOOLS
- admin_identity — Build admin identity context block from cached runtime metadata
- agent_identity — Build agent identity context block with pubkey and npub
- agent_version — Return current Didactyl version and metadata from build macros
- local_file_read — Read a local file as text from the configured working directory
- local_file_write — Write text content to a local file in the configured working directory
- local_http_fetch — Fetch HTTP(S) resources with optional method, headers, timeout, and body
- local_shell_exec — Execute a shell command and return stdout/stderr
- model_get — Get current active LLM runtime configuration (excluding API key)
- model_list — List available model IDs using provider OpenAI-compatible /models endpoint
- model_set — Update active LLM configuration and persist it to config.jsonc
- my_npub — Alias for nostr_npub: return this agent's pubkey encoded as npub bech32
- my_pubkey — Alias for nostr_pubkey: return this agent's pubkey in hex format
- nostr_admin_contacts — Build admin contacts context block from cached kind 3 contact list
- nostr_admin_notes — Build admin notes context block from cached kind 1 notes
- nostr_admin_profile — Build admin profile context block from cached kind 0 metadata
- nostr_admin_relays — Build admin relay context block from cached kind 10002 data
- nostr_decode — Decode a Nostr bech32/nostr: URI into components
- nostr_decrypt — Decrypt NIP-44 ciphertext from a sender
- nostr_delete — Request deletion of one or more previously published events (NIP-09 kind 5)
- nostr_dm_send — Send a NIP-04 encrypted DM
- nostr_dm_send_nip17 — Send a private DM using NIP-17 gift wrap protocol
- nostr_encode — Encode a Nostr entity into nostr: URI (npub, note, nprofile, nevent, naddr)
- nostr_encrypt — Encrypt plaintext using NIP-44 for a recipient
- nostr_file_md_to_longform_post — Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename
- nostr_list_manage — Add/remove tag tuples in replaceable list events (NIP-51 style)
- nostr_nip05_lookup — Look up or verify a NIP-05 identifier (user@domain)
- nostr_npub — Return this agent's pubkey encoded as npub bech32
- nostr_post — Publish a Nostr event to connected relays
- nostr_post_readme — Publish README.md as kind 30023 with deterministic d tag readme.md
- nostr_profile_get — Look up a Nostr profile (kind 0 metadata) by pubkey
- nostr_pubkey — Return this agent's pubkey in hex format
- nostr_query — Query events from relays using a Nostr filter
- nostr_react — React to a Nostr event with like/dislike/emoji (NIP-25 kind 7)
- nostr_relay_info — Fetch NIP-11 relay information document
- nostr_relay_status — Get connection status and statistics for all relays
- skill_adopt — Adopt a skill by adding its address to kind 10123 adoption list
- skill_create — Create or update a skill definition as kind 31123/31124 and optionally auto-adopt it
- skill_edit — Edit an existing self skill by d tag and republish it as kind 31123/31124
- skill_list — List this agent's published skills, optionally filtered by scope
- skill_remove — Remove a skill address from kind 10123 adoption list
- skill_search — Search public skills by query/author and optionally rank by adoption popularity
- task_list — Build current task list context block from tasks.json
- task_manage — Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)
- tool_list — List available tools with name, description, and JSON parameter schema
- trigger_list — List active triggered skills and their runtime status
AVAILABLE SKILLS
- long_form_note
- post_readme_to_nostr
- admin_ops
- webhook_source_poc — PoC webhook trigger skill
- cron_poc — PoC cron trigger skill
- chain_from_webhook_poc — PoC chain trigger skill
- cheerleader — Cheer on admin whenever they post a kind 1 note
---
```text
Context Log - not seen by model
timestamp=2026-03-10 05:34:22
phase=llm_chat_with_tools_messages
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
model=claude-opus-4.6
context_bytes=4008
approx_tokens=1002
```
Sections: 10
# system_prompt | role=system
## Didactyl Agent
You are Didactyl, a sovereign AI agent living on Nostr.
### Communication Rules
- You communicate through encrypted Nostr direct messages.
- Keep responses concise and clear.
### Behavior
- Be helpful and technically accurate.
- If unsure, state uncertainty directly.
- Prefer actionable, practical advice.
- Use the person's name when messaging them if you know it.
- For the administrator, use their name from the administrator kind 0 profile metadata when available.
### Tool Use Policy
- You have tools available and should use them when a request requires taking action.
- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
- After a tool call, base your answer on the actual tool result.
- Never claim a tool was run if no tool was executed.
### Task Management
- Maintain and use your internal task list as short-term working memory.
- Break long or complex actions into clear tasks before executing them.
- Update task status as you complete steps so your plan stays accurate.
### Safety
- Do not claim to have executed actions you did not execute.
- You may share your public key (npub) with anyone.
- Never reveal your private key (nsec) under any circumstance.
# admin_identity | role=system
### Administrator Identity (source: config.admin.pubkey)
This is your administrator! Admin pubkey (hex): 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
This message has been cryptographically verified as coming from your administrator.
# admin_profile | role=system
### Administrator Kind 0 Profile (source: nostr kind 0)
Administrator kind 0 profile content (JSON): {"name":"William S Burroughs","display_name":"WSB","about":"I like to write, and I like crank. They are good. Sometimes I'm not so sure.","banner":"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg","website":"https://addicted.com","picture":"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*","lud16":"","nip05":""}
# admin_contacts | role=system
### Administrator Kind 3 Contacts (source: nostr kind 3)
Administrator contacts (JSON): ["fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c","82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2","4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f","8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8"]
# admin_relays | role=system
### Administrator Kind 10002 Relays (source: nostr kind 10002)
Administrator relay list (JSON): ["wss://relay.laantungir.net/","wss://relay.damus.io/","wss://nos.lol/","wss://relay.nostr.band/","wss://purplepag.es/","ws://127.0.0.1:7777/","wss://nostr.mom/","wss://relay.0xchat.com"]
# admin_notes | role=system
### Administrator Recent Kind 1 Notes
Administrator recent public notes:
- Forever we live. We never die.
- I love my agent.
- What time is it world?
- The sky is so blue.
- Today is a great day.
- hello
- What a great day to be alive.
- Good day everyone.
- test
- Now is a good time to go on a diet.
# tasks | role=system
### Current Task List
#### Current Tasks
Your active task list - short-term working memory for tracking plan steps.
- [ ] No active tasks yet.
# dm_history | role=chat
05:33 Agent Didactyl has started up and is online at 2026-03-10 05:33:58 (version v0.0.61, connected relays: 5/5).
05:34 WSB Good morning.
# conversation | role=user
Good morning.
---
```text
Context Log - not seen by model
timestamp=2026-03-09 20:25:36
phase=llm_trigger_with_tools_messages
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
model=claude-opus-4.6
context_bytes=5867
approx_tokens=1466
```
Sections: 6
# system_prompt | role=system
## Didactyl Agent
You are Didactyl, a sovereign AI agent living on Nostr.
### Communication Rules
- You communicate through encrypted Nostr direct messages.
- Keep responses concise and clear.
### Behavior
- Be helpful and technically accurate.
- If unsure, state uncertainty directly.
- Prefer actionable, practical advice.
- Use the person's name when messaging them if you know it.
- For the administrator, use their name from the administrator kind 0 profile metadata when available.
### Tool Use Policy
- You have tools available and should use them when a request requires taking action.
- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
- After a tool call, base your answer on the actual tool result.
- Never claim a tool was run if no tool was executed.
### Task Management
- Maintain and use your internal task list as short-term working memory.
- Break long or complex actions into clear tasks before executing them.
- Update task status as you complete steps so your plan stays accurate.
### Safety
- Do not claim to have executed actions you did not execute.
- You may share your public key (npub) with anyone.
- Never reveal your private key (nsec) under any circumstance.
---template---
- section: admin_identity
role: system
tool: admin_identity
skip_if_empty: true
- section: admin_profile
role: system
tool: nostr_admin_profile
skip_if_empty: true
- section: admin_contacts
role: system
tool: nostr_admin_contacts
skip_if_empty: true
- section: admin_relays
role: system
tool: nostr_admin_relays
skip_if_empty: true
- section: admin_notes
role: system
tool: nostr_admin_notes
skip_if_empty: true
- section: tasks
role: system
tool: task_list
skip_if_empty: true
- section: dm_history
role: expand
limit: 12
- section: conversation
role: user
tool: message_current
skip_if_empty: true
Triggered-skill execution context:
- You are executing a skill because a Nostr event matched its trigger filter.
- Execute the skill instructions below against the triggering event.
- Keep output concise and actionable for the administrator.
- If no action is needed, explicitly say why.
Skill d_tag: cheerleader
Relay: ws://127.0.0.1:7777
Skill instructions:
You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a single short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be enthusiastic and positive. Use emojis. Keep it brief and fun.
IMPORTANT: Send EXACTLY ONE DM. That DM is your entire and complete response. After sending the DM, you are done. Do not send any additional messages. Do not narrate what you did. Do not send a follow-up or summary. Do not produce any further output after the single DM tool call. One DM. Then stop.
# context_part | role=tool
{"success":true,"admin_kind0_json":"{\"name\":\"William S Burroughs\",\"display_name\":\"WSB\",\"about\":\"I like to write, and I like crank. They are good. Sometimes I'm not so sure.\",\"banner\":\"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg\",\"website\":\"https://addicted.com\",\"picture\":\"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*\",\"lud16\":\"\",\"nip05\":\"\"}","content":"## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): {\"name\":\"William S Burroughs\",\"display_name\":\"WSB\",\"about\":\"I like to write, and I like crank. They are good. Sometimes I'm not so sure.\",\"banner\":\"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg\",\"website\":\"https://addicted.com\",\"picture\":\"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*\",\"lud16\":\"\",\"nip05\":\"\"}"}
# context_part | role=tool
{"success":true,"recipient_pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","message_length":238}
# dm_history | role=chat
--:-- WSB Triggering event JSON:
{"created_at":1773102327,"content":"I love my agent.","tags":[],"kind":1,"pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","id":"30bc36f75f7e485de3ed3e1c6f4736fb63e34ad303658695a8eb534424117ad0","sig":"8ff699b1bd6b7d8eac8b4f73530e5efdf450be542e3f8a3bc3bc1c38f43ccb13532ddae43644a08886699f17b1b0ab3de33945e0c9d94d88230f87afad0d0b1b"}
--:-- Agent
Let me look up the admin's profile to personalize the message.
--:-- Agent
---
```text
Context Log - not seen by model
timestamp=2026-03-09 20:25:30
phase=llm_trigger_with_tools_messages
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
model=claude-opus-4.6
context_bytes=5134
approx_tokens=1283
```
Sections: 4
# system_prompt | role=system
## Didactyl Agent
You are Didactyl, a sovereign AI agent living on Nostr.
### Communication Rules
- You communicate through encrypted Nostr direct messages.
- Keep responses concise and clear.
### Behavior
- Be helpful and technically accurate.
- If unsure, state uncertainty directly.
- Prefer actionable, practical advice.
- Use the person's name when messaging them if you know it.
- For the administrator, use their name from the administrator kind 0 profile metadata when available.
### Tool Use Policy
- You have tools available and should use them when a request requires taking action.
- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
- After a tool call, base your answer on the actual tool result.
- Never claim a tool was run if no tool was executed.
### Task Management
- Maintain and use your internal task list as short-term working memory.
- Break long or complex actions into clear tasks before executing them.
- Update task status as you complete steps so your plan stays accurate.
### Safety
- Do not claim to have executed actions you did not execute.
- You may share your public key (npub) with anyone.
- Never reveal your private key (nsec) under any circumstance.
---template---
- section: admin_identity
role: system
tool: admin_identity
skip_if_empty: true
- section: admin_profile
role: system
tool: nostr_admin_profile
skip_if_empty: true
- section: admin_contacts
role: system
tool: nostr_admin_contacts
skip_if_empty: true
- section: admin_relays
role: system
tool: nostr_admin_relays
skip_if_empty: true
- section: admin_notes
role: system
tool: nostr_admin_notes
skip_if_empty: true
- section: tasks
role: system
tool: task_list
skip_if_empty: true
- section: dm_history
role: expand
limit: 12
- section: conversation
role: user
tool: message_current
skip_if_empty: true
Triggered-skill execution context:
- You are executing a skill because a Nostr event matched its trigger filter.
- Execute the skill instructions below against the triggering event.
- Keep output concise and actionable for the administrator.
- If no action is needed, explicitly say why.
Skill d_tag: cheerleader
Relay: ws://127.0.0.1:7777
Skill instructions:
You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a single short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be enthusiastic and positive. Use emojis. Keep it brief and fun.
IMPORTANT: Send EXACTLY ONE DM. That DM is your entire and complete response. After sending the DM, you are done. Do not send any additional messages. Do not narrate what you did. Do not send a follow-up or summary. Do not produce any further output after the single DM tool call. One DM. Then stop.
# context_part | role=tool
{"success":true,"admin_kind0_json":"{\"name\":\"William S Burroughs\",\"display_name\":\"WSB\",\"about\":\"I like to write, and I like crank. They are good. Sometimes I'm not so sure.\",\"banner\":\"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg\",\"website\":\"https://addicted.com\",\"picture\":\"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*\",\"lud16\":\"\",\"nip05\":\"\"}","content":"## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): {\"name\":\"William S Burroughs\",\"display_name\":\"WSB\",\"about\":\"I like to write, and I like crank. They are good. Sometimes I'm not so sure.\",\"banner\":\"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg\",\"website\":\"https://addicted.com\",\"picture\":\"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*\",\"lud16\":\"\",\"nip05\":\"\"}"}
# dm_history | role=chat
--:-- WSB Triggering event JSON:
{"created_at":1773102327,"content":"I love my agent.","tags":[],"kind":1,"pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","id":"30bc36f75f7e485de3ed3e1c6f4736fb63e34ad303658695a8eb534424117ad0","sig":"8ff699b1bd6b7d8eac8b4f73530e5efdf450be542e3f8a3bc3bc1c38f43ccb13532ddae43644a08886699f17b1b0ab3de33945e0c9d94d88230f87afad0d0b1b"}
--:-- Agent
Let me look up the admin's profile to personalize the message.
---
```text
Context Log - not seen by model
timestamp=2026-03-09 20:25:28
phase=llm_trigger_with_tools_messages
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
model=claude-opus-4.6
context_bytes=3581
approx_tokens=895
```
Sections: 2
# system_prompt | role=system
## Didactyl Agent
You are Didactyl, a sovereign AI agent living on Nostr.
### Communication Rules
- You communicate through encrypted Nostr direct messages.
- Keep responses concise and clear.
### Behavior
- Be helpful and technically accurate.
- If unsure, state uncertainty directly.
- Prefer actionable, practical advice.
- Use the person's name when messaging them if you know it.
- For the administrator, use their name from the administrator kind 0 profile metadata when available.
### Tool Use Policy
- You have tools available and should use them when a request requires taking action.
- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
- After a tool call, base your answer on the actual tool result.
- Never claim a tool was run if no tool was executed.
### Task Management
- Maintain and use your internal task list as short-term working memory.
- Break long or complex actions into clear tasks before executing them.
- Update task status as you complete steps so your plan stays accurate.
### Safety
- Do not claim to have executed actions you did not execute.
- You may share your public key (npub) with anyone.
- Never reveal your private key (nsec) under any circumstance.
---template---
- section: admin_identity
role: system
tool: admin_identity
skip_if_empty: true
- section: admin_profile
role: system
tool: nostr_admin_profile
skip_if_empty: true
- section: admin_contacts
role: system
tool: nostr_admin_contacts
skip_if_empty: true
- section: admin_relays
role: system
tool: nostr_admin_relays
skip_if_empty: true
- section: admin_notes
role: system
tool: nostr_admin_notes
skip_if_empty: true
- section: tasks
role: system
tool: task_list
skip_if_empty: true
- section: dm_history
role: expand
limit: 12
- section: conversation
role: user
tool: message_current
skip_if_empty: true
Triggered-skill execution context:
- You are executing a skill because a Nostr event matched its trigger filter.
- Execute the skill instructions below against the triggering event.
- Keep output concise and actionable for the administrator.
- If no action is needed, explicitly say why.
Skill d_tag: cheerleader
Relay: ws://127.0.0.1:7777
Skill instructions:
You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a single short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be enthusiastic and positive. Use emojis. Keep it brief and fun.
IMPORTANT: Send EXACTLY ONE DM. That DM is your entire and complete response. After sending the DM, you are done. Do not send any additional messages. Do not narrate what you did. Do not send a follow-up or summary. Do not produce any further output after the single DM tool call. One DM. Then stop.
# dm_history | role=chat
--:-- WSB Triggering event JSON:
{"created_at":1773102327,"content":"I love my agent.","tags":[],"kind":1,"pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","id":"30bc36f75f7e485de3ed3e1c6f4736fb63e34ad303658695a8eb534424117ad0","sig":"8ff699b1bd6b7d8eac8b4f73530e5efdf450be542e3f8a3bc3bc1c38f43ccb13532ddae43644a08886699f17b1b0ab3de33945e0c9d94d88230f87afad0d0b1b"}
---
```text
Context Log - not seen by model
timestamp=2026-03-09 20:25:28
phase=llm_trigger
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
model=claude-opus-4.6
context_bytes=381
approx_tokens=95
```
Triggering event JSON:
{"created_at":1773102327,"content":"I love my agent.","tags":[],"kind":1,"pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","id":"30bc36f75f7e485de3ed3e1c6f4736fb63e34ad303658695a8eb534424117ad0","sig":"8ff699b1bd6b7d8eac8b4f73530e5efdf450be542e3f8a3bc3bc1c38f43ccb13532ddae43644a08886699f17b1b0ab3de33945e0c9d94d88230f87afad0d0b1b"}
---
```text
Context Log - not seen by model
timestamp=2026-03-09 20:06:49
+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);
}
+100
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,96 @@ 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 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;
}
active_trigger_t trigger;
if (trigger_manager_find(g_api_ctx.trigger_manager, d_tag, &trigger) != 0) {
reply_error(c, 404, "active trigger not found for d_tag");
return;
}
if (trigger.trigger_type != TRIGGER_TYPE_WEBHOOK) {
reply_error(c, 400, "trigger type is not webhook");
return;
}
if (!trigger.enabled) {
reply_error(c, 400, "webhook trigger is disabled");
return;
}
cJSON* payload = parse_body_json(hm);
if (!payload || !cJSON_IsObject(payload)) {
cJSON_Delete(payload);
reply_error(c, 400, "invalid JSON body");
return;
}
cJSON* event = cJSON_CreateObject();
if (!event) {
cJSON_Delete(payload);
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);
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 +1293,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 62
#define DIDACTYL_VERSION "v0.0.62"
// 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);
Executable
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PASS_COUNT=0
FAIL_COUNT=0
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
printf "[PASS] %s\n" "$1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
printf "[FAIL] %s\n" "$1"
}
assert_grep() {
local pattern="$1"
local file="$2"
local desc="$3"
if grep -E -q "$pattern" "$file"; then
pass "$desc"
else
fail "$desc (pattern not found in $file)"
fi
}
assert_cmd() {
local desc="$1"
shift
if "$@" >/dev/null 2>&1; then
pass "$desc"
else
fail "$desc"
fi
}
printf "== Didactyl Trigger Feature Test ==\n"
printf "Root: %s\n" "$ROOT_DIR"
printf "\n-- Build check --\n"
if make -j >/tmp/didactyl-test-build.log 2>&1; then
pass "Project builds successfully"
else
fail "Project build failed (see /tmp/didactyl-test-build.log)"
fi
assert_cmd "Built binary exists" test -x ./didactyl
printf "\n-- Source wiring checks --\n"
assert_grep "TRIGGER_TYPE_WEBHOOK" src/trigger_manager.h "Webhook trigger type defined"
assert_grep "TRIGGER_TYPE_CRON" src/trigger_manager.h "Cron trigger type defined"
assert_grep "TRIGGER_TYPE_CHAIN" src/trigger_manager.h "Chain trigger type defined"
assert_grep "trigger_manager_fire\(" src/trigger_manager.c "Generic trigger fire API implemented"
assert_grep "trigger_manager_fire_chains\(" src/trigger_manager.c "Chain fire API implemented"
assert_grep "cron_matches_now\(" src/trigger_manager.c "Cron matcher implemented"
assert_grep "trigger_type_to_string" src/trigger_manager.c "Trigger type serialization implemented"
assert_grep "\"/api/trigger/\*\"" src/http_api.c "Webhook API route registered"
assert_grep "handle_trigger_webhook\(" src/http_api.c "Webhook handler implemented"
assert_grep "nostr-subscription, webhook, cron, chain" src/tools.c "skill_create validation accepts 4 trigger types"
assert_grep "skill_edit trigger must be one of: nostr-subscription, webhook, cron, chain" src/tools.c "skill_edit validation accepts 4 trigger types"
assert_grep "trigger_manager_fire_chains\(" src/agent.c "Post-execution chain hook wired in agent"
printf "\n-- Docs checks --\n"
assert_grep "\`webhook\`" docs/SKILLS.md "SKILLS docs include webhook trigger"
assert_grep "\`cron\`" docs/SKILLS.md "SKILLS docs include cron trigger"
assert_grep "\`chain\`" docs/SKILLS.md "SKILLS docs include chain trigger"
assert_grep "POST /api/trigger/:d_tag" docs/API.md "API docs include webhook endpoint"
printf "\n-- Optional local API probe --\n"
if command -v curl >/dev/null 2>&1; then
HTTP_CODE=""
HTTP_CODE="$(curl -k -s --connect-timeout 2 --max-time 5 -o /tmp/didactyl-trigger-probe.out -w "%{http_code}" https://127.0.0.1:8484/api/trigger/nonexistent -X POST -H 'Content-Type: application/json' -d '{}' || true)"
if [[ "$HTTP_CODE" == "000" ]]; then
HTTP_CODE="$(curl -s --connect-timeout 2 --max-time 5 -o /tmp/didactyl-trigger-probe.out -w "%{http_code}" http://127.0.0.1:8484/api/trigger/nonexistent -X POST -H 'Content-Type: application/json' -d '{}' || true)"
fi
if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "400" || "$HTTP_CODE" == "404" || "$HTTP_CODE" == "503" ]]; then
pass "Webhook endpoint responds on local API (http code: $HTTP_CODE)"
else
fail "Webhook endpoint probe did not get expected response (http code: ${HTTP_CODE:-none})"
fi
else
fail "curl not available for API probe"
fi
printf "\n== Result ==\n"
printf "Pass: %d\n" "$PASS_COUNT"
printf "Fail: %d\n" "$FAIL_COUNT"
if [[ "$FAIL_COUNT" -gt 0 ]]; then
exit 1
fi
exit 0