Compare commits

...
20 Commits
Author SHA1 Message Date
Your Name 5857058a21 v0.0.71 - Add nprofile, nevent, and naddr support to nostr_encode with argument validation 2026-03-13 15:42:28 -04:00
Your Name f9cb6b19c2 v0.0.70 - Add c_utils_lib category-based debug filtering with tests/docs and root API log tail shortcut script 2026-03-13 09:30:37 -04:00
Your Name 868ac46de2 v0.0.69 - Add startup skill to publish core docs as longform notes and switch admin announcement DMs to NIP-04 2026-03-12 10:36:04 -04:00
Your Name 35f2fe53f1 chore: update .gitignore to exclude large logs and binaries 2026-03-12 03:02:41 -04:00
Your Name 5cdd582990 v0.0.68 - Update SKILLS.md with seed determinism fields and private kind 31124 NIP-44 encrypted payload spec 2026-03-12 02:19:03 -04:00
Your Name 5b8b8310f7 v0.0.67 - Add agent self-context subscriptions, tools, aliases, and prompt template sections 2026-03-10 17:33:35 -04:00
Your Name 6dbd952023 v0.0.66 - Complete tools refactor, move all tools-named sources/headers into src/tools, and update build wiring 2026-03-10 15:37:31 -04:00
Your Name 1d4cb9407d v0.0.65 - Filter skill_list by 10123 adoption tags, gate live trigger registration on adoption, and allow underscores in skill d-tags 2026-03-10 10:40:17 -04:00
Your Name 5c3083894b v0.0.64 - Just keeping things fresh 2026-03-10 10:03:29 -04:00
Your Name f0bd4c6473 v0.0.63 - Deprecate template trigger action and force llm execution path 2026-03-10 07:54:19 -04:00
Your Name cb45012495 v0.0.62 - fix: webhook trigger lookup uses trigger manager and add startup PoC triggers/tests 2026-03-10 05:48:21 -04:00
Your Name 6c78f6e79f v0.0.61 - feat: add webhook cron and chain trigger types 2026-03-09 20:58:52 -04:00
Your Name 421e1abb15 v0.0.60 - Remove forced final trigger DM, add skill_edit tool, and complete d_tag terminology migration 2026-03-09 20:19:14 -04:00
Your Name c93972019c v0.0.59 - Use tool-capable multi-turn execution for triggered skills 2026-03-09 18:31:15 -04:00
Your Name f7dd1f98c6 v0.0.58 - Disable pool dedup on trigger subscriptions to restore kind-1 firing 2026-03-09 18:14:33 -04:00
Your Name 93bfd0741d v0.0.57 - Register triggers live from self-skill websocket events 2026-03-09 17:19:19 -04:00
Your Name ffed72c9fd v0.0.56 - Load triggers deterministically from startup events before EOSE 2026-03-09 16:29:17 -04:00
Your Name 1b9ecb782c v0.0.55 - Defer trigger loading until self-skill EOSE and add trigger subscription debug instrumentation 2026-03-09 16:17:16 -04:00
Your Name aec697ad7b v0.0.54 - Add cheerleader triggered skill to active config with valid admin filter and adoption entry 2026-03-09 11:25:45 -04:00
Your Name 6359681cb3 v0.0.53 - Add skill_create trigger schema fields and startup cheerleader triggered skill example 2026-03-09 10:40:39 -04:00
64 changed files with 34376 additions and 6166 deletions
+3
View File
@@ -19,6 +19,9 @@ test_keys.txt
*.a
*.tar.gz
debug.log
context.log
didactyl_static_x86_64
didactyl_static_x86_64_debug
a.out
# Editor/IDE
+1 -1
View File
@@ -2,6 +2,6 @@
description: "Increments and pushes the repo"
---
Run increment_and_push.sh followed in the command line with a good description of the changes that were made.
Run increment_and_push.sh followed in the command line with a good description of the changes that were made. Don't run any other git commands.
For example: ./increment_and_push.sh "Fixed that nasty bug"
+7 -2
View File
@@ -92,10 +92,15 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
-D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core \
-I. -Isrc -Isrc/tools -Inostr_core_lib -Inostr_core_lib/nostr_core \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
src/main.c src/config.c src/context.c src/llm.c \
src/nostr_handler.c src/agent.c src/tools.c src/trigger_manager.c \
src/nostr_handler.c src/agent.c src/tools/tools_common.c src/tools/tools_schema.c src/tools/tools_dispatch.c \
src/tools/tool_agent.c src/tools/tool_meta.c src/tools/tool_model.c \
src/tools/tool_nostr_query.c src/tools/tool_nostr_identity.c src/tools/tool_nostr_social.c \
src/tools/tool_nostr_relay.c src/tools/tool_nostr_dm.c src/tools/tool_admin.c \
src/tools/tool_task.c src/tools/tool_nostr_list.c src/tools/tool_local.c \
src/tools/tool_skill.c src/tools/tool_nostr_post.c src/trigger_manager.c \
src/prompt_template.c src/http_api.c src/mongoose.c src/debug.c \
-o /build/didactyl_static \
nostr_core_lib/libnostr_core_x64.a \
+18 -1
View File
@@ -11,7 +11,23 @@ SRCS = \
$(SRC_DIR)/llm.c \
$(SRC_DIR)/nostr_handler.c \
$(SRC_DIR)/agent.c \
$(SRC_DIR)/tools.c \
$(SRC_DIR)/tools/tools_common.c \
$(SRC_DIR)/tools/tools_schema.c \
$(SRC_DIR)/tools/tools_dispatch.c \
$(SRC_DIR)/tools/tool_agent.c \
$(SRC_DIR)/tools/tool_meta.c \
$(SRC_DIR)/tools/tool_model.c \
$(SRC_DIR)/tools/tool_nostr_query.c \
$(SRC_DIR)/tools/tool_nostr_identity.c \
$(SRC_DIR)/tools/tool_nostr_social.c \
$(SRC_DIR)/tools/tool_nostr_relay.c \
$(SRC_DIR)/tools/tool_nostr_dm.c \
$(SRC_DIR)/tools/tool_admin.c \
$(SRC_DIR)/tools/tool_task.c \
$(SRC_DIR)/tools/tool_nostr_list.c \
$(SRC_DIR)/tools/tool_local.c \
$(SRC_DIR)/tools/tool_skill.c \
$(SRC_DIR)/tools/tool_nostr_post.c \
$(SRC_DIR)/trigger_manager.c \
$(SRC_DIR)/prompt_template.c \
$(SRC_DIR)/http_api.c \
@@ -20,6 +36,7 @@ SRCS = \
INCLUDES = \
-I$(SRC_DIR) \
-I$(SRC_DIR)/tools \
-I../nostr_core_lib \
-I../nostr_core_lib/cjson \
-I../nostr_core_lib/nostr_core
+36 -2
View File
@@ -47,17 +47,19 @@ Because all identity, communication, and memory live on Nostr, the agent is **po
Agents learn capabilities through skills — Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. An agent can use public or private skills.
Think of it like a woodshop: a **skill** is knowing how to carve — the technique, the judgment, the decision-making. A **tool** is the chisel. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. Every skill execution involves the LLM reasoning about what to do and which tools to use.
Skills support context modes (`inject`, `full`, `override`) and per-skill LLM fallback chains (for example: `anthropic/claude-sonnet-4-20250514, openai/gpt-4o-mini, cheap`) so each skill can tune behavior and cost. See [`docs/SKILLS.md`](docs/SKILLS.md).
### Private inference.
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.52
## Current Status — v0.0.71
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.52 — Added dm_history expand section to soul template so history is injected into context
> Last release update: v0.0.71 — Add nprofile, nevent, and naddr support to nostr_encode with argument validation
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
@@ -429,6 +431,38 @@ All dependencies are statically linked into the binary at build time. No system
| libssl / libcrypto | TLS for WebSocket relay connections | Statically linked (Alpine/MUSL) |
| libsecp256k1 | Schnorr signatures, ECDH | Statically linked (Alpine/MUSL) |
## Roadmap: Nostr-Native Portability
Didactyl's long-term architecture goal is **zero filesystem dependency after first boot**. The config file is the only tie to the local filesystem. The plan:
1. **First boot** — Read `config.jsonc`, publish all identity, soul, skills, and adoption list as Nostr events to relays.
2. **Subsequent boots** — Given only the agent's keys, retrieve everything needed from Nostr relays: soul, skills, adoption list, trigger definitions, admin pubkey, relay list. No config file required.
3. **True portability** — Start your agent from any computer. All you need are its keys. All state lives on Nostr.
This makes Didactyl fundamentally different from filesystem-bound agents. Destroying the host computer does not kill the agent — its identity, memory, and capabilities persist on the relay network.
### What already lives on Nostr
| Data | Event Kind | Status |
|---|---|---|
| Agent profile | Kind 0 | Implemented |
| Relay list | Kind 10002 | Implemented |
| DM relay list | Kind 10050 | Implemented |
| Public skills | Kind 31123 | Implemented |
| Private skills | Kind 31124 | Implemented |
| Skill adoption list | Kind 10123 | Implemented |
| Soul/personality | Kind 31120 | Implemented |
| Trigger definitions | Tags on skill events | Implemented |
### What still needs migration
| Data | Current Location | Target |
|---|---|---|
| Admin pubkey | `config.jsonc` | Derive from kind 3 contact list or dedicated config event |
| LLM provider/key | `config.jsonc` | Encrypted kind 30078 app-specific event or NIP-78 |
| Security tiers | `config.jsonc` | Agent config event on Nostr |
| API settings | `config.jsonc` | Local-only — stays on filesystem as runtime flag |
## Roadmap
- [x] MVP chat agent — DM in, LLM response out
+88
View File
@@ -31,6 +31,25 @@
"temperature": 0.7 // sampling temperature (0.0 2.0)
},
// ─── Tools & Runtime Limits ───────────────────────────────────────
// Centralized turn and timeout limits used by DM agent loops,
// triggered skills, HTTP API prompt runs, and local HTTP fetch tool.
"tools": {
"enabled": true,
"max_turns": 8, // default max turns for normal agent tool loops
"trigger_max_turns": 6, // max turns for triggered-skill executions
"api_default_max_turns": 4, // default when HTTP body omits max_turns
"api_max_turns_ceiling": 16, // hard cap applied to API-provided max_turns
"local_http_fetch_default_timeout_seconds": 20,
"local_http_fetch_max_timeout_seconds": 120,
"shell": {
"enabled": true,
"timeout_seconds": 30,
"max_output_bytes": 65536,
"working_directory": "."
}
},
// ─── Security Tiers ───────────────────────────────────────────────
// Controls who can interact with the agent and what they can do.
"security": {
@@ -213,6 +232,23 @@
]
},
// Kind 31123: Public triggered skill — cheerleader
// Watches for admin kind-1 notes and sends an encouraging DM.
{
"kind": 31123,
"content": "You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be warm, playful, and positive. Mention something specific from their note so it feels personal. Keep it to 2-3 sentences.",
"tags": [
["d", "cheerleader"],
["app", "didactyl"],
["scope", "public"],
["description", "Cheer on admin whenever they post a kind 1 note"],
["trigger", "nostr-subscription"],
["filter", "{\"kinds\":[1],\"authors\":[\"REPLACE_WITH_ADMIN_HEX_PUBKEY\"]}"],
["action", "llm"],
["enabled", "true"]
]
},
// Kind 31124: Private skill — admin_ops
// Private operational procedures (admin-only).
{
@@ -229,6 +265,57 @@
]
},
// Kind 31124: Private triggered skill — webhook-echo
// Example webhook trigger that emits a fixed DM response.
{
"kind": 31124,
"content": "When this webhook trigger fires, DM the admin with exactly: WEBHOOK_SOURCE_POC_OK",
"tags": [
["d", "webhook-echo"],
["app", "didactyl"],
["scope", "private"],
["description", "PoC webhook trigger skill"],
["trigger", "webhook"],
["filter", "{}"],
["action", "llm"],
["enabled", "true"]
]
},
// Kind 31124: Private triggered skill — cron_poc
// Example cron trigger that runs every minute.
{
"kind": 31124,
"content": "DM the administrator with the current time, and tell him that your cron skill triggered.",
"tags": [
["d", "cron_poc"],
["app", "didactyl"],
["scope", "private"],
["description", "PoC cron trigger skill"],
["trigger", "cron"],
["filter", "*/1 * * * *"],
["action", "template"],
["enabled", "true"]
]
},
// Kind 31124: Private triggered skill — chain_from_webhook_poc
// Example chain trigger that runs after webhook-echo completes.
{
"kind": 31124,
"content": "LOG: chain_from_webhook_poc fired",
"tags": [
["d", "chain_from_webhook_poc"],
["app", "didactyl"],
["scope", "private"],
["description", "PoC chain trigger skill"],
["trigger", "chain"],
["filter", "webhook-echo"],
["action", "template"],
["enabled", "true"]
]
},
// Kind 10123: Skill adoption list
// References which public skills this agent has adopted.
{
@@ -237,6 +324,7 @@
"tags": [
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"],
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"],
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:cheerleader"],
["app", "didactyl"],
["scope", "public"]
]
+18509 -40
View File
File diff suppressed because one or more lines are too long
+25
View File
@@ -26,6 +26,31 @@
tool: nostr_admin_notes
skip_if_empty: true
- section: agent_identity
role: system
tool: agent_identity
skip_if_empty: true
- section: agent_profile
role: system
tool: nostr_agent_profile
skip_if_empty: true
- section: agent_contacts
role: system
tool: nostr_agent_contacts
skip_if_empty: true
- section: agent_relays
role: system
tool: nostr_agent_relays
skip_if_empty: true
- section: agent_notes
role: system
tool: nostr_agent_notes
skip_if_empty: true
- section: tasks
role: system
tool: task_list
+4194
View File
File diff suppressed because one or more lines are too long
+41 -2
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.
@@ -477,8 +516,8 @@ The following endpoints are planned but not yet implemented:
| GET | `/api/events/soul` | Fetch agent soul event |
| PUT | `/api/events/soul` | Update soul content |
| GET | `/api/events/skills` | List published skills |
| GET | `/api/events/skills/:slug` | Fetch skill by slug |
| PUT | `/api/events/skills/:slug` | Update skill |
| GET | `/api/events/skills/:d_tag` | Fetch skill by d_tag |
| PUT | `/api/events/skills/:d_tag` | Update skill |
| GET | `/api/events/adoption` | Fetch adoption list |
| GET | `/api/events/profile` | Fetch agent profile |
| PUT | `/api/events/profile` | Update agent profile |
+190
View File
@@ -0,0 +1,190 @@
# Didactyl — LLM Context
See also: [SKILLS.md](SKILLS.md) · [TOOLS.md](TOOLS.md)
## What Is Context?
Every time Didactyl talks to an LLM, it sends a **context** — the complete package of information the model needs to reason and respond. Understanding context is fundamental to understanding how Didactyl works.
Context is not just "the prompt." It is everything the LLM receives in a single request:
1. **Messages** — the conversation: system prompts, user messages, assistant responses, tool results
2. **Tool schemas** — the JSON descriptions of every tool the LLM can call
3. **Model parameters** — temperature, max tokens, etc.
The LLM sees all of this together and produces its response based on the totality of what it was given.
---
## The OpenAI Chat Completion Format
Didactyl uses the OpenAI-compatible chat completion API format, which is the industry standard. Every LLM request looks like this:
```json
{
"model": "claude-opus-4.6",
"messages": [
{"role": "system", "content": "You are Didactyl, a sovereign AI agent..."},
{"role": "system", "content": "Admin profile: ..."},
{"role": "user", "content": "Post a note about Bitcoin"},
{"role": "assistant", "content": null, "tool_calls": [...]},
{"role": "tool", "content": "{\"success\":true,...}", "tool_call_id": "..."},
{"role": "assistant", "content": "Done! I posted your note."}
],
"tools": [
{
"type": "function",
"function": {
"name": "nostr_post",
"description": "Publish a Nostr event",
"parameters": { ... }
}
}
],
"temperature": 0.7,
"max_tokens": 512
}
```
### Messages
Messages are an ordered array. Each message has a `role`:
| Role | Purpose |
|------|---------|
| `system` | Instructions, identity, context data — the LLM reads these but the user didn't write them |
| `user` | What the human (or trigger event) said |
| `assistant` | What the LLM previously said or did (including tool calls) |
| `tool` | Results from tool executions, fed back to the LLM |
The LLM processes all messages in order and generates the next response.
### Tool Schemas
The `tools` array describes every tool the LLM can call. Each tool has a name, description, and JSON Schema for its parameters. The LLM uses these descriptions to decide when and how to call tools.
Tools are not part of the message history — they are a separate capability declaration sent alongside the messages.
### Model Parameters
Temperature, max tokens, and other settings control how the LLM generates its response. These can be set globally or overridden per-skill.
---
## How Didactyl Builds Context
Didactyl assembles context dynamically for each request. The context varies depending on what triggered the request (DM, trigger event) and which skill is active.
### Context Assembly Flow
```mermaid
flowchart TD
INPUT[Input: DM message or trigger event] --> MODE{Skill context_mode?}
MODE -->|inject| INJECT[Start with agent soul/personality]
INJECT --> ADD_ADMIN[Add admin identity + profile]
ADD_ADMIN --> ADD_SKILLS[Add adopted skill instructions]
ADD_SKILLS --> ADD_HISTORY[Add conversation history]
ADD_HISTORY --> ADD_SKILL_INST[Append this skill instructions]
ADD_SKILL_INST --> ADD_USER[Add user message or trigger event]
MODE -->|full| FULL[Start with skill template only]
FULL --> RESOLVE[Resolve template variables via tool calls]
RESOLVE --> ADD_USER2[Add user message or trigger event]
MODE -->|override| OVERRIDE[Replace soul with skill prompt]
OVERRIDE --> ADD_ADMIN2[Add admin identity + profile]
ADD_ADMIN2 --> ADD_SKILLS2[Add adopted skill instructions]
ADD_SKILLS2 --> ADD_HISTORY2[Add conversation history]
ADD_HISTORY2 --> ADD_USER3[Add user message or trigger event]
ADD_USER --> TOOLS[Attach tool schemas]
ADD_USER2 --> TOOLS
ADD_USER3 --> TOOLS
TOOLS --> LLM[Send to LLM]
```
### Context Parts
These are the building blocks that get assembled into the messages array:
| Part | Source | Description |
|------|--------|-------------|
| **Soul/Personality** | Kind 31120 event | The agent's identity, rules, and behavior instructions |
| **Admin Identity** | Config / kind 3 | Who the admin is, their pubkey |
| **Admin Profile** | Kind 0 event | Admin's display name, about, picture |
| **Admin Relay List** | Kind 10002 event | Admin's preferred relays |
| **Admin Notes** | Kind 1 events | Admin's recent public posts — gives the agent awareness of what the admin is doing |
| **Adopted Skills** | Kind 31123/31124 events | Instructions from other adopted skills |
| **Conversation History** | Kind 4/14 events | Recent DM exchanges between admin and agent |
| **Skill Instructions** | Active skill content | The specific skill being executed |
| **User Message** | DM or trigger event | The input that triggered this execution |
| **Tool Schemas** | Tool registry | JSON descriptions of available tools (sent separately from messages) |
### Template Variables Are Tool Calls
The soul's template system uses `tool:` directives to populate context sections. Each section can specify a tool to call, and the tool's output becomes that section's content:
```yaml
- section: admin_profile
role: system
tool: nostr_admin_profile
skip_if_empty: true
```
This calls `nostr_admin_profile`, gets the result, and inserts it as a system message. Template variables like `{{admin_profile}}` in skill templates work the same way — they resolve to tool calls. There is one unified resolution mechanism: `tools_execute()`.
This means new context sources are added by adding tools, not by modifying the template engine.
---
## Context Modes
Skills control how much of the default context the LLM sees using the `context_mode` field:
| Mode | What the LLM sees | Use case |
|------|-------------------|----------|
| `inject` | Everything: soul + admin context + history + skill instructions | Default. Agent stays itself, gains new knowledge. |
| `full` | Only what the skill template provides | Focused tasks: spellcheck, translation. Minimal context = cheaper, faster. |
| `override` | Skill replaces soul, but admin context + history still included | Different personality, same capabilities. |
Most skills use `inject` (the default). `full` is for specialized tasks where you want to minimize token cost. `override` is rare.
---
## Context for Triggered Skills
When a trigger fires (Nostr subscription match, webhook POST, cron timer, chain completion), the context is assembled the same way as for a DM — the only difference is the user message:
| Input Source | User Message Content |
|---|---|
| Admin DM | The DM text |
| Nostr subscription trigger | The matching Nostr event as JSON |
| Webhook trigger | The HTTP POST payload as JSON |
| Cron trigger | A synthetic event with timestamp and cron expression |
| Chain trigger | A synthetic event with the source skill's d_tag and output |
The LLM receives the skill instructions as system context and the triggering event as the user message, then reasons about what to do and which tools to use.
---
## Token Budget
Context has a cost — every token sent to the LLM costs money and takes time. Didactyl manages this through:
- **Context mode selection** — `full` mode skills send minimal context
- **Skip-if-empty sections** — template sections with no content are omitted
- **History limits** — conversation history is capped (default: 12 turns)
- **Skill content limits** — adopted skill instructions are truncated to prevent context overflow
- **Per-skill model selection** — cheap models for simple tasks, expensive models for complex ones
The `/api/context/current` and `/api/context/parts` API endpoints let you inspect exactly what context would be sent right now, including character counts and token estimates.
---
## Related Documentation
- Skills — how to define LLM execution units: [SKILLS.md](SKILLS.md)
- Tools — what the LLM can do: [TOOLS.md](TOOLS.md)
- API — inspect context at runtime: [API.md](API.md)
+152 -114
View File
@@ -1,12 +1,14 @@
# Didactyl — Skills
See also: [TOOLS.md](TOOLS.md)
See also: [CONTEXT.md](CONTEXT.md) · [TOOLS.md](TOOLS.md)
## Overview
A skill is a **self-contained LLM execution unit** stored as a Nostr event. Each skill defines what the LLM sees (context template), which model runs it (LLM specification with fallbacks), what tools are available, and whether the agent's identity (soul) is included.
A skill is a **set of instructions for the LLM** stored as a Nostr event. Skills teach the agent how to accomplish tasks — the LLM reads the instructions, reasons about them, and uses tools to take action.
Skills are portable, shareable, and discoverable — they live on Nostr relays as standard events.
Think of it like a woodshop: a **skill** is knowing how to carve — the technique, the judgment, the decision-making. A **tool** is the chisel. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. If you want a hardcoded program that runs without reasoning, that's a tool or an external program — not a skill.
Skills are portable, shareable, and discoverable — they live on Nostr relays as standard events. Every skill execution involves the LLM.
---
@@ -34,6 +36,7 @@ The skill event's `content` field is a JSON object that defines the complete exe
"tools": false,
"max_tokens": 2000,
"temperature": 0.1,
"seed": 42,
"template": "system:\nYou are a spelling and grammar checker.\n\nRules:\n- Fix spelling errors\n- Fix grammar errors\n- Preserve original formatting\n- Ignore: API, JSON, HTTP, nostr, pubkey, npub, nsec, NIP\n- Canadian English preferred\n- Return ONLY the corrected text, no explanations\n\nuser:\n{{message}}"
},
"tags": [
@@ -56,83 +59,91 @@ The skill event's `content` field is a JSON object that defines the complete exe
| `soul` | bool | `true` | Whether to include the agent's soul in context |
| `max_tokens` | int | — | Override max tokens for this skill |
| `temperature` | float | — | Override temperature for this skill |
| `seed` | int | — | Optional deterministic seed (effective only on providers/engines that support seeded sampling) |
### Determinism Controls
For reproducible outputs, set `temperature` low (typically `0`) and also set `seed` when the selected model backend supports it. If a provider ignores seeds, `temperature` still applies but deterministic replay is not guaranteed.
---
## Private Skill Encoding (`31124`)
Private skills use NIP-44 encryption on the event `content` field.
Rules for kind `31124`:
- Keep `d` tag exposed so the event remains addressable/replaceable.
- Move all other skill metadata (including non-`d` tags) into the plaintext payload before encryption.
- Encrypt the full payload with NIP-44 and store the ciphertext in event `content`.
- On receive: resolve by `d`, decrypt `content`, then read both content variables and tag values from decrypted payload.
### Private Skill Event (on relay)
```json
{
"kind": 31124,
"content": "<nip44-ciphertext>",
"tags": [
["d", "mention-monitor"]
]
}
```
### Decrypted Private Payload (application-level JSON)
```json
{
"content": {
"description": "Monitor mentions and DM summaries",
"context_mode": "inject",
"llm": "openai/gpt-4o-mini, fast",
"tools": ["nostr_query", "nostr_dm"],
"temperature": 0,
"seed": 42,
"template": "When {{triggering_event}} includes Bitcoin or Lightning, summarize and DM admin."
},
"private_tags": [
["scope", "private"],
["trigger", "nostr-subscription"],
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"],
["enabled", "true"]
]
}
```
This mirrors the DM-style encrypted payload pattern (historically associated with NIP-04), but private skills should use NIP-44 ciphertext encoding.
## Context Modes
Skills control how the LLM context window is assembled.
Skills control how the LLM context window is assembled using the `context_mode` field:
### inject
| Mode | Description | Use Case |
|------|-------------|----------|
| `inject` | Skill instructions appended to the agent's default context | Behavioral rules, knowledge additions |
| `full` | Skill provides its own complete context template | Spellcheck, translation, focused analysis |
| `override` | Skill replaces the default system prompt, keeps standard context structure | Different personality, same capabilities |
The skill's instructions are appended to the agent's soul context. The soul is always present.
Default is `inject`. See the agent's soul/personality documentation for details on how context assembly works.
```
┌─────────────────────────────────────┐
│ Soul (agent identity + rules) │
│ │
│ ┌─────────────────────────────┐ │
│ │ Skill instructions │ │
│ ├─────────────────────────────┤ │
│ │ Admin context, history, etc │ │
│ └─────────────────────────────┘ │
│ │
│ User message │
└─────────────────────────────────────┘
```
### Template Variables Are Tool Calls
### full
Template variables are tool calls. When the template engine encounters a variable like `{{admin_profile}}`, it runs the corresponding tool and inserts the result into the context that gets sent to the LLM.
The skill provides its own context template. The soul is **not included** unless the template explicitly references `{{soul}}`. This is for specialized, focused tasks that don't need the agent's identity.
This is the unified resolution model: the soul prompt template uses `tool:` section directives, and skill templates use `{{tool_name}}` inline syntax, but both resolve through `tools_execute()`. There is no separate variable-resolver layer — every variable is backed by a tool.
```
┌─────────────────────────────────────┐
│ Skill-defined system prompt │
│ (from skill template) │
│ │
│ User message │
└─────────────────────────────────────┘
```
| Variable | Tool Called | Description |
|----------|-----------|-------------|
| `{{soul}}` | *(built-in)* | The agent's identity/system context |
| `{{admin_profile}}` | `nostr_admin_profile` | Admin kind 0 profile |
| `{{admin_notes}}` | `nostr_admin_notes` | Admin recent notes |
| `{{admin_relays}}` | `nostr_admin_relays` | Admin relay list |
| `{{adopted_skills}}` | `adopted_skills` | Other adopted skill instructions |
| `{{dm_history}}` | *(expand directive)* | Recent DM conversation |
| `{{message}}` | *(built-in)* | Current user message |
| `{{triggering_event}}` | `trigger_event` | For triggered skills, the event JSON |
A `full` mode skill can still include the soul if desired:
```
system:
{{soul}}
You are now in translation mode. Translate the following to {{target_language}}.
Respond ONLY with the translation.
user:
{{message}}
```
### override
The skill replaces the soul's system prompt but keeps the standard context assembly structure (admin context, history, tools, etc.).
### Summary
| Mode | Soul | Template | Tools | Use Case |
|------|------|----------|-------|----------|
| `inject` | ✅ Always | Soul's template + skill instructions appended | Agent default | Behavioral rules, knowledge |
| `full` | ❌ Unless `{{soul}}` in template | Skill provides template | Skill specifies | Spellcheck, translation, focused analysis |
| `override` | Replaced by skill | Soul's template structure | Agent default | Different personality, same capabilities |
### Template Variables
Skill templates may still use placeholders (for example `{{message}}` or `{{triggering_event}}`) for deterministic triggered-skill interpolation, but the soul runtime context is now assembled primarily through template `tool:` directives rather than a separate `{{tools}}`/variable-resolver layer.
| Variable | Source |
|----------|--------|
| `{{soul}}` | The agent's identity/system context |
| `{{admin_profile}}` | Admin kind 0 profile |
| `{{admin_notes}}` | Admin recent notes |
| `{{admin_relays}}` | Admin relay list |
| `{{adopted_skills}}` | Other adopted skill instructions |
| `{{dm_history}}` | Recent DM conversation |
| `{{message}}` | Current user message |
| `{{triggering_event}}` | For triggered skills, the event JSON |
This means new data sources are added by adding tools, not by extending the template engine. If you can call it as a tool, you can use it as a template variable.
---
@@ -204,53 +215,90 @@ 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 |
| `action` | No | `template` or `llm` (default: `llm`) |
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` |
| `filter` | Yes | Type-specific filter — see Trigger Types below |
| `enabled` | No | Whether active (default: `true`) |
### Template Actions
> **Note:** The `action` tag is deprecated. All triggered skills are LLM-mediated. If you need a fast hardcoded action without LLM reasoning, use a tool or an external program — not a skill. Skills are instructions for the LLM; the LLM is always in the loop.
Fast, deterministic, no LLM. The skill content is a template with placeholders from the triggering event:
### Triggered Skill Execution
```
DM admin: '{author_display_name} posted: {content_preview}'
```
When a trigger fires, the skill content is sent to the LLM as instructions along with the triggering event JSON. The LLM reads both, reasons about what to do, and uses tools to take action.
Placeholders: `{event_id}`, `{pubkey}`, `{author_display_name}`, `{kind}`, `{content}`, `{content_preview}`, `{created_at}`, `{relay_url}`
Output prefixes: `DM admin: ...`, `DM <pubkey>: ...`, `POST: ...`, `LOG: ...`
### LLM-Mediated Actions
The skill content defines the execution context. The triggering event is available as `{{triggering_event}}`. The skill's LLM spec, context mode, and tool access all apply.
This is the same execution model as DM-invoked skills — the only difference is the input source. A DM-invoked skill receives a user message; a triggered skill receives a triggering event.
```json
{
"kind": 31124,
"content": {
"description": "Analyze mentions and notify admin",
"context_mode": "full",
"llm": "openai/gpt-4o-mini, cheap",
"tools": ["nostr_dm_send"],
"template": "system:\nYou monitor Nostr mentions. When the triggering event mentions Bitcoin or Lightning, summarize it and DM the admin. Otherwise, ignore silently.\n\nTriggering event:\n{{triggering_event}}"
},
"kind": 31123,
"content": "You monitor Nostr mentions. When the triggering event mentions Bitcoin or Lightning, summarize it and DM the admin. Otherwise, ignore silently.",
"tags": [
["d", "mention-monitor"],
["trigger", "nostr-subscription"],
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"],
["action", "llm"],
["enabled", "true"]
]
}
```
A cron-triggered skill:
```json
{
"kind": 31123,
"content": "DM the admin a short heartbeat status message with the current time.",
"tags": [
["d", "heartbeat"],
["trigger", "cron"],
["filter", "*/5 * * * *"],
["enabled", "true"]
]
}
```
### 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 +312,20 @@ 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]
LOOKUP --> CHECK_TYPE{Action type?}
CHECK_TYPE -->|template| INTERPOLATE[Interpolate + execute prefix]
CHECK_TYPE -->|llm| RESOLVE[Resolve LLM + assemble context + run]
EVENT_IN[Matching Nostr event] --> LOOKUP[Find associated skill]
WEBHOOK_IN[POST /api/trigger/:d_tag] --> LOOKUP
CRON_TICK[cron poll match] --> LOOKUP
LOOKUP --> RESOLVE[Resolve LLM + assemble context + run with tools]
RESOLVE --> CHAIN_CHECK[Check chain triggers]
CHAIN_CHECK --> CHAIN_FIRE[Fire matching chain triggers]
end
PUBLISHED --> LOAD_SKILLS
@@ -314,18 +368,6 @@ sequenceDiagram
---
## The Soul and Skills
The **soul** is the agent's default identity and context template. Skills interact with it based on their context mode:
- **`inject`** — soul always present; skill instructions layered on top
- **`full`** — soul absent unless template includes `{{soul}}`
- **`override`** — skill replaces soul prompt, keeps standard context structure
A spelling checker runs with no soul — purely functional, minimal context, cheap model. A complex analysis skill includes the full soul and all context parts. The soul is the default, not a requirement.
---
## Limits and Safety
| Limit | Default | Description |
@@ -333,7 +375,6 @@ A spelling checker runs with no soul — purely functional, minimal context, che
| Max concurrent triggers | 16 | Prevents resource exhaustion |
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution |
| LLM action rate limit | 10/min | Prevents runaway LLM costs |
| Template action rate limit | 60/min | Prevents DM spam |
---
@@ -351,9 +392,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 |
+194
View File
@@ -0,0 +1,194 @@
# Didactyl — Nostr Subscriptions
## Overview
Didactyl maintains persistent websocket subscriptions to Nostr relays for the lifetime of the process. Subscriptions are opened during startup and are **never closed** — the relay pool keeps them alive, automatically reconnecting and resubscribing when relays drop.
All subscriptions are created via `nostr_relay_pool_subscribe()` from `nostr_core_lib` and are sent to every relay in the configured relay list.
## Startup Sequence
The subscriptions are opened in a specific order during `main()` startup. The diagram below shows the full sequence:
```mermaid
flowchart TD
A[nostr_handler_init] --> B[Connect to all relays]
B --> C[nostr_handler_reconcile_startup_events]
C --> D[Publish startup events to relays]
D --> E[trigger_manager_init]
E --> F[trigger_manager_load_from_startup_events]
F --> G["Subscribe: Admin Context"]
G --> H["Subscribe: Self Skills"]
H --> I[Send startup DM to admin]
I --> J["Subscribe: DMs"]
J --> K[Enter main poll loop]
K --> L["Poll: nostr_handler_poll + trigger_manager_poll + http_api_poll"]
style F fill:#2a7,stroke:#333,color:#fff
style G fill:#27a,stroke:#333,color:#fff
style H fill:#27a,stroke:#333,color:#fff
style J fill:#27a,stroke:#333,color:#fff
```
## Subscription Categories
### 1. Admin Context Subscription
**Function:** `nostr_handler_subscribe_admin_context()` in `src/nostr_handler.c`
**When:** During startup, before self-skill subscription
**Condition:** Only if `admin_context.enabled` is true in config
This creates up to two persistent subscriptions for the admin's pubkey:
#### Profile Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 0 (profile), 3 (contacts), 10002 (relay list) — each configurable |
| **Authors** | Admin pubkey |
| **Limit** | 32 |
| **Callback** | `on_admin_context_event` |
| **Dedup** | Enabled |
| **Close on EOSE** | No |
Tracks the admin's profile metadata, contact list (WoT), and relay preferences. Used to build agent context about who the admin is.
#### Kind 1 Notes Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 1 |
| **Authors** | Admin pubkey |
| **Limit** | Configurable via `kind_1_limit`, default 10, max 256 |
| **Callback** | `on_admin_context_event` |
| **Dedup** | Enabled |
| **Close on EOSE** | No |
| **Condition** | Only if `admin_context.track_kind_1` is true |
Tracks the admin's recent public notes. Used for agent context and as the event source for triggered skills that watch admin posts.
### 2. Self-Skill Subscription
**Function:** `nostr_handler_subscribe_self_skills()` in `src/nostr_handler.c`
**When:** During startup, after admin context subscription
| Field | Value |
|-------|-------|
| **Kinds** | 31123 (public skill), 31124 (private skill), 10123 (adoption list) |
| **Authors** | Agent's own pubkey |
| **Limit** | 300 |
| **Callback** | `on_self_skill_event` |
| **EOSE Callback** | `on_self_skill_eose` |
| **Dedup** | Disabled (handles dedup internally via cache upsert) |
| **Close on EOSE** | No |
This is the core skill awareness subscription. It serves three purposes:
1. **Cache population** — Every arriving event is stored in the in-memory self-skill cache via `self_skill_cache_upsert_event_locked()`, making skills available for LLM tool calls and context building.
2. **Live trigger registration** — When a kind 31123 or 31124 event arrives with `trigger=nostr-subscription` and a valid `filter` tag, `register_trigger_from_self_skill_event()` immediately calls `trigger_manager_add()` to create a persistent trigger subscription. This means skills published from any client are automatically activated without restart.
3. **Deferred bulk load** — After EOSE, the `on_self_skill_eose` callback fires `trigger_manager_load_from_skills()` as a one-time bulk scan of the adoption list. This catches any skills that were already cached before the per-event path was wired up.
### 3. DM Subscriptions
**Function:** `nostr_handler_subscribe_dms()` in `src/nostr_handler.c`
**When:** During startup, after self-skill subscription and startup DM
**Required:** Yes — startup fails if DM subscription cannot be created
Creates one or two subscriptions depending on the configured DM protocol:
#### NIP-04 DM Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 4 |
| **#p** | Agent's own pubkey |
| **Since** | Process start time |
| **Limit** | 100 |
| **Callback** | `on_event` (routes to `agent_on_message`) |
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
| **Close on EOSE** | No |
| **Condition** | `dm_protocol` is `nip04` or `both` |
#### NIP-17 DM Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 1059 (gift wrap) |
| **#p** | Agent's own pubkey |
| **Since** | Process start time |
| **Limit** | 400 |
| **Callback** | `on_event` (unwraps gift wrap, routes to `agent_on_message`) |
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
| **Close on EOSE** | No |
| **Condition** | `dm_protocol` is `nip17` or `both` |
### 4. Trigger Subscriptions
**Function:** `register_trigger_subscription_locked()` in `src/trigger_manager.c`
**When:** Dynamically, whenever a trigger is registered via `trigger_manager_add()`
**Created by:** `nostr_handler_subscribe_with_filter()` wrapper
Each active trigger gets its own persistent subscription based on the skill's `filter` tag:
| Field | Value |
|-------|-------|
| **Filter** | Parsed from the skill's `filter` tag JSON |
| **Since** | From filter, or defaults to `now - 30s` |
| **Limit** | From filter, or defaults to 200 |
| **Callback** | `on_trigger_subscription_event` |
| **Dedup** | Enabled |
| **Close on EOSE** | No |
When an event matches the filter, `maybe_fire_trigger_locked()` checks cooldown and dedup, then executes the trigger action (LLM or template).
Trigger subscriptions are created at three points:
- **Startup config scan** — `trigger_manager_load_from_startup_events()` parses `startup_events[]` from config for skills with trigger tags
- **Live self-skill event** — `register_trigger_from_self_skill_event()` in the self-skill subscription callback
- **EOSE bulk load** — `trigger_manager_load_from_skills()` after self-skill EOSE
- **Runtime tool call** — `skill_create` tool with trigger parameters
## Subscription Parameters
All subscriptions share these common pool parameters:
| Parameter | Value | Meaning |
|-----------|-------|---------|
| `close_on_eose` | 0 | Subscription stays open after initial EOSE |
| `result_mode` | `NOSTR_POOL_EOSE_FULL_SET` | EOSE fires after all relays respond or timeout |
| `relay_timeout_seconds` | 30 | Per-relay timeout for initial response |
| `eose_timeout_seconds` | 120 | Overall EOSE timeout across all relays |
## Subscription Lifecycle
```mermaid
flowchart LR
INIT["Process Start"] --> CONNECT["Connect Relays"]
CONNECT --> SUB["Open Subscriptions"]
SUB --> LIVE["Live Event Stream"]
LIVE --> |"Relay disconnects"| RECON["Auto-Reconnect"]
RECON --> |"Relay reconnects"| RESUB["Auto-Resubscribe"]
RESUB --> LIVE
LIVE --> |"SIGINT/SIGTERM"| SHUT["Shutdown"]
SHUT --> CLOSE["Close All + Cleanup"]
```
Subscriptions are never manually closed during normal operation. The relay pool handles reconnection and resubscription transparently. On shutdown, `trigger_manager_cleanup()` closes trigger subscriptions and `nostr_handler_cleanup()` destroys the pool.
## Summary Table
| Subscription | Kinds | Target | Persistent | Created At |
|-------------|-------|--------|-----------|------------|
| Admin Profile | 0, 3, 10002 | Admin pubkey | Yes | Startup |
| Admin Notes | 1 | Admin pubkey | Yes | Startup |
| Self Skills | 31123, 31124, 10123 | Own pubkey | Yes | Startup |
| DMs NIP-04 | 4 | Own pubkey (#p) | Yes | Startup |
| DMs NIP-17 | 1059 | Own pubkey (#p) | Yes | Startup |
| Trigger N | Per skill filter | Varies | Yes | Dynamic |
## Related Documentation
- [Skills](SKILLS.md) — Skill event format and trigger tags
- [Tools](TOOLS.md) — `skill_create` tool with trigger parameters
- [API](API.md) — `trigger_list` and `trigger_status` endpoints
+3 -1
View File
@@ -12,7 +12,9 @@ This document describes the tools architecture: what tools are, how they are exp
## What Tools Are
Tools are the agent's hands. They are hardcoded C functions that the LLM can invoke during a conversation to take actions in the world.
Tools are in the agent's hands — the chisels in the woodshop. They are hardcoded C functions that the LLM can invoke during a conversation to take actions in the world.
A **skill** teaches the agent *how* to carve — the technique, the judgment, the decision-making. A **tool** is the chisel — the physical capability. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. If you want a hardcoded program that runs without reasoning, that's a tool or an external program — not a skill.
## How Tools Work
+310
View File
@@ -0,0 +1,310 @@
# Decentralized Didactyl
## The Question
Can you run Didactyl on multiple servers to gain censorship resistance, geographic distribution, and high availability?
---
## Same Keys vs. Different Keys
### Same Private Key on Multiple Servers
Running two or more Didactyl instances with the **same nsec** means they share a Nostr identity — same pubkey, same signature authority. This breaks almost immediately.
The core problem is **state conflicts on replaceable events**. Didactyl's identity is built on replaceable Nostr event kinds — kind `10002` (relay list), kind `10123` (skill adoption list), kind `31120` (soul), kind `31123`/`31124` (skills), kind `0` (profile). Replaceable events use `created_at` timestamps where the latest event wins. Two instances publishing the same replaceable kind within seconds creates a race where relays disagree on which version is canonical.
Beyond relay-level conflicts, each Didactyl process maintains significant **in-memory state** that is never shared: DM dedup caches, message fingerprint ring buffers, trigger cooldown timers, conversation history, and the self-skill cache. Two instances with the same key both subscribe to `#p` = their shared pubkey. The same admin DM arrives at both. Both process it. Both call the LLM. Both reply. The admin gets two possibly contradictory responses.
Making same-key work would require a coordination layer — leader election, distributed locks, or external shared state — all of which add complexity and defeat the decentralization goal.
### Different Private Keys on Multiple Servers
Each instance is a **distinct agent** with its own Nostr identity. No state conflicts, no replaceable-event races, no shared in-memory caches to synchronize. Each agent independently subscribes to relays, processes DMs, calls its LLM, and replies.
This is architecturally clean and aligned with how Nostr works. The question becomes: how do multiple independent agents cooperate to serve a single admin?
---
## How We Arrived at the Broadcast-Debounce Model
The first instinct with different keys is **delegation** — a primary agent receives the admin's DM and farms out subtasks to specialist agents. But this adds a single point of failure (the primary), requires an agent-to-agent trust protocol, and means the admin is still talking to one agent.
A simpler model: **broadcast the same message to all agents, let them all respond independently, and debounce on the admin side.** The admin sends the same DM to N agent pubkeys. Each agent processes it through its own LLM, its own tools, its own context. The admin collects replies and accepts the first one (or the best one). The others are discarded.
This requires no coordination protocol between agents. No leader election. No shared state. No new Nostr event kinds. The agents don't even need to know about each other. All complexity lives at the edge — in the admin's client.
---
## The Broadcast-Debounce Architecture
```mermaid
flowchart TD
ADMIN[Admin Client<br/>with fan-out + debounce]
ADMIN -->|"DM: task"| A[Agent A<br/>Server 1 — Iceland<br/>anthropic/claude-sonnet]
ADMIN -->|"DM: task"| B[Agent B<br/>Server 2 — Brazil<br/>openai/gpt-4o]
ADMIN -->|"DM: task"| C[Agent C<br/>Server 3 — Singapore<br/>local Ollama]
A -->|"DM reply"| ADMIN
B -->|"DM reply"| ADMIN
C -->|"DM reply"| ADMIN
ADMIN -->|"Accept first reply,<br/>discard rest"| RESULT[Response shown to admin]
```
### What Each Agent Needs
Each agent runs standard Didactyl with its own config:
- **Own nsec/npub** — distinct Nostr identity
- **Same admin pubkey** — all agents recognize the same admin
- **Same soul** — published as kind `31120` from each agent's key, or adopted from a shared author
- **Same adopted skills** — each agent's kind `10123` references the same skill events
- **Own LLM config** — can be different providers for diversity
- **Own relay list** — can overlap or be completely different for geographic distribution
No code changes to Didactyl are required. Each agent is a standard, unmodified instance.
### What the Admin Client Needs
The admin side needs a thin coordination layer:
1. **Fan-out** — when the admin sends a message, the client sends it as a DM to all N agent pubkeys
2. **Collection** — the client listens for DM replies from all agent pubkeys
3. **Debounce** — the client tags each outgoing message with a task identifier (hash of message + nonce). The first reply that corresponds to a pending task is accepted. Subsequent replies for the same task are logged but suppressed from display.
This could be implemented as:
- A modified Nostr client
- A thin proxy between the admin and the agents
- A Didactyl agent whose sole job is fan-out and collection
### Properties
| Property | Benefit |
|----------|---------|
| **No shared state** | Each agent is fully independent — no coordination protocol needed |
| **No code changes** | Works with current Didactyl for read-only tasks |
| **Censorship resistant** | Kill any N-1 servers, the last one still works |
| **Provider diverse** | Different LLMs on each agent — different failure modes, different strengths |
| **Geographically distributed** | Different jurisdictions, different relay access |
| **Graceful degradation** | Losing agents means fewer responses, not failure |
| **Complexity at the edge** | Agents stay simple and sovereign; the admin client handles coordination |
### The Write-Action Problem
For read-only tasks — questions, summaries, drafts — the model works perfectly. All agents respond, admin picks one, no side effects.
For write actions — "post a note about Bitcoin" — all three agents independently publish to Nostr. Three posts appear. The admin wanted one.
**Solution: two-phase draft-then-confirm.** Teach all agents via their soul or an adopted skill to never execute write actions directly. Instead, they draft the action and report what they *would* do. The admin reviews the drafts, picks one, and sends a confirmation DM to that specific agent. The other agents' drafts are simply never confirmed.
The skill instruction is straightforward:
> When asked to publish, post, react, delete, or execute any action with side effects, draft the content and present it for approval. Do not execute until the admin explicitly confirms.
This requires no code changes — only a soul or skill update. It also happens to be good practice for any agent handling important actions.
### Trigger Handling
Triggered skills (cron, nostr-subscription, webhook) present the same duplication issue as write actions — all agents fire the same trigger independently.
Options:
- **Disable triggers on all but one agent** — simple, but creates a single point of failure for triggered tasks
- **Use the draft-then-confirm pattern for triggered actions** — triggers draft and report to admin rather than acting directly
- **Accept duplication for idempotent triggers** — reactions, replaceable event updates, and monitoring alerts are harmless when duplicated
- **Shard triggers across agents** — Agent A handles cron triggers, Agent B handles nostr-subscription triggers, Agent C handles webhooks
### Future: Agent Awareness
In the basic model, agents don't know about each other. A future enhancement could add awareness:
- Each agent publishes a heartbeat event (kind `30078` with a timestamp)
- Other agents (or the admin client) monitor heartbeats to detect which agents are alive
- The admin client adjusts fan-out based on which agents are responsive
This remains fully Nostr-native — heartbeats are just events on relays — and requires no direct agent-to-agent communication.
---
## Beyond Nostr Posts: Software Development as the Task
The broadcast-debounce model above assumes the agents' primary job is Nostr social activity — posting notes, reacting, querying relays. But what if the task is **software development and infrastructure management**? For example: creating and maintaining a website, deployed across servers in different jurisdictions.
This changes the model fundamentally. The agents are no longer just talking — they are **building things on their local machines**.
### What Didactyl Already Has
Each agent already has local system tools:
| Tool | Capability |
|------|-----------|
| `local_shell_exec` | Execute any shell command — git, npm, docker, systemctl, curl |
| `local_file_read` | Read source files from the working directory |
| `local_file_write` | Write/create source files in the working directory |
| `local_http_fetch` | Make HTTP requests — test endpoints, call APIs, download resources |
An agent with these tools can already: clone a repo, edit source code, run a build, start a dev server, deploy to production, check if a site is up, read logs, and fix issues. The LLM reasons about what to do; the tools are the hands.
### The Shift: From Redundancy to Distribution
With Nostr posting, multiple agents doing the same task is **redundancy** — you want one result and discard the rest. With software development, multiple agents doing *different parts* of the same project is **distribution** — you want all of them to contribute.
```mermaid
flowchart TD
subgraph "Nostr Posting Model"
direction LR
N_ADMIN[Admin] -->|"Same task"| N_A[Agent A]
N_ADMIN -->|"Same task"| N_B[Agent B]
N_ADMIN -->|"Same task"| N_C[Agent C]
N_A -->|"Response 1"| N_DEDUP[Debounce:<br/>pick one]
N_B -->|"Response 2"| N_DEDUP
N_C -->|"Response 3"| N_DEDUP
end
subgraph "Software Dev Model"
direction LR
S_ADMIN[Admin] -->|"Build the site"| S_A[Agent A:<br/>Frontend]
S_ADMIN -->|"Build the site"| S_B[Agent B:<br/>Backend API]
S_ADMIN -->|"Build the site"| S_C[Agent C:<br/>Infrastructure]
S_A -->|"React app ready"| S_MERGE[Integration:<br/>combine all parts]
S_B -->|"API deployed"| S_MERGE
S_C -->|"Nginx + TLS configured"| S_MERGE
end
```
### Architecture: Agents as Jurisdiction-Local Builders
Consider a website that needs to exist in multiple jurisdictions for censorship resistance. Each agent lives on a server in a different country and maintains a local copy of the site.
```mermaid
flowchart TD
ADMIN[Admin<br/>npub1admin...]
GIT[(Git Repo<br/>shared source of truth)]
subgraph "Iceland"
A[Agent A<br/>npub1aaa...]
A_FS[Local filesystem:<br/>/var/www/mysite]
A_SRV[Nginx serving<br/>mysite.is]
A --> A_FS --> A_SRV
end
subgraph "Brazil"
B[Agent B<br/>npub1bbb...]
B_FS[Local filesystem:<br/>/var/www/mysite]
B_SRV[Nginx serving<br/>mysite.br]
B --> B_FS --> B_SRV
end
subgraph "Singapore"
C[Agent C<br/>npub1ccc...]
C_FS[Local filesystem:<br/>/var/www/mysite]
C_SRV[Nginx serving<br/>mysite.sg]
C --> C_FS --> C_SRV
end
ADMIN -->|"DM: update the homepage"| A
ADMIN -->|"DM: update the homepage"| B
ADMIN -->|"DM: update the homepage"| C
A <-->|"git pull / push"| GIT
B <-->|"git pull / push"| GIT
C <-->|"git pull / push"| GIT
```
### Git as the Coordination Layer
In the Nostr posting model, the write-action problem was solved with draft-then-confirm. For software development, the natural coordination layer is **git**.
- All agents share a git repository (hosted on Gitea, GitHub, a Nostr-based git relay, or even a bare repo on one of the servers)
- Each agent works on its local clone
- Before making changes, the agent pulls latest
- After making changes, the agent commits and pushes
- Conflicts are resolved by the LLM (or flagged to the admin)
Git gives you:
- **Atomic changes** — commits are all-or-nothing
- **Conflict detection** — if two agents edit the same file, git tells you
- **History** — every change is traceable to which agent made it
- **Rollback** — bad changes can be reverted
The agent already has `local_shell_exec` which can run `git pull`, `git add`, `git commit`, `git push`. No new tools needed.
### Three Operational Modes
Depending on the task, the admin can use different patterns:
#### Mode 1: Broadcast-Identical (Same Task, All Servers)
> "Pull latest and rebuild the site"
All agents do the same thing on their local server. This is the deployment/ops pattern — you want the same action everywhere. No debounce needed; all agents should execute.
#### Mode 2: Broadcast-Debounce (Same Task, One Result)
> "Write a new About page"
All agents draft a version. Admin picks the best one. That agent commits and pushes. The other agents then pull the update in the next sync cycle. This is the creative/development pattern.
#### Mode 3: Directed (Specific Task, Specific Agent)
> To Agent A only: "Debug why the Iceland server returns 502"
The admin sends a DM to one specific agent because the task is server-specific. This is the ops/debugging pattern.
### The Sync Cycle
For the site to stay consistent across jurisdictions, agents need a periodic sync. This can be a **cron-triggered skill**:
```
You are a deployment sync agent. Every 15 minutes:
1. Run `git pull` in the project directory
2. If there are new changes, run the build command
3. Restart the web server if the build succeeded
4. DM the admin a brief status: what changed, build result, server status
5. If the pull fails due to conflicts, DM the admin with the conflict details and do not build
```
This skill already works with Didactyl's existing `cron` trigger type and `local_shell_exec` tool. Each agent runs it independently on its own server.
### What Changes vs. the Nostr Model
| Aspect | Nostr Posting | Software Development |
|--------|--------------|---------------------|
| **Primary tools** | `nostr_post`, `nostr_query` | `local_shell_exec`, `local_file_write`, `local_file_read` |
| **Coordination** | Debounce on admin side | Git repo as shared state |
| **Write conflicts** | Replaceable events (last-write-wins) | Git merge conflicts (explicit resolution) |
| **Broadcast meaning** | Redundancy — pick one response | Deployment — all servers execute |
| **Agent specialization** | All agents are identical | Agents can have server-specific skills |
| **State** | Nostr relays (stateless agents) | Local filesystem (stateful agents) |
| **Failure mode** | Agent down = fewer responses | Agent down = one jurisdiction offline |
### The Stateful Agent Problem
This is the fundamental shift. Nostr-posting agents are essentially stateless — their identity and skills live on relays, and destroying the host doesn't kill the agent. Software development agents are **deeply stateful** — they have local files, running services, build artifacts, and server configurations that don't exist on Nostr.
Git mitigates this: if an agent's server dies, you spin up a new server, install Didactyl with the same agent keys, and `git clone` the repo. The agent recovers its working state from git. But the server-specific state (Nginx config, TLS certs, DNS, running processes) needs to be reconstructable too.
This suggests a **infrastructure-as-code** discipline where everything about the server's configuration is in the git repo:
- Nginx configs
- Docker compose files
- TLS certificate automation (Let's Encrypt)
- Deployment scripts
The agent's first-boot skill becomes: "Clone the repo, run the setup script, verify the site is serving."
### Jurisdiction-Specific Considerations
Each agent can have **server-specific skills** that account for local differences:
| Agent | Jurisdiction | Specific Skills |
|-------|-------------|----------------|
| Agent A | Iceland | Icelandic privacy law compliance, `.is` domain management |
| Agent B | Brazil | LGPD compliance checks, `.br` domain management |
| Agent C | Singapore | PDPA compliance, `.sg` domain management, Asia-Pacific CDN config |
These are adopted as private skills (kind `31124`) specific to each agent, while the core development skills (kind `31123`) are shared across all agents.
### Summary
The broadcast-debounce model extends naturally to software development, but the coordination mechanism shifts from admin-side debounce to **git as shared state**. The three operational modes — broadcast-identical for deployment, broadcast-debounce for development, and directed for debugging — cover the full range of tasks. Didactyl's existing tools (`local_shell_exec`, `local_file_read`, `local_file_write`) and trigger types (`cron` for sync cycles) already support this without code changes. The main new requirement is disciplined infrastructure-as-code so that agent state is recoverable from git.
+3 -3
View File
@@ -75,9 +75,9 @@ All endpoints return JSON. All mutations use POST/PUT/DELETE. All reads use GET.
| GET | `/api/events/soul` | Fetch the agent soul event (kind 31120, d=soul) |
| PUT | `/api/events/soul` | Update soul content, republish to relays |
| GET | `/api/events/skills` | List all published skills (kind 31123/31124 by own pubkey) |
| GET | `/api/events/skills/:slug` | Fetch a single skill by slug |
| PUT | `/api/events/skills/:slug` | Update skill content/tags, republish |
| DELETE | `/api/events/skills/:slug` | Remove skill from adoption list |
| GET | `/api/events/skills/:d_tag` | Fetch a single skill by d_tag |
| PUT | `/api/events/skills/:d_tag` | Update skill content/tags, republish |
| DELETE | `/api/events/skills/:d_tag` | Remove skill from adoption list |
| GET | `/api/events/adoption` | Fetch kind 10123 adoption list |
| GET | `/api/events/startup` | List startup events from config |
| GET | `/api/events/profile` | Fetch agent kind 0 profile |
+1 -1
View File
@@ -53,7 +53,7 @@ These are not yet implemented but are on the roadmap:
| GET | `/api/events/soul` | Agent soul/system prompt event |
| PUT | `/api/events/soul` | Update soul content |
| GET | `/api/events/skills` | List skills |
| GET/PUT | `/api/events/skills/:slug` | Read/update individual skills |
| GET/PUT | `/api/events/skills/:d_tag` | Read/update individual skills |
| GET | `/api/events/profile` | Agent Nostr profile |
| GET | `/api/tools` | List all tool schemas |
| POST | `/api/tools/:name/execute` | Execute a tool directly |
+204
View File
@@ -0,0 +1,204 @@
# Agent Self-Context: Know Thyself
## Problem
When the agent is asked about itself — its profile, contacts, relays, or recent notes — it has no idea. The context template currently injects **administrator** identity/profile/contacts/relays/notes into the system prompt, but nothing equivalent for the **agent's own** Nostr identity beyond a bare pubkey+npub from `agent_identity`.
The agent publishes its own kind 0 (profile), kind 3 (contacts), kind 10002 (relays), and kind 1 (notes) at startup, but never subscribes to or caches those events for self-awareness.
## Solution Overview
Mirror the admin context pattern for the agent itself:
1. **Subscribe** to the agent's own kind 0/3/10002/1 events from relays
2. **Cache** them in `nostr_handler.c` (same pattern as `g_admin_kind0_json` etc.)
3. **Expose** them via new `nostr_handler_get_agent_*` API functions
4. **Create context tools** that format the cached data into context blocks
5. **Add to context template** so the agent always knows about itself
6. **Add callable tool aliases** like `my_kind0_profile` for on-demand use
```mermaid
flowchart TD
A[Startup: publish kind 0/3/10002/1] --> B[Subscribe to own events]
B --> C[Cache in nostr_handler globals]
C --> D[Context tools read cache]
D --> E[Context template injects into system prompt]
D --> F[LLM can call tools on-demand]
subgraph Admin Context - existing
G[g_admin_kind0_json]
H[g_admin_kind3 contacts]
I[g_admin_kind10002_json]
J[g_admin_kind1_notes]
end
subgraph Agent Context - new
K[g_agent_kind0_json]
L[g_agent_kind3 contacts]
M[g_agent_kind10002_json]
N[g_agent_kind1_notes]
end
```
## Detailed Changes
### 1. nostr_handler.c — Agent Self-Context Cache
Add new static globals mirroring the admin pattern:
```c
static char* g_agent_kind0_json = NULL; // kind 0 profile JSON
static char* g_agent_kind10002_json = NULL; // kind 10002 relay list JSON
static char** g_agent_kind3_contacts = NULL; // kind 3 contact pubkeys
static int g_agent_kind3_contact_count = 0;
static admin_kind1_note_t* g_agent_kind1_notes = NULL; // reuse struct
static int g_agent_kind1_note_count = 0;
```
### 2. nostr_handler.c — Agent Self-Context Subscription
Create `nostr_handler_subscribe_agent_context()` that subscribes to kinds 0, 3, 10002, 1 filtered by the agent's own pubkey. This is separate from the self-skills subscription (which handles 31123/31124/10123).
The callback `on_agent_context_event()` will parse and cache events using the same logic as `on_admin_context_event()`.
**Alternative considered:** Expanding `nostr_handler_subscribe_self_skills()` to include these kinds. Rejected because the self-skills sub has different EOSE handling and a callback for skill loading. Keeping them separate is cleaner.
### 3. nostr_handler.h — New API Functions
```c
int nostr_handler_subscribe_agent_context(void);
char* nostr_handler_get_agent_kind0_context(void);
char* nostr_handler_get_agent_kind3_context(void);
char* nostr_handler_get_agent_kind10002_context(void);
char* nostr_handler_get_agent_kind1_notes_context(void);
```
### 4. main.c — Call Agent Context Subscription at Startup
Add `nostr_handler_subscribe_agent_context()` call after admin context subscription, before self-skills subscription.
### 5. tool_agent.c — New Context Tools
Create four new tool functions following the exact pattern from `tool_admin.c`:
| Tool Name | Description | Data Source |
|-----------|-------------|-------------|
| `nostr_agent_profile` | Agent's kind 0 profile metadata | `nostr_handler_get_agent_kind0_context()` |
| `nostr_agent_contacts` | Agent's kind 3 contact list | `nostr_handler_get_agent_kind3_context()` |
| `nostr_agent_relays` | Agent's kind 10002 relay list | `nostr_handler_get_agent_kind10002_context()` |
| `nostr_agent_notes` | Agent's recent kind 1 notes | `nostr_handler_get_agent_kind1_notes_context()` |
Each returns a `content` field with markdown-formatted context, e.g.:
```
## Agent Kind 0 Profile (source: nostr kind 0)
Agent kind 0 profile content (JSON): {"name":"Didactyl Agent","display_name":"Didactyl",...}
```
### 6. tools_internal.h — Declare New Functions
```c
char* execute_nostr_agent_profile(tools_context_t* ctx, const char* args_json);
char* execute_nostr_agent_contacts(tools_context_t* ctx, const char* args_json);
char* execute_nostr_agent_relays(tools_context_t* ctx, const char* args_json);
char* execute_nostr_agent_notes(tools_context_t* ctx, const char* args_json);
```
### 7. tools_dispatch.c — Register + Aliases
Add dispatch entries:
```c
if (strcmp(tool_name, "nostr_agent_profile") == 0) return execute_nostr_agent_profile(ctx, args_json);
if (strcmp(tool_name, "nostr_agent_contacts") == 0) return execute_nostr_agent_contacts(ctx, args_json);
if (strcmp(tool_name, "nostr_agent_relays") == 0) return execute_nostr_agent_relays(ctx, args_json);
if (strcmp(tool_name, "nostr_agent_notes") == 0) return execute_nostr_agent_notes(ctx, args_json);
// Friendly aliases
if (strcmp(tool_name, "my_kind0_profile") == 0) return execute_nostr_agent_profile(ctx, args_json);
if (strcmp(tool_name, "my_contacts") == 0) return execute_nostr_agent_contacts(ctx, args_json);
if (strcmp(tool_name, "my_relays") == 0) return execute_nostr_agent_relays(ctx, args_json);
if (strcmp(tool_name, "my_notes") == 0) return execute_nostr_agent_notes(ctx, args_json);
```
### 8. tools_schema.c — OpenAI Function Schemas
Add 8 new tool schemas (4 canonical + 4 aliases), all with empty parameters (no-arg tools), following the pattern of `admin_identity`/`nostr_admin_profile` etc.
### 9. Context Template Update
Update the kind 31120 soul content in `config.jsonc` and `context_template.md` to add agent sections **after** admin sections:
```yaml
- section: admin_notes
role: system
tool: nostr_admin_notes
skip_if_empty: true
# NEW: Agent self-context sections
- section: agent_identity
role: system
tool: agent_identity
skip_if_empty: true
- section: agent_profile
role: system
tool: nostr_agent_profile
skip_if_empty: true
- section: agent_contacts
role: system
tool: nostr_agent_contacts
skip_if_empty: true
- section: agent_relays
role: system
tool: nostr_agent_relays
skip_if_empty: true
- section: agent_notes
role: system
tool: nostr_agent_notes
skip_if_empty: true
- section: tasks
role: system
tool: task_list
skip_if_empty: true
```
### 10. nostr_handler.c Cleanup
Add cleanup for agent context globals in `nostr_handler_cleanup()`, mirroring `free_admin_context_locked()`.
## File Change Summary
| File | Change Type | Description |
|------|-------------|-------------|
| `src/nostr_handler.h` | Modify | Add 5 new function declarations |
| `src/nostr_handler.c` | Modify | Add agent context cache globals, subscription, event handler, getter functions, cleanup |
| `src/main.c` | Modify | Call `nostr_handler_subscribe_agent_context()` at startup |
| `src/tools/tool_agent.c` | Modify | Add 4 new context tool execute functions |
| `src/tools/tools_internal.h` | Modify | Declare 4 new execute functions |
| `src/tools/tools_dispatch.c` | Modify | Add 8 dispatch entries (4 tools + 4 aliases) |
| `src/tools/tools_schema.c` | Modify | Add 8 OpenAI function schemas |
| `config.jsonc` | Modify | Update kind 31120 template section |
| `config.jsonc.example` | Modify | Update kind 31120 template section |
| `context_template.md` | Modify | Add agent_* sections after admin_* sections |
## Context Token Impact
Each agent context section adds roughly the same token count as its admin counterpart:
- agent_identity: ~40 tokens (already exists, just adding to template)
- agent_profile: ~80-150 tokens (depends on profile richness)
- agent_contacts: ~50-200 tokens (depends on contact count)
- agent_relays: ~50-100 tokens
- agent_notes: ~100-300 tokens (depends on note count/length)
Total additional context: ~320-790 tokens. With `skip_if_empty: true`, empty sections cost 0 tokens.
## Design Decisions
1. **Separate subscription vs expanding self-skills sub**: Separate is cleaner — different EOSE semantics, different callback needs.
2. **Cache from relay vs read from config**: Cache from relay is more accurate (reflects what's actually published, not just what config says). The startup_events in config are the *intent*; the relay data is the *reality*.
3. **Alias naming**: `my_kind0_profile` matches the existing `my_npub`/`my_pubkey` pattern. Also adding `my_contacts`, `my_relays`, `my_notes` for consistency.
4. **No new config section needed**: The agent context subscription is unconditional — an agent should always know about itself. No `agent_context.enabled` toggle needed.
+162
View File
@@ -0,0 +1,162 @@
# Cheerleader Triggered Skill — Implementation Plan
## Goal
Create the first triggered skill: whenever the admin posts a kind 1 note on Nostr, the agent reads the note content and sends a DM back to the admin cheering them on, praising them, and telling them how good looking they are.
---
## Current State
The entire triggered-skill infrastructure is **already built and functional**:
| Component | File | Status |
|---|---|---|
| Trigger manager (poll, cooldown, add/remove) | `src/trigger_manager.c` | ✅ Complete |
| LLM-mediated trigger execution | `src/agent.c` `agent_on_trigger()` | ✅ Complete |
| `skill_create` tool with trigger support | `src/tools.c` `execute_skill_create()` | ✅ Complete |
| Trigger polling in main loop | `src/main.c` | ✅ Complete |
| Config parsing for triggers section | `src/config.c` | ✅ Complete |
| `trigger_list` tool | `src/tools.c` | ✅ Complete |
### Gap Found
The `skill_create` tool **schema** (what the LLM sees) only exposes 5 parameters: `d_tag`, `content`, `scope`, `description`, `auto_adopt`. The execution function already handles `trigger`, `filter`, `action`, and `enabled` — but these are **not declared in the tool schema**, so the LLM will never pass them.
---
## Implementation Steps
### 1. Fix `skill_create` Tool Schema
**File:** `src/tools.c` lines ~1607-1641
Add four new properties to the `skill_create` tool definition so the LLM can see and use them:
```c
// After auto_adopt property (line ~1634):
cJSON* p_skill_create_trigger = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_trigger, "type", "string");
cJSON_AddStringToObject(p_skill_create_trigger, "description",
"Trigger type. Use nostr-subscription to activate on matching Nostr events");
cJSON_AddItemToObject(t22_props, "trigger", p_skill_create_trigger);
cJSON* p_skill_create_filter = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_filter, "type", "string");
cJSON_AddStringToObject(p_skill_create_filter, "description",
"Nostr subscription filter JSON for the trigger, e.g. {\"kinds\":[1],\"authors\":[\"<hex>\"]}");
cJSON_AddItemToObject(t22_props, "filter", p_skill_create_filter);
cJSON* p_skill_create_action = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_action, "type", "string");
cJSON_AddStringToObject(p_skill_create_action, "description",
"Action type: llm (default, full LLM reasoning) or template (fast interpolation)");
cJSON_AddItemToObject(t22_props, "action", p_skill_create_action);
cJSON* p_skill_create_enabled = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_enabled, "type", "boolean");
cJSON_AddStringToObject(p_skill_create_enabled, "description",
"Whether the trigger is active. Default: true");
cJSON_AddItemToObject(t22_props, "enabled", p_skill_create_enabled);
```
This is ~20 lines of code. No changes needed to the execution function — it already handles all four parameters.
### 2. Add Cheerleader Skill as Startup Event
**File:** `config.jsonc.example`
Add a new kind 31123 skill event with trigger tags. Insert before the kind 10123 adoption list event:
```jsonc
// Kind 31123: Public triggered skill — cheerleader
// Watches for admin kind 1 notes and sends encouraging DMs.
{
"kind": 31123,
"content": "You are the admin's biggest fan and personal cheerleader. When the admin posts a note on Nostr, read the note content carefully and send them a DM that:\n\n1. References what they actually wrote about\n2. Cheers them on enthusiastically\n3. Praises their intelligence and insight\n4. Tells them how good looking they are\n5. Encourages them to keep posting\n\nBe genuine, warm, and over-the-top positive. Use their name from the admin profile if available. Keep it to 2-3 sentences max.",
"tags": [
["d", "cheerleader"],
["app", "didactyl"],
["scope", "public"],
["description", "Cheer on the admin whenever they post a kind 1 note"],
["trigger", "nostr-subscription"],
["filter", "{\"kinds\":[1],\"authors\":[\"ADMIN_PUBKEY_HEX\"]}"],
["action", "llm"],
["enabled", "true"]
]
}
```
> **Note:** The `ADMIN_PUBKEY_HEX` placeholder in the filter must be replaced with the actual admin pubkey from the config. Since this is a static config example, we use a placeholder. At runtime, the user replaces it with their admin pubkey.
### 3. Add to Adoption List
**File:** `config.jsonc.example`
Add the cheerleader skill address to the kind 10123 adoption list tags:
```jsonc
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:cheerleader"]
```
### 4. Verify Trigger Filter
The Nostr filter for this trigger:
```json
{"kinds": [1], "authors": ["<admin_pubkey_hex>"]}
```
This matches:
- **Kind 1** — text notes only (not DMs, not reactions, not reposts)
- **Authors** — only the admin's pubkey (not anyone else's notes)
The trigger manager polls every 10 seconds (`trigger_manager_poll` checks `now - last_poll_at < 10`), applies the `since` parameter to only fetch events newer than the last seen, and enforces the configured cooldown (default 60 seconds) between firings.
---
## Data Flow
```mermaid
sequenceDiagram
participant Admin as Admin Client
participant Relay as Nostr Relay
participant TM as Trigger Manager
participant Agent as agent_on_trigger
participant LLM as LLM API
Admin->>Relay: Publish kind 1 note
Note over TM: Poll every 10s
TM->>Relay: Query filter: kinds=1, authors=admin
Relay-->>TM: New event found
TM->>TM: Check cooldown, check last_seen_created_at
TM->>Agent: agent_on_trigger with skill content + event
Agent->>Agent: Build system prompt with soul + skill instructions
Agent->>LLM: llm_chat with system + triggering event JSON
LLM-->>Agent: Cheerful response
Agent->>Relay: DM to admin via nostr_handler_send_dm_auto
Relay-->>Admin: Encrypted DM with encouragement
```
---
## File Changes Summary
| File | Change | Lines |
|---|---|---|
| `src/tools.c` | Add trigger/filter/action/enabled to skill_create schema | ~20 lines added |
| `config.jsonc.example` | Add cheerleader skill startup event | ~20 lines added |
| `config.jsonc.example` | Add cheerleader to adoption list | 1 line added |
---
## Testing
1. Build with `make`
2. Start the agent
3. Verify trigger loads on startup via `trigger_list` tool or HTTP API `/status`
4. Post a kind 1 note from the admin account
5. Wait up to ~10 seconds for the poll cycle
6. Receive a cheerful DM from the agent
7. Verify cooldown works — posting again within 60s should not trigger a second DM
+1 -1
View File
@@ -109,7 +109,7 @@ The agent selects the profile matching the active model, falling back to `defaul
│ - skill_remove (remove) │
│ │
│ Structure per skill: │
│ - slug (string) │
│ - d_tag (string) │
│ - description (string) │
│ - content (string, full) │
│ - scope (public/private) │
+1 -1
View File
@@ -34,7 +34,7 @@ NIP: NIP-23
Event Kind: 30023
Format: The content field must be markdown text...
Required Tags:
- d: Addressable identifier slug...
- d: Addressable identifier d_tag...
- title: Human-readable article title
- published_at: Unix timestamp as string...
Procedure:
+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`
+81
View File
@@ -0,0 +1,81 @@
# Didactyl Integration Plan: Unified `nostr_core_lib` Logging into One File
## Goal
Route `nostr_core_lib` logs through didactyl logging so there is one authoritative file for errors, using existing didactyl logging in [`debug.c`](../src/debug.c:1) plus the new callback API in [`nostr_log.h`](../nostr_core_lib/nostr_core/nostr_log.h:1).
## Current State
- Didactyl writes logs to path from `DIDACTYL_LOG_FILE` or default `debug.log` in [`debug_open_log_file`](../src/debug.c:10).
- Didactyl log emission is centralized in [`debug_log`](../src/debug.c:31).
- Relay pool setup occurs in [`nostr_handler_init`](../src/nostr_handler.c:1519).
- `nostr_core_lib` now exposes callback logging API in [`nostr_log.h`](../nostr_core_lib/nostr_core/nostr_log.h:1).
## Desired End State
- `nostr_core_lib` logs are forwarded into didactyl logger via callback registration.
- One file path configured by `DIDACTYL_LOG_FILE` receives didactyl plus nostr_core_lib errors.
- Log lines preserve source component tags such as `nostr:websocket` and `nostr:nip013`.
## Architecture
```mermaid
flowchart TD
A[nostr_core_lib emits callback log] --> B[didactyl bridge callback in nostr_handler.c]
B --> C[map nostr level to didactyl level]
C --> D[debug_log in debug.c]
D --> E[DIDACTYL_LOG_FILE single destination]
```
## Implementation Steps
1. Add bridge function in [`nostr_handler.c`](../src/nostr_handler.c:1519)
- Create static callback `nostr_core_log_bridge level component message user_data`.
- Prefix forwarded lines with `nostr:` namespace.
2. Map levels from `nostr_core_lib` to didactyl
- `NOSTR_LOG_LEVEL_ERROR` to `DEBUG_LEVEL_ERROR`
- `NOSTR_LOG_LEVEL_WARN` to `DEBUG_LEVEL_WARN`
- `NOSTR_LOG_LEVEL_INFO` to `DEBUG_LEVEL_INFO`
- `NOSTR_LOG_LEVEL_DEBUG` to `DEBUG_LEVEL_DEBUG`
- `NOSTR_LOG_LEVEL_TRACE` to `DEBUG_LEVEL_TRACE`
3. Register callback during startup in [`nostr_handler_init`](../src/nostr_handler.c:1519)
- Before relay setup, call `nostr_set_log_callback`.
- Set threshold with `nostr_set_log_level` aligned to current didactyl debug level.
4. Enforce single-file policy
- Set `DIDACTYL_LOG_FILE` to desired final log path.
- Keep didactyl as only file owner via [`debug_open_log_file`](../src/debug.c:10).
5. Ensure error visibility
- Keep bridge forwarding at least error and warn levels even when app runs at lower verbosity.
- Optionally pin library threshold to `WARN` or `ERROR` for production profiles.
## Validation Plan
1. Start didactyl with explicit file path
- Example environment: `DIDACTYL_LOG_FILE=didactyl.log`.
2. Trigger nostr websocket activity
- Confirm `nostr:websocket` entries appear in same file.
3. Trigger nip013 path
- Confirm `nostr:nip013` entries appear in same file.
4. Trigger normal didactyl LLM call
- Confirm `[didactyl] llm request` still lands in same file.
5. Verify there is no second active file for nostr core logs.
## Rollout Notes
- Implement bridge first, then verify in development run.
- If log volume is too high, reduce callback threshold by setting `nostr_set_log_level`.
- Keep one destination file operationally stable by configuring and documenting `DIDACTYL_LOG_FILE`.
## Files to Change in Code Mode
- [`src/nostr_handler.c`](../src/nostr_handler.c)
- Optional docs update in [`README.md`](../README.md)
- Optional operational note in [`docs/TOOLS.md`](../docs/TOOLS.md)
+30 -30
View File
@@ -12,12 +12,12 @@ Skills are Nostr events. The tools are thin orchestration wrappers over the exis
| Kind | Purpose | Replaceable? | Key tag |
|---|---|---|---|
| `31123` | Public skill definition | Yes (d-tag) | `d=<slug>` |
| `31124` | Private skill definition | Yes (d-tag) | `d=<slug>` |
| `31123` | Public skill definition | Yes (d-tag) | `d=<d_tag>` |
| `31124` | Private skill definition | Yes (d-tag) | `d=<d_tag>` |
| `10123` | Public skill adoption list | Yes (replaceable) | `a` refs to 31123 events |
All skill events carry these standard tags:
- `["d", "<slug>"]` — unique identifier within the author's pubkey
- `["d", "<d_tag>"]` — unique identifier within the author's pubkey
- `["app", "didactyl"]` — app namespace
- `["scope", "public"]` or `["scope", "private"]`
@@ -38,37 +38,37 @@ All skill events carry these standard tags:
"parameters": {
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
"d_tag": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
"content": { "type": "string", "description": "Skill body — markdown instructions or structured JSON" },
"scope": { "type": "string", "description": "public (kind 31123) or private (kind 31124). Default: public" },
"description": { "type": "string", "description": "Short one-line description for the skill" },
"auto_adopt": { "type": "boolean", "description": "Automatically add to adoption list (kind 10123). Default: true" }
},
"required": ["slug", "content"]
"required": ["d_tag", "content"]
}
}
```
**Execution logic (`execute_skill_create`):**
1. Validate `slug` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
1. Validate `d_tag` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
2. Determine kind: `31123` if scope is `"public"` or absent; `31124` if `"private"`
3. Build tags array:
- `["d", slug]`
- `["d", d_tag]`
- `["app", "didactyl"]`
- `["scope", scope]`
- `["description", description]` if provided
4. Call `nostr_handler_publish_kind_event(kind, content, tags, &result)`
5. If `auto_adopt` is true (default), update the kind `10123` adoption list:
- Query existing `10123` event for own pubkey (same pattern as `execute_nostr_list_manage`)
- Add `["a", "31123:<own_pubkey>:<slug>"]` tag if not already present
- Add `["a", "31123:<own_pubkey>:<d_tag>"]` tag if not already present
- Republish the updated `10123` event
6. Return JSON with `success`, `event_id`, `naddr_uri`, `slug`, `adopted`
6. Return JSON with `success`, `event_id`, `naddr_uri`, `d_tag`, `adopted`
**Key design decisions:**
- Auto-adopt defaults to `true` — creating a skill you don't adopt is unusual
- Private skills (31124) are NOT added to the public adoption list (10123)
- Republishing with the same slug replaces the previous version (replaceable event)
- Republishing with the same d_tag replaces the previous version (replaceable event)
---
@@ -99,7 +99,7 @@ All skill events carry these standard tags:
- Private only: `{"kinds": [31124], "authors": [own_pubkey]}`
2. Call `nostr_handler_query_json(filter, 8000)`
3. Parse results, extract for each event:
- `slug` (from d-tag)
- `d_tag` (from d-tag)
- `kind`
- `scope` (from scope tag)
- `description` (from description tag, if present)
@@ -123,21 +123,21 @@ All skill events carry these standard tags:
"type": "object",
"properties": {
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author" },
"slug": { "type": "string", "description": "Skill slug (d-tag value)" },
"d_tag": { "type": "string", "description": "Skill d_tag (d-tag value)" },
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
},
"required": ["pubkey", "slug"]
"required": ["pubkey", "d_tag"]
}
}
```
**Execution logic (`execute_skill_adopt`):**
1. Validate pubkey (64-char hex) and slug (non-empty)
1. Validate pubkey (64-char hex) and d_tag (non-empty)
2. Default kind to 31123 if not provided
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
3. Build the `a`-tag value: `"<kind>:<pubkey>:<d_tag>"`
4. Query existing kind `10123` event for own pubkey
5. Check if `["a", "<kind>:<pubkey>:<slug>"]` already exists — if so, return success with `already_adopted: true`
5. Check if `["a", "<kind>:<pubkey>:<d_tag>"]` already exists — if so, return success with `already_adopted: true`
6. Add the tag, republish `10123`
7. Return JSON with `success`, `adopted_address`, `event_id`
@@ -157,10 +157,10 @@ All skill events carry these standard tags:
"type": "object",
"properties": {
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author. Defaults to own pubkey." },
"slug": { "type": "string", "description": "Skill slug to remove" },
"d_tag": { "type": "string", "description": "Skill d_tag to remove" },
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
},
"required": ["slug"]
"required": ["d_tag"]
}
}
```
@@ -169,7 +169,7 @@ All skill events carry these standard tags:
1. Default pubkey to own pubkey if not provided
2. Default kind to 31123
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
3. Build the `a`-tag value: `"<kind>:<pubkey>:<d_tag>"`
4. Query existing kind `10123` event for own pubkey
5. Find and remove matching `["a", ...]` tag
6. Republish `10123`
@@ -211,7 +211,7 @@ All skill events carry these standard tags:
- Return skill summaries
3. If `query` is provided (keyword search):
- Query `{"kinds": [31123]}` with limit
- Filter results client-side by matching `query` against slug, description tag, or content
- Filter results client-side by matching `query` against d_tag, description tag, or content
- Return matching skill summaries
4. Default (no params): return own adopted skills from `10123`
@@ -236,7 +236,7 @@ This is essentially the same pattern already used in `execute_nostr_list_manage(
### Shared helper: skill summary extraction
```c
// Extract slug, kind, scope, description, created_at from a skill event JSON
// Extract d_tag, kind, scope, description, created_at from a skill event JSON
static cJSON* extract_skill_summary(cJSON* event);
```
@@ -269,7 +269,7 @@ flowchart TD
1. **Schema registration** — Add 5 new tool definitions in `tools_build_openai_schema_json()` (t22t26)
2. **Execution functions** — Add 5 new `execute_skill_*()` static functions
3. **Dispatch** — Add 5 new `strcmp` branches in `tools_execute()`
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_slug()`
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_d_tag()`
### `README.md`
@@ -284,19 +284,19 @@ flowchart TD
## Slug Validation Rules
A valid skill slug must:
A valid skill d_tag must:
- Be 164 characters
- Contain only lowercase letters, digits, and hyphens
- Not start or end with a hyphen
- Not contain consecutive hyphens
```c
static int validate_skill_slug(const char* slug) {
if (!slug || slug[0] == '\0' || strlen(slug) > 64) return 0;
if (slug[0] == '-') return 0;
static int validate_skill_d_tag(const char* d_tag) {
if (!d_tag || d_tag[0] == '\0' || strlen(d_tag) > 64) return 0;
if (d_tag[0] == '-') return 0;
int prev_dash = 0;
for (size_t i = 0; slug[i]; i++) {
char c = slug[i];
for (size_t i = 0; d_tag[i]; i++) {
char c = d_tag[i];
if (c == '-') {
if (prev_dash) return 0;
prev_dash = 1;
@@ -306,7 +306,7 @@ static int validate_skill_slug(const char* slug) {
return 0;
}
}
if (slug[strlen(slug) - 1] == '-') return 0;
if (d_tag[strlen(d_tag) - 1] == '-') return 0;
return 1;
}
```
@@ -315,7 +315,7 @@ static int validate_skill_slug(const char* slug) {
## Implementation Order
1. **Shared helpers**`validate_skill_slug`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
1. **Shared helpers**`validate_skill_d_tag`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
2. **`skill_create`** — most important tool, enables the agent to author skills
3. **`skill_list`** — lets the agent see what it has published
4. **`skill_adopt`** — adopt skills from other authors
+5 -5
View File
@@ -19,7 +19,7 @@ A skill is a Nostr event (kind `31123` for public, `31124` for private) that def
- **Parameters** like temperature and max_tokens
- A **description** for human display
Skills are replaceable events keyed by their `d` tag (the slug). Publishing a new event with the same `d` tag replaces the previous version.
Skills are replaceable events keyed by their `d` tag (the d_tag). Publishing a new event with the same `d` tag replaces the previous version.
### Skill Event Structure
@@ -206,7 +206,7 @@ To find skills by a specific author:
1. Receive the event from the relay
2. Parse `event.content` as JSON to get the skill definition object
3. Extract `description`, `template`, `temperature`, `max_tokens`
4. Extract the `d` tag value from `event.tags` as the skill slug
4. Extract the `d` tag value from `event.tags` as the skill d_tag
5. Extract the `description` tag from `event.tags` as a display label
6. The skill is ready to display and execute
@@ -261,7 +261,7 @@ To find skills by a specific author:
- **Skills list**: Radio buttons or selectable cards, one skill selected at a time
- Each skill card shows:
- Skill slug (the `d` tag)
- Skill d_tag (the `d` tag)
- Description (from the `description` tag or content field)
- Author pubkey (truncated, e.g., `52a3e8...0acb8`)
- **Run Selected Skill** button: executes the selected skill against the current text
@@ -398,7 +398,7 @@ sequenceDiagram
**Building the event:**
```javascript
function buildSkillEvent(slug, description, template, temperature, maxTokens) {
function buildSkillEvent(d_tag, description, template, temperature, maxTokens) {
const content = JSON.stringify({
description: description,
context_mode: 'full',
@@ -413,7 +413,7 @@ function buildSkillEvent(slug, description, template, temperature, maxTokens) {
kind: 31123,
content: content,
tags: [
['d', slug],
['d', d_tag],
['app', 'didactyl'],
['scope', 'public'],
['description', description],
+14 -14
View File
@@ -62,13 +62,13 @@ Today, skills are passive — they're injected into the LLM context via [`append
#### A. `/run` slash command (admin direct invocation)
```
/run deploy-website staging # run own adopted skill by slug
/run deploy-website staging # run own adopted skill by d_tag
/run 31123:<pubkey>:deploy-website staging # run anyone's skill by address
/run deploy-website {"target": "production"} # JSON args
```
Parsing:
1. First token after `/run` is the skill identifier (slug or `kind:pubkey:slug` address)
1. First token after `/run` is the skill identifier (d_tag or `kind:pubkey:d_tag` address)
2. Everything after is args (try JSON first, fall back to `{"input": "plain text"}`)
#### B. `skill_run` tool (LLM-mediated invocation)
@@ -78,13 +78,13 @@ The LLM can invoke skills on behalf of the admin during conversation:
```json
{
"name": "skill_run",
"description": "Fetch and execute a skill one-shot without adopting it. Works with own adopted skills by slug or any public skill by address.",
"description": "Fetch and execute a skill one-shot without adopting it. Works with own adopted skills by d_tag or any public skill by address.",
"parameters": {
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Skill slug for own adopted skills" },
"address": { "type": "string", "description": "Full skill address: kind:pubkey:slug" },
"pubkey": { "type": "string", "description": "Author pubkey, used with slug to form address" },
"d_tag": { "type": "string", "description": "Skill d_tag for own adopted skills" },
"address": { "type": "string", "description": "Full skill address: kind:pubkey:d_tag" },
"pubkey": { "type": "string", "description": "Author pubkey, used with d_tag to form address" },
"args": { "type": "string", "description": "Arguments or context to pass to the skill" },
"sandbox": { "type": "boolean", "description": "Override sandbox setting. Default: true for external, false for own skills" }
}
@@ -104,8 +104,8 @@ Both `/run` and `skill_run` use the same underlying executor:
```mermaid
graph TD
A[/run or skill_run called] --> B{Skill identifier type?}
B -->|slug only| C[Look up in adopted skills cache]
B -->|address or pubkey+slug| D[Fetch skill event from Nostr]
B -->|d_tag only| C[Look up in adopted skills cache]
B -->|address or pubkey+d_tag| D[Fetch skill event from Nostr]
C --> E{Found?}
D --> E
E -->|No| F[Return error: skill not found]
@@ -133,7 +133,7 @@ Skill execution context:
- The user's arguments provide the context for this execution.
- Keep output concise and actionable.
Skill slug: deploy-website
Skill d_tag: deploy-website
Skill address: 31123:<pubkey>:deploy-website
Skill source: [own | external:<author_display_name>]
@@ -175,7 +175,7 @@ graph TD
A[Friend creates skill] -->|kind 31123| B[Published on Nostr relays]
B --> C{How do you find it?}
C -->|skill_search popular:true| D[Discovery via WoT adoption lists]
C -->|Friend tells you the slug| E[Direct reference]
C -->|Friend tells you the d_tag| E[Direct reference]
C -->|skill_search pubkey:friend| F[Browse friends skills]
D --> G[skill_run - try it once, sandboxed]
E --> G
@@ -185,7 +185,7 @@ graph TD
H -->|No| J[Done - nothing persisted]
I --> K[Shows in adopted skills context]
K --> L[Agent uses it automatically]
L -->|Or invoke directly| M[/run skill-slug args]
L -->|Or invoke directly| M[/run skill-d_tag args]
```
## 3. Skill-Tool Maturity Levels
@@ -255,7 +255,7 @@ steps:
args:
kind: 30023
content: "{{file_content}}"
tags: [["d", "{{slug}}"]]
tags: [["d", "{{d_tag}}"]]
# Conditional - simple
- if: "{{listing.success}}"
@@ -266,7 +266,7 @@ steps:
- return: "Failed: {{listing.error}}"
# Return final result
- return: "Published to {{slug}}"
- return: "Published to {{d_tag}}"
```
### Variable Substitution
@@ -333,7 +333,7 @@ typedef enum {
| Scenario | Default sandbox | Override |
|----------|----------------|---------|
| Own adopted skill via `/run slug` | No sandbox | N/A |
| Own adopted skill via `/run d_tag` | No sandbox | N/A |
| External skill via `/run address` | Sandbox ON | `/run --unsafe address` |
| `skill_run` tool, own skill | No sandbox | `sandbox: true` |
| `skill_run` tool, external skill | Sandbox ON | `sandbox: false` |
+285
View File
@@ -0,0 +1,285 @@
# Plan: Refactor tools.c into Individual Tool Files
## Problem
`src/tools.c` is ~6,123 lines containing 44 `execute_*` functions, a ~650-line schema builder, shared helpers, and a dispatcher. It is the largest file in the project and difficult to navigate, review, and maintain.
## Goal
Split `src/tools.c` into focused files organized by tool group, with shared helpers in a common module. The public API (`tools.h`) remains unchanged — no callers need modification.
## Target File Structure
```
src/
├── tools.h # Public API (UNCHANGED)
├── tools_internal.h # Shared internal helpers + per-tool-file function declarations
├── tools_common.c # Shared helper implementations
├── tools_schema.c # tools_build_openai_schema_json()
├── tools_dispatch.c # tools_init, tools_cleanup, tools_execute dispatcher
└── tools/
├── tool_nostr_post.c # nostr_post, nostr_post_readme, nostr_file_md_to_longform_post
├── tool_nostr_query.c # nostr_query
├── tool_nostr_identity.c # nostr_pubkey, nostr_npub, nostr_encode, nostr_decode
├── tool_nostr_social.c # nostr_react, nostr_profile_get, nostr_nip05_lookup
├── tool_nostr_dm.c # nostr_dm_send, nostr_dm_send_nip17, nostr_encrypt, nostr_decrypt
├── tool_nostr_relay.c # nostr_relay_status, nostr_relay_info
├── tool_nostr_list.c # nostr_list_manage, nostr_delete
├── tool_skill.c # skill_create, skill_list, skill_adopt, skill_remove, skill_edit, skill_search
├── tool_task.c # task_list, task_manage
├── tool_local.c # local_shell_exec, local_file_read, local_file_write, local_http_fetch
├── tool_admin.c # admin_identity, nostr_admin_profile, nostr_admin_contacts, nostr_admin_relays, nostr_admin_notes
├── tool_agent.c # agent_identity, agent_version, message_current
├── tool_model.c # model_get, model_set, model_list
└── tool_meta.c # tool_list, trigger_list
```
## Shared Helpers → tools_common.c
These helpers are used by multiple tool groups and must be non-static in `tools_common.c`, declared in `tools_internal.h`:
| Helper | Current lines | Used by |
|---|---|---|
| `json_error()` | 27-35 | ALL tools |
| `repair_json_control_chars()` | 37-101 | nostr_post, nostr_post_readme |
| `find_key_start()` | 103-111 | nostr_post loose parser |
| `skip_ws()` | 113-116 | parse_tool_args_json, loose parser |
| `parse_loose_kind()` | 118-135 | nostr_post |
| `parse_loose_json_string_value()` | 137-186 | nostr_post |
| `parse_loose_nostr_post_args()` | 188-212 | nostr_post |
| `ensure_tags_array()` | 214-227 | nostr_post |
| `has_tag_key()` | 229-243 | longform post helpers |
| `add_string_tag()` | 245-255 | nostr_post, delete, react, skill, longform |
| `remove_tag_key_all()` | 257-270 | upsert_string_tag |
| `upsert_string_tag()` | 272-276 | skill_edit |
| `parse_tool_args_json()` | 590-564 | ALL tools |
| `is_hex_string_len()` | 581-588 | nostr tools, skill tools, DM tools |
| `find_tag_value_string()` | 855-872 | skill tools, list_manage, longform |
| `tag_tuple_equal()` | 813-828 | skill tools, list_manage |
| `tags_contains_tuple()` | 830-840 | skill tools, list_manage |
| `remove_matching_tag_tuples()` | 842-853 | skill tools, list_manage |
| `build_tool_path()` | (used by local tools + readme) | local tools, readme |
## Tool-Local Helpers (stay static in their tool file)
| Helper | Destination |
|---|---|
| `validate_skill_d_tag()` | `tool_skill.c` |
| `fetch_adoption_list_tags()` | `tool_skill.c` |
| `publish_adoption_list()` | `tool_skill.c` |
| `adoption_tags_contains_address()` | `tool_skill.c` |
| `extract_skill_summary()` | `tool_skill.c` |
| `ci_contains()` | `tool_skill.c` |
| `d_tagify_string()` | `tool_nostr_post.c` |
| `first_markdown_h1()` | `tool_nostr_post.c` |
| `first_markdown_paragraph()` | `tool_nostr_post.c` |
| `trim_copy()` | `tool_nostr_post.c` |
| `auto_enrich_longform_tags()` | `tool_nostr_post.c` |
| `basename_lowercase_dup()` | `tool_nostr_post.c` |
| `detect_ca_bundle_path_for_tools()` | `tool_local.c` |
| `local_http_fetch_write_cb()` | `tool_local.c` |
| `persist_llm_config()` | `tool_model.c` |
## Tool Function Signatures
Each tool file exposes its `execute_*` functions via `tools_internal.h`. Two signature patterns exist:
```c
// Pattern A: context-free (only needs args)
char* execute_nostr_post(const char* args_json);
// Pattern B: context-dependent
char* execute_skill_create(tools_context_t* ctx, const char* args_json);
```
## tools_internal.h Structure
```c
#ifndef DIDACTYL_TOOLS_INTERNAL_H
#define DIDACTYL_TOOLS_INTERNAL_H
#include "tools.h"
#include "cjson/cJSON.h"
// ─── Shared helpers (tools_common.c) ───
char* json_error(const char* msg);
cJSON* parse_tool_args_json(const char* args_json);
int is_hex_string_len(const char* s, size_t expected_len);
int add_string_tag(cJSON* tags, const char* key, const char* value);
int remove_tag_key_all(cJSON* tags, const char* key);
int upsert_string_tag(cJSON* tags, const char* key, const char* value);
int has_tag_key(cJSON* tags, const char* key);
cJSON* ensure_tags_array(cJSON** tags_inout);
cJSON* find_tag_value_string(cJSON* tags, const char* key);
int tag_tuple_equal(cJSON* a, cJSON* b);
int tags_contains_tuple(cJSON* tags, cJSON* tuple);
int remove_matching_tag_tuples(cJSON* tags, cJSON* tuple);
char* repair_json_control_chars(const char* in);
cJSON* parse_loose_nostr_post_args(const char* in);
int build_tool_path(tools_context_t* ctx, const char* rel, char* out, size_t out_size);
// ─── Per-tool-file execute functions ───
// tool_nostr_post.c
char* execute_nostr_post(const char* args_json);
char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_json);
char* execute_nostr_file_md_to_longform_post(tools_context_t* ctx, const char* args_json);
// tool_nostr_query.c
char* execute_nostr_query(const char* args_json);
// tool_nostr_identity.c
char* execute_nostr_pubkey(tools_context_t* ctx, const char* args_json);
char* execute_nostr_npub(tools_context_t* ctx, const char* args_json);
char* execute_nostr_encode(const char* args_json);
char* execute_nostr_decode(const char* args_json);
// tool_nostr_social.c
char* execute_nostr_react(const char* args_json);
char* execute_nostr_profile_get(const char* args_json);
char* execute_nostr_nip05_lookup(const char* args_json);
// tool_nostr_dm.c
char* execute_nostr_dm_send(const char* args_json);
char* execute_nostr_dm_send_nip17(const char* args_json);
char* execute_nostr_encrypt(tools_context_t* ctx, const char* args_json);
char* execute_nostr_decrypt(tools_context_t* ctx, const char* args_json);
// tool_nostr_relay.c
char* execute_nostr_relay_status(const char* args_json);
char* execute_nostr_relay_info(const char* args_json);
// tool_nostr_list.c
char* execute_nostr_list_manage(tools_context_t* ctx, const char* args_json);
char* execute_nostr_delete(const char* args_json);
// tool_skill.c
char* execute_skill_create(tools_context_t* ctx, const char* args_json);
char* execute_skill_list(tools_context_t* ctx, const char* args_json);
char* execute_skill_adopt(tools_context_t* ctx, const char* args_json);
char* execute_skill_remove(tools_context_t* ctx, const char* args_json);
char* execute_skill_edit(tools_context_t* ctx, const char* args_json);
char* execute_skill_search(const char* args_json);
// tool_task.c
char* execute_task_list(tools_context_t* ctx, const char* args_json);
char* execute_task_manage(tools_context_t* ctx, const char* args_json);
// tool_local.c
char* execute_local_http_fetch(tools_context_t* ctx, const char* args_json);
char* execute_local_shell_exec(tools_context_t* ctx, const char* args_json);
char* execute_local_file_read(tools_context_t* ctx, const char* args_json);
char* execute_local_file_write(tools_context_t* ctx, const char* args_json);
// tool_admin.c
char* execute_admin_identity(tools_context_t* ctx, const char* args_json);
char* execute_nostr_admin_profile(tools_context_t* ctx, const char* args_json);
char* execute_nostr_admin_contacts(tools_context_t* ctx, const char* args_json);
char* execute_nostr_admin_relays(tools_context_t* ctx, const char* args_json);
char* execute_nostr_admin_notes(tools_context_t* ctx, const char* args_json);
// tool_agent.c
char* execute_agent_identity(tools_context_t* ctx, const char* args_json);
char* execute_agent_version(const char* args_json);
char* execute_message_current(tools_context_t* ctx, const char* args_json);
// tool_model.c
char* execute_model_get(const char* args_json);
char* execute_model_set(tools_context_t* ctx, const char* args_json);
char* execute_model_list(const char* args_json);
// tool_meta.c
char* execute_tool_list(tools_context_t* ctx, const char* args_json);
char* execute_trigger_list(tools_context_t* ctx, const char* args_json);
#endif
```
## Makefile Changes
Replace the single `$(SRC_DIR)/tools.c` entry with:
```makefile
SRCS = \
$(SRC_DIR)/main.c \
$(SRC_DIR)/config.c \
$(SRC_DIR)/context.c \
$(SRC_DIR)/llm.c \
$(SRC_DIR)/nostr_handler.c \
$(SRC_DIR)/agent.c \
$(SRC_DIR)/tools_common.c \
$(SRC_DIR)/tools_schema.c \
$(SRC_DIR)/tools_dispatch.c \
$(SRC_DIR)/tools/tool_nostr_post.c \
$(SRC_DIR)/tools/tool_nostr_query.c \
$(SRC_DIR)/tools/tool_nostr_identity.c \
$(SRC_DIR)/tools/tool_nostr_social.c \
$(SRC_DIR)/tools/tool_nostr_dm.c \
$(SRC_DIR)/tools/tool_nostr_relay.c \
$(SRC_DIR)/tools/tool_nostr_list.c \
$(SRC_DIR)/tools/tool_skill.c \
$(SRC_DIR)/tools/tool_task.c \
$(SRC_DIR)/tools/tool_local.c \
$(SRC_DIR)/tools/tool_admin.c \
$(SRC_DIR)/tools/tool_agent.c \
$(SRC_DIR)/tools/tool_model.c \
$(SRC_DIR)/tools/tool_meta.c \
$(SRC_DIR)/trigger_manager.c \
$(SRC_DIR)/prompt_template.c \
$(SRC_DIR)/http_api.c \
$(SRC_DIR)/mongoose.c \
$(SRC_DIR)/debug.c
```
## Implementation Steps
### Phase 1: Create shared infrastructure
- [ ] Create `src/tools_internal.h` with shared helper declarations and all `execute_*` prototypes
- [ ] Create `src/tools_common.c` — move shared helpers from `tools.c`, remove `static`, add `#include "tools_internal.h"`
- [ ] Create `src/tools_schema.c` — move `tools_build_openai_schema_json()` from `tools.c`
- [ ] Create `src/tools_dispatch.c` — move `tools_init()`, `tools_cleanup()`, `tools_execute()` from `tools.c`
- [ ] Update `Makefile` SRCS to include new files (keep `tools.c` temporarily with remaining functions)
- [ ] Build and verify — all tests pass, binary runs
### Phase 2: Extract tool files one at a time
Each step: move `execute_*` functions + their local helpers into the new file, remove from `tools.c`, build, verify.
- [ ] Create `src/tools/` directory
- [ ] Extract `src/tools/tool_nostr_post.c``execute_nostr_post`, `execute_nostr_post_readme`, `execute_nostr_file_md_to_longform_post` + local helpers: `d_tagify_string`, `first_markdown_h1`, `first_markdown_paragraph`, `trim_copy`, `auto_enrich_longform_tags`, `basename_lowercase_dup`
- [ ] Extract `src/tools/tool_nostr_query.c``execute_nostr_query`
- [ ] Extract `src/tools/tool_nostr_identity.c``execute_nostr_pubkey`, `execute_nostr_npub`, `execute_nostr_encode`, `execute_nostr_decode`
- [ ] Extract `src/tools/tool_nostr_social.c``execute_nostr_react`, `execute_nostr_profile_get`, `execute_nostr_nip05_lookup`
- [ ] Extract `src/tools/tool_nostr_dm.c``execute_nostr_dm_send`, `execute_nostr_dm_send_nip17`, `execute_nostr_encrypt`, `execute_nostr_decrypt`
- [ ] Extract `src/tools/tool_nostr_relay.c``execute_nostr_relay_status`, `execute_nostr_relay_info`
- [ ] Extract `src/tools/tool_nostr_list.c``execute_nostr_list_manage`, `execute_nostr_delete`
- [ ] Extract `src/tools/tool_skill.c``execute_skill_create`, `execute_skill_list`, `execute_skill_adopt`, `execute_skill_remove`, `execute_skill_edit`, `execute_skill_search` + local helpers: `validate_skill_d_tag`, `ci_contains`, `fetch_adoption_list_tags`, `publish_adoption_list`, `adoption_tags_contains_address`, `extract_skill_summary`
- [ ] Extract `src/tools/tool_task.c``execute_task_list`, `execute_task_manage`
- [ ] Extract `src/tools/tool_local.c``execute_local_http_fetch`, `execute_local_shell_exec`, `execute_local_file_read`, `execute_local_file_write` + local helpers: `detect_ca_bundle_path_for_tools`, `local_http_fetch_write_cb`, `free_string_array_heap`
- [ ] Extract `src/tools/tool_admin.c``execute_admin_identity`, `execute_nostr_admin_profile`, `execute_nostr_admin_contacts`, `execute_nostr_admin_relays`, `execute_nostr_admin_notes`
- [ ] Extract `src/tools/tool_agent.c``execute_agent_identity`, `execute_agent_version`, `execute_message_current`
- [ ] Extract `src/tools/tool_model.c``execute_model_get`, `execute_model_set`, `execute_model_list` + local helper: `persist_llm_config`
- [ ] Extract `src/tools/tool_meta.c``execute_tool_list`, `execute_trigger_list`
### Phase 3: Cleanup
- [ ] Delete `src/tools.c` (now empty — all code moved)
- [ ] Remove `src/tools.c` from Makefile SRCS
- [ ] Final build + full test run
- [ ] Verify binary size is comparable (no accidental code duplication)
- [ ] Increment version and push
## Validation Criteria
After each extraction step:
1. `make -j4` compiles with zero warnings
2. `./didactyl --debug 5` starts and runs normally
3. Tool execution via DM or `--test-tool` produces identical results
## Risk Mitigation
- **Build after every file extraction** — catch issues immediately
- **No logic changes** — purely mechanical code movement
- **Keep `tools.h` unchanged** — zero impact on callers (`agent.c`, `http_api.c`, `main.c`)
- **Git commit after each phase** — easy rollback points
+5 -5
View File
@@ -21,7 +21,7 @@ Create `src/trigger_manager.c` and `src/trigger_manager.h` — the core componen
#define TRIGGER_COOLDOWN_SECONDS 60
typedef struct {
char skill_slug[65];
char skill_d_tag[65];
char skill_content[4096]; // the action template or LLM prompt
int action_type; // 0 = llm, 1 = template
char filter_json[2048]; // the Nostr subscription filter
@@ -43,11 +43,11 @@ typedef struct {
```c
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_slug,
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_d_tag,
const char* content, const char* filter_json,
int action_type, int enabled);
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_slug,
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag);
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_d_tag,
const char* content, const char* filter_json,
int action_type, int enabled);
int trigger_manager_active_count(trigger_manager_t* mgr);
@@ -115,7 +115,7 @@ When `skill_remove` is called for a triggered skill:
The agent currently only handles DM-initiated conversations via `agent_on_message()`. Add a new entry point:
```c
void agent_on_trigger(const char* skill_slug,
void agent_on_trigger(const char* skill_d_tag,
const char* skill_content,
cJSON* triggering_event,
const char* relay_url);
+138 -41
View File
@@ -13,7 +13,8 @@
#include "llm.h"
#include "nostr_handler.h"
#include "tools.h"
#include "trigger_manager.h"
#include "tools/tools.h"
#include "prompt_template.h"
#include "cjson/cJSON.h"
#include "debug.h"
@@ -48,7 +49,7 @@ typedef struct {
typedef struct {
int kind;
char author_pubkey_hex[65];
char slug[65];
char d_tag[65];
char* content;
} agent_adopted_skill_t;
@@ -473,14 +474,14 @@ static char* build_slash_help_all_json(void) {
int sn = cJSON_GetArraySize(skills);
for (int i = 0; i < sn; i++) {
cJSON* row = cJSON_GetArrayItem(skills, i);
cJSON* slug = row ? cJSON_GetObjectItemCaseSensitive(row, "slug") : NULL;
cJSON* d_tag = row ? cJSON_GetObjectItemCaseSensitive(row, "d_tag") : NULL;
cJSON* desc = row ? cJSON_GetObjectItemCaseSensitive(row, "description") : NULL;
const char* slug_s = (slug && cJSON_IsString(slug) && slug->valuestring) ? slug->valuestring : NULL;
const char* d_tag_s = (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring) ? d_tag->valuestring : NULL;
const char* desc_s = (desc && cJSON_IsString(desc) && desc->valuestring) ? desc->valuestring : "";
if (!slug_s || slug_s[0] == '\0') {
if (!d_tag_s || d_tag_s[0] == '\0') {
continue;
}
if (append_textf_local(&out, &cap, &used, "- %s%s%s\n", slug_s, desc_s[0] ? "" : "", desc_s) != 0) {
if (append_textf_local(&out, &cap, &used, "- %s%s%s\n", d_tag_s, desc_s[0] ? "" : "", desc_s) != 0) {
cJSON_Delete(skill_root);
free(skill_list_json);
free(out);
@@ -805,6 +806,8 @@ static char* format_context_payload_for_log(const char* context_payload) {
}
out[0] = '\0';
size_t used = 0;
char* conversation_role = NULL;
char* conversation_content = NULL;
int n = cJSON_GetArraySize(root);
(void)appendf(&out, &cap, &used, "Sections: %d\n\n", n);
@@ -830,6 +833,14 @@ static char* format_context_payload_for_log(const char* context_payload) {
continue;
}
if (section && strcmp(section, "conversation") == 0) {
free(conversation_role);
free(conversation_content);
conversation_role = strdup(role_s);
conversation_content = strdup(content_s);
continue;
}
char* display_content = demote_markdown_headings_for_log(content_s);
const char* safe_display_content = display_content ? display_content : content_s;
@@ -901,8 +912,19 @@ static char* format_context_payload_for_log(const char* context_payload) {
}
}
if (conversation_role && conversation_content) {
(void)appendf(&out,
&cap,
&used,
"# conversation | role=%s\n\n%s\n\n",
conversation_role,
conversation_content);
}
free(chat);
free(latest_admin_line);
free(conversation_role);
free(conversation_content);
cJSON_Delete(root);
return out;
@@ -1428,8 +1450,8 @@ static cJSON* find_tag_value_string_local(cJSON* tags, const char* key) {
return NULL;
}
static int parse_skill_address_tag_local(const char* addr, int* out_kind, char out_pubkey_hex[65], char out_slug[65]) {
if (!addr || !out_kind || !out_pubkey_hex || !out_slug) {
static int parse_skill_address_tag_local(const char* addr, int* out_kind, char out_pubkey_hex[65], char out_d_tag[65]) {
if (!addr || !out_kind || !out_pubkey_hex || !out_d_tag) {
return -1;
}
@@ -1457,11 +1479,11 @@ static int parse_skill_address_tag_local(const char* addr, int* out_kind, char o
memcpy(out_pubkey_hex, p1 + 1, 64U);
out_pubkey_hex[64] = '\0';
size_t slug_len = strlen(p2 + 1);
if (slug_len == 0 || slug_len >= 65U) {
size_t d_tag_len = strlen(p2 + 1);
if (d_tag_len == 0 || d_tag_len >= 65U) {
return -1;
}
memcpy(out_slug, p2 + 1, slug_len + 1U);
memcpy(out_d_tag, p2 + 1, d_tag_len + 1U);
*out_kind = kind;
return 0;
@@ -1473,7 +1495,7 @@ static void clear_adopted_skills_cache_locked(void) {
g_adopted_skills[i].content = NULL;
g_adopted_skills[i].kind = 0;
g_adopted_skills[i].author_pubkey_hex[0] = '\0';
g_adopted_skills[i].slug[0] = '\0';
g_adopted_skills[i].d_tag[0] = '\0';
}
g_adopted_skills_count = 0;
}
@@ -1522,8 +1544,8 @@ static int refresh_adopted_skills_cache_if_needed(void) {
int skill_kind = 0;
char skill_author[65] = {0};
char skill_slug[65] = {0};
if (parse_skill_address_tag_local(val->valuestring, &skill_kind, skill_author, skill_slug) != 0) {
char skill_d_tag[65] = {0};
if (parse_skill_address_tag_local(val->valuestring, &skill_kind, skill_author, skill_d_tag) != 0) {
continue;
}
@@ -1545,7 +1567,7 @@ static int refresh_adopted_skills_cache_if_needed(void) {
if (!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
strcmp(ev_pubkey->valuestring, skill_author) != 0 ||
!ev_d || !cJSON_IsString(ev_d) || !ev_d->valuestring ||
strcmp(ev_d->valuestring, skill_slug) != 0) {
strcmp(ev_d->valuestring, skill_d_tag) != 0) {
continue;
}
cJSON* dup = cJSON_Duplicate(ev, 1);
@@ -1574,7 +1596,7 @@ static int refresh_adopted_skills_cache_if_needed(void) {
cJSON_AddItemToObject(skill_filter, "kinds", sk_kinds);
cJSON_AddItemToArray(sk_authors, cJSON_CreateString(skill_author));
cJSON_AddItemToObject(skill_filter, "authors", sk_authors);
cJSON_AddItemToArray(d_values, cJSON_CreateString(skill_slug));
cJSON_AddItemToArray(d_values, cJSON_CreateString(skill_d_tag));
cJSON_AddItemToObject(skill_filter, "#d", d_values);
cJSON_AddNumberToObject(skill_filter, "limit", 1);
@@ -1611,7 +1633,7 @@ static int refresh_adopted_skills_cache_if_needed(void) {
tmp[tmp_count].kind = skill_kind;
snprintf(tmp[tmp_count].author_pubkey_hex, sizeof(tmp[tmp_count].author_pubkey_hex), "%s", skill_author);
snprintf(tmp[tmp_count].slug, sizeof(tmp[tmp_count].slug), "%s", skill_slug);
snprintf(tmp[tmp_count].d_tag, sizeof(tmp[tmp_count].d_tag), "%s", skill_d_tag);
tmp[tmp_count].content = strdup(content->valuestring);
if (tmp[tmp_count].content) {
tmp_count++;
@@ -1631,17 +1653,17 @@ static int refresh_adopted_skills_cache_if_needed(void) {
continue;
}
char slug[65] = {0};
char d_tag_val[65] = {0};
if (se->tags_json) {
cJSON* tags = cJSON_Parse(se->tags_json);
cJSON* d_tag = tags ? find_tag_value_string_local(tags, "d") : NULL;
if (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring && d_tag->valuestring[0] != '\0') {
snprintf(slug, sizeof(slug), "%s", d_tag->valuestring);
snprintf(d_tag_val, sizeof(d_tag_val), "%s", d_tag->valuestring);
}
cJSON_Delete(tags);
}
if (slug[0] == '\0') {
snprintf(slug, sizeof(slug), "startup-skill-%d", i + 1);
if (d_tag_val[0] == '\0') {
snprintf(d_tag_val, sizeof(d_tag_val), "startup-skill-%d", i + 1);
}
tmp[tmp_count].kind = se->kind;
@@ -1651,7 +1673,7 @@ static int refresh_adopted_skills_cache_if_needed(void) {
g_cfg->keys.public_key_hex[0] != '\0'
? g_cfg->keys.public_key_hex
: "unknown");
snprintf(tmp[tmp_count].slug, sizeof(tmp[tmp_count].slug), "%s", slug);
snprintf(tmp[tmp_count].d_tag, sizeof(tmp[tmp_count].d_tag), "%s", d_tag_val);
tmp[tmp_count].content = strdup(se->content);
if (tmp[tmp_count].content) {
tmp_count++;
@@ -1782,11 +1804,11 @@ static int append_adopted_skills_context(cJSON* messages) {
int head_n = snprintf(payload + used,
capacity - used,
"\n\n---\n\n### Skill\nSkill slug: %s\nSkill address: %d:%s:%s\n\nInstructions:\n",
g_adopted_skills[i].slug,
"\n\n---\n\n### Skill\nSkill d_tag: %s\nSkill address: %d:%s:%s\n\nInstructions:\n",
g_adopted_skills[i].d_tag,
g_adopted_skills[i].kind,
g_adopted_skills[i].author_pubkey_hex,
g_adopted_skills[i].slug);
g_adopted_skills[i].d_tag);
if (head_n < 0 || (size_t)head_n >= (capacity - used)) {
break;
}
@@ -1889,11 +1911,11 @@ void agent_set_trigger_manager(struct trigger_manager* trigger_manager) {
g_tools_ctx.trigger_manager = trigger_manager;
}
void agent_on_trigger(const char* skill_slug,
void agent_on_trigger(const char* skill_d_tag,
const char* skill_content,
cJSON* triggering_event,
const char* relay_url) {
if (!g_cfg || !g_system_context || !skill_slug || !skill_content || !triggering_event) {
if (!g_cfg || !g_system_context || !skill_d_tag || !skill_content || !triggering_event) {
return;
}
@@ -1910,9 +1932,9 @@ void agent_on_trigger(const char* skill_slug,
"- Execute the skill instructions below against the triggering event.\n"
"- Keep output concise and actionable for the administrator.\n"
"- If no action is needed, explicitly say why.\n\n"
"Skill slug: ";
"Skill d_tag: ";
size_t system_len = strlen(g_system_context) + 2 + strlen(trigger_prefix) + strlen(skill_slug) +
size_t system_len = strlen(g_system_context) + 2 + strlen(trigger_prefix) + strlen(skill_d_tag) +
strlen("\nRelay: ") + strlen(relay) + strlen("\n\nSkill instructions:\n") +
strlen(skill_content) + 1U;
char* system_prompt = (char*)malloc(system_len);
@@ -1926,7 +1948,7 @@ void agent_on_trigger(const char* skill_slug,
"%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s",
g_system_context,
trigger_prefix,
skill_slug,
skill_d_tag,
relay,
skill_content);
@@ -1941,20 +1963,95 @@ void agent_on_trigger(const char* skill_slug,
snprintf(user_prompt, user_len, "Triggering event JSON:\n%s", event_json);
free(event_json);
append_context_log(g_cfg->admin.pubkey, "llm_trigger", user_prompt);
char* response = llm_chat(system_prompt, user_prompt);
free(system_prompt);
free(user_prompt);
if (!response) {
const char* fallback = "Triggered skill execution failed: LLM unavailable.";
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, fallback);
char* tools_json = tools_build_openai_schema_json(&g_tools_ctx);
if (!tools_json) {
free(system_prompt);
free(user_prompt);
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: tools unavailable.");
return;
}
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, response);
free(response);
cJSON* messages = cJSON_CreateArray();
if (!messages || !cJSON_IsArray(messages) ||
append_simple_message(messages, "system", system_prompt) != 0 ||
append_simple_message(messages, "user", user_prompt) != 0) {
cJSON_Delete(messages);
free(tools_json);
free(system_prompt);
free(user_prompt);
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: prompt initialization failed.");
return;
}
append_context_log(g_cfg->admin.pubkey, "llm_trigger", user_prompt);
free(system_prompt);
free(user_prompt);
int max_turns = g_cfg->tools.trigger_max_turns > 0
? g_cfg->tools.trigger_max_turns
: (g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8);
for (int turn = 0; turn < max_turns; turn++) {
char* messages_json = cJSON_PrintUnformatted(messages);
if (!messages_json) {
break;
}
append_context_log(g_cfg->admin.pubkey, "llm_trigger_with_tools_messages", messages_json);
llm_response_t resp;
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp);
free(messages_json);
if (rc != 0) {
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: LLM request failed.");
cJSON_Delete(messages);
free(tools_json);
return;
}
if (resp.tool_call_count <= 0) {
llm_response_free(&resp);
break;
}
if (append_assistant_tool_calls_message(messages, &resp) != 0) {
llm_response_free(&resp);
break;
}
for (int i = 0; i < resp.tool_call_count; i++) {
llm_tool_call_t* tc = &resp.tool_calls[i];
char* tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json);
if (!tool_result) {
tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}");
}
if (append_tool_result_message(messages,
tc->id ? tc->id : "",
tool_result ? tool_result : "{\"success\":false,\"error\":\"tool execution failed\"}") != 0) {
free(tool_result);
llm_response_free(&resp);
cJSON_Delete(messages);
free(tools_json);
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: tool result append failed.");
return;
}
free(tool_result);
}
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);
}
int agent_build_admin_messages_json(const char* current_user_message,
+1 -1
View File
@@ -10,7 +10,7 @@ struct trigger_manager;
int agent_init(didactyl_config_t* config, const char* system_context);
void agent_set_trigger_manager(struct trigger_manager* trigger_manager);
void agent_on_trigger(const char* skill_slug,
void agent_on_trigger(const char* skill_d_tag,
const char* skill_content,
cJSON* triggering_event,
const char* relay_url);
+59 -9
View File
@@ -222,12 +222,36 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(tools, "enabled");
cJSON* max_turns = cJSON_GetObjectItemCaseSensitive(tools, "max_turns");
cJSON* trigger_max_turns = cJSON_GetObjectItemCaseSensitive(tools, "trigger_max_turns");
cJSON* api_default_max_turns = cJSON_GetObjectItemCaseSensitive(tools, "api_default_max_turns");
cJSON* api_max_turns_ceiling = cJSON_GetObjectItemCaseSensitive(tools, "api_max_turns_ceiling");
cJSON* local_http_fetch_default_timeout_seconds =
cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_default_timeout_seconds");
cJSON* local_http_fetch_max_timeout_seconds =
cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_max_timeout_seconds");
if (enabled && cJSON_IsBool(enabled)) {
config->tools.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
if (max_turns && cJSON_IsNumber(max_turns)) {
config->tools.max_turns = (int)max_turns->valuedouble;
}
if (trigger_max_turns && cJSON_IsNumber(trigger_max_turns)) {
config->tools.trigger_max_turns = (int)trigger_max_turns->valuedouble;
}
if (api_default_max_turns && cJSON_IsNumber(api_default_max_turns)) {
config->tools.api_default_max_turns = (int)api_default_max_turns->valuedouble;
}
if (api_max_turns_ceiling && cJSON_IsNumber(api_max_turns_ceiling)) {
config->tools.api_max_turns_ceiling = (int)api_max_turns_ceiling->valuedouble;
}
if (local_http_fetch_default_timeout_seconds && cJSON_IsNumber(local_http_fetch_default_timeout_seconds)) {
config->tools.local_http_fetch_default_timeout_seconds =
(int)local_http_fetch_default_timeout_seconds->valuedouble;
}
if (local_http_fetch_max_timeout_seconds && cJSON_IsNumber(local_http_fetch_max_timeout_seconds)) {
config->tools.local_http_fetch_max_timeout_seconds =
(int)local_http_fetch_max_timeout_seconds->valuedouble;
}
cJSON* shell = cJSON_GetObjectItemCaseSensitive(tools, "shell");
if (!shell || !cJSON_IsObject(shell)) {
@@ -255,6 +279,31 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
return -1;
}
if (config->tools.max_turns < 1) {
config->tools.max_turns = 8;
}
if (config->tools.trigger_max_turns < 1) {
config->tools.trigger_max_turns = config->tools.max_turns;
}
if (config->tools.api_default_max_turns < 1) {
config->tools.api_default_max_turns = config->tools.max_turns;
}
if (config->tools.api_max_turns_ceiling < 1) {
config->tools.api_max_turns_ceiling = 16;
}
if (config->tools.api_default_max_turns > config->tools.api_max_turns_ceiling) {
config->tools.api_default_max_turns = config->tools.api_max_turns_ceiling;
}
if (config->tools.local_http_fetch_default_timeout_seconds < 1) {
config->tools.local_http_fetch_default_timeout_seconds = 20;
}
if (config->tools.local_http_fetch_max_timeout_seconds < 1) {
config->tools.local_http_fetch_max_timeout_seconds = 120;
}
if (config->tools.local_http_fetch_default_timeout_seconds > config->tools.local_http_fetch_max_timeout_seconds) {
config->tools.local_http_fetch_default_timeout_seconds = config->tools.local_http_fetch_max_timeout_seconds;
}
return 0;
}
@@ -548,27 +597,23 @@ static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) {
return 0;
}
const char* slug = NULL;
cJSON* slug_val = find_tag_value_string(tags, "slug");
if (slug_val && cJSON_IsString(slug_val) && slug_val->valuestring && slug_val->valuestring[0] != '\0') {
slug = slug_val->valuestring;
}
const char* d_tag = NULL;
if (!slug) {
if (!d_tag) {
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields");
if (content_fields && cJSON_IsObject(content_fields)) {
cJSON* name = cJSON_GetObjectItemCaseSensitive(content_fields, "name");
if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') {
slug = name->valuestring;
d_tag = name->valuestring;
}
}
}
if (!slug) {
if (!d_tag) {
return 0;
}
return set_tag_value_string(tags, "d", slug);
return set_tag_value_string(tags, "d", d_tag);
}
static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
@@ -750,6 +795,11 @@ int config_load(const char* path, didactyl_config_t* config) {
config->tools.enabled = 1;
config->tools.max_turns = 8;
config->tools.trigger_max_turns = 6;
config->tools.api_default_max_turns = 4;
config->tools.api_max_turns_ceiling = 16;
config->tools.local_http_fetch_default_timeout_seconds = 20;
config->tools.local_http_fetch_max_timeout_seconds = 120;
config->tools.shell.enabled = 1;
config->tools.shell.timeout_seconds = 30;
config->tools.shell.max_output_bytes = 65536;
+5
View File
@@ -47,6 +47,11 @@ typedef struct {
typedef struct {
int enabled;
int max_turns;
int trigger_max_turns;
int api_default_max_turns;
int api_max_turns_ceiling;
int local_http_fetch_default_timeout_seconds;
int local_http_fetch_max_timeout_seconds;
shell_tools_config_t shell;
} tools_config_t;
+34
View File
@@ -1,14 +1,31 @@
#include "debug.h"
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
debug_level_t g_debug_level = DEBUG_LEVEL_NONE;
static FILE* g_debug_file = NULL;
static void debug_open_log_file(void) {
if (g_debug_file) return;
const char* path = getenv("DIDACTYL_LOG_FILE");
if (!path || path[0] == '\0') {
path = "debug.log";
}
g_debug_file = fopen(path, "a");
if (g_debug_file) {
setvbuf(g_debug_file, NULL, _IOLBF, 0);
}
}
void debug_init(int level) {
if (level < 0) level = 0;
if (level > 5) level = 5;
g_debug_level = (debug_level_t)level;
debug_open_log_file();
}
void debug_log(debug_level_t level, const char* file, int line, const char* format, ...) {
@@ -28,11 +45,17 @@ void debug_log(debug_level_t level, const char* file, int line, const char* form
}
printf("[%s] [%s] ", timestamp, level_str);
if (g_debug_file) {
fprintf(g_debug_file, "[%s] [%s] ", timestamp, level_str);
}
if (file && g_debug_level >= DEBUG_LEVEL_TRACE) {
const char* filename = strrchr(file, '/');
filename = filename ? filename + 1 : file;
printf("[%s:%d] ", filename, line);
if (g_debug_file) {
fprintf(g_debug_file, "[%s:%d] ", filename, line);
}
}
va_list args;
@@ -40,6 +63,17 @@ void debug_log(debug_level_t level, const char* file, int line, const char* form
vprintf(format, args);
va_end(args);
if (g_debug_file) {
va_list args_file;
va_start(args_file, format);
vfprintf(g_debug_file, format, args_file);
va_end(args_file);
}
printf("\n");
fflush(stdout);
if (g_debug_file) {
fprintf(g_debug_file, "\n");
fflush(g_debug_file);
}
}
+117 -6
View File
@@ -5,13 +5,14 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "agent.h"
#include "llm.h"
#include "main.h"
#include "nostr_handler.h"
#include "trigger_manager.h"
#include "tools.h"
#include "tools/tools.h"
#include "cjson/cJSON.h"
#include "debug.h"
#include "mongoose.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;
@@ -524,14 +615,14 @@ static char* build_slash_help_all_json_local(void) {
int sn = cJSON_GetArraySize(skills);
for (int i = 0; i < sn; i++) {
cJSON* row = cJSON_GetArrayItem(skills, i);
cJSON* slug = row ? cJSON_GetObjectItemCaseSensitive(row, "slug") : NULL;
cJSON* d_tag = row ? cJSON_GetObjectItemCaseSensitive(row, "d_tag") : NULL;
cJSON* desc = row ? cJSON_GetObjectItemCaseSensitive(row, "description") : NULL;
const char* slug_s = (slug && cJSON_IsString(slug) && slug->valuestring) ? slug->valuestring : NULL;
const char* d_tag_s = (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring) ? d_tag->valuestring : NULL;
const char* desc_s = (desc && cJSON_IsString(desc) && desc->valuestring) ? desc->valuestring : "";
if (!slug_s || slug_s[0] == '\0') {
if (!d_tag_s || d_tag_s[0] == '\0') {
continue;
}
if (append_textf_http(&out, &cap, &used, "- %s%s%s\n", slug_s, desc_s[0] ? "" : "", desc_s) != 0) {
if (append_textf_http(&out, &cap, &used, "- %s%s%s\n", d_tag_s, desc_s[0] ? "" : "", desc_s) != 0) {
cJSON_Delete(skill_root);
free(skill_list_json);
free(out);
@@ -664,8 +755,13 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo,
const char* tool_limit_message) {
if (!convo || !cJSON_IsArray(convo)) return NULL;
int max_ceiling = 16;
if (g_api_ctx.cfg && g_api_ctx.cfg->tools.api_max_turns_ceiling > 0) {
max_ceiling = g_api_ctx.cfg->tools.api_max_turns_ceiling;
}
if (max_turns < 1) max_turns = 1;
if (max_turns > 16) max_turns = 16;
if (max_turns > max_ceiling) max_turns = max_ceiling;
char* tools_json = tools_build_openai_schema_json(g_api_ctx.tools_ctx);
if (!tools_json) {
@@ -797,6 +893,9 @@ static cJSON* run_prompt_with_tools(cJSON* body) {
}
int max_turns = 4;
if (g_api_ctx.cfg && g_api_ctx.cfg->tools.api_default_max_turns > 0) {
max_turns = g_api_ctx.cfg->tools.api_default_max_turns;
}
cJSON* max_turns_node = cJSON_GetObjectItemCaseSensitive(body, "max_turns");
if (max_turns_node && cJSON_IsNumber(max_turns_node)) {
max_turns = (int)max_turns_node->valuedouble;
@@ -1013,6 +1112,9 @@ static void handle_prompt_agent(struct mg_connection* c, const struct mg_http_me
}
int max_turns = 4;
if (g_api_ctx.cfg && g_api_ctx.cfg->tools.api_default_max_turns > 0) {
max_turns = g_api_ctx.cfg->tools.api_default_max_turns;
}
cJSON* max_turns_node = cJSON_GetObjectItemCaseSensitive(body, "max_turns");
if (max_turns_node && cJSON_IsNumber(max_turns_node)) {
max_turns = (int)max_turns_node->valuedouble;
@@ -1202,6 +1304,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");
}
+28 -32
View File
@@ -10,6 +10,7 @@
#include <unistd.h>
#include "cjson/cJSON.h"
#include "debug.h"
typedef struct {
char* data;
@@ -92,11 +93,9 @@ static char* perform_http_request(const char* url, const char* body, int is_post
}
if (url_looks_like_websocket(url)) {
fprintf(stderr,
"[didactyl] llm config error: base_url must be HTTP(S), got WebSocket URL: %s\n",
url);
fprintf(stderr,
"[didactyl] llm hint: set llm.base_url to an OpenAI-compatible HTTPS endpoint, e.g. https://api.example.com/v1\n");
DEBUG_ERROR("[didactyl] llm config error: base_url must be HTTP(S), got WebSocket URL: %s",
url);
DEBUG_WARN("[didactyl] llm hint: set llm.base_url to an OpenAI-compatible HTTPS endpoint, e.g. https://api.example.com/v1");
curl_easy_cleanup(curl);
return NULL;
}
@@ -129,14 +128,13 @@ static char* perform_http_request(const char* url, const char* body, int is_post
if (is_post) {
size_t body_len = body ? strlen(body) : 0U;
size_t body_preview_len = body_len > 4000U ? 4000U : body_len;
fprintf(stderr,
"[didactyl] llm request: method=POST url=%s body_bytes=%zu body_preview=%.4000s%s\n",
url,
body_len,
body ? body : "",
body_len > body_preview_len ? "..." : "");
DEBUG_INFO("[didactyl] llm request: method=POST url=%s body_bytes=%zu body_preview=%.4000s%s",
url,
body_len,
body ? body : "",
body_len > body_preview_len ? "..." : "");
} else {
fprintf(stderr, "[didactyl] llm request: method=GET url=%s\n", url);
DEBUG_INFO("[didactyl] llm request: method=GET url=%s", url);
}
CURLcode res = curl_easy_perform(curl);
@@ -147,33 +145,32 @@ static char* perform_http_request(const char* url, const char* body, int is_post
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
fprintf(stderr, "[didactyl] llm http request failed: curl=%s\n", curl_easy_strerror(res));
DEBUG_ERROR("[didactyl] llm http request failed: curl=%s", curl_easy_strerror(res));
if (rb.data && rb.len > 0) {
fprintf(stderr, "[didactyl] llm partial response: %.600s%s\n",
rb.data,
rb.len > 600 ? "..." : "");
DEBUG_WARN("[didactyl] llm partial response: %.600s%s",
rb.data,
rb.len > 600 ? "..." : "");
}
free(rb.data);
return NULL;
}
if (status < 200 || status >= 300) {
fprintf(stderr, "[didactyl] llm http request failed: status=%ld\n", status);
DEBUG_ERROR("[didactyl] llm http request failed: status=%ld", status);
if (status == 101) {
fprintf(stderr,
"[didactyl] llm hint: received HTTP 101 (Switching Protocols), which usually means llm.base_url points to a WebSocket server instead of an HTTP LLM API\n");
DEBUG_WARN("[didactyl] llm hint: received HTTP 101 (Switching Protocols), which usually means llm.base_url points to a WebSocket server instead of an HTTP LLM API");
}
if (rb.data && rb.len > 0) {
fprintf(stderr, "[didactyl] llm error response: %.1200s%s\n",
rb.data,
rb.len > 1200 ? "..." : "");
DEBUG_WARN("[didactyl] llm error response: %.1200s%s",
rb.data,
rb.len > 1200 ? "..." : "");
}
free(rb.data);
return NULL;
}
if (!rb.data) {
fprintf(stderr, "[didactyl] llm http request failed: empty response body\n");
DEBUG_ERROR("[didactyl] llm http request failed: empty response body");
return NULL;
}
@@ -332,9 +329,9 @@ char* llm_chat(const char* system_prompt, const char* user_message) {
llm_response_t parsed;
if (parse_llm_response(raw, &parsed) != 0) {
fprintf(stderr, "[didactyl] failed to parse llm response (non-tool path): %.1200s%s\n",
raw,
strlen(raw) > 1200 ? "..." : "");
DEBUG_ERROR("[didactyl] failed to parse llm response (non-tool path): %.1200s%s",
raw,
strlen(raw) > 1200 ? "..." : "");
free(raw);
return NULL;
}
@@ -393,9 +390,8 @@ int llm_chat_with_tools_messages(const char* messages_json,
}
if (filtered_count > 0) {
fprintf(stderr,
"[didactyl] llm request sanitizer: removed %d empty text message(s) before provider request\n",
filtered_count);
DEBUG_INFO("[didactyl] llm request sanitizer: removed %d empty text message(s) before provider request",
filtered_count);
}
cJSON_AddItemToObject(root, "messages", messages);
@@ -420,9 +416,9 @@ int llm_chat_with_tools_messages(const char* messages_json,
int rc = parse_llm_response(raw, out_response);
if (rc != 0) {
fprintf(stderr, "[didactyl] failed to parse llm response (tools path): %.1200s%s\n",
raw,
strlen(raw) > 1200 ? "..." : "");
DEBUG_ERROR("[didactyl] failed to parse llm response (tools path): %.1200s%s",
raw,
strlen(raw) > 1200 ? "..." : "");
}
free(raw);
return rc;
+51 -3
View File
@@ -14,12 +14,43 @@
#include "nostr_handler.h"
#include "debug.h"
#include "trigger_manager.h"
#include "tools.h"
#include "tools/tools.h"
#include "cjson/cJSON.h"
#include "http_api.h"
static volatile sig_atomic_t g_running = 1;
typedef struct {
trigger_manager_t* trigger_manager;
int loaded_once;
} deferred_trigger_load_ctx_t;
static void on_self_skill_eose_load_triggers(int event_count, void* user_data) {
deferred_trigger_load_ctx_t* ctx = (deferred_trigger_load_ctx_t*)user_data;
if (!ctx || !ctx->trigger_manager) {
return;
}
if (ctx->loaded_once) {
DEBUG_TRACE("[didactyl] deferred trigger load skipped (already loaded once) event_count=%d", event_count);
return;
}
DEBUG_INFO("[didactyl] deferred trigger load begin after self-skill EOSE (event_count=%d)", event_count);
if (trigger_manager_load_from_skills(ctx->trigger_manager) != 0) {
DEBUG_WARN("[didactyl] deferred trigger load failed after self-skill EOSE");
return;
}
ctx->loaded_once = 1;
char* status = trigger_manager_status_json(ctx->trigger_manager);
if (status) {
DEBUG_INFO("[didactyl] deferred trigger load status: %s", status);
free(status);
}
}
static void signal_handler(int signum) {
(void)signum;
g_running = 0;
@@ -231,10 +262,21 @@ int main(int argc, char** argv) {
return 1;
}
agent_set_trigger_manager(&trigger_manager);
nostr_handler_set_trigger_manager(&trigger_manager);
if (trigger_manager_load_from_skills(&trigger_manager) != 0) {
DEBUG_WARN("[didactyl] startup phase: trigger manager load from skills failed (continuing)");
deferred_trigger_load_ctx_t deferred_trigger_ctx = {
.trigger_manager = &trigger_manager,
.loaded_once = 0
};
nostr_handler_set_self_skill_eose_callback(on_self_skill_eose_load_triggers, &deferred_trigger_ctx);
DEBUG_INFO("[didactyl] startup phase: deferred trigger load will run after self-skill EOSE");
DEBUG_INFO("[didactyl] startup phase: startup-config trigger load begin");
if (trigger_manager_load_from_startup_events(&trigger_manager) != 0) {
DEBUG_WARN("[didactyl] startup phase: startup-config trigger load failed (continuing)");
}
DEBUG_INFO("[didactyl] startup phase: startup-config trigger load end");
DEBUG_INFO("[didactyl] startup phase: subscribe admin context begin");
if (nostr_handler_subscribe_admin_context() != 0) {
@@ -242,6 +284,12 @@ int main(int argc, char** argv) {
}
DEBUG_INFO("[didactyl] startup phase: subscribe admin context end");
DEBUG_INFO("[didactyl] startup phase: subscribe agent self context begin");
if (nostr_handler_subscribe_agent_context() != 0) {
DEBUG_WARN("[didactyl] startup phase: subscribe agent self context failed (continuing)");
}
DEBUG_INFO("[didactyl] startup phase: subscribe agent self context end");
DEBUG_INFO("[didactyl] startup phase: subscribe self skill cache begin");
if (nostr_handler_subscribe_self_skills() != 0) {
DEBUG_WARN("[didactyl] startup phase: subscribe self skill cache failed (continuing)");
+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 52
#define DIDACTYL_VERSION "v0.0.52"
#define DIDACTYL_VERSION_PATCH 71
#define DIDACTYL_VERSION "v0.0.71"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+636 -1
View File
@@ -11,6 +11,7 @@
#include "../../nostr_core_lib/cjson/cJSON.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
#include "debug.h"
#include "trigger_manager.h"
#define NIP17_MAX_RELAYS 32
#define NIP17_MAX_GIFT_WRAPS 8
@@ -41,6 +42,13 @@ typedef struct {
static admin_kind1_note_t* g_admin_kind1_notes = NULL;
static int g_admin_kind1_note_count = 0;
static char* g_agent_kind0_json = NULL;
static char* g_agent_kind10002_json = NULL;
static char** g_agent_contacts = NULL;
static int g_agent_contact_count = 0;
static admin_kind1_note_t* g_agent_kind1_notes = NULL;
static int g_agent_kind1_note_count = 0;
static pthread_mutex_t g_admin_ctx_mutex = PTHREAD_MUTEX_INITIALIZER;
static cJSON* g_self_skill_events = NULL;
@@ -48,6 +56,11 @@ static pthread_mutex_t g_self_skill_mutex = PTHREAD_MUTEX_INITIALIZER;
#define DM_DEDUP_CACHE_SIZE 256
static nostr_self_skill_eose_cb_t g_self_skill_eose_cb = NULL;
static void* g_self_skill_eose_user_data = NULL;
static struct trigger_manager* g_trigger_manager = NULL;
static char g_seen_dm_ids[DM_DEDUP_CACHE_SIZE][65];
static int g_seen_dm_count = 0;
static int g_seen_dm_next = 0;
@@ -57,6 +70,58 @@ static pthread_mutex_t g_dm_dedup_mutex = PTHREAD_MUTEX_INITIALIZER;
#define SENDER_PROTOCOL_CACHE_SIZE 128
#define DM_HISTORY_RING_SIZE 256
static int didactyl_debug_to_nostr_log_level(void) {
switch (g_debug_level) {
case DEBUG_LEVEL_TRACE: return NOSTR_LOG_LEVEL_TRACE;
case DEBUG_LEVEL_DEBUG: return NOSTR_LOG_LEVEL_DEBUG;
case DEBUG_LEVEL_INFO: return NOSTR_LOG_LEVEL_INFO;
case DEBUG_LEVEL_WARN: return NOSTR_LOG_LEVEL_WARN;
case DEBUG_LEVEL_ERROR: return NOSTR_LOG_LEVEL_ERROR;
case DEBUG_LEVEL_NONE:
default:
return NOSTR_LOG_LEVEL_ERROR;
}
}
static debug_level_t nostr_log_to_didactyl_level(int level) {
switch (level) {
case NOSTR_LOG_LEVEL_TRACE: return DEBUG_LEVEL_TRACE;
case NOSTR_LOG_LEVEL_DEBUG: return DEBUG_LEVEL_DEBUG;
case NOSTR_LOG_LEVEL_INFO: return DEBUG_LEVEL_INFO;
case NOSTR_LOG_LEVEL_WARN: return DEBUG_LEVEL_WARN;
case NOSTR_LOG_LEVEL_ERROR:
default:
return DEBUG_LEVEL_ERROR;
}
}
static void nostr_core_log_bridge(int level, const char* component, const char* message, void* user_data) {
(void)user_data;
debug_level_t mapped = nostr_log_to_didactyl_level(level);
const char* comp = (component && component[0] != '\0') ? component : "core";
const char* msg = message ? message : "";
switch (mapped) {
case DEBUG_LEVEL_TRACE:
DEBUG_TRACE("[didactyl] [nostr:%s] %s", comp, msg);
break;
case DEBUG_LEVEL_DEBUG:
DEBUG_LOG("[didactyl] [nostr:%s] %s", comp, msg);
break;
case DEBUG_LEVEL_INFO:
DEBUG_INFO("[didactyl] [nostr:%s] %s", comp, msg);
break;
case DEBUG_LEVEL_WARN:
DEBUG_WARN("[didactyl] [nostr:%s] %s", comp, msg);
break;
case DEBUG_LEVEL_ERROR:
default:
DEBUG_ERROR("[didactyl] [nostr:%s] %s", comp, msg);
break;
}
}
typedef struct {
char pubkey_hex[65];
dm_protocol_t protocol;
@@ -263,9 +328,15 @@ static void on_admin_context_event(cJSON* event, const char* relay_url, void* us
static int parse_kind3_wot_contacts(cJSON* tags);
static int parse_kind10002_relays(cJSON* tags);
static void upsert_kind1_note(time_t created_at, const char* content);
static void free_agent_context_locked(void);
static int parse_kind3_agent_contacts(cJSON* tags);
static int parse_kind10002_agent_relays(cJSON* tags);
static void upsert_agent_kind1_note(time_t created_at, const char* content);
static void on_agent_context_event(cJSON* event, const char* relay_url, void* user_data);
static int startup_self_kind1_exists(void);
static void load_startup_display_name(void);
static void build_startup_kind1_content(char* out, size_t out_size, const char* fallback);
static void register_trigger_from_self_skill_event(cJSON* event);
static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_data);
static void dm_history_clear_locked(void);
static void dm_history_remember(const char* peer_pubkey_hex, const char* content, int incoming, time_t created_at);
@@ -442,6 +513,136 @@ static const char* find_tag_value_local(cJSON* tags, const char* key) {
return NULL;
}
static int parse_enabled_flag_local(const char* enabled_s) {
if (!enabled_s || enabled_s[0] == '\0') {
return 1;
}
return (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
}
static int self_skill_is_adopted_local(int kind, const char* pubkey_hex, const char* d_tag) {
if ((kind != 31123 && kind != 31124) || !pubkey_hex || !d_tag || d_tag[0] == '\0') {
return 0;
}
char addr[256];
snprintf(addr, sizeof(addr), "%d:%s:%s", kind, pubkey_hex, d_tag);
int adopted = 0;
pthread_mutex_lock(&g_self_skill_mutex);
if (g_self_skill_events && cJSON_IsArray(g_self_skill_events)) {
int n = cJSON_GetArraySize(g_self_skill_events);
for (int i = 0; i < n && !adopted; i++) {
cJSON* ev = cJSON_GetArrayItem(g_self_skill_events, i);
cJSON* ev_kind = ev ? cJSON_GetObjectItemCaseSensitive(ev, "kind") : NULL;
cJSON* ev_pubkey = ev ? cJSON_GetObjectItemCaseSensitive(ev, "pubkey") : NULL;
cJSON* ev_tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL;
if (!ev_kind || !cJSON_IsNumber(ev_kind) || (int)ev_kind->valuedouble != 10123 ||
!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
strcmp(ev_pubkey->valuestring, pubkey_hex) != 0 ||
!ev_tags || !cJSON_IsArray(ev_tags)) {
continue;
}
int tag_count = cJSON_GetArraySize(ev_tags);
for (int ti = 0; ti < tag_count; ti++) {
cJSON* tag = cJSON_GetArrayItem(ev_tags, ti);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (!k || !v || !cJSON_IsString(k) || !k->valuestring ||
!cJSON_IsString(v) || !v->valuestring) {
continue;
}
if (strcmp(k->valuestring, "a") == 0 && strcmp(v->valuestring, addr) == 0) {
adopted = 1;
break;
}
}
}
}
pthread_mutex_unlock(&g_self_skill_mutex);
return adopted;
}
static void register_trigger_from_self_skill_event(cJSON* event) {
if (!event || !g_cfg || !g_trigger_manager) {
return;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!kind || !cJSON_IsNumber(kind) ||
!pubkey || !cJSON_IsString(pubkey) || !pubkey->valuestring ||
!content || !cJSON_IsString(content) || !content->valuestring ||
!tags || !cJSON_IsArray(tags)) {
return;
}
int kind_val = (int)kind->valuedouble;
if (kind_val != 31123 && kind_val != 31124) {
return;
}
if (strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) != 0) {
return;
}
const char* d_tag = find_tag_value_local(tags, "d");
const char* trigger = find_tag_value_local(tags, "trigger");
const char* filter = find_tag_value_local(tags, "filter");
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_supported ||
!filter || filter[0] == '\0') {
return;
}
if (!self_skill_is_adopted_local(kind_val, pubkey->valuestring, d_tag)) {
DEBUG_INFO("[didactyl] live self-skill trigger ignored (not adopted) d_tag=%s", d_tag);
return;
}
if (action && strcmp(action, "template") == 0) {
DEBUG_WARN("[didactyl] live self-skill trigger action template is deprecated; forcing llm for d_tag=%s", d_tag);
}
trigger_action_type_t action_type = TRIGGER_ACTION_LLM;
int is_enabled = parse_enabled_flag_local(enabled);
int rc = trigger_manager_add((trigger_manager_t*)g_trigger_manager,
d_tag,
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",
d_tag,
"llm",
is_enabled);
} else {
DEBUG_WARN("[didactyl] live self-skill trigger register failed d_tag=%s", d_tag);
}
}
static void self_skill_cache_upsert_event_locked(cJSON* event) {
if (!event || !cJSON_IsObject(event)) {
return;
@@ -942,6 +1143,16 @@ static void on_eose(cJSON** events, int event_count, void* user_data) {
DEBUG_TRACE("[didactyl] DEBUG on_eose called: event_count=%d", event_count);
}
static void on_self_skill_eose(cJSON** events, int event_count, void* user_data) {
(void)events;
(void)user_data;
DEBUG_INFO("[didactyl] self-skill EOSE received: cached skill events=%d", event_count);
if (g_self_skill_eose_cb) {
g_self_skill_eose_cb(event_count, g_self_skill_eose_user_data);
}
}
static void free_admin_context_locked(void) {
free(g_admin_kind0_json);
g_admin_kind0_json = NULL;
@@ -967,6 +1178,31 @@ static void free_admin_context_locked(void) {
g_admin_kind1_note_count = 0;
}
static void free_agent_context_locked(void) {
free(g_agent_kind0_json);
g_agent_kind0_json = NULL;
free(g_agent_kind10002_json);
g_agent_kind10002_json = NULL;
if (g_agent_contacts) {
for (int i = 0; i < g_agent_contact_count; i++) {
free(g_agent_contacts[i]);
}
free(g_agent_contacts);
}
g_agent_contacts = NULL;
g_agent_contact_count = 0;
if (g_agent_kind1_notes) {
for (int i = 0; i < g_agent_kind1_note_count; i++) {
free(g_agent_kind1_notes[i].content);
}
free(g_agent_kind1_notes);
}
g_agent_kind1_notes = NULL;
g_agent_kind1_note_count = 0;
}
static int parse_kind3_wot_contacts(cJSON* tags) {
if (!tags || !cJSON_IsArray(tags)) {
return 0;
@@ -1095,6 +1331,134 @@ static void upsert_kind1_note(time_t created_at, const char* content) {
}
}
static int parse_kind3_agent_contacts(cJSON* tags) {
if (!tags || !cJSON_IsArray(tags)) {
return 0;
}
if (g_agent_contacts) {
for (int i = 0; i < g_agent_contact_count; i++) {
free(g_agent_contacts[i]);
}
free(g_agent_contacts);
g_agent_contacts = NULL;
g_agent_contact_count = 0;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, "p") != 0 || strlen(val->valuestring) != 64U) {
continue;
}
char* dup = strdup(val->valuestring);
if (!dup) {
return -1;
}
char** grown = (char**)realloc(g_agent_contacts, (size_t)(g_agent_contact_count + 1) * sizeof(char*));
if (!grown) {
free(dup);
return -1;
}
g_agent_contacts = grown;
g_agent_contacts[g_agent_contact_count++] = dup;
}
return 0;
}
static int parse_kind10002_agent_relays(cJSON* tags) {
if (!tags || !cJSON_IsArray(tags)) {
free(g_agent_kind10002_json);
g_agent_kind10002_json = strdup("[]");
return g_agent_kind10002_json ? 0 : -1;
}
cJSON* relays = cJSON_CreateArray();
if (!relays) {
return -1;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, "r") != 0 || val->valuestring[0] == '\0') {
continue;
}
cJSON_AddItemToArray(relays, cJSON_CreateString(val->valuestring));
}
char* relays_json = cJSON_PrintUnformatted(relays);
cJSON_Delete(relays);
if (!relays_json) {
return -1;
}
free(g_agent_kind10002_json);
g_agent_kind10002_json = relays_json;
return 0;
}
static void upsert_agent_kind1_note(time_t created_at, const char* content) {
if (!content) {
return;
}
int limit = g_cfg && g_cfg->admin_context.kind_1_limit > 0 ? g_cfg->admin_context.kind_1_limit : 10;
if (limit > 256) {
limit = 256;
}
char* dup = strdup(content);
if (!dup) {
return;
}
admin_kind1_note_t* grown = (admin_kind1_note_t*)realloc(g_agent_kind1_notes,
(size_t)(g_agent_kind1_note_count + 1) * sizeof(admin_kind1_note_t));
if (!grown) {
free(dup);
return;
}
g_agent_kind1_notes = grown;
g_agent_kind1_notes[g_agent_kind1_note_count].created_at = created_at;
g_agent_kind1_notes[g_agent_kind1_note_count].content = dup;
g_agent_kind1_note_count++;
while (g_agent_kind1_note_count > limit) {
free(g_agent_kind1_notes[0].content);
memmove(&g_agent_kind1_notes[0],
&g_agent_kind1_notes[1],
(size_t)(g_agent_kind1_note_count - 1) * sizeof(admin_kind1_note_t));
g_agent_kind1_note_count--;
}
}
static void on_admin_context_event(cJSON* event, const char* relay_url, void* user_data) {
(void)relay_url;
(void)user_data;
@@ -1140,6 +1504,51 @@ static void on_admin_context_event(cJSON* event, const char* relay_url, void* us
pthread_mutex_unlock(&g_admin_ctx_mutex);
}
static void on_agent_context_event(cJSON* event, const char* relay_url, void* user_data) {
(void)relay_url;
(void)user_data;
if (!event || !g_cfg) {
return;
}
if (g_cfg->security.verify_signatures && nostr_verify_event_signature(event) != 0) {
return;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!kind || !pubkey || !cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) || !pubkey->valuestring) {
return;
}
if (strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) != 0) {
return;
}
int k = (int)kind->valuedouble;
pthread_mutex_lock(&g_admin_ctx_mutex);
if (k == 0 && content && cJSON_IsString(content) && content->valuestring) {
free(g_agent_kind0_json);
g_agent_kind0_json = strdup(content->valuestring);
} else if (k == 3 && tags && cJSON_IsArray(tags)) {
(void)parse_kind3_agent_contacts(tags);
} else if (k == 10002 && tags && cJSON_IsArray(tags)) {
(void)parse_kind10002_agent_relays(tags);
} else if (k == 1 && content && cJSON_IsString(content) && content->valuestring) {
time_t ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : time(NULL);
upsert_agent_kind1_note(ts, content->valuestring);
}
pthread_mutex_unlock(&g_admin_ctx_mutex);
}
static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_data) {
(void)relay_url;
(void)user_data;
@@ -1152,6 +1561,8 @@ static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_
return;
}
register_trigger_from_self_skill_event(event);
pthread_mutex_lock(&g_self_skill_mutex);
self_skill_cache_upsert_event_locked(event);
pthread_mutex_unlock(&g_self_skill_mutex);
@@ -1175,6 +1586,9 @@ int nostr_handler_init(didactyl_config_t* config) {
DEBUG_INFO("[didactyl] initializing relay pool with %d relays", g_cfg->relay_count);
nostr_set_log_callback(nostr_core_log_bridge, NULL);
nostr_set_log_level((nostr_log_level_t)didactyl_debug_to_nostr_log_level());
nostr_pool_reconnect_config_t reconnect = *nostr_pool_reconnect_config_default();
reconnect.enable_auto_reconnect = 1;
reconnect.ping_interval_seconds = 20;
@@ -1320,6 +1734,100 @@ int nostr_handler_subscribe_admin_context(void) {
return rc;
}
int nostr_handler_subscribe_agent_context(void) {
if (!g_cfg || !g_pool) {
return -1;
}
int rc = 0;
cJSON* profile_filter = cJSON_CreateObject();
cJSON* profile_kinds = cJSON_CreateArray();
cJSON* profile_authors = cJSON_CreateArray();
if (!profile_filter || !profile_kinds || !profile_authors) {
cJSON_Delete(profile_filter);
cJSON_Delete(profile_kinds);
cJSON_Delete(profile_authors);
return -1;
}
cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(0));
cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(3));
cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToObject(profile_filter, "kinds", profile_kinds);
cJSON_AddItemToArray(profile_authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(profile_filter, "authors", profile_authors);
cJSON_AddNumberToObject(profile_filter, "limit", 64);
nostr_pool_subscription_t* profile_sub = nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
profile_filter,
on_agent_context_event,
on_eose,
NULL,
0,
1,
NOSTR_POOL_EOSE_FULL_SET,
30,
120);
cJSON_Delete(profile_filter);
if (!profile_sub) {
rc = -1;
}
cJSON* notes_filter = cJSON_CreateObject();
cJSON* notes_kinds = cJSON_CreateArray();
cJSON* notes_authors = cJSON_CreateArray();
if (!notes_filter || !notes_kinds || !notes_authors) {
cJSON_Delete(notes_filter);
cJSON_Delete(notes_kinds);
cJSON_Delete(notes_authors);
return -1;
}
int kind1_limit = g_cfg->admin_context.kind_1_limit > 0 ? g_cfg->admin_context.kind_1_limit : 10;
if (kind1_limit > 256) {
kind1_limit = 256;
}
cJSON_AddItemToArray(notes_kinds, cJSON_CreateNumber(1));
cJSON_AddItemToObject(notes_filter, "kinds", notes_kinds);
cJSON_AddItemToArray(notes_authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(notes_filter, "authors", notes_authors);
cJSON_AddNumberToObject(notes_filter, "limit", kind1_limit);
nostr_pool_subscription_t* notes_sub = nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
notes_filter,
on_agent_context_event,
on_eose,
NULL,
0,
1,
NOSTR_POOL_EOSE_FULL_SET,
30,
120);
cJSON_Delete(notes_filter);
if (!notes_sub) {
rc = -1;
}
if (rc == 0) {
DEBUG_INFO("[didactyl] agent self-context subscriptions active for pubkey %.16s...", g_cfg->keys.public_key_hex);
}
return rc;
}
void nostr_handler_set_trigger_manager(struct trigger_manager* trigger_manager) {
g_trigger_manager = trigger_manager;
}
int nostr_handler_subscribe_self_skills(void) {
if (!g_cfg || !g_pool) {
return -1;
@@ -1349,7 +1857,7 @@ int nostr_handler_subscribe_self_skills(void) {
g_cfg->relay_count,
filter,
on_self_skill_event,
on_eose,
on_self_skill_eose,
NULL,
0,
0,
@@ -1493,6 +2001,11 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
return 0;
}
void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data) {
g_self_skill_eose_cb = callback;
g_self_skill_eose_user_data = user_data;
}
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message) {
if (!g_cfg || !g_pool || !recipient_pubkey_hex || !message) {
return -1;
@@ -1755,6 +2268,42 @@ char* nostr_handler_query_json(cJSON* filter, int timeout_ms) {
return out;
}
nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
cJSON* filter,
void (*on_event)(cJSON* event, const char* relay_url, void* user_data),
void (*on_eose)(cJSON** events, int event_count, void* user_data),
void* user_data,
int close_on_eose,
int enable_deduplication,
nostr_pool_eose_result_mode_t result_mode,
int relay_timeout_seconds,
int eose_timeout_seconds) {
if (!g_cfg || !g_pool || !filter || !on_event) {
return NULL;
}
return nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
filter,
on_event,
on_eose,
user_data,
close_on_eose,
enable_deduplication,
result_mode,
relay_timeout_seconds,
eose_timeout_seconds);
}
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription) {
if (!subscription) {
return 0;
}
return nostr_pool_subscription_close(subscription);
}
static void publish_pending_startup_events_for_relay_index(int relay_index, const char* reason) {
if (!g_cfg || !g_pool || !g_startup_publish_tracking_enabled || !g_startup_published) {
return;
@@ -2409,6 +2958,89 @@ char* nostr_handler_get_admin_kind1_notes_context(void) {
return out;
}
char* nostr_handler_get_agent_kind0_context(void) {
if (!g_cfg) {
return NULL;
}
pthread_mutex_lock(&g_admin_ctx_mutex);
char* out = g_agent_kind0_json ? strdup(g_agent_kind0_json) : NULL;
pthread_mutex_unlock(&g_admin_ctx_mutex);
return out;
}
char* nostr_handler_get_agent_kind3_context(void) {
if (!g_cfg) {
return NULL;
}
pthread_mutex_lock(&g_admin_ctx_mutex);
cJSON* out = cJSON_CreateArray();
if (!out) {
pthread_mutex_unlock(&g_admin_ctx_mutex);
return NULL;
}
for (int i = 0; i < g_agent_contact_count; i++) {
const char* pk = g_agent_contacts[i];
if (!pk || pk[0] == '\0') continue;
cJSON_AddItemToArray(out, cJSON_CreateString(pk));
}
pthread_mutex_unlock(&g_admin_ctx_mutex);
char* out_json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return out_json;
}
char* nostr_handler_get_agent_kind10002_context(void) {
if (!g_cfg) {
return NULL;
}
pthread_mutex_lock(&g_admin_ctx_mutex);
char* out = g_agent_kind10002_json ? strdup(g_agent_kind10002_json) : NULL;
pthread_mutex_unlock(&g_admin_ctx_mutex);
return out;
}
char* nostr_handler_get_agent_kind1_notes_context(void) {
if (!g_cfg) {
return NULL;
}
pthread_mutex_lock(&g_admin_ctx_mutex);
if (g_agent_kind1_note_count <= 0 || !g_agent_kind1_notes) {
pthread_mutex_unlock(&g_admin_ctx_mutex);
return NULL;
}
size_t total = strlen("Agent recent public notes:\n") + 1U;
for (int i = 0; i < g_agent_kind1_note_count; i++) {
total += strlen("- ") + strlen(g_agent_kind1_notes[i].content ? g_agent_kind1_notes[i].content : "") + 1U;
}
char* out = (char*)malloc(total);
if (!out) {
pthread_mutex_unlock(&g_admin_ctx_mutex);
return NULL;
}
out[0] = '\0';
strcat(out, "Agent recent public notes:\n");
for (int i = 0; i < g_agent_kind1_note_count; i++) {
strcat(out, "- ");
strcat(out, g_agent_kind1_notes[i].content ? g_agent_kind1_notes[i].content : "");
strcat(out, "\n");
}
pthread_mutex_unlock(&g_admin_ctx_mutex);
return out;
}
int nostr_handler_is_wot_contact(const char* pubkey_hex) {
if (!pubkey_hex || strlen(pubkey_hex) != 64U) {
return 0;
@@ -2473,6 +3105,8 @@ void nostr_handler_cleanup(void) {
nostr_relay_pool_destroy(g_pool);
}
nostr_set_log_callback(NULL, NULL);
free(g_last_relay_statuses);
g_last_relay_statuses = NULL;
free(g_startup_published);
@@ -2501,6 +3135,7 @@ void nostr_handler_cleanup(void) {
pthread_mutex_lock(&g_admin_ctx_mutex);
free_admin_context_locked();
free_agent_context_locked();
pthread_mutex_unlock(&g_admin_ctx_mutex);
pthread_mutex_lock(&g_self_skill_mutex);
+23
View File
@@ -3,6 +3,9 @@
#include "config.h"
#include "cjson/cJSON.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
struct trigger_manager;
typedef enum {
DIDACTYL_SENDER_ADMIN = 1,
@@ -27,16 +30,32 @@ typedef struct {
char** relays;
} nostr_publish_result_t;
typedef void (*nostr_self_skill_eose_cb_t)(int event_count, void* user_data);
int nostr_handler_init(didactyl_config_t* config);
void nostr_handler_set_trigger_manager(struct trigger_manager* trigger_manager);
int nostr_handler_subscribe_admin_context(void);
int nostr_handler_subscribe_agent_context(void);
int nostr_handler_subscribe_self_skills(void);
char* nostr_handler_get_self_events_by_kind_json(int kind);
void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data);
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result);
void nostr_handler_publish_result_free(nostr_publish_result_t* result);
char* nostr_handler_query_json(cJSON* filter, int timeout_ms);
nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
cJSON* filter,
void (*on_event)(cJSON* event, const char* relay_url, void* user_data),
void (*on_eose)(cJSON** events, int event_count, void* user_data),
void* user_data,
int close_on_eose,
int enable_deduplication,
nostr_pool_eose_result_mode_t result_mode,
int relay_timeout_seconds,
int eose_timeout_seconds);
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription);
int nostr_handler_poll(int timeout_ms);
int nostr_handler_reconcile_startup_events(void);
void nostr_handler_refresh_relay_statuses(void);
@@ -48,6 +67,10 @@ char* nostr_handler_get_admin_kind0_context(void);
char* nostr_handler_get_admin_kind3_context(void);
char* nostr_handler_get_admin_kind10002_context(void);
char* nostr_handler_get_admin_kind1_notes_context(void);
char* nostr_handler_get_agent_kind0_context(void);
char* nostr_handler_get_agent_kind3_context(void);
char* nostr_handler_get_agent_kind10002_context(void);
char* nostr_handler_get_agent_kind1_notes_context(void);
int nostr_handler_is_wot_contact(const char* pubkey_hex);
char* nostr_handler_relay_status_json(void);
char* nostr_handler_relay_info_json(const char* relay_url);
-5687
View File
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../nostr_handler.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
char* execute_admin_identity(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
const char* tier_text = "Sender tier unknown.";
if (ctx->template_sender_tier == 1) {
tier_text = "This message has been cryptographically verified as coming from your administrator.";
} else if (ctx->template_sender_tier == 2) {
tier_text = "This message is from a web-of-trust contact (not the administrator).";
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_pubkey", ctx->cfg->admin.pubkey[0] ? ctx->cfg->admin.pubkey : "unknown");
cJSON_AddStringToObject(out, "sender_verification", tier_text);
char content[768];
snprintf(content,
sizeof(content),
"## Administrator Identity (source: config.admin.pubkey)\n\n"
"This is your administrator! Admin pubkey (hex): %s\n\n"
"%s",
ctx->cfg->admin.pubkey[0] ? ctx->cfg->admin.pubkey : "unknown",
tier_text);
cJSON_AddStringToObject(out, "content", content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_nostr_admin_profile(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char* kind0 = nostr_handler_get_admin_kind0_context();
const char* profile_json = (kind0 && kind0[0]) ? kind0 : "{}";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(kind0);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_kind0_json", profile_json);
size_t content_len = strlen("## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): ") + strlen(profile_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind0);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): %s",
profile_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(kind0);
return json;
}
char* execute_nostr_admin_contacts(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char* kind3 = nostr_handler_get_admin_kind3_context();
const char* contacts_json = (kind3 && kind3[0]) ? kind3 : "[]";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(kind3);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_kind3_json", contacts_json);
size_t content_len = strlen("## Administrator Kind 3 Contacts (source: nostr kind 3)\n\nAdministrator contacts (JSON): ") + strlen(contacts_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind3);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Kind 3 Contacts (source: nostr kind 3)\n\nAdministrator contacts (JSON): %s",
contacts_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(kind3);
return json;
}
char* execute_nostr_admin_relays(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char* kind10002 = nostr_handler_get_admin_kind10002_context();
const char* relays_json = (kind10002 && kind10002[0]) ? kind10002 : "[]";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(kind10002);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_kind10002_json", relays_json);
size_t content_len = strlen("## Administrator Kind 10002 Relays (source: nostr kind 10002)\n\nAdministrator relay list (JSON): ") + strlen(relays_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind10002);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Kind 10002 Relays (source: nostr kind 10002)\n\nAdministrator relay list (JSON): %s",
relays_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(kind10002);
return json;
}
char* execute_nostr_admin_notes(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char* notes = nostr_handler_get_admin_kind1_notes_context();
const char* notes_text = (notes && notes[0]) ? notes : "";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(notes);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_notes_content", notes_text);
size_t content_len = strlen("## Administrator Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(notes);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Recent Kind 1 Notes\n\n%s",
notes_text);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(notes);
return json;
}
+257
View File
@@ -0,0 +1,257 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../main.h"
#include "../nostr_handler.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
char* execute_message_current(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", ctx->template_current_user_message ? ctx->template_current_user_message : "");
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_agent_identity(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char npub[128] = {0};
if (nostr_key_to_bech32(ctx->cfg->keys.public_key, "npub", npub) != NOSTR_SUCCESS) {
strcpy(npub, "unknown");
}
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_pubkey", ctx->cfg->keys.public_key_hex[0] ? ctx->cfg->keys.public_key_hex : "unknown");
cJSON_AddStringToObject(out, "agent_npub", npub);
char content[384];
snprintf(content,
sizeof(content),
"## Agent Identity\n\nYour pubkey (hex): %s\nYour npub: %s",
ctx->cfg->keys.public_key_hex[0] ? ctx->cfg->keys.public_key_hex : "unknown",
npub);
cJSON_AddStringToObject(out, "content", content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_nostr_agent_profile(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char* kind0 = nostr_handler_get_agent_kind0_context();
const char* profile_json = (kind0 && kind0[0]) ? kind0 : "{}";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(kind0);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_kind0_json", profile_json);
size_t content_len = strlen("## Agent Kind 0 Profile (source: nostr kind 0)\n\nAgent kind 0 profile content (JSON): ") + strlen(profile_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind0);
return NULL;
}
snprintf(content,
content_len,
"## Agent Kind 0 Profile (source: nostr kind 0)\n\nAgent kind 0 profile content (JSON): %s",
profile_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(kind0);
return json;
}
char* execute_nostr_agent_contacts(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char* kind3 = nostr_handler_get_agent_kind3_context();
const char* contacts_json = (kind3 && kind3[0]) ? kind3 : "[]";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(kind3);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_kind3_json", contacts_json);
size_t content_len = strlen("## Agent Kind 3 Contacts (source: nostr kind 3)\n\nAgent contacts (JSON): ") + strlen(contacts_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind3);
return NULL;
}
snprintf(content,
content_len,
"## Agent Kind 3 Contacts (source: nostr kind 3)\n\nAgent contacts (JSON): %s",
contacts_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(kind3);
return json;
}
char* execute_nostr_agent_relays(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char* kind10002 = nostr_handler_get_agent_kind10002_context();
const char* relays_json = (kind10002 && kind10002[0]) ? kind10002 : "[]";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(kind10002);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_kind10002_json", relays_json);
size_t content_len = strlen("## Agent Kind 10002 Relays (source: nostr kind 10002)\n\nAgent relay list (JSON): ") + strlen(relays_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind10002);
return NULL;
}
snprintf(content,
content_len,
"## Agent Kind 10002 Relays (source: nostr kind 10002)\n\nAgent relay list (JSON): %s",
relays_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(kind10002);
return json;
}
char* execute_nostr_agent_notes(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char* notes = nostr_handler_get_agent_kind1_notes_context();
const char* notes_text = (notes && notes[0]) ? notes : "";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(notes);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_notes_content", notes_text);
size_t content_len = strlen("## Agent Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(notes);
return NULL;
}
snprintf(content,
content_len,
"## Agent Recent Kind 1 Notes\n\n%s",
notes_text);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(notes);
return json;
}
char* execute_agent_version(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "name", DIDACTYL_NAME);
cJSON_AddStringToObject(out, "description", DIDACTYL_DESCRIPTION);
cJSON_AddStringToObject(out, "software", DIDACTYL_SOFTWARE);
cJSON_AddStringToObject(out, "version", DIDACTYL_VERSION);
cJSON_AddNumberToObject(out, "version_major", DIDACTYL_VERSION_MAJOR);
cJSON_AddNumberToObject(out, "version_minor", DIDACTYL_VERSION_MINOR);
cJSON_AddNumberToObject(out, "version_patch", DIDACTYL_VERSION_PATCH);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
+515
View File
@@ -0,0 +1,515 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <curl/curl.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/wait.h>
#include <unistd.h>
#include "cjson/cJSON.h"
typedef struct {
char* data;
size_t len;
size_t cap;
size_t max_bytes;
int truncated;
} local_http_fetch_buffer_t;
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static int is_safe_relative_path_local(const char* path) {
if (!path || path[0] == '\0') return 0;
if (path[0] == '/') return 0;
if (strstr(path, "..") != NULL) return 0;
if (strchr(path, '\\') != NULL) return 0;
return 1;
}
static int build_tool_path_local(tools_context_t* ctx, const char* rel_path, char* out, size_t out_size) {
if (!ctx || !ctx->cfg || !rel_path || !out || out_size == 0) return -1;
if (!is_safe_relative_path_local(rel_path)) return -1;
const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0'
? ctx->cfg->tools.shell.working_directory
: ".";
int n = 0;
if (strcmp(cwd, ".") == 0) {
n = snprintf(out, out_size, "%s", rel_path);
} else {
n = snprintf(out, out_size, "%s/%s", cwd, rel_path);
}
if (n < 0 || (size_t)n >= out_size) return -1;
return 0;
}
static char* shell_quote_single_local(const char* in) {
if (!in) return NULL;
size_t len = strlen(in);
size_t extra = 2U;
for (size_t i = 0; i < len; i++) {
if (in[i] == '\'') {
extra += 4U;
} else {
extra += 1U;
}
}
char* out = (char*)malloc(extra + 1U);
if (!out) return NULL;
size_t j = 0;
out[j++] = '\'';
for (size_t i = 0; i < len; i++) {
if (in[i] == '\'') {
out[j++] = '\'';
out[j++] = '\\';
out[j++] = '\'';
out[j++] = '\'';
} else {
out[j++] = in[i];
}
}
out[j++] = '\'';
out[j] = '\0';
return out;
}
static size_t local_http_fetch_write_cb_local(void* contents, size_t size, size_t nmemb, void* userp) {
local_http_fetch_buffer_t* rb = (local_http_fetch_buffer_t*)userp;
size_t total = size * nmemb;
if (!rb || total == 0) return total;
if (rb->len >= rb->max_bytes) {
rb->truncated = 1;
return total;
}
size_t allowed = rb->max_bytes - rb->len;
size_t to_copy = total <= allowed ? total : allowed;
if (rb->len + to_copy + 1U > rb->cap) {
size_t new_cap = rb->cap == 0 ? 1024U : rb->cap;
while (new_cap < rb->len + to_copy + 1U) {
new_cap *= 2U;
}
char* bigger = (char*)realloc(rb->data, new_cap);
if (!bigger) return 0;
rb->data = bigger;
rb->cap = new_cap;
}
memcpy(rb->data + rb->len, contents, to_copy);
rb->len += to_copy;
rb->data[rb->len] = '\0';
if (to_copy < total) {
rb->truncated = 1;
}
return total;
}
static const char* detect_ca_bundle_path_for_tools_local(void) {
const char* env = getenv("SSL_CERT_FILE");
if (env && env[0] != '\0' && access(env, R_OK) == 0) {
return env;
}
static const char* candidates[] = {
"/etc/ssl/certs/ca-certificates.crt",
"/etc/ssl/cert.pem",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/ca-bundle.pem"
};
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
if (access(candidates[i], R_OK) == 0) {
return candidates[i];
}
}
return NULL;
}
char* execute_local_http_fetch(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* url = cJSON_GetObjectItemCaseSensitive(args, "url");
cJSON* method = cJSON_GetObjectItemCaseSensitive(args, "method");
cJSON* headers = cJSON_GetObjectItemCaseSensitive(args, "headers");
cJSON* body = cJSON_GetObjectItemCaseSensitive(args, "body");
cJSON* timeout = cJSON_GetObjectItemCaseSensitive(args, "timeout_seconds");
cJSON* maxb = cJSON_GetObjectItemCaseSensitive(args, "max_bytes");
if (!url || !cJSON_IsString(url) || !url->valuestring || url->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("local_http_fetch requires string url");
}
if (headers && !cJSON_IsArray(headers)) {
cJSON_Delete(args);
return json_error_local("local_http_fetch headers must be an array when provided");
}
if (body && !cJSON_IsString(body)) {
cJSON_Delete(args);
return json_error_local("local_http_fetch body must be a string when provided");
}
const char* method_str = (method && cJSON_IsString(method) && method->valuestring && method->valuestring[0] != '\0')
? method->valuestring
: "GET";
if (body && cJSON_IsString(body) && body->valuestring && strcasecmp(method_str, "GET") == 0) {
cJSON_Delete(args);
return json_error_local("local_http_fetch GET requests cannot include body");
}
int default_timeout = ctx->cfg->tools.local_http_fetch_default_timeout_seconds > 0
? ctx->cfg->tools.local_http_fetch_default_timeout_seconds
: 20;
int max_timeout = ctx->cfg->tools.local_http_fetch_max_timeout_seconds > 0
? ctx->cfg->tools.local_http_fetch_max_timeout_seconds
: 120;
if (default_timeout > max_timeout) {
default_timeout = max_timeout;
}
int timeout_seconds = (timeout && cJSON_IsNumber(timeout)) ? (int)timeout->valuedouble : default_timeout;
if (timeout_seconds <= 0) timeout_seconds = default_timeout;
if (timeout_seconds > max_timeout) timeout_seconds = max_timeout;
int hard_max = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
int max_bytes = (maxb && cJSON_IsNumber(maxb)) ? (int)maxb->valuedouble : hard_max;
if (max_bytes <= 0 || max_bytes > hard_max) max_bytes = hard_max;
CURL* curl = curl_easy_init();
if (!curl) {
cJSON_Delete(args);
return json_error_local("local_http_fetch failed to initialize curl");
}
local_http_fetch_buffer_t rb;
memset(&rb, 0, sizeof(rb));
rb.max_bytes = (size_t)max_bytes;
struct curl_slist* req_headers = NULL;
req_headers = curl_slist_append(req_headers, "Accept: */*");
if (headers && cJSON_IsArray(headers)) {
int n = cJSON_GetArraySize(headers);
for (int i = 0; i < n; i++) {
cJSON* h = cJSON_GetArrayItem(headers, i);
if (h && cJSON_IsString(h) && h->valuestring && h->valuestring[0] != '\0') {
req_headers = curl_slist_append(req_headers, h->valuestring);
}
}
}
curl_easy_setopt(curl, CURLOPT_URL, url->valuestring);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)timeout_seconds);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, local_http_fetch_write_cb_local);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, req_headers);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "didactyl/local_http_fetch");
const char* ca_bundle = detect_ca_bundle_path_for_tools_local();
if (ca_bundle) {
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
}
if (strcasecmp(method_str, "GET") == 0) {
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
} else if (strcasecmp(method_str, "POST") == 0) {
curl_easy_setopt(curl, CURLOPT_POST, 1L);
if (body && cJSON_IsString(body) && body->valuestring) {
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body->valuestring);
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body->valuestring));
}
} else {
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method_str);
if (body && cJSON_IsString(body) && body->valuestring) {
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body->valuestring);
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body->valuestring));
}
}
CURLcode res = curl_easy_perform(curl);
long status_code = 0;
char* content_type = NULL;
char* content_type_copy = NULL;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status_code);
curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &content_type);
if (content_type && content_type[0] != '\0') {
content_type_copy = strdup(content_type);
}
curl_slist_free_all(req_headers);
curl_easy_cleanup(curl);
cJSON* out = cJSON_CreateObject();
if (!out) {
free(rb.data);
return NULL;
}
int http_ok = (status_code >= 200 && status_code < 300) ? 1 : 0;
int success = (res == CURLE_OK && http_ok) ? 1 : 0;
cJSON_AddBoolToObject(out, "success", success);
cJSON_AddStringToObject(out, "url", url->valuestring);
cJSON_AddStringToObject(out, "method", method_str);
cJSON_AddNumberToObject(out, "status_code", status_code);
cJSON_AddBoolToObject(out, "http_ok", http_ok);
cJSON_AddBoolToObject(out, "truncated", rb.truncated ? 1 : 0);
cJSON_AddNumberToObject(out, "bytes_received", (double)rb.len);
if (content_type_copy && content_type_copy[0] != '\0') {
cJSON_AddStringToObject(out, "content_type", content_type_copy);
}
if (res != CURLE_OK) {
cJSON_AddStringToObject(out, "curl_error", curl_easy_strerror(res));
}
cJSON_AddStringToObject(out, "body", rb.data ? rb.data : "");
free(rb.data);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
cJSON_Delete(args);
free(content_type_copy);
return json;
}
char* execute_local_shell_exec(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
if (!ctx->cfg->tools.shell.enabled) return json_error_local("shell tool disabled");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* command = cJSON_GetObjectItemCaseSensitive(args, "command");
if (!command || !cJSON_IsString(command) || !command->valuestring || command->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("local_shell_exec requires string command");
}
const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0'
? ctx->cfg->tools.shell.working_directory
: ".";
int timeout_s = ctx->cfg->tools.shell.timeout_seconds > 0 ? ctx->cfg->tools.shell.timeout_seconds : 30;
char* quoted_cwd = shell_quote_single_local(cwd);
char* quoted_cmd = shell_quote_single_local(command->valuestring);
cJSON_Delete(args);
if (!quoted_cwd || !quoted_cmd) {
free(quoted_cwd);
free(quoted_cmd);
return json_error_local("allocation failure");
}
int needed = snprintf(NULL,
0,
"cd %s && timeout %ds sh -lc %s 2>&1",
quoted_cwd,
timeout_s,
quoted_cmd);
if (needed <= 0) {
free(quoted_cwd);
free(quoted_cmd);
return json_error_local("failed to build shell command");
}
char* cmd = (char*)malloc((size_t)needed + 1U);
if (!cmd) {
free(quoted_cwd);
free(quoted_cmd);
return json_error_local("allocation failure");
}
snprintf(cmd,
(size_t)needed + 1U,
"cd %s && timeout %ds sh -lc %s 2>&1",
quoted_cwd,
timeout_s,
quoted_cmd);
free(quoted_cwd);
free(quoted_cmd);
FILE* fp = popen(cmd, "r");
free(cmd);
if (!fp) return json_error_local("failed to execute command");
int max_bytes = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
char* output = (char*)calloc((size_t)max_bytes + 1U, 1U);
if (!output) {
pclose(fp);
return json_error_local("allocation failure");
}
size_t used = 0;
while (!feof(fp) && used < (size_t)max_bytes) {
size_t n = fread(output + used, 1, (size_t)max_bytes - used, fp);
used += n;
if (n == 0) break;
}
int raw_status = pclose(fp);
int exit_status = raw_status;
if (raw_status != -1) {
if (WIFEXITED(raw_status)) {
exit_status = WEXITSTATUS(raw_status);
} else if (WIFSIGNALED(raw_status)) {
exit_status = 128 + WTERMSIG(raw_status);
}
}
cJSON* out = cJSON_CreateObject();
if (!out) {
free(output);
return NULL;
}
cJSON_AddBoolToObject(out, "success", exit_status == 0 ? 1 : 0);
cJSON_AddNumberToObject(out, "exit_status", exit_status);
cJSON_AddStringToObject(out, "output", output);
free(output);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_local_file_read(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* path = cJSON_GetObjectItemCaseSensitive(args, "path");
cJSON* maxb = cJSON_GetObjectItemCaseSensitive(args, "max_bytes");
if (!path || !cJSON_IsString(path) || !path->valuestring) {
cJSON_Delete(args);
return json_error_local("local_file_read requires string path");
}
int hard_max = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
int max_bytes = (maxb && cJSON_IsNumber(maxb)) ? (int)maxb->valuedouble : hard_max;
if (max_bytes <= 0 || max_bytes > hard_max) max_bytes = hard_max;
char file_path[PATH_MAX];
if (build_tool_path_local(ctx, path->valuestring, file_path, sizeof(file_path)) != 0) {
cJSON_Delete(args);
return json_error_local("local_file_read path is not allowed");
}
FILE* fp = fopen(file_path, "rb");
cJSON_Delete(args);
if (!fp) return json_error_local("local_file_read failed to open file");
char* buf = (char*)calloc((size_t)max_bytes + 1U, 1U);
if (!buf) {
fclose(fp);
return json_error_local("allocation failure");
}
size_t n = fread(buf, 1, (size_t)max_bytes, fp);
int truncated = !feof(fp) ? 1 : 0;
fclose(fp);
buf[n] = '\0';
cJSON* out = cJSON_CreateObject();
if (!out) {
free(buf);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "path", file_path);
cJSON_AddNumberToObject(out, "bytes_read", (double)n);
cJSON_AddBoolToObject(out, "truncated", truncated);
cJSON_AddStringToObject(out, "content", buf);
free(buf);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_local_file_write(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* path = cJSON_GetObjectItemCaseSensitive(args, "path");
cJSON* content = cJSON_GetObjectItemCaseSensitive(args, "content");
cJSON* append = cJSON_GetObjectItemCaseSensitive(args, "append");
if (!path || !cJSON_IsString(path) || !path->valuestring ||
!content || !cJSON_IsString(content) || !content->valuestring) {
cJSON_Delete(args);
return json_error_local("local_file_write requires string path and content");
}
char file_path[PATH_MAX];
if (build_tool_path_local(ctx, path->valuestring, file_path, sizeof(file_path)) != 0) {
cJSON_Delete(args);
return json_error_local("local_file_write path is not allowed");
}
const char* content_str = content->valuestring;
size_t len = strlen(content_str);
int do_append = (append && cJSON_IsBool(append) && cJSON_IsTrue(append)) ? 1 : 0;
FILE* fp = fopen(file_path, do_append ? "ab" : "wb");
cJSON_Delete(args);
if (!fp) return json_error_local("local_file_write failed to open file");
size_t n = fwrite(content_str, 1, len, fp);
fclose(fp);
if (n != len) return json_error_local("local_file_write failed to write all bytes");
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "path", file_path);
cJSON_AddNumberToObject(out, "bytes_written", (double)n);
cJSON_AddBoolToObject(out, "append", do_append);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
+103
View File
@@ -0,0 +1,103 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdlib.h>
#include "cjson/cJSON.h"
#include "../trigger_manager.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
char* execute_tool_list(tools_context_t* ctx, const char* args_json) {
(void)args_json;
if (!ctx) {
return json_error_local("tool context unavailable");
}
char* schema_json = tools_build_openai_schema_json(ctx);
if (!schema_json) {
return json_error_local("failed to build tool schemas");
}
cJSON* schema_arr = cJSON_Parse(schema_json);
free(schema_json);
if (!schema_arr || !cJSON_IsArray(schema_arr)) {
cJSON_Delete(schema_arr);
return json_error_local("failed to parse tool schemas");
}
cJSON* out = cJSON_CreateObject();
cJSON* tools = cJSON_CreateArray();
if (!out || !tools) {
cJSON_Delete(schema_arr);
cJSON_Delete(out);
cJSON_Delete(tools);
return json_error_local("out of memory");
}
cJSON* item = NULL;
cJSON_ArrayForEach(item, schema_arr) {
cJSON* fn = cJSON_GetObjectItemCaseSensitive(item, "function");
if (!fn || !cJSON_IsObject(fn)) {
continue;
}
cJSON* name = cJSON_GetObjectItemCaseSensitive(fn, "name");
if (!name || !cJSON_IsString(name) || !name->valuestring) {
continue;
}
cJSON* row = cJSON_CreateObject();
if (!row) {
continue;
}
cJSON_AddStringToObject(row, "name", name->valuestring);
cJSON* description = cJSON_GetObjectItemCaseSensitive(fn, "description");
if (description && cJSON_IsString(description) && description->valuestring) {
cJSON_AddStringToObject(row, "description", description->valuestring);
} else {
cJSON_AddStringToObject(row, "description", "");
}
cJSON* parameters = cJSON_GetObjectItemCaseSensitive(fn, "parameters");
if (parameters) {
cJSON_AddItemToObject(row, "parameters", cJSON_Duplicate(parameters, 1));
} else {
cJSON_AddItemToObject(row, "parameters", cJSON_CreateObject());
}
cJSON_AddItemToArray(tools, row);
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddNumberToObject(out, "count", (double)cJSON_GetArraySize(tools));
cJSON_AddItemToObject(out, "tools", tools);
char* result = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
cJSON_Delete(schema_arr);
return result;
}
char* execute_trigger_list(tools_context_t* ctx, const char* args_json) {
(void)args_json;
if (!ctx || !ctx->trigger_manager) {
return json_error_local("trigger manager unavailable");
}
char* status = trigger_manager_status_json(ctx->trigger_manager);
if (!status) {
return json_error_local("failed to build trigger status");
}
return status;
}
+325
View File
@@ -0,0 +1,325 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../config.h"
#include "../llm.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static int json_object_set_string(cJSON* obj, const char* key, const char* value) {
if (!obj || !key || !value) return -1;
cJSON_DeleteItemFromObjectCaseSensitive(obj, key);
cJSON* v = cJSON_CreateString(value);
if (!v) return -1;
cJSON_AddItemToObject(obj, key, v);
return 0;
}
static char* read_entire_file_local(const char* file_path, size_t* out_bytes) {
if (!file_path) return NULL;
FILE* fp = fopen(file_path, "rb");
if (!fp) return NULL;
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return NULL;
}
long len = ftell(fp);
if (len < 0) {
fclose(fp);
return NULL;
}
if (fseek(fp, 0, SEEK_SET) != 0) {
fclose(fp);
return NULL;
}
char* buf = (char*)malloc((size_t)len + 1U);
if (!buf) {
fclose(fp);
return NULL;
}
size_t n = fread(buf, 1, (size_t)len, fp);
fclose(fp);
if (n != (size_t)len) {
free(buf);
return NULL;
}
buf[len] = '\0';
if (out_bytes) *out_bytes = (size_t)len;
return buf;
}
static int persist_llm_config(tools_context_t* ctx, const llm_config_t* cfg) {
if (!ctx || !ctx->cfg || !cfg) return -1;
if (ctx->cfg->config_path[0] == '\0') return -1;
size_t src_len = 0;
char* raw = read_entire_file_local(ctx->cfg->config_path, &src_len);
if (!raw) return -1;
char* src = jsonc_strip_comments(raw, src_len);
free(raw);
if (!src) return -1;
cJSON* root = cJSON_ParseWithLength(src, strlen(src));
free(src);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return -1;
}
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
if (!llm || !cJSON_IsObject(llm)) {
cJSON_DeleteItemFromObjectCaseSensitive(root, "llm");
llm = cJSON_CreateObject();
if (!llm) {
cJSON_Delete(root);
return -1;
}
cJSON_AddItemToObject(root, "llm", llm);
}
if (json_object_set_string(llm, "provider", cfg->provider) != 0 ||
json_object_set_string(llm, "api_key", cfg->api_key) != 0 ||
json_object_set_string(llm, "model", cfg->model) != 0 ||
json_object_set_string(llm, "base_url", cfg->base_url) != 0) {
cJSON_Delete(root);
return -1;
}
cJSON_DeleteItemFromObjectCaseSensitive(llm, "max_tokens");
cJSON_AddNumberToObject(llm, "max_tokens", cfg->max_tokens);
cJSON_DeleteItemFromObjectCaseSensitive(llm, "temperature");
cJSON_AddNumberToObject(llm, "temperature", cfg->temperature);
char* out = cJSON_Print(root);
cJSON_Delete(root);
if (!out) return -1;
FILE* fp = fopen(ctx->cfg->config_path, "wb");
if (!fp) {
free(out);
return -1;
}
size_t out_len = strlen(out);
size_t n = fwrite(out, 1, out_len, fp);
fclose(fp);
free(out);
return n == out_len ? 0 : -1;
}
static int assign_string_field(cJSON* item, char* dst, size_t dst_size, int* changed) {
if (!item) return 0;
if (!cJSON_IsString(item) || !item->valuestring) return -1;
size_t n = strlen(item->valuestring);
if (n >= dst_size) return -1;
memcpy(dst, item->valuestring, n + 1U);
if (changed) *changed = 1;
return 0;
}
char* execute_model_get(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
llm_config_t cfg;
if (llm_get_config(&cfg) != 0) {
return json_error_local("llm runtime unavailable");
}
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "provider", cfg.provider);
cJSON_AddStringToObject(out, "model", cfg.model);
cJSON_AddStringToObject(out, "base_url", cfg.base_url);
cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens);
cJSON_AddNumberToObject(out, "temperature", cfg.temperature);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_model_set(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
llm_config_t cfg;
if (llm_get_config(&cfg) != 0) {
cJSON_Delete(args);
return json_error_local("llm runtime unavailable");
}
int changed = 0;
cJSON* provider = cJSON_GetObjectItemCaseSensitive(args, "provider");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(args, "api_key");
cJSON* model = cJSON_GetObjectItemCaseSensitive(args, "model");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(args, "base_url");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(args, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(args, "temperature");
if (assign_string_field(provider, cfg.provider, sizeof(cfg.provider), &changed) != 0 ||
assign_string_field(api_key, cfg.api_key, sizeof(cfg.api_key), &changed) != 0 ||
assign_string_field(model, cfg.model, sizeof(cfg.model), &changed) != 0 ||
assign_string_field(base_url, cfg.base_url, sizeof(cfg.base_url), &changed) != 0) {
cJSON_Delete(args);
return json_error_local("model_set string field invalid or too long");
}
if (max_tokens) {
if (!cJSON_IsNumber(max_tokens)) {
cJSON_Delete(args);
return json_error_local("model_set max_tokens must be a number");
}
cfg.max_tokens = (int)max_tokens->valuedouble;
changed = 1;
}
if (temperature) {
if (!cJSON_IsNumber(temperature)) {
cJSON_Delete(args);
return json_error_local("model_set temperature must be a number");
}
cfg.temperature = temperature->valuedouble;
changed = 1;
}
cJSON_Delete(args);
if (!changed) {
return json_error_local("model_set requires at least one field to update");
}
if (llm_set_config(&cfg) != 0) {
return json_error_local("failed to update runtime llm config");
}
ctx->cfg->llm = cfg;
if (persist_llm_config(ctx, &cfg) != 0) {
return json_error_local("failed to persist llm config to config file");
}
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "provider", cfg.provider);
cJSON_AddStringToObject(out, "model", cfg.model);
cJSON_AddStringToObject(out, "base_url", cfg.base_url);
cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens);
cJSON_AddNumberToObject(out, "temperature", cfg.temperature);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
static void append_model_id(cJSON* ids, cJSON* item) {
if (!ids || !item) return;
if (cJSON_IsString(item) && item->valuestring) {
cJSON_AddItemToArray(ids, cJSON_CreateString(item->valuestring));
return;
}
if (cJSON_IsObject(item)) {
cJSON* id = cJSON_GetObjectItemCaseSensitive(item, "id");
if (id && cJSON_IsString(id) && id->valuestring) {
cJSON_AddItemToArray(ids, cJSON_CreateString(id->valuestring));
}
}
}
char* execute_model_list(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(args, "base_url");
if (base_url && (!cJSON_IsString(base_url) || !base_url->valuestring)) {
cJSON_Delete(args);
return json_error_local("model_list base_url must be a string");
}
const char* base_url_override = (base_url && base_url->valuestring && base_url->valuestring[0] != '\0')
? base_url->valuestring
: NULL;
char* raw = llm_list_models_json(base_url_override);
cJSON_Delete(args);
if (!raw) {
return json_error_local("model_list request failed");
}
cJSON* root = cJSON_Parse(raw);
free(raw);
if (!root) {
cJSON_Delete(root);
return json_error_local("model_list returned invalid JSON");
}
cJSON* ids = cJSON_CreateArray();
cJSON* out = cJSON_CreateObject();
if (!ids || !out) {
cJSON_Delete(ids);
cJSON_Delete(out);
cJSON_Delete(root);
return NULL;
}
if (cJSON_IsArray(root)) {
int n = cJSON_GetArraySize(root);
for (int i = 0; i < n; i++) {
append_model_id(ids, cJSON_GetArrayItem(root, i));
}
} else if (cJSON_IsObject(root)) {
cJSON* data = cJSON_GetObjectItemCaseSensitive(root, "data");
if (data && cJSON_IsArray(data)) {
int n = cJSON_GetArraySize(data);
for (int i = 0; i < n; i++) {
append_model_id(ids, cJSON_GetArrayItem(data, i));
}
}
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(ids));
cJSON_AddItemToObject(out, "models", ids);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
cJSON_Delete(root);
return json;
}
+218
View File
@@ -0,0 +1,218 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../nostr_handler.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static int is_hex_string_len_local(const char* s, size_t expected_len) {
if (!s) return 0;
if (strlen(s) != expected_len) return 0;
for (size_t i = 0; i < expected_len; i++) {
if (!((s[i] >= '0' && s[i] <= '9') ||
(s[i] >= 'a' && s[i] <= 'f') ||
(s[i] >= 'A' && s[i] <= 'F'))) {
return 0;
}
}
return 1;
}
char* execute_nostr_dm_send(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* recipient = cJSON_GetObjectItemCaseSensitive(args, "recipient_pubkey");
cJSON* message = cJSON_GetObjectItemCaseSensitive(args, "message");
if (!recipient || !cJSON_IsString(recipient) || !recipient->valuestring || !is_hex_string_len_local(recipient->valuestring, 64U) ||
!message || !cJSON_IsString(message) || !message->valuestring || message->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("nostr_dm_send requires recipient_pubkey hex and non-empty message");
}
int rc = nostr_handler_send_dm(recipient->valuestring, message->valuestring);
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(args);
return NULL;
}
cJSON_AddBoolToObject(out, "success", rc == 0 ? 1 : 0);
cJSON_AddStringToObject(out, "recipient_pubkey", recipient->valuestring);
cJSON_AddNumberToObject(out, "message_length", (double)strlen(message->valuestring));
cJSON_Delete(args);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_nostr_encrypt(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* recipient = cJSON_GetObjectItemCaseSensitive(args, "recipient_pubkey");
cJSON* plaintext = cJSON_GetObjectItemCaseSensitive(args, "plaintext");
if (!recipient || !cJSON_IsString(recipient) || !recipient->valuestring || !is_hex_string_len_local(recipient->valuestring, 64U) ||
!plaintext || !cJSON_IsString(plaintext) || !plaintext->valuestring || plaintext->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("nostr_encrypt requires recipient_pubkey hex and plaintext");
}
unsigned char recipient_pubkey[32];
if (nostr_hex_to_bytes(recipient->valuestring, recipient_pubkey, sizeof(recipient_pubkey)) != 0) {
cJSON_Delete(args);
return json_error_local("nostr_encrypt invalid recipient_pubkey");
}
size_t out_cap = (strlen(plaintext->valuestring) * 4U) + 1024U;
char* ciphertext = (char*)malloc(out_cap);
if (!ciphertext) {
cJSON_Delete(args);
return json_error_local("allocation failure");
}
int rc = nostr_nip44_encrypt(ctx->cfg->keys.private_key,
recipient_pubkey,
plaintext->valuestring,
ciphertext,
out_cap);
if (rc != NOSTR_SUCCESS) {
free(ciphertext);
cJSON_Delete(args);
return json_error_local("nostr_encrypt failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
free(ciphertext);
cJSON_Delete(args);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "recipient_pubkey", recipient->valuestring);
cJSON_AddStringToObject(out, "ciphertext", ciphertext);
free(ciphertext);
cJSON_Delete(args);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_nostr_decrypt(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* sender = cJSON_GetObjectItemCaseSensitive(args, "sender_pubkey");
cJSON* ciphertext = cJSON_GetObjectItemCaseSensitive(args, "ciphertext");
if (!sender || !cJSON_IsString(sender) || !sender->valuestring || !is_hex_string_len_local(sender->valuestring, 64U) ||
!ciphertext || !cJSON_IsString(ciphertext) || !ciphertext->valuestring || ciphertext->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("nostr_decrypt requires sender_pubkey hex and ciphertext");
}
unsigned char sender_pubkey[32];
if (nostr_hex_to_bytes(sender->valuestring, sender_pubkey, sizeof(sender_pubkey)) != 0) {
cJSON_Delete(args);
return json_error_local("nostr_decrypt invalid sender_pubkey");
}
size_t out_cap = strlen(ciphertext->valuestring) + 1024U;
char* plaintext = (char*)malloc(out_cap);
if (!plaintext) {
cJSON_Delete(args);
return json_error_local("allocation failure");
}
int rc = nostr_nip44_decrypt(ctx->cfg->keys.private_key,
sender_pubkey,
ciphertext->valuestring,
plaintext,
out_cap);
if (rc != NOSTR_SUCCESS) {
free(plaintext);
cJSON_Delete(args);
return json_error_local("nostr_decrypt failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
free(plaintext);
cJSON_Delete(args);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "sender_pubkey", sender->valuestring);
cJSON_AddStringToObject(out, "plaintext", plaintext);
free(plaintext);
cJSON_Delete(args);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_nostr_dm_send_nip17(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* recipient = cJSON_GetObjectItemCaseSensitive(args, "recipient_pubkey");
cJSON* message = cJSON_GetObjectItemCaseSensitive(args, "message");
cJSON* subject = cJSON_GetObjectItemCaseSensitive(args, "subject");
if (!recipient || !cJSON_IsString(recipient) || !recipient->valuestring || !is_hex_string_len_local(recipient->valuestring, 64U) ||
!message || !cJSON_IsString(message) || !message->valuestring || message->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("nostr_dm_send_nip17 requires recipient_pubkey hex and non-empty message");
}
if (subject && !cJSON_IsString(subject)) {
cJSON_Delete(args);
return json_error_local("nostr_dm_send_nip17 subject must be a string when provided");
}
int rc = nostr_handler_send_dm_nip17(recipient->valuestring,
message->valuestring,
(subject && subject->valuestring) ? subject->valuestring : NULL);
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(args);
return NULL;
}
cJSON_AddBoolToObject(out, "success", rc == 0 ? 1 : 0);
cJSON_AddStringToObject(out, "recipient_pubkey", recipient->valuestring);
cJSON_AddStringToObject(out, "protocol", "nip17");
cJSON_AddNumberToObject(out, "message_length", (double)strlen(message->valuestring));
cJSON_Delete(args);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
+257
View File
@@ -0,0 +1,257 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static int is_hex_string_len_local(const char* s, size_t expected_len) {
if (!s) return 0;
if (strlen(s) != expected_len) return 0;
for (size_t i = 0; i < expected_len; i++) {
if (!((s[i] >= '0' && s[i] <= '9') ||
(s[i] >= 'a' && s[i] <= 'f') ||
(s[i] >= 'A' && s[i] <= 'F'))) {
return 0;
}
}
return 1;
}
char* execute_nostr_encode(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* type = cJSON_GetObjectItemCaseSensitive(args, "type");
cJSON* hex = cJSON_GetObjectItemCaseSensitive(args, "hex");
cJSON* relays = cJSON_GetObjectItemCaseSensitive(args, "relays");
cJSON* kind = cJSON_GetObjectItemCaseSensitive(args, "kind");
cJSON* identifier = cJSON_GetObjectItemCaseSensitive(args, "identifier");
if (!type || !cJSON_IsString(type) || !type->valuestring ||
!hex || !cJSON_IsString(hex) || !hex->valuestring || !is_hex_string_len_local(hex->valuestring, 64U)) {
cJSON_Delete(args);
return json_error_local("nostr_encode requires type string and hex 64-char string");
}
char type_name[32];
snprintf(type_name, sizeof(type_name), "%s", type->valuestring);
unsigned char key_bytes[32];
if (nostr_hex_to_bytes(hex->valuestring, key_bytes, sizeof(key_bytes)) != 0) {
cJSON_Delete(args);
return json_error_local("nostr_encode invalid hex");
}
const char** relay_ptrs = NULL;
int relay_count = 0;
if (relays) {
if (!cJSON_IsArray(relays)) {
cJSON_Delete(args);
return json_error_local("nostr_encode relays must be an array of relay URLs");
}
relay_count = cJSON_GetArraySize(relays);
if (relay_count > 0) {
relay_ptrs = (const char**)malloc((size_t)relay_count * sizeof(char*));
if (!relay_ptrs) {
cJSON_Delete(args);
return json_error_local("memory allocation failed");
}
for (int i = 0; i < relay_count; i++) {
cJSON* relay = cJSON_GetArrayItem(relays, i);
if (!relay || !cJSON_IsString(relay) || !relay->valuestring || relay->valuestring[0] == '\0') {
free(relay_ptrs);
cJSON_Delete(args);
return json_error_local("nostr_encode relays must contain non-empty strings");
}
relay_ptrs[i] = relay->valuestring;
}
}
}
char bech32[256] = {0};
char uri[1200] = {0};
int rc = NOSTR_ERROR_INVALID_INPUT;
if (strcmp(type_name, "npub") == 0) {
rc = nostr_key_to_bech32(key_bytes, "npub", bech32);
if (rc == NOSTR_SUCCESS) {
snprintf(uri, sizeof(uri), "nostr:%s", bech32);
}
} else if (strcmp(type_name, "nsec") == 0) {
rc = nostr_key_to_bech32(key_bytes, "nsec", bech32);
if (rc == NOSTR_SUCCESS) {
snprintf(uri, sizeof(uri), "nostr:%s", bech32);
}
} else if (strcmp(type_name, "note") == 0) {
rc = nostr_key_to_bech32(key_bytes, "note", bech32);
if (rc == NOSTR_SUCCESS) {
snprintf(uri, sizeof(uri), "nostr:%s", bech32);
}
} else if (strcmp(type_name, "nprofile") == 0) {
rc = nostr_build_uri_nprofile(key_bytes, relay_ptrs, relay_count, uri, sizeof(uri));
} else if (strcmp(type_name, "nevent") == 0) {
int kind_value = -1;
if (kind) {
if (!cJSON_IsNumber(kind)) {
free(relay_ptrs);
cJSON_Delete(args);
return json_error_local("nostr_encode nevent kind must be an integer");
}
kind_value = (int)kind->valuedouble;
}
rc = nostr_build_uri_nevent(key_bytes, relay_ptrs, relay_count, NULL, kind_value, 0, uri, sizeof(uri));
} else if (strcmp(type_name, "naddr") == 0) {
if (!kind || !cJSON_IsNumber(kind)) {
free(relay_ptrs);
cJSON_Delete(args);
return json_error_local("nostr_encode naddr requires kind integer");
}
if (!identifier || !cJSON_IsString(identifier) || !identifier->valuestring || identifier->valuestring[0] == '\0') {
free(relay_ptrs);
cJSON_Delete(args);
return json_error_local("nostr_encode naddr requires non-empty identifier string");
}
rc = nostr_build_uri_naddr(identifier->valuestring, key_bytes, (int)kind->valuedouble,
relay_ptrs, relay_count, uri, sizeof(uri));
} else {
free(relay_ptrs);
cJSON_Delete(args);
return json_error_local("nostr_encode currently supports npub, nsec, note, nprofile, nevent, naddr");
}
free(relay_ptrs);
cJSON_Delete(args);
if (rc != NOSTR_SUCCESS) {
return json_error_local("nostr_encode failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "type", type_name);
cJSON_AddStringToObject(out, "uri", uri);
cJSON_AddBoolToObject(out, "limited_support", 1);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_nostr_decode(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* uri_in = cJSON_GetObjectItemCaseSensitive(args, "uri");
if (!uri_in || !cJSON_IsString(uri_in) || !uri_in->valuestring || uri_in->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("nostr_decode requires uri string");
}
const char* in = uri_in->valuestring;
if (strncmp(in, "nostr:", 6) == 0) {
in += 6;
}
unsigned char key[32];
char key_hex[65] = {0};
const char* type = NULL;
int rc = NOSTR_ERROR_INVALID_INPUT;
if (strncmp(in, "npub1", 5) == 0) {
type = "npub";
rc = nostr_decode_npub(in, key);
} else if (strncmp(in, "nsec1", 5) == 0) {
type = "nsec";
rc = nostr_decode_nsec(in, key);
} else {
cJSON_Delete(args);
return json_error_local("nostr_decode currently supports npub and nsec");
}
cJSON_Delete(args);
if (rc != NOSTR_SUCCESS) {
return json_error_local("nostr_decode failed");
}
nostr_bytes_to_hex(key, 32, key_hex);
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "type", type);
if (strcmp(type, "npub") == 0) {
cJSON_AddStringToObject(out, "pubkey", key_hex);
} else {
cJSON_AddStringToObject(out, "private_key", key_hex);
}
cJSON_AddBoolToObject(out, "limited_support", 1);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_nostr_pubkey(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "pubkey", ctx->cfg->keys.public_key_hex);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_nostr_npub(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char npub[256] = {0};
if (nostr_key_to_bech32(ctx->cfg->keys.public_key, "npub", npub) != NOSTR_SUCCESS) {
return json_error_local("failed to encode npub");
}
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "npub", npub);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
+361
View File
@@ -0,0 +1,361 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../nostr_handler.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static int is_hex_string_len_local(const char* s, size_t expected_len) {
if (!s) return 0;
if (strlen(s) != expected_len) return 0;
for (size_t i = 0; i < expected_len; i++) {
if (!((s[i] >= '0' && s[i] <= '9') ||
(s[i] >= 'a' && s[i] <= 'f') ||
(s[i] >= 'A' && s[i] <= 'F'))) {
return 0;
}
}
return 1;
}
static int add_string_tag_local(cJSON* tags, const char* key, const char* value) {
if (!tags || !cJSON_IsArray(tags) || !key || !value) return -1;
cJSON* tuple = cJSON_CreateArray();
if (!tuple) return -1;
cJSON_AddItemToArray(tuple, cJSON_CreateString(key));
cJSON_AddItemToArray(tuple, cJSON_CreateString(value));
cJSON_AddItemToArray(tags, tuple);
return 0;
}
static int tag_tuple_equal_local(cJSON* a, cJSON* b) {
if (!a || !b || !cJSON_IsArray(a) || !cJSON_IsArray(b)) return 0;
int an = cJSON_GetArraySize(a);
int bn = cJSON_GetArraySize(b);
if (an != bn) return 0;
for (int i = 0; i < an; i++) {
cJSON* av = cJSON_GetArrayItem(a, i);
cJSON* bv = cJSON_GetArrayItem(b, i);
if (!av || !bv || !cJSON_IsString(av) || !cJSON_IsString(bv) || !av->valuestring || !bv->valuestring) {
return 0;
}
if (strcmp(av->valuestring, bv->valuestring) != 0) return 0;
}
return 1;
}
static int tags_contains_tuple_local(cJSON* tags, cJSON* tuple) {
if (!tags || !tuple || !cJSON_IsArray(tags) || !cJSON_IsArray(tuple)) return 0;
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* t = cJSON_GetArrayItem(tags, i);
if (tag_tuple_equal_local(t, tuple)) return 1;
}
return 0;
}
static int remove_matching_tag_tuples_local(cJSON* tags, cJSON* tuple) {
if (!tags || !tuple || !cJSON_IsArray(tags) || !cJSON_IsArray(tuple)) return 0;
int removed = 0;
for (int i = cJSON_GetArraySize(tags) - 1; i >= 0; i--) {
cJSON* t = cJSON_GetArrayItem(tags, i);
if (tag_tuple_equal_local(t, tuple)) {
cJSON_DeleteItemFromArray(tags, i);
removed++;
}
}
return removed;
}
char* execute_nostr_delete(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* event_ids = cJSON_GetObjectItemCaseSensitive(args, "event_ids");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(args, "kinds");
cJSON* reason = cJSON_GetObjectItemCaseSensitive(args, "reason");
if (!event_ids || !cJSON_IsArray(event_ids) || cJSON_GetArraySize(event_ids) <= 0) {
cJSON_Delete(args);
return json_error_local("nostr_delete requires non-empty event_ids array");
}
if (kinds && !cJSON_IsArray(kinds)) {
cJSON_Delete(args);
return json_error_local("nostr_delete kinds must be an array when provided");
}
if (reason && !cJSON_IsString(reason)) {
cJSON_Delete(args);
return json_error_local("nostr_delete reason must be a string when provided");
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
cJSON_Delete(args);
return json_error_local("failed to create tags");
}
int event_id_count = cJSON_GetArraySize(event_ids);
for (int i = 0; i < event_id_count; i++) {
cJSON* id = cJSON_GetArrayItem(event_ids, i);
if (!id || !cJSON_IsString(id) || !id->valuestring || !is_hex_string_len_local(id->valuestring, 64U)) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("nostr_delete event_ids must contain 64-char hex strings");
}
if (add_string_tag_local(tags, "e", id->valuestring) != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("failed to add e tag");
}
}
if (kinds) {
int kind_count = cJSON_GetArraySize(kinds);
for (int i = 0; i < kind_count; i++) {
cJSON* k = cJSON_GetArrayItem(kinds, i);
if (!k || !cJSON_IsNumber(k)) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("nostr_delete kinds must contain integers");
}
char kind_buf[32];
snprintf(kind_buf, sizeof(kind_buf), "%d", (int)k->valuedouble);
if (add_string_tag_local(tags, "k", kind_buf) != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("failed to add k tag");
}
}
}
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
const char* reason_content = (reason && reason->valuestring) ? reason->valuestring : "";
int rc = nostr_handler_publish_kind_event(5, reason_content, tags, &publish_result);
cJSON_Delete(tags);
cJSON_Delete(args);
if (rc != 0) {
nostr_handler_publish_result_free(&publish_result);
return json_error_local("nostr_delete failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "nostr_delete published");
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddNumberToObject(out, "requested_event_count", event_id_count);
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
cJSON* relays = cJSON_CreateArray();
if (!relays) {
nostr_handler_publish_result_free(&publish_result);
cJSON_Delete(out);
return NULL;
}
for (int i = 0; i < publish_result.relay_count; i++) {
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
}
cJSON_AddItemToObject(out, "relays", relays);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
nostr_handler_publish_result_free(&publish_result);
return json;
}
char* execute_nostr_list_manage(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* list_kind = cJSON_GetObjectItemCaseSensitive(args, "list_kind");
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
cJSON* items = cJSON_GetObjectItemCaseSensitive(args, "items");
if (!list_kind || !cJSON_IsNumber(list_kind) ||
!action || !cJSON_IsString(action) || !action->valuestring ||
!items || !cJSON_IsArray(items)) {
cJSON_Delete(args);
return json_error_local("nostr_list_manage requires list_kind, action, and items");
}
if (strcmp(action->valuestring, "add") != 0 && strcmp(action->valuestring, "remove") != 0) {
cJSON_Delete(args);
return json_error_local("nostr_list_manage action must be add or remove");
}
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
if (!filter || !kinds || !authors) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
cJSON_Delete(args);
return json_error_local("failed to create list query filter");
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber((int)list_kind->valuedouble));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", 1);
char* events_json = nostr_handler_query_json(filter, 8000);
cJSON_Delete(filter);
cJSON* events = events_json ? cJSON_Parse(events_json) : NULL;
free(events_json);
char* list_content = strdup("");
if (!list_content) {
cJSON_Delete(events);
cJSON_Delete(args);
return json_error_local("allocation failure");
}
cJSON* updated_tags = cJSON_CreateArray();
if (!updated_tags) {
free(list_content);
cJSON_Delete(events);
cJSON_Delete(args);
return json_error_local("failed to create list tags");
}
if (events && cJSON_IsArray(events) && cJSON_GetArraySize(events) > 0) {
cJSON* ev0 = cJSON_GetArrayItem(events, 0);
if (ev0 && cJSON_IsObject(ev0)) {
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev0, "content");
if (content && cJSON_IsString(content) && content->valuestring) {
free(list_content);
list_content = strdup(content->valuestring);
if (!list_content) {
cJSON_Delete(updated_tags);
cJSON_Delete(events);
cJSON_Delete(args);
return json_error_local("allocation failure");
}
}
cJSON* tags = cJSON_GetObjectItemCaseSensitive(ev0, "tags");
if (tags && cJSON_IsArray(tags)) {
cJSON_Delete(updated_tags);
updated_tags = cJSON_Duplicate(tags, 1);
if (!updated_tags) {
free(list_content);
cJSON_Delete(events);
cJSON_Delete(args);
return json_error_local("failed to duplicate existing list tags");
}
}
}
}
int items_affected = 0;
int item_count = cJSON_GetArraySize(items);
for (int i = 0; i < item_count; i++) {
cJSON* tuple = cJSON_GetArrayItem(items, i);
if (!tuple || !cJSON_IsArray(tuple) || cJSON_GetArraySize(tuple) <= 0) {
continue;
}
int tuple_ok = 1;
int tuple_size = cJSON_GetArraySize(tuple);
for (int j = 0; j < tuple_size; j++) {
cJSON* part = cJSON_GetArrayItem(tuple, j);
if (!part || !cJSON_IsString(part) || !part->valuestring) {
tuple_ok = 0;
break;
}
}
if (!tuple_ok) {
continue;
}
if (strcmp(action->valuestring, "add") == 0) {
if (!tags_contains_tuple_local(updated_tags, tuple)) {
cJSON* dup = cJSON_Duplicate(tuple, 1);
if (!dup) {
free(list_content);
cJSON_Delete(updated_tags);
cJSON_Delete(events);
cJSON_Delete(args);
return json_error_local("failed to duplicate list item");
}
cJSON_AddItemToArray(updated_tags, dup);
items_affected++;
}
} else {
items_affected += remove_matching_tag_tuples_local(updated_tags, tuple);
}
}
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event((int)list_kind->valuedouble,
list_content,
updated_tags,
&publish_result);
free(list_content);
cJSON_Delete(updated_tags);
cJSON_Delete(events);
if (rc != 0) {
cJSON_Delete(args);
nostr_handler_publish_result_free(&publish_result);
return json_error_local("nostr_list_manage failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(args);
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddNumberToObject(out, "list_kind", publish_result.kind);
cJSON_AddStringToObject(out, "action", action->valuestring);
cJSON_Delete(args);
cJSON_AddNumberToObject(out, "items_affected", items_affected);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
nostr_handler_publish_result_free(&publish_result);
return json;
}
+927
View File
@@ -0,0 +1,927 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <ctype.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "cjson/cJSON.h"
#include "../nostr_handler.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static const char* skip_ws_local(const char* p) {
while (p && *p && isspace((unsigned char)*p)) p++;
return p;
}
static char* sanitize_json_string_controls_local(const char* in) {
if (!in) return NULL;
size_t len = strlen(in);
size_t cap = (len * 2U) + 1U;
char* out = (char*)malloc(cap);
if (!out) return NULL;
int in_string = 0;
int escaping = 0;
size_t j = 0;
for (size_t i = 0; i < len; i++) {
char c = in[i];
if (escaping) {
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
escaping = 0;
continue;
}
if (c == '\\') {
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
if (in_string) escaping = 1;
continue;
}
if (c == '"') {
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
in_string = !in_string;
continue;
}
if (in_string && (c == '\n' || c == '\r' || c == '\t')) {
if (j + 2U >= cap) {
free(out);
return NULL;
}
out[j++] = '\\';
out[j++] = (c == '\n') ? 'n' : (c == '\r') ? 'r' : 't';
continue;
}
if (j + 1U >= cap) {
free(out);
return NULL;
}
out[j++] = c;
}
out[j] = '\0';
return out;
}
static cJSON* parse_tool_args_json_local(const char* args_json) {
const char* raw_args_json = args_json ? args_json : "{}";
raw_args_json = skip_ws_local(raw_args_json);
if (!raw_args_json || raw_args_json[0] == '\0') {
raw_args_json = "{}";
}
cJSON* args = cJSON_Parse(raw_args_json);
char* repaired_args_json = NULL;
if (args && cJSON_IsString(args) && args->valuestring) {
cJSON* nested = cJSON_Parse(args->valuestring);
if (nested) {
cJSON_Delete(args);
args = nested;
}
}
if (!args) {
repaired_args_json = sanitize_json_string_controls_local(raw_args_json);
if (repaired_args_json) {
args = cJSON_Parse(repaired_args_json);
if (args && cJSON_IsString(args) && args->valuestring) {
cJSON* nested = cJSON_Parse(args->valuestring);
if (nested) {
cJSON_Delete(args);
args = nested;
}
}
}
}
free(repaired_args_json);
return args;
}
static const char* find_key_start_local(const char* in, const char* key) {
if (!in || !key) return NULL;
char pattern[64];
int n = snprintf(pattern, sizeof(pattern), "\"%s\"", key);
if (n <= 0 || (size_t)n >= sizeof(pattern)) return NULL;
return strstr(in, pattern);
}
static int parse_loose_kind_local(const char* in, int* out_kind) {
if (!in || !out_kind) return -1;
const char* p = find_key_start_local(in, "kind");
if (!p) return -1;
p = strchr(p, ':');
if (!p) return -1;
p = skip_ws_local(p + 1);
if (!p || !*p) return -1;
char* end = NULL;
long v = strtol(p, &end, 10);
if (end == p) return -1;
*out_kind = (int)v;
return 0;
}
static char* parse_loose_json_string_value_local(const char* in, const char* key) {
if (!in || !key) return NULL;
const char* p = find_key_start_local(in, key);
if (!p) return NULL;
p = strchr(p, ':');
if (!p) return NULL;
p = skip_ws_local(p + 1);
if (!p || *p != '"') return NULL;
p++;
size_t max_len = strlen(p);
char* out = (char*)malloc(max_len + 1U);
if (!out) return NULL;
size_t j = 0;
int escaping = 0;
for (size_t i = 0; p[i] != '\0'; i++) {
char c = p[i];
if (escaping) {
switch (c) {
case 'n': out[j++] = '\n'; break;
case 'r': out[j++] = '\r'; break;
case 't': out[j++] = '\t'; break;
case '"': out[j++] = '"'; break;
case '\\': out[j++] = '\\'; break;
default: out[j++] = c; break;
}
escaping = 0;
continue;
}
if (c == '\\') {
escaping = 1;
continue;
}
if (c == '"') {
out[j] = '\0';
return out;
}
out[j++] = c;
}
out[j] = '\0';
return out;
}
static cJSON* parse_loose_nostr_post_args_local(const char* in) {
if (!in) return NULL;
int kind = 0;
if (parse_loose_kind_local(in, &kind) != 0) {
return NULL;
}
char* content = parse_loose_json_string_value_local(in, "content");
if (!content) {
return NULL;
}
cJSON* args = cJSON_CreateObject();
if (!args) {
free(content);
return NULL;
}
cJSON_AddNumberToObject(args, "kind", kind);
cJSON_AddStringToObject(args, "content", content);
free(content);
return args;
}
static cJSON* ensure_tags_array_local(cJSON** tags_inout) {
if (!tags_inout) return NULL;
if (*tags_inout) {
if (cJSON_IsArray(*tags_inout)) {
return *tags_inout;
}
cJSON_Delete(*tags_inout);
*tags_inout = NULL;
}
*tags_inout = cJSON_CreateArray();
return *tags_inout;
}
static int has_tag_key_local(cJSON* tags, const char* key) {
if (!tags || !cJSON_IsArray(tags) || !key) return 0;
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag)) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
if (k && cJSON_IsString(k) && k->valuestring && strcmp(k->valuestring, key) == 0) {
return 1;
}
}
return 0;
}
static int add_string_tag_local(cJSON* tags, const char* key, const char* value) {
if (!tags || !cJSON_IsArray(tags) || !key || !value || value[0] == '\0') return -1;
cJSON* tag = cJSON_CreateArray();
if (!tag) return -1;
cJSON_AddItemToArray(tag, cJSON_CreateString(key));
cJSON_AddItemToArray(tag, cJSON_CreateString(value));
cJSON_AddItemToArray(tags, tag);
return 0;
}
static char* trim_copy_local(const char* start, size_t len) {
while (len > 0 && isspace((unsigned char)start[0])) {
start++;
len--;
}
while (len > 0 && isspace((unsigned char)start[len - 1])) {
len--;
}
char* out = (char*)malloc(len + 1U);
if (!out) return NULL;
memcpy(out, start, len);
out[len] = '\0';
return out;
}
static char* first_markdown_h1_local(const char* content) {
if (!content) return NULL;
const char* p = content;
while (*p) {
const char* line = p;
const char* nl = strchr(line, '\n');
size_t len = nl ? (size_t)(nl - line) : strlen(line);
while (len > 0 && line[len - 1] == '\r') len--;
size_t i = 0;
while (i < len && isspace((unsigned char)line[i])) i++;
if (i < len && line[i] == '#') {
size_t j = i;
while (j < len && line[j] == '#') j++;
if (j < len && isspace((unsigned char)line[j])) {
while (j < len && isspace((unsigned char)line[j])) j++;
if (j < len) return trim_copy_local(line + j, len - j);
}
}
if (!nl) break;
p = nl + 1;
}
return NULL;
}
static int is_paragraph_line_local(const char* line, size_t len) {
size_t i = 0;
while (i < len && isspace((unsigned char)line[i])) i++;
if (i >= len) return 0;
char c = line[i];
if (c == '#' || c == '>' || c == '-' || c == '*' || c == '`' || c == '|') return 0;
if (isdigit((unsigned char)c)) {
size_t j = i;
while (j < len && isdigit((unsigned char)line[j])) j++;
if (j < len && line[j] == '.') return 0;
}
return 1;
}
static char* first_markdown_paragraph_local(const char* content) {
if (!content) return NULL;
const char* p = content;
while (*p) {
const char* line = p;
const char* nl = strchr(line, '\n');
size_t len = nl ? (size_t)(nl - line) : strlen(line);
while (len > 0 && line[len - 1] == '\r') len--;
if (is_paragraph_line_local(line, len)) {
size_t cap = 1024;
size_t used = 0;
char* out = (char*)malloc(cap);
if (!out) return NULL;
const char* q = line;
const char* qnl = nl;
size_t qlen = len;
while (1) {
if (!is_paragraph_line_local(q, qlen)) break;
size_t start = 0;
while (start < qlen && isspace((unsigned char)q[start])) start++;
size_t end = qlen;
while (end > start && isspace((unsigned char)q[end - 1])) end--;
size_t part_len = end - start;
if (used + part_len + 2 >= cap) {
cap = (cap * 2U) + part_len + 16U;
char* bigger = (char*)realloc(out, cap);
if (!bigger) {
free(out);
return NULL;
}
out = bigger;
}
if (part_len > 0) {
if (used > 0) out[used++] = ' ';
memcpy(out + used, q + start, part_len);
used += part_len;
}
if (!qnl) break;
q = qnl + 1;
qnl = strchr(q, '\n');
qlen = qnl ? (size_t)(qnl - q) : strlen(q);
while (qlen > 0 && q[qlen - 1] == '\r') qlen--;
}
out[used] = '\0';
return out;
}
if (!nl) break;
p = nl + 1;
}
return NULL;
}
static char* first_markdown_image_url_local(const char* content) {
if (!content) return NULL;
const char* p = content;
while ((p = strstr(p, "![")) != NULL) {
const char* close_bracket = strchr(p + 2, ']');
if (!close_bracket || close_bracket[1] != '(') {
p += 2;
continue;
}
const char* url_start = close_bracket + 2;
const char* url_end = strchr(url_start, ')');
if (!url_end || url_end <= url_start) {
p += 2;
continue;
}
return trim_copy_local(url_start, (size_t)(url_end - url_start));
}
return NULL;
}
static char* d_tagify_string_local(const char* in, const char* fallback) {
if (!in || in[0] == '\0') {
return fallback ? strdup(fallback) : NULL;
}
size_t len = strlen(in);
char* out = (char*)malloc(len + 1U);
if (!out) return NULL;
size_t j = 0;
int prev_dash = 0;
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)in[i];
if (isalnum(c)) {
out[j++] = (char)tolower(c);
prev_dash = 0;
} else if (!prev_dash && j > 0) {
out[j++] = '-';
prev_dash = 1;
}
}
while (j > 0 && out[j - 1] == '-') j--;
out[j] = '\0';
if (j == 0) {
free(out);
return fallback ? strdup(fallback) : NULL;
}
return out;
}
static void ensure_nip23_metadata_tags_local(int kind, const char* content, cJSON** tags_inout) {
if (!content || (kind != 30023 && kind != 30024) || !tags_inout) {
return;
}
cJSON* tags = ensure_tags_array_local(tags_inout);
if (!tags) return;
char* title = has_tag_key_local(tags, "title") ? NULL : first_markdown_h1_local(content);
char* summary = has_tag_key_local(tags, "summary") ? NULL : first_markdown_paragraph_local(content);
char* image = has_tag_key_local(tags, "image") ? NULL : first_markdown_image_url_local(content);
if (title) (void)add_string_tag_local(tags, "title", title);
if (summary) (void)add_string_tag_local(tags, "summary", summary);
if (image) (void)add_string_tag_local(tags, "image", image);
if (!has_tag_key_local(tags, "published_at")) {
char published_at[32];
snprintf(published_at, sizeof(published_at), "%lld", (long long)time(NULL));
(void)add_string_tag_local(tags, "published_at", published_at);
}
if (!has_tag_key_local(tags, "d")) {
char* d_tag = NULL;
if (title) {
d_tag = d_tagify_string_local(title, "long-form-note");
} else {
char* derived_title = first_markdown_h1_local(content);
d_tag = d_tagify_string_local(derived_title, "long-form-note");
free(derived_title);
}
if (d_tag) {
(void)add_string_tag_local(tags, "d", d_tag);
free(d_tag);
}
}
free(title);
free(summary);
free(image);
}
static int is_safe_relative_path_local(const char* path) {
if (!path || path[0] == '\0') return 0;
if (path[0] == '/') return 0;
if (strstr(path, "..") != NULL) return 0;
if (strchr(path, '\\') != NULL) return 0;
return 1;
}
static int build_tool_path_local(tools_context_t* ctx, const char* rel_path, char* out, size_t out_size) {
if (!ctx || !ctx->cfg || !rel_path || !out || out_size == 0) return -1;
if (!is_safe_relative_path_local(rel_path)) return -1;
const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0'
? ctx->cfg->tools.shell.working_directory
: ".";
int n = 0;
if (strcmp(cwd, ".") == 0) {
n = snprintf(out, out_size, "%s", rel_path);
} else {
n = snprintf(out, out_size, "%s/%s", cwd, rel_path);
}
if (n < 0 || (size_t)n >= out_size) return -1;
return 0;
}
static char* read_entire_file_local(const char* file_path, size_t* out_bytes) {
if (!file_path) return NULL;
FILE* fp = fopen(file_path, "rb");
if (!fp) return NULL;
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return NULL;
}
long size = ftell(fp);
if (size < 0) {
fclose(fp);
return NULL;
}
if (fseek(fp, 0, SEEK_SET) != 0) {
fclose(fp);
return NULL;
}
char* buf = (char*)malloc((size_t)size + 1U);
if (!buf) {
fclose(fp);
return NULL;
}
size_t n = fread(buf, 1, (size_t)size, fp);
fclose(fp);
buf[n] = '\0';
if (out_bytes) *out_bytes = n;
return buf;
}
static char* basename_lowercase_dup_local(const char* path) {
if (!path || path[0] == '\0') return NULL;
const char* base = strrchr(path, '/');
base = base ? (base + 1) : path;
if (base[0] == '\0') return NULL;
char* out = strdup(base);
if (!out) return NULL;
for (size_t i = 0; out[i] != '\0'; i++) {
out[i] = (char)tolower((unsigned char)out[i]);
}
return out;
}
char* execute_nostr_post(const char* args_json) {
const char* raw_args_json = args_json ? args_json : "{}";
raw_args_json = skip_ws_local(raw_args_json);
if (!raw_args_json || raw_args_json[0] == '\0') {
raw_args_json = "{}";
}
cJSON* args = cJSON_Parse(raw_args_json);
char* repaired_args_json = NULL;
if (args && cJSON_IsString(args) && args->valuestring) {
cJSON* nested = cJSON_Parse(args->valuestring);
if (nested) {
cJSON_Delete(args);
args = nested;
}
}
if (!args && raw_args_json) {
repaired_args_json = sanitize_json_string_controls_local(raw_args_json);
if (repaired_args_json) {
args = cJSON_Parse(repaired_args_json);
if (args && cJSON_IsString(args) && args->valuestring) {
cJSON* nested = cJSON_Parse(args->valuestring);
if (nested) {
cJSON_Delete(args);
args = nested;
}
}
}
}
if (!args && raw_args_json) {
args = parse_loose_nostr_post_args_local(raw_args_json);
}
if (!args) {
free(repaired_args_json);
return json_error_local("invalid arguments JSON");
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(args, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(args, "content");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(args, "tags");
if (!kind || !cJSON_IsNumber(kind) || !content || !cJSON_IsString(content) || !content->valuestring) {
cJSON_Delete(args);
free(repaired_args_json);
return json_error_local("nostr_post requires integer kind and string content");
}
if (tags && !cJSON_IsArray(tags)) {
cJSON_Delete(args);
free(repaired_args_json);
return json_error_local("nostr_post tags must be an array when provided");
}
cJSON* tags_dup = NULL;
if (tags) {
tags_dup = cJSON_Duplicate(tags, 1);
if (!tags_dup) {
cJSON_Delete(args);
free(repaired_args_json);
return json_error_local("nostr_post failed to duplicate tags");
}
}
ensure_nip23_metadata_tags_local((int)kind->valuedouble, content->valuestring, &tags_dup);
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event((int)kind->valuedouble,
content->valuestring,
tags_dup,
&publish_result);
cJSON_Delete(tags_dup);
cJSON_Delete(args);
free(repaired_args_json);
if (rc != 0) {
nostr_handler_publish_result_free(&publish_result);
return json_error_local("nostr_post failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "nostr_post published");
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
if (publish_result.note_uri[0] != '\0') cJSON_AddStringToObject(out, "note_uri", publish_result.note_uri);
if (publish_result.naddr_uri[0] != '\0') cJSON_AddStringToObject(out, "naddr_uri", publish_result.naddr_uri);
if (publish_result.d_tag[0] != '\0') cJSON_AddStringToObject(out, "d_tag", publish_result.d_tag);
cJSON* relays = cJSON_CreateArray();
if (!relays) {
nostr_handler_publish_result_free(&publish_result);
cJSON_Delete(out);
return NULL;
}
for (int i = 0; i < publish_result.relay_count; i++) {
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
}
cJSON_AddItemToObject(out, "relays", relays);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
nostr_handler_publish_result_free(&publish_result);
return json;
}
char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_tool_args_json_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char readme_path[PATH_MAX];
if (build_tool_path_local(ctx, "README.md", readme_path, sizeof(readme_path)) != 0) {
return json_error_local("failed to resolve README path");
}
size_t bytes_read = 0;
char* content = read_entire_file_local(readme_path, &bytes_read);
if (!content) {
return json_error_local("failed to read README.md");
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
free(content);
return json_error_local("failed to create tags");
}
if (add_string_tag_local(tags, "d", "readme.md") != 0 ||
add_string_tag_local(tags,
"image",
"https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg") != 0) {
cJSON_Delete(tags);
free(content);
return json_error_local("failed to set required tags");
}
ensure_nip23_metadata_tags_local(30023, content, &tags);
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(30023, content, tags, &publish_result);
cJSON_Delete(tags);
free(content);
if (rc != 0) {
nostr_handler_publish_result_free(&publish_result);
return json_error_local("nostr_post_readme failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "nostr_post_readme published");
cJSON_AddStringToObject(out, "path", readme_path);
cJSON_AddNumberToObject(out, "bytes_read", (double)bytes_read);
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
if (publish_result.note_uri[0] != '\0') cJSON_AddStringToObject(out, "note_uri", publish_result.note_uri);
if (publish_result.naddr_uri[0] != '\0') cJSON_AddStringToObject(out, "naddr_uri", publish_result.naddr_uri);
if (publish_result.d_tag[0] != '\0') cJSON_AddStringToObject(out, "d_tag", publish_result.d_tag);
cJSON* relays = cJSON_CreateArray();
if (!relays) {
nostr_handler_publish_result_free(&publish_result);
cJSON_Delete(out);
return NULL;
}
for (int i = 0; i < publish_result.relay_count; i++) {
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
}
cJSON_AddItemToObject(out, "relays", relays);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
nostr_handler_publish_result_free(&publish_result);
return json;
}
char* execute_nostr_file_md_to_longform_post(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_tool_args_json_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* file = cJSON_GetObjectItemCaseSensitive(args, "file");
cJSON* title = cJSON_GetObjectItemCaseSensitive(args, "title");
cJSON* image = cJSON_GetObjectItemCaseSensitive(args, "image");
cJSON* summary = cJSON_GetObjectItemCaseSensitive(args, "summary");
if (!file || !cJSON_IsString(file) || !file->valuestring || file->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("nostr_file_md_to_longform_post requires string file");
}
if (title && !cJSON_IsString(title)) {
cJSON_Delete(args);
return json_error_local("nostr_file_md_to_longform_post title must be string when provided");
}
if (image && !cJSON_IsString(image)) {
cJSON_Delete(args);
return json_error_local("nostr_file_md_to_longform_post image must be string when provided");
}
if (summary && !cJSON_IsString(summary)) {
cJSON_Delete(args);
return json_error_local("nostr_file_md_to_longform_post summary must be string when provided");
}
char file_path[PATH_MAX];
if (build_tool_path_local(ctx, file->valuestring, file_path, sizeof(file_path)) != 0) {
cJSON_Delete(args);
return json_error_local("failed to resolve markdown file path");
}
size_t bytes_read = 0;
char* content = read_entire_file_local(file_path, &bytes_read);
if (!content) {
cJSON_Delete(args);
return json_error_local("failed to read markdown file");
}
char* d_tag = basename_lowercase_dup_local(file->valuestring);
if (!d_tag || d_tag[0] == '\0') {
free(d_tag);
free(content);
cJSON_Delete(args);
return json_error_local("failed to derive d tag from filename");
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
free(d_tag);
free(content);
cJSON_Delete(args);
return json_error_local("failed to create tags");
}
if (add_string_tag_local(tags, "d", d_tag) != 0) {
free(d_tag);
cJSON_Delete(tags);
free(content);
cJSON_Delete(args);
return json_error_local("failed to set d tag");
}
if (title && title->valuestring && title->valuestring[0] != '\0') {
if (add_string_tag_local(tags, "title", title->valuestring) != 0) {
free(d_tag);
cJSON_Delete(tags);
free(content);
cJSON_Delete(args);
return json_error_local("failed to set title tag");
}
}
if (image && image->valuestring && image->valuestring[0] != '\0') {
if (add_string_tag_local(tags, "image", image->valuestring) != 0) {
free(d_tag);
cJSON_Delete(tags);
free(content);
cJSON_Delete(args);
return json_error_local("failed to set image tag");
}
}
if (summary && summary->valuestring && summary->valuestring[0] != '\0') {
if (add_string_tag_local(tags, "summary", summary->valuestring) != 0) {
free(d_tag);
cJSON_Delete(tags);
free(content);
cJSON_Delete(args);
return json_error_local("failed to set summary tag");
}
}
ensure_nip23_metadata_tags_local(30023, content, &tags);
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(30023, content, tags, &publish_result);
cJSON_Delete(tags);
free(content);
cJSON_Delete(args);
if (rc != 0) {
free(d_tag);
nostr_handler_publish_result_free(&publish_result);
return json_error_local("nostr_file_md_to_longform_post failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
free(d_tag);
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "nostr_file_md_to_longform_post published");
cJSON_AddStringToObject(out, "path", file_path);
cJSON_AddStringToObject(out, "d_tag", d_tag);
cJSON_AddNumberToObject(out, "bytes_read", (double)bytes_read);
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
if (publish_result.note_uri[0] != '\0') cJSON_AddStringToObject(out, "note_uri", publish_result.note_uri);
if (publish_result.naddr_uri[0] != '\0') cJSON_AddStringToObject(out, "naddr_uri", publish_result.naddr_uri);
cJSON* relays = cJSON_CreateArray();
if (!relays) {
free(d_tag);
nostr_handler_publish_result_free(&publish_result);
cJSON_Delete(out);
return NULL;
}
for (int i = 0; i < publish_result.relay_count; i++) {
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
}
cJSON_AddItemToObject(out, "relays", relays);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(d_tag);
nostr_handler_publish_result_free(&publish_result);
return json;
}
+62
View File
@@ -0,0 +1,62 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdlib.h>
#include "cjson/cJSON.h"
#include "../nostr_handler.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
char* execute_nostr_query(const char* args_json) {
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
if (!args) return json_error_local("invalid arguments JSON");
cJSON* filter = cJSON_GetObjectItemCaseSensitive(args, "filter");
cJSON* timeout = cJSON_GetObjectItemCaseSensitive(args, "timeout_ms");
if (!filter || !cJSON_IsObject(filter)) {
cJSON_Delete(args);
return json_error_local("nostr_query requires object filter");
}
cJSON* filter_dup = cJSON_Duplicate(filter, 1);
if (!filter_dup) {
cJSON_Delete(args);
return json_error_local("failed to duplicate filter");
}
int timeout_ms = (timeout && cJSON_IsNumber(timeout)) ? (int)timeout->valuedouble : 8000;
char* events_json = nostr_handler_query_json(filter_dup, timeout_ms);
cJSON_Delete(filter_dup);
cJSON_Delete(args);
if (!events_json) return json_error_local("nostr_query failed");
cJSON* out = cJSON_CreateObject();
if (!out) {
free(events_json);
return NULL;
}
cJSON* events = cJSON_Parse(events_json);
free(events_json);
if (!events) {
cJSON_Delete(out);
return json_error_local("nostr_query returned invalid JSON");
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddItemToObject(out, "events", events);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
+94
View File
@@ -0,0 +1,94 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdlib.h>
#include "cjson/cJSON.h"
#include "../nostr_handler.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
char* execute_nostr_relay_status(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON_Delete(args);
char* status_json = nostr_handler_relay_status_json();
if (!status_json) {
return json_error_local("nostr_relay_status failed");
}
cJSON* status = cJSON_Parse(status_json);
free(status_json);
if (!status) {
return json_error_local("nostr_relay_status returned invalid JSON");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(status);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddItemToObject(out, "status", status);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_nostr_relay_info(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* relay_url = cJSON_GetObjectItemCaseSensitive(args, "relay_url");
if (!relay_url || !cJSON_IsString(relay_url) || !relay_url->valuestring || relay_url->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("nostr_relay_info requires relay_url");
}
char* info_json = nostr_handler_relay_info_json(relay_url->valuestring);
cJSON_Delete(args);
if (!info_json) {
return json_error_local("nostr_relay_info failed");
}
cJSON* info = cJSON_Parse(info_json);
free(info_json);
if (!info || !cJSON_IsObject(info)) {
cJSON_Delete(info);
return json_error_local("nostr_relay_info returned invalid JSON");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(info);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddItemToObject(out, "info", info);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
+302
View File
@@ -0,0 +1,302 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../nostr_handler.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static int is_hex_string_len_local(const char* s, size_t expected_len) {
if (!s) return 0;
if (strlen(s) != expected_len) return 0;
for (size_t i = 0; i < expected_len; i++) {
if (!((s[i] >= '0' && s[i] <= '9') ||
(s[i] >= 'a' && s[i] <= 'f') ||
(s[i] >= 'A' && s[i] <= 'F'))) {
return 0;
}
}
return 1;
}
static int add_string_tag_local(cJSON* tags, const char* key, const char* value) {
if (!tags || !cJSON_IsArray(tags) || !key || !value) return -1;
cJSON* tuple = cJSON_CreateArray();
if (!tuple) return -1;
cJSON_AddItemToArray(tuple, cJSON_CreateString(key));
cJSON_AddItemToArray(tuple, cJSON_CreateString(value));
cJSON_AddItemToArray(tags, tuple);
return 0;
}
static void free_string_array_heap_local(char** items, int count) {
if (!items) return;
for (int i = 0; i < count; i++) free(items[i]);
free(items);
}
char* execute_nostr_react(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(args, "event_id");
cJSON* event_pubkey = cJSON_GetObjectItemCaseSensitive(args, "event_pubkey");
cJSON* event_kind = cJSON_GetObjectItemCaseSensitive(args, "event_kind");
cJSON* reaction = cJSON_GetObjectItemCaseSensitive(args, "reaction");
if (!event_id || !cJSON_IsString(event_id) || !event_id->valuestring || !is_hex_string_len_local(event_id->valuestring, 64U) ||
!event_pubkey || !cJSON_IsString(event_pubkey) || !event_pubkey->valuestring || !is_hex_string_len_local(event_pubkey->valuestring, 64U)) {
cJSON_Delete(args);
return json_error_local("nostr_react requires event_id and event_pubkey as 64-char hex strings");
}
if (event_kind && !cJSON_IsNumber(event_kind)) {
cJSON_Delete(args);
return json_error_local("nostr_react event_kind must be an integer when provided");
}
if (reaction && !cJSON_IsString(reaction)) {
cJSON_Delete(args);
return json_error_local("nostr_react reaction must be a string when provided");
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
cJSON_Delete(args);
return json_error_local("failed to create tags");
}
if (add_string_tag_local(tags, "e", event_id->valuestring) != 0 ||
add_string_tag_local(tags, "p", event_pubkey->valuestring) != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("failed to add required reaction tags");
}
if (event_kind) {
char kind_buf[32];
snprintf(kind_buf, sizeof(kind_buf), "%d", (int)event_kind->valuedouble);
if (add_string_tag_local(tags, "k", kind_buf) != 0) {
cJSON_Delete(tags);
cJSON_Delete(args);
return json_error_local("failed to add k tag");
}
}
const char* reaction_content = (reaction && reaction->valuestring && reaction->valuestring[0] != '\0') ? reaction->valuestring : "+";
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(7, reaction_content, tags, &publish_result);
cJSON_Delete(tags);
cJSON_Delete(args);
if (rc != 0) {
nostr_handler_publish_result_free(&publish_result);
return json_error_local("nostr_react failed");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "message", "nostr_react published");
cJSON_AddStringToObject(out, "reaction", reaction_content);
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
cJSON* relays = cJSON_CreateArray();
if (!relays) {
nostr_handler_publish_result_free(&publish_result);
cJSON_Delete(out);
return NULL;
}
for (int i = 0; i < publish_result.relay_count; i++) {
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
}
cJSON_AddItemToObject(out, "relays", relays);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
nostr_handler_publish_result_free(&publish_result);
return json;
}
char* execute_nostr_profile_get(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(args, "pubkey");
if (!pubkey || !cJSON_IsString(pubkey) || !pubkey->valuestring || !is_hex_string_len_local(pubkey->valuestring, 64U)) {
cJSON_Delete(args);
return json_error_local("nostr_profile_get requires pubkey as 64-char hex string");
}
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
if (!filter || !kinds || !authors) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
cJSON_Delete(args);
return json_error_local("failed to create profile query filter");
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey->valuestring));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", 1);
char* events_json = nostr_handler_query_json(filter, 8000);
cJSON_Delete(filter);
cJSON_Delete(args);
if (!events_json) {
return json_error_local("nostr_profile_get query failed");
}
cJSON* events = cJSON_Parse(events_json);
free(events_json);
if (!events || !cJSON_IsArray(events)) {
cJSON_Delete(events);
return json_error_local("nostr_profile_get returned invalid events JSON");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(events);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
int found = cJSON_GetArraySize(events) > 0;
cJSON_AddBoolToObject(out, "found", found ? 1 : 0);
if (found) {
cJSON* ev = cJSON_GetArrayItem(events, 0);
cJSON* ev_dup = ev ? cJSON_Duplicate(ev, 1) : NULL;
if (ev_dup) {
cJSON_AddItemToObject(out, "event", ev_dup);
}
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
if (content && cJSON_IsString(content) && content->valuestring) {
cJSON* profile = cJSON_Parse(content->valuestring);
if (profile && cJSON_IsObject(profile)) {
cJSON_AddItemToObject(out, "profile", profile);
} else {
cJSON_Delete(profile);
cJSON_AddStringToObject(out, "profile_raw", content->valuestring);
}
}
}
cJSON_Delete(events);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_nostr_nip05_lookup(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* identifier = cJSON_GetObjectItemCaseSensitive(args, "identifier");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(args, "pubkey");
if (!identifier || !cJSON_IsString(identifier) || !identifier->valuestring || strchr(identifier->valuestring, '@') == NULL) {
cJSON_Delete(args);
return json_error_local("nostr_nip05_lookup requires identifier in user@domain format");
}
if (pubkey && (!cJSON_IsString(pubkey) || !pubkey->valuestring || !is_hex_string_len_local(pubkey->valuestring, 64U))) {
cJSON_Delete(args);
return json_error_local("nostr_nip05_lookup pubkey must be a 64-char hex string when provided");
}
char found_pubkey[65] = {0};
char** relays = NULL;
int relay_count = 0;
int rc = 0;
if (pubkey) {
rc = nostr_nip05_verify(identifier->valuestring,
pubkey->valuestring,
&relays,
&relay_count,
10);
} else {
rc = nostr_nip05_lookup(identifier->valuestring,
found_pubkey,
&relays,
&relay_count,
10);
}
cJSON* out = cJSON_CreateObject();
if (!out) {
free_string_array_heap_local(relays, relay_count);
cJSON_Delete(args);
return NULL;
}
cJSON_AddBoolToObject(out, "success", rc == NOSTR_SUCCESS ? 1 : 0);
cJSON_AddStringToObject(out, "identifier", identifier->valuestring);
if (pubkey) {
cJSON_AddBoolToObject(out, "verified", rc == NOSTR_SUCCESS ? 1 : 0);
cJSON_AddStringToObject(out, "pubkey", pubkey->valuestring);
} else if (rc == NOSTR_SUCCESS) {
cJSON_AddStringToObject(out, "pubkey", found_pubkey);
}
cJSON* relay_arr = cJSON_CreateArray();
if (!relay_arr) {
cJSON_Delete(out);
free_string_array_heap_local(relays, relay_count);
cJSON_Delete(args);
return NULL;
}
for (int i = 0; i < relay_count; i++) {
cJSON_AddItemToArray(relay_arr, cJSON_CreateString(relays[i] ? relays[i] : ""));
}
cJSON_AddItemToObject(out, "relays", relay_arr);
if (rc != NOSTR_SUCCESS) {
cJSON_AddNumberToObject(out, "error_code", rc);
}
free_string_array_heap_local(relays, relay_count);
cJSON_Delete(args);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
File diff suppressed because it is too large Load Diff
+565
View File
@@ -0,0 +1,565 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "cjson/cJSON.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static int is_safe_relative_path_local(const char* path) {
if (!path || path[0] == '\0') return 0;
if (path[0] == '/') return 0;
if (strstr(path, "..") != NULL) return 0;
if (strchr(path, '\\') != NULL) return 0;
return 1;
}
static int build_tool_path_local(tools_context_t* ctx, const char* rel_path, char* out, size_t out_size) {
if (!ctx || !ctx->cfg || !rel_path || !out || out_size == 0) return -1;
if (!is_safe_relative_path_local(rel_path)) return -1;
const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0'
? ctx->cfg->tools.shell.working_directory
: ".";
int n = 0;
if (strcmp(cwd, ".") == 0) {
n = snprintf(out, out_size, "%s", rel_path);
} else {
n = snprintf(out, out_size, "%s/%s", cwd, rel_path);
}
if (n < 0 || (size_t)n >= out_size) return -1;
return 0;
}
static cJSON* tasks_create_empty_root_local(void) {
cJSON* root = cJSON_CreateObject();
cJSON* tasks = cJSON_CreateArray();
if (!root || !tasks) {
cJSON_Delete(root);
cJSON_Delete(tasks);
return NULL;
}
cJSON_AddItemToObject(root, "tasks", tasks);
cJSON_AddNumberToObject(root, "next_id", 1);
return root;
}
static const char* normalize_task_status_local(const char* status) {
if (!status) return NULL;
if (strcmp(status, "pending") == 0) return "pending";
if (strcmp(status, "active") == 0) return "active";
if (strcmp(status, "done") == 0) return "done";
return NULL;
}
static cJSON* tasks_load_root_local(const char* path) {
if (!path) return NULL;
FILE* fp = fopen(path, "rb");
if (!fp) {
if (access(path, F_OK) == 0) {
return NULL;
}
return tasks_create_empty_root_local();
}
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return NULL;
}
long len = ftell(fp);
if (len < 0) {
fclose(fp);
return NULL;
}
if (fseek(fp, 0, SEEK_SET) != 0) {
fclose(fp);
return NULL;
}
char* buf = (char*)malloc((size_t)len + 1U);
if (!buf) {
fclose(fp);
return NULL;
}
size_t n = fread(buf, 1, (size_t)len, fp);
fclose(fp);
if (n != (size_t)len) {
free(buf);
return NULL;
}
buf[len] = '\0';
cJSON* root = cJSON_Parse(buf);
free(buf);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return NULL;
}
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(root, "tasks");
if (!tasks || !cJSON_IsArray(tasks)) {
cJSON_DeleteItemFromObjectCaseSensitive(root, "tasks");
cJSON_AddItemToObject(root, "tasks", cJSON_CreateArray());
}
cJSON* next_id = cJSON_GetObjectItemCaseSensitive(root, "next_id");
if (!next_id || !cJSON_IsNumber(next_id) || next_id->valuedouble < 1) {
cJSON_DeleteItemFromObjectCaseSensitive(root, "next_id");
cJSON_AddNumberToObject(root, "next_id", 1);
}
return root;
}
static int tasks_save_root_local(const char* path, cJSON* root) {
if (!path || !root) return -1;
char* raw = cJSON_PrintUnformatted(root);
if (!raw) return -1;
FILE* fp = fopen(path, "wb");
if (!fp) {
free(raw);
return -1;
}
size_t len = strlen(raw);
size_t n = fwrite(raw, 1, len, fp);
fclose(fp);
free(raw);
return (n == len) ? 0 : -1;
}
static cJSON* task_find_by_id_local(cJSON* tasks, int id, int* out_index) {
if (!tasks || !cJSON_IsArray(tasks) || id <= 0) return NULL;
int n = cJSON_GetArraySize(tasks);
for (int i = 0; i < n; i++) {
cJSON* task = cJSON_GetArrayItem(tasks, i);
cJSON* tid = task ? cJSON_GetObjectItemCaseSensitive(task, "id") : NULL;
if (tid && cJSON_IsNumber(tid) && (int)tid->valuedouble == id) {
if (out_index) *out_index = i;
return task;
}
}
return NULL;
}
char* execute_task_manage(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
if (!action || !cJSON_IsString(action) || !action->valuestring || action->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("task_manage requires string action");
}
char tasks_path[PATH_MAX];
if (build_tool_path_local(ctx, "tasks.json", tasks_path, sizeof(tasks_path)) != 0) {
cJSON_Delete(args);
return json_error_local("failed to resolve tasks file path");
}
cJSON* root = tasks_load_root_local(tasks_path);
if (!root) {
cJSON_Delete(args);
return json_error_local("failed to load tasks file");
}
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(root, "tasks");
if (!tasks || !cJSON_IsArray(tasks)) {
cJSON_DeleteItemFromObjectCaseSensitive(root, "tasks");
tasks = cJSON_CreateArray();
cJSON_AddItemToObject(root, "tasks", tasks);
}
cJSON* next_id_item = cJSON_GetObjectItemCaseSensitive(root, "next_id");
int next_id = (next_id_item && cJSON_IsNumber(next_id_item) && next_id_item->valuedouble >= 1)
? (int)next_id_item->valuedouble
: 1;
const char* action_s = action->valuestring;
int mutated = 0;
if (strcmp(action_s, "list") == 0) {
} else if (strcmp(action_s, "add") == 0) {
cJSON* text = cJSON_GetObjectItemCaseSensitive(args, "text");
cJSON* status = cJSON_GetObjectItemCaseSensitive(args, "status");
if (!text || !cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage add requires non-empty string text");
}
const char* normalized_status = "pending";
if (status && !cJSON_IsNull(status)) {
if (!cJSON_IsString(status) || !status->valuestring) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage add status must be string when provided");
}
normalized_status = normalize_task_status_local(status->valuestring);
if (!normalized_status) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage add status must be pending, active, or done");
}
}
cJSON* task = cJSON_CreateObject();
if (!task) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("allocation failure");
}
time_t now = time(NULL);
cJSON_AddNumberToObject(task, "id", next_id++);
cJSON_AddStringToObject(task, "text", text->valuestring);
cJSON_AddStringToObject(task, "status", normalized_status);
cJSON_AddNumberToObject(task, "created_at", (double)now);
cJSON_AddNumberToObject(task, "updated_at", (double)now);
cJSON_AddItemToArray(tasks, task);
mutated = 1;
} else if (strcmp(action_s, "update") == 0) {
cJSON* id = cJSON_GetObjectItemCaseSensitive(args, "id");
cJSON* text = cJSON_GetObjectItemCaseSensitive(args, "text");
cJSON* status = cJSON_GetObjectItemCaseSensitive(args, "status");
if (!id || !cJSON_IsNumber(id) || id->valuedouble < 1) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage update requires integer id");
}
if ((!text || cJSON_IsNull(text)) && (!status || cJSON_IsNull(status))) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage update requires text and/or status");
}
cJSON* task = task_find_by_id_local(tasks, (int)id->valuedouble, NULL);
if (!task) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task not found");
}
if (text && !cJSON_IsNull(text)) {
if (!cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage update text must be non-empty string when provided");
}
cJSON_DeleteItemFromObjectCaseSensitive(task, "text");
cJSON_AddStringToObject(task, "text", text->valuestring);
mutated = 1;
}
if (status && !cJSON_IsNull(status)) {
if (!cJSON_IsString(status) || !status->valuestring) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage update status must be string when provided");
}
const char* normalized_status = normalize_task_status_local(status->valuestring);
if (!normalized_status) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage update status must be pending, active, or done");
}
cJSON_DeleteItemFromObjectCaseSensitive(task, "status");
cJSON_AddStringToObject(task, "status", normalized_status);
mutated = 1;
}
if (mutated) {
time_t now = time(NULL);
cJSON_DeleteItemFromObjectCaseSensitive(task, "updated_at");
cJSON_AddNumberToObject(task, "updated_at", (double)now);
}
} else if (strcmp(action_s, "remove") == 0) {
cJSON* id = cJSON_GetObjectItemCaseSensitive(args, "id");
if (!id || !cJSON_IsNumber(id) || id->valuedouble < 1) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage remove requires integer id");
}
int idx = -1;
cJSON* task = task_find_by_id_local(tasks, (int)id->valuedouble, &idx);
if (!task || idx < 0) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task not found");
}
cJSON_DeleteItemFromArray(tasks, idx);
mutated = 1;
} else if (strcmp(action_s, "clear") == 0) {
cJSON* status = cJSON_GetObjectItemCaseSensitive(args, "status");
if (!status || cJSON_IsNull(status)) {
while (cJSON_GetArraySize(tasks) > 0) {
cJSON_DeleteItemFromArray(tasks, 0);
}
mutated = 1;
} else {
if (!cJSON_IsString(status) || !status->valuestring) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage clear status must be string when provided");
}
const char* normalized_status = normalize_task_status_local(status->valuestring);
if (!normalized_status) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage clear status must be pending, active, or done");
}
int i = 0;
while (i < cJSON_GetArraySize(tasks)) {
cJSON* task = cJSON_GetArrayItem(tasks, i);
cJSON* task_status = task ? cJSON_GetObjectItemCaseSensitive(task, "status") : NULL;
const char* task_status_s = (task_status && cJSON_IsString(task_status) && task_status->valuestring)
? task_status->valuestring
: "pending";
if (strcmp(task_status_s, normalized_status) == 0) {
cJSON_DeleteItemFromArray(tasks, i);
mutated = 1;
} else {
i++;
}
}
}
} else if (strcmp(action_s, "replace") == 0) {
cJSON* tasks_in = cJSON_GetObjectItemCaseSensitive(args, "tasks");
if (!tasks_in || !cJSON_IsArray(tasks_in)) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage replace requires array tasks");
}
while (cJSON_GetArraySize(tasks) > 0) {
cJSON_DeleteItemFromArray(tasks, 0);
}
int n = cJSON_GetArraySize(tasks_in);
time_t now = time(NULL);
for (int i = 0; i < n; i++) {
cJSON* text = cJSON_GetArrayItem(tasks_in, i);
if (!text || !cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
continue;
}
cJSON* task = cJSON_CreateObject();
if (!task) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("allocation failure");
}
cJSON_AddNumberToObject(task, "id", next_id++);
cJSON_AddStringToObject(task, "text", text->valuestring);
cJSON_AddStringToObject(task, "status", "pending");
cJSON_AddNumberToObject(task, "created_at", (double)now);
cJSON_AddNumberToObject(task, "updated_at", (double)now);
cJSON_AddItemToArray(tasks, task);
}
mutated = 1;
} else {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("task_manage action must be one of: list, add, update, remove, clear, replace");
}
if (mutated) {
cJSON_DeleteItemFromObjectCaseSensitive(root, "next_id");
cJSON_AddNumberToObject(root, "next_id", next_id);
if (tasks_save_root_local(tasks_path, root) != 0) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error_local("failed to save tasks file");
}
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(args);
cJSON_Delete(root);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "action", action_s);
cJSON_AddStringToObject(out, "path", tasks_path);
cJSON_AddBoolToObject(out, "mutated", mutated ? 1 : 0);
cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(tasks));
cJSON* tasks_dup = cJSON_Duplicate(tasks, 1);
if (!tasks_dup) {
cJSON_Delete(args);
cJSON_Delete(root);
cJSON_Delete(out);
return NULL;
}
cJSON_AddItemToObject(out, "tasks", tasks_dup);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
cJSON_Delete(args);
cJSON_Delete(root);
return json;
}
char* execute_task_list(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
char* listed = execute_task_manage(ctx, "{\"action\":\"list\"}");
if (!listed) return json_error_local("task_list failed to list tasks");
cJSON* listed_root = cJSON_Parse(listed);
if (!listed_root || !cJSON_IsObject(listed_root)) {
cJSON_Delete(listed_root);
free(listed);
return json_error_local("task_list received invalid task_manage response");
}
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(listed_root, "tasks");
int tcount = (tasks && cJSON_IsArray(tasks)) ? cJSON_GetArraySize(tasks) : 0;
size_t cap = 512U;
char* content = (char*)malloc(cap);
if (!content) {
cJSON_Delete(listed_root);
free(listed);
return NULL;
}
size_t used = 0U;
int n = snprintf(content + used,
cap - used,
"## Current Task List\n\n### Current Tasks\n\nYour active task list - short-term working memory for tracking plan steps.\n\n");
if (n < 0) {
free(content);
cJSON_Delete(listed_root);
free(listed);
return NULL;
}
used += (size_t)n;
int written = 0;
for (int i = 0; i < tcount; i++) {
cJSON* task = cJSON_GetArrayItem(tasks, i);
cJSON* id = task ? cJSON_GetObjectItemCaseSensitive(task, "id") : NULL;
cJSON* text = task ? cJSON_GetObjectItemCaseSensitive(task, "text") : NULL;
cJSON* status = task ? cJSON_GetObjectItemCaseSensitive(task, "status") : NULL;
if (!text || !cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
continue;
}
const char* status_s = (status && cJSON_IsString(status) && status->valuestring) ? status->valuestring : "pending";
const char* mark = " ";
if (strcmp(status_s, "done") == 0) mark = "x";
else if (strcmp(status_s, "active") == 0) mark = "-";
int id_value = (id && cJSON_IsNumber(id)) ? (int)id->valuedouble : (i + 1);
int needed = snprintf(NULL, 0, "- [%s] %d. %s\n", mark, id_value, text->valuestring);
if (needed < 0) continue;
if (used + (size_t)needed + 1U > cap) {
size_t next = cap;
while (used + (size_t)needed + 1U > next) next *= 2U;
char* grown = (char*)realloc(content, next);
if (!grown) {
free(content);
cJSON_Delete(listed_root);
free(listed);
return NULL;
}
content = grown;
cap = next;
}
snprintf(content + used, cap - used, "- [%s] %d. %s\n", mark, id_value, text->valuestring);
used += (size_t)needed;
written++;
}
if (written == 0) {
const char* fallback = "- [ ] No active tasks yet.\n";
size_t need = strlen(fallback);
if (used + need + 1U > cap) {
size_t next = cap;
while (used + need + 1U > next) next *= 2U;
char* grown = (char*)realloc(content, next);
if (!grown) {
free(content);
cJSON_Delete(listed_root);
free(listed);
return NULL;
}
content = grown;
cap = next;
}
memcpy(content + used, fallback, need + 1U);
used += need;
}
cJSON* args = parse_args_local(args_json);
if (!args) {
free(content);
cJSON_Delete(listed_root);
free(listed);
return json_error_local("invalid arguments JSON");
}
cJSON_Delete(args);
cJSON* out = cJSON_CreateObject();
if (!out) {
free(content);
cJSON_Delete(listed_root);
free(listed);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", content);
cJSON* tasks_dup = tasks ? cJSON_Duplicate(tasks, 1) : cJSON_CreateArray();
if (!tasks_dup) tasks_dup = cJSON_CreateArray();
cJSON_AddItemToObject(out, "tasks", tasks_dup);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(content);
cJSON_Delete(listed_root);
free(listed);
return json;
}
View File
+3
View File
@@ -0,0 +1,3 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
+215
View File
@@ -0,0 +1,215 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
static char* json_error(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
int tools_init_legacy(tools_context_t* ctx, didactyl_config_t* cfg) {
if (!ctx || !cfg) return -1;
memset(ctx, 0, sizeof(*ctx));
ctx->cfg = cfg;
ctx->trigger_manager = NULL;
ctx->template_sender_tier = 0;
ctx->template_current_user_message = NULL;
return 0;
}
void tools_cleanup_legacy(tools_context_t* ctx) {
if (!ctx) return;
memset(ctx, 0, sizeof(*ctx));
}
char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const char* args_json) {
if (!tool_name) return json_error("missing tool name");
if (strcmp(tool_name, "nostr_post") == 0) {
return execute_nostr_post(args_json);
}
if (strcmp(tool_name, "nostr_delete") == 0) {
return execute_nostr_delete(args_json);
}
if (strcmp(tool_name, "nostr_react") == 0) {
return execute_nostr_react(args_json);
}
if (strcmp(tool_name, "nostr_profile_get") == 0) {
return execute_nostr_profile_get(args_json);
}
if (strcmp(tool_name, "nostr_relay_status") == 0) {
return execute_nostr_relay_status(args_json);
}
if (strcmp(tool_name, "nostr_nip05_lookup") == 0) {
return execute_nostr_nip05_lookup(args_json);
}
if (strcmp(tool_name, "nostr_encode") == 0) {
return execute_nostr_encode(args_json);
}
if (strcmp(tool_name, "nostr_decode") == 0) {
return execute_nostr_decode(args_json);
}
if (strcmp(tool_name, "nostr_dm_send") == 0) {
return execute_nostr_dm_send(args_json);
}
if (strcmp(tool_name, "nostr_relay_info") == 0) {
return execute_nostr_relay_info(args_json);
}
if (strcmp(tool_name, "nostr_encrypt") == 0) {
return execute_nostr_encrypt(ctx, args_json);
}
if (strcmp(tool_name, "nostr_decrypt") == 0) {
return execute_nostr_decrypt(ctx, args_json);
}
if (strcmp(tool_name, "nostr_dm_send_nip17") == 0) {
return execute_nostr_dm_send_nip17(args_json);
}
if (strcmp(tool_name, "nostr_list_manage") == 0) {
return execute_nostr_list_manage(ctx, args_json);
}
if (strcmp(tool_name, "nostr_query") == 0) {
return execute_nostr_query(args_json);
}
if (strcmp(tool_name, "agent_version") == 0) {
return execute_agent_version(args_json);
}
if (strcmp(tool_name, "nostr_pubkey") == 0) {
return execute_nostr_pubkey(ctx, args_json);
}
if (strcmp(tool_name, "nostr_npub") == 0) {
return execute_nostr_npub(ctx, args_json);
}
if (strcmp(tool_name, "my_pubkey") == 0) {
return execute_nostr_pubkey(ctx, args_json);
}
if (strcmp(tool_name, "my_npub") == 0) {
return execute_nostr_npub(ctx, args_json);
}
if (strcmp(tool_name, "local_http_fetch") == 0) {
return execute_local_http_fetch(ctx, args_json);
}
if (strcmp(tool_name, "local_shell_exec") == 0) {
return execute_local_shell_exec(ctx, args_json);
}
if (strcmp(tool_name, "local_file_read") == 0) {
return execute_local_file_read(ctx, args_json);
}
if (strcmp(tool_name, "local_file_write") == 0) {
return execute_local_file_write(ctx, args_json);
}
if (strcmp(tool_name, "skill_create") == 0) {
return execute_skill_create(ctx, args_json);
}
if (strcmp(tool_name, "skill_list") == 0) {
return execute_skill_list(ctx, args_json);
}
if (strcmp(tool_name, "skill_adopt") == 0) {
return execute_skill_adopt(ctx, args_json);
}
if (strcmp(tool_name, "skill_remove") == 0) {
return execute_skill_remove(ctx, args_json);
}
if (strcmp(tool_name, "skill_search") == 0) {
return execute_skill_search(args_json);
}
if (strcmp(tool_name, "skill_edit") == 0) {
return execute_skill_edit(ctx, args_json);
}
if (strcmp(tool_name, "trigger_list") == 0) {
return execute_trigger_list(ctx, args_json);
}
if (strcmp(tool_name, "tool_list") == 0) {
return execute_tool_list(ctx, args_json);
}
if (strcmp(tool_name, "model_get") == 0) {
return execute_model_get(args_json);
}
if (strcmp(tool_name, "model_set") == 0) {
return execute_model_set(ctx, args_json);
}
if (strcmp(tool_name, "model_list") == 0) {
return execute_model_list(args_json);
}
if (strcmp(tool_name, "nostr_post_readme") == 0) {
return execute_nostr_post_readme(ctx, args_json);
}
if (strcmp(tool_name, "nostr_file_md_to_longform_post") == 0) {
return execute_nostr_file_md_to_longform_post(ctx, args_json);
}
if (strcmp(tool_name, "task_manage") == 0) {
return execute_task_manage(ctx, args_json);
}
if (strcmp(tool_name, "admin_identity") == 0) {
return execute_admin_identity(ctx, args_json);
}
if (strcmp(tool_name, "nostr_admin_profile") == 0) {
return execute_nostr_admin_profile(ctx, args_json);
}
if (strcmp(tool_name, "nostr_admin_contacts") == 0) {
return execute_nostr_admin_contacts(ctx, args_json);
}
if (strcmp(tool_name, "nostr_admin_relays") == 0) {
return execute_nostr_admin_relays(ctx, args_json);
}
if (strcmp(tool_name, "nostr_admin_notes") == 0) {
return execute_nostr_admin_notes(ctx, args_json);
}
if (strcmp(tool_name, "task_list") == 0) {
return execute_task_list(ctx, args_json);
}
if (strcmp(tool_name, "message_current") == 0) {
return execute_message_current(ctx, args_json);
}
if (strcmp(tool_name, "agent_identity") == 0) {
return execute_agent_identity(ctx, args_json);
}
if (strcmp(tool_name, "nostr_agent_profile") == 0) {
return execute_nostr_agent_profile(ctx, args_json);
}
if (strcmp(tool_name, "nostr_agent_contacts") == 0) {
return execute_nostr_agent_contacts(ctx, args_json);
}
if (strcmp(tool_name, "nostr_agent_relays") == 0) {
return execute_nostr_agent_relays(ctx, args_json);
}
if (strcmp(tool_name, "nostr_agent_notes") == 0) {
return execute_nostr_agent_notes(ctx, args_json);
}
if (strcmp(tool_name, "my_kind0_profile") == 0) {
return execute_nostr_agent_profile(ctx, args_json);
}
if (strcmp(tool_name, "my_contacts") == 0) {
return execute_nostr_agent_contacts(ctx, args_json);
}
if (strcmp(tool_name, "my_relays") == 0) {
return execute_nostr_agent_relays(ctx, args_json);
}
if (strcmp(tool_name, "my_notes") == 0) {
return execute_nostr_agent_notes(ctx, args_json);
}
return json_error("unknown tool");
}
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg) {
return tools_init_legacy(ctx, cfg);
}
void tools_cleanup(tools_context_t* ctx) {
tools_cleanup_legacy(ctx);
}
char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json) {
return tools_execute_legacy(ctx, tool_name, args_json);
}
+60
View File
@@ -0,0 +1,60 @@
#ifndef DIDACTYL_TOOLS_INTERNAL_H
#define DIDACTYL_TOOLS_INTERNAL_H
#include "tools.h"
int tools_init_legacy(tools_context_t* ctx, didactyl_config_t* cfg);
void tools_cleanup_legacy(tools_context_t* ctx);
char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx);
char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const char* args_json);
char* execute_tool_list(tools_context_t* ctx, const char* args_json);
char* execute_trigger_list(tools_context_t* ctx, const char* args_json);
char* execute_message_current(tools_context_t* ctx, const char* args_json);
char* execute_agent_identity(tools_context_t* ctx, const char* args_json);
char* execute_agent_version(const char* args_json);
char* execute_nostr_agent_profile(tools_context_t* ctx, const char* args_json);
char* execute_nostr_agent_contacts(tools_context_t* ctx, const char* args_json);
char* execute_nostr_agent_relays(tools_context_t* ctx, const char* args_json);
char* execute_nostr_agent_notes(tools_context_t* ctx, const char* args_json);
char* execute_model_get(const char* args_json);
char* execute_model_set(tools_context_t* ctx, const char* args_json);
char* execute_model_list(const char* args_json);
char* execute_nostr_query(const char* args_json);
char* execute_nostr_pubkey(tools_context_t* ctx, const char* args_json);
char* execute_nostr_npub(tools_context_t* ctx, const char* args_json);
char* execute_nostr_encode(const char* args_json);
char* execute_nostr_decode(const char* args_json);
char* execute_nostr_react(const char* args_json);
char* execute_nostr_profile_get(const char* args_json);
char* execute_nostr_nip05_lookup(const char* args_json);
char* execute_nostr_relay_status(const char* args_json);
char* execute_nostr_relay_info(const char* args_json);
char* execute_nostr_dm_send(const char* args_json);
char* execute_nostr_encrypt(tools_context_t* ctx, const char* args_json);
char* execute_nostr_decrypt(tools_context_t* ctx, const char* args_json);
char* execute_nostr_dm_send_nip17(const char* args_json);
char* execute_nostr_delete(const char* args_json);
char* execute_nostr_list_manage(tools_context_t* ctx, const char* args_json);
char* execute_admin_identity(tools_context_t* ctx, const char* args_json);
char* execute_nostr_admin_profile(tools_context_t* ctx, const char* args_json);
char* execute_nostr_admin_contacts(tools_context_t* ctx, const char* args_json);
char* execute_nostr_admin_relays(tools_context_t* ctx, const char* args_json);
char* execute_nostr_admin_notes(tools_context_t* ctx, const char* args_json);
char* execute_task_list(tools_context_t* ctx, const char* args_json);
char* execute_task_manage(tools_context_t* ctx, const char* args_json);
char* execute_local_http_fetch(tools_context_t* ctx, const char* args_json);
char* execute_local_shell_exec(tools_context_t* ctx, const char* args_json);
char* execute_local_file_read(tools_context_t* ctx, const char* args_json);
char* execute_local_file_write(tools_context_t* ctx, const char* args_json);
char* execute_nostr_post(const char* args_json);
char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_json);
char* execute_nostr_file_md_to_longform_post(tools_context_t* ctx, const char* args_json);
char* execute_skill_create(tools_context_t* ctx, const char* args_json);
char* execute_skill_list(tools_context_t* ctx, const char* args_json);
char* execute_skill_adopt(tools_context_t* ctx, const char* args_json);
char* execute_skill_remove(tools_context_t* ctx, const char* args_json);
char* execute_skill_edit(tools_context_t* ctx, const char* args_json);
char* execute_skill_search(const char* args_json);
#endif
File diff suppressed because it is too large Load Diff
+634 -155
View File
@@ -6,16 +6,232 @@
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <ctype.h>
#include "agent.h"
#include "cjson/cJSON.h"
#include "debug.h"
#include "nostr_handler.h"
typedef struct {
trigger_manager_t* mgr;
char skill_d_tag[TRIGGER_SKILL_D_TAG_MAX];
} trigger_subscription_ctx_t;
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;
@@ -47,13 +263,13 @@ static int ensure_capacity(trigger_manager_t* mgr, int needed) {
return 0;
}
static int find_trigger_index_locked(trigger_manager_t* mgr, const char* skill_slug) {
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
static int find_trigger_index_locked(trigger_manager_t* mgr, const char* skill_d_tag) {
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0') {
return -1;
}
for (int i = 0; i < mgr->count; i++) {
if (strncmp(mgr->triggers[i].skill_slug, skill_slug, TRIGGER_SKILL_SLUG_MAX) == 0) {
if (strncmp(mgr->triggers[i].skill_d_tag, skill_d_tag, TRIGGER_SKILL_D_TAG_MAX) == 0) {
return i;
}
}
@@ -80,8 +296,8 @@ static cJSON* find_tag_value_string(cJSON* tags, const char* key) {
return NULL;
}
static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65], char out_slug[65]) {
if (!addr || !out_kind || !out_pubkey || !out_slug) {
static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65], char out_d_tag[65]) {
if (!addr || !out_kind || !out_pubkey || !out_d_tag) {
return -1;
}
@@ -103,84 +319,19 @@ static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65
memcpy(out_pubkey, p1 + 1, 64U);
out_pubkey[64] = '\0';
size_t slug_len = strlen(p2 + 1);
if (slug_len == 0 || slug_len >= 65U) return -1;
memcpy(out_slug, p2 + 1, slug_len + 1U);
size_t d_tag_len = strlen(p2 + 1);
if (d_tag_len == 0 || d_tag_len >= 65U) return -1;
memcpy(out_d_tag, p2 + 1, d_tag_len + 1U);
*out_kind = kind;
return 0;
}
static char* build_template_output(const active_trigger_t* t, cJSON* event, const char* relay_url) {
(void)relay_url;
if (!t || !event) return NULL;
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
const char* content_s = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
const char* pubkey_s = (pubkey && cJSON_IsString(pubkey) && pubkey->valuestring) ? pubkey->valuestring : "unknown";
const char* id_s = (id && cJSON_IsString(id) && id->valuestring) ? id->valuestring : "";
int kind_i = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : -1;
const char* tmpl = t->skill_content;
if (!tmpl || tmpl[0] == '\0') {
return NULL;
}
char out[TRIGGER_SKILL_CONTENT_MAX + 256];
snprintf(out,
sizeof(out),
"%s\n\n[event id=%s kind=%d pubkey=%s]\n%s",
tmpl,
id_s,
kind_i,
pubkey_s,
content_s);
return strdup(out);
}
static void execute_template_action(trigger_manager_t* mgr,
const active_trigger_t* t,
cJSON* event,
const char* relay_url) {
if (!mgr || !mgr->cfg || !t || !event) {
return;
}
char* rendered = build_template_output(t, event, relay_url);
if (!rendered) {
return;
}
if (strncmp(rendered, "DM admin:", 9) == 0) {
const char* body = rendered + 9;
while (*body == ' ') body++;
(void)nostr_handler_send_dm_auto(mgr->cfg->admin.pubkey, body);
} else if (strncmp(rendered, "POST:", 5) == 0) {
const char* body = rendered + 5;
while (*body == ' ') body++;
(void)nostr_handler_publish_kind_event(1, body, NULL, NULL);
} else if (strncmp(rendered, "LOG:", 4) == 0) {
const char* body = rendered + 4;
while (*body == ' ') body++;
DEBUG_INFO("[didactyl] trigger template log (%s): %s", t->skill_slug, body);
} else {
(void)nostr_handler_send_dm_auto(mgr->cfg->admin.pubkey, rendered);
}
free(rendered);
}
static void execute_llm_action(const active_trigger_t* t, cJSON* event, const char* relay_url) {
if (!t || !event) {
return;
}
agent_on_trigger(t->skill_slug, t->skill_content, event, relay_url);
agent_on_trigger(t->skill_d_tag, t->skill_content, event, relay_url);
}
static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* event, const char* relay_url) {
@@ -210,16 +361,110 @@ static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* e
active_trigger_t trigger_copy = *t;
pthread_mutex_unlock(&mgr->mutex);
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
execute_template_action(mgr, &trigger_copy, event, relay_url);
} else {
execute_llm_action(&trigger_copy, event, relay_url);
}
(void)mgr;
execute_llm_action(&trigger_copy, event, relay_url);
pthread_mutex_lock(&mgr->mutex);
return 1;
}
static void on_trigger_subscription_eose(cJSON** events, int event_count, void* user_data) {
(void)events;
(void)event_count;
(void)user_data;
}
static void close_trigger_subscription_locked(active_trigger_t* t) {
if (!t) {
return;
}
if (t->subscription) {
(void)nostr_handler_close_subscription(t->subscription);
t->subscription = NULL;
}
if (t->subscription_ctx) {
free(t->subscription_ctx);
t->subscription_ctx = NULL;
}
}
static void on_trigger_subscription_event(cJSON* event, const char* relay_url, void* user_data) {
trigger_subscription_ctx_t* ctx = (trigger_subscription_ctx_t*)user_data;
if (!ctx || !ctx->mgr || !event) {
return;
}
(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) {
if (!mgr || !t) {
return -1;
}
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;
}
cJSON* filter = cJSON_Parse(t->filter_json);
if (!filter || !cJSON_IsObject(filter)) {
cJSON_Delete(filter);
return -1;
}
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter, "since");
if (!since || !cJSON_IsNumber(since)) {
time_t now = time(NULL);
time_t default_since = now > 30 ? now - 30 : 0;
cJSON_AddNumberToObject(filter, "since", (double)default_since);
}
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter, "limit");
if (!limit || !cJSON_IsNumber(limit)) {
cJSON_AddNumberToObject(filter, "limit", 200);
}
trigger_subscription_ctx_t* ctx = (trigger_subscription_ctx_t*)calloc(1, sizeof(*ctx));
if (!ctx) {
cJSON_Delete(filter);
return -1;
}
ctx->mgr = mgr;
snprintf(ctx->skill_d_tag, sizeof(ctx->skill_d_tag), "%s", t->skill_d_tag);
nostr_pool_subscription_t* sub = nostr_handler_subscribe_with_filter(
filter,
on_trigger_subscription_event,
on_trigger_subscription_eose,
ctx,
0,
0,
NOSTR_POOL_EOSE_FULL_SET,
30,
120);
cJSON_Delete(filter);
if (!sub) {
free(ctx);
return -1;
}
t->subscription = sub;
t->subscription_ctx = ctx;
return 0;
}
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg) {
if (!mgr || !cfg) {
return -1;
@@ -286,8 +531,8 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
int kind = 0;
char pubkey[65] = {0};
char slug[65] = {0};
if (parse_address_tag(val->valuestring, &kind, pubkey, slug) != 0) {
char d_tag[65] = {0};
if (parse_address_tag(val->valuestring, &kind, pubkey, d_tag) != 0) {
continue;
}
@@ -309,7 +554,7 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
if (!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
strcmp(ev_pubkey->valuestring, pubkey) != 0 ||
!ev_d || !cJSON_IsString(ev_d) || !ev_d->valuestring ||
strcmp(ev_d->valuestring, slug) != 0) {
strcmp(ev_d->valuestring, d_tag) != 0) {
continue;
}
cJSON* dup = cJSON_Duplicate(ev, 1);
@@ -338,7 +583,7 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
cJSON_AddItemToObject(skill_filter, "kinds", sk_kinds);
cJSON_AddItemToArray(sk_authors, cJSON_CreateString(pubkey));
cJSON_AddItemToObject(skill_filter, "authors", sk_authors);
cJSON_AddItemToArray(d_values, cJSON_CreateString(slug));
cJSON_AddItemToArray(d_values, cJSON_CreateString(d_tag));
cJSON_AddItemToObject(skill_filter, "#d", d_values);
cJSON_AddNumberToObject(skill_filter, "limit", 1);
@@ -373,10 +618,17 @@ 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') {
trigger_action_type_t at = (strcmp(action_s, "template") == 0) ? TRIGGER_ACTION_TEMPLATE : TRIGGER_ACTION_LLM;
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') {
if (strcmp(action_s, "template") == 0) {
DEBUG_WARN("[didactyl] trigger action template is deprecated; forcing llm for d_tag=%s", d_tag);
}
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
if (trigger_manager_add(mgr, slug, content->valuestring, filter_s, at, is_enabled) == 0) {
if (trigger_manager_add(mgr, d_tag, content->valuestring, filter_s, TRIGGER_ACTION_LLM, trigger_s, is_enabled) == 0) {
loaded++;
}
}
@@ -389,26 +641,109 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
return 0;
}
int trigger_manager_add(trigger_manager_t* mgr,
const char* skill_slug,
const char* content,
const char* filter_json,
trigger_action_type_t action_type,
int enabled) {
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) {
if (!mgr || !mgr->cfg) {
return -1;
}
if (action_type != TRIGGER_ACTION_LLM && action_type != TRIGGER_ACTION_TEMPLATE) {
int loaded = 0;
int considered = 0;
for (int i = 0; i < mgr->cfg->startup_event_count; i++) {
startup_event_t* ev = &mgr->cfg->startup_events[i];
if (!ev) {
continue;
}
if (ev->kind != 31123 && ev->kind != 31124) {
continue;
}
considered++;
if (!ev->content || ev->content[0] == '\0' || !ev->tags_json || ev->tags_json[0] == '\0') {
continue;
}
cJSON* tags = cJSON_Parse(ev->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
DEBUG_WARN("[didactyl] startup trigger skip: invalid tags_json for startup event index=%d", i);
continue;
}
cJSON* d = find_tag_value_string(tags, "d");
cJSON* trigger = find_tag_value_string(tags, "trigger");
cJSON* filter = find_tag_value_string(tags, "filter");
cJSON* action = find_tag_value_string(tags, "action");
cJSON* enabled = find_tag_value_string(tags, "enabled");
const char* d_tag = (d && cJSON_IsString(d) && d->valuestring) ? d->valuestring : NULL;
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL;
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
if (!d_tag || d_tag[0] == '\0') {
cJSON_Delete(tags);
DEBUG_WARN("[didactyl] startup trigger skip: missing d tag for startup event index=%d", i);
continue;
}
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;
}
if (strcmp(action_s, "template") == 0) {
DEBUG_WARN("[didactyl] startup trigger action template is deprecated; forcing llm for d_tag=%s", d_tag);
}
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, TRIGGER_ACTION_LLM, trigger_s, is_enabled) == 0) {
loaded++;
DEBUG_INFO("[didactyl] startup trigger registered d_tag=%s action=%s enabled=%d", d_tag, "llm", is_enabled);
} else {
DEBUG_WARN("[didactyl] startup trigger failed d_tag=%s", d_tag);
}
cJSON_Delete(tags);
}
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from startup config (considered=%d)", loaded, considered);
return 0;
}
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) {
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) {
return -1;
}
if (action_type == TRIGGER_ACTION_TEMPLATE) {
DEBUG_WARN("[didactyl] trigger action template is deprecated; forcing llm for d_tag=%s", skill_d_tag);
action_type = TRIGGER_ACTION_LLM;
}
if (action_type != TRIGGER_ACTION_LLM) {
return -1;
}
pthread_mutex_lock(&mgr->mutex);
int existing = find_trigger_index_locked(mgr, skill_slug);
int existing = find_trigger_index_locked(mgr, skill_d_tag);
if (existing >= 0) {
pthread_mutex_unlock(&mgr->mutex);
return trigger_manager_update(mgr, skill_slug, 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;
@@ -426,35 +761,51 @@ int trigger_manager_add(trigger_manager_t* mgr,
active_trigger_t* t = &mgr->triggers[mgr->count];
memset(t, 0, sizeof(*t));
snprintf(t->skill_slug, sizeof(t->skill_slug), "%s", skill_slug);
snprintf(t->skill_d_tag, sizeof(t->skill_d_tag), "%s", skill_d_tag);
snprintf(t->skill_content, sizeof(t->skill_content), "%s", content);
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
t->action_type = action_type;
t->enabled = clamp_enabled(enabled);
t->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;
if (register_trigger_subscription_locked(mgr, t) != 0) {
pthread_mutex_unlock(&mgr->mutex);
DEBUG_WARN("[didactyl] trigger add rejected: failed to create subscription d_tag=%s", skill_d_tag);
memset(t, 0, sizeof(*t));
return -1;
}
mgr->count++;
pthread_mutex_unlock(&mgr->mutex);
DEBUG_INFO("[didactyl] trigger added slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
DEBUG_INFO("[didactyl] trigger added d_tag=%s action=%d enabled=%d", skill_d_tag, (int)action_type, clamp_enabled(enabled));
return 0;
}
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug) {
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag) {
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0') {
return -1;
}
pthread_mutex_lock(&mgr->mutex);
int idx = find_trigger_index_locked(mgr, skill_slug);
int idx = find_trigger_index_locked(mgr, skill_d_tag);
if (idx < 0) {
pthread_mutex_unlock(&mgr->mutex);
return 0;
}
close_trigger_subscription_locked(&mgr->triggers[idx]);
if (idx < mgr->count - 1) {
memmove(&mgr->triggers[idx],
&mgr->triggers[idx + 1],
@@ -466,30 +817,144 @@ int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug) {
pthread_mutex_unlock(&mgr->mutex);
DEBUG_INFO("[didactyl] trigger removed slug=%s", skill_slug);
DEBUG_INFO("[didactyl] trigger removed d_tag=%s", skill_d_tag);
return 0;
}
int trigger_manager_update(trigger_manager_t* mgr,
const char* skill_slug,
const char* content,
const char* filter_json,
trigger_action_type_t action_type,
int enabled) {
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
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;
}
if (action_type != TRIGGER_ACTION_LLM && action_type != TRIGGER_ACTION_TEMPLATE) {
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);
}
}
(void)mgr;
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;
}
if (action_type == TRIGGER_ACTION_TEMPLATE) {
DEBUG_WARN("[didactyl] trigger action template is deprecated; forcing llm for d_tag=%s", skill_d_tag);
action_type = TRIGGER_ACTION_LLM;
}
if (action_type != TRIGGER_ACTION_LLM) {
return -1;
}
pthread_mutex_lock(&mgr->mutex);
int idx = find_trigger_index_locked(mgr, skill_slug);
int idx = find_trigger_index_locked(mgr, skill_d_tag);
if (idx < 0) {
pthread_mutex_unlock(&mgr->mutex);
return trigger_manager_add(mgr, skill_slug, 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];
@@ -497,10 +962,22 @@ 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);
DEBUG_WARN("[didactyl] trigger update failed to (re)subscribe d_tag=%s", skill_d_tag);
return -1;
}
pthread_mutex_unlock(&mgr->mutex);
DEBUG_INFO("[didactyl] trigger updated slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
DEBUG_INFO("[didactyl] trigger updated d_tag=%s action=%d enabled=%d", skill_d_tag, (int)action_type, clamp_enabled(enabled));
return 0;
}
@@ -522,67 +999,64 @@ int trigger_manager_active_count(trigger_manager_t* mgr) {
}
int trigger_manager_poll(trigger_manager_t* mgr) {
if (!mgr || !mgr->cfg || !mgr->cfg->triggers.enabled) {
return 0;
if (!mgr) {
return -1;
}
time_t now = time(NULL);
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 10) {
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 = mgr->count;
for (int i = 0; i < count; i++) {
active_trigger_t snapshot = mgr->triggers[i];
if (!snapshot.enabled || snapshot.filter_json[0] == '\0') {
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;
}
cJSON* filter = cJSON_Parse(snapshot.filter_json);
if (!filter || !cJSON_IsObject(filter)) {
cJSON_Delete(filter);
const char* expr = (t->cron_expr[0] != '\0') ? t->cron_expr : t->filter_json;
if (!cron_matches_now(expr, now)) {
continue;
}
time_t since = snapshot.last_seen_created_at > 0 ? snapshot.last_seen_created_at + 1 : now - 30;
if (since < 0) since = 0;
cJSON_AddNumberToObject(filter, "since", (double)since);
cJSON_AddNumberToObject(filter, "limit", 8);
char* events_json = nostr_handler_query_json(filter, 1200);
cJSON_Delete(filter);
if (!events_json) {
if (t->last_cron_fire > 0 && (now - t->last_cron_fire) < 50) {
continue;
}
cJSON* events = cJSON_Parse(events_json);
free(events_json);
if (!events || !cJSON_IsArray(events)) {
cJSON_Delete(events);
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);
(void)mgr;
execute_llm_action(&trigger_copy, event, "cron");
cJSON_Delete(event);
fired++;
}
int n = cJSON_GetArraySize(events);
for (int e = 0; e < n; e++) {
cJSON* ev = cJSON_GetArrayItem(events, e);
if (!ev || !cJSON_IsObject(ev)) continue;
int idx = find_trigger_index_locked(mgr, snapshot.skill_slug);
if (idx < 0) {
continue;
}
(void)maybe_fire_trigger_locked(mgr, idx, ev, NULL);
}
cJSON_Delete(events);
pthread_mutex_lock(&mgr->mutex);
}
pthread_mutex_unlock(&mgr->mutex);
return 0;
return fired;
}
char* trigger_manager_status_json(trigger_manager_t* mgr) {
@@ -623,10 +1097,12 @@ char* trigger_manager_status_json(trigger_manager_t* mgr) {
continue;
}
cJSON_AddStringToObject(item, "skill_slug", t->skill_slug);
cJSON_AddStringToObject(item, "skill_d_tag", t->skill_d_tag);
cJSON_AddStringToObject(item, "filter_json", t->filter_json);
cJSON_AddStringToObject(item, "action", t->action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm");
cJSON_AddStringToObject(item, "type", trigger_type_to_string(t->trigger_type));
cJSON_AddStringToObject(item, "action", "llm");
cJSON_AddBoolToObject(item, "enabled", t->enabled ? 1 : 0);
cJSON_AddBoolToObject(item, "subscribed", t->subscription ? 1 : 0);
cJSON_AddNumberToObject(item, "last_fired", (double)t->last_fired);
cJSON_AddNumberToObject(item, "last_seen_created_at", (double)t->last_seen_created_at);
cJSON_AddItemToArray(arr, item);
@@ -648,6 +1124,9 @@ void trigger_manager_cleanup(trigger_manager_t* mgr) {
}
pthread_mutex_lock(&mgr->mutex);
for (int i = 0; i < mgr->count; i++) {
close_trigger_subscription_locked(&mgr->triggers[i]);
}
free(mgr->triggers);
mgr->triggers = NULL;
mgr->count = 0;
+34 -5
View File
@@ -5,9 +5,11 @@
#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
#define TRIGGER_SKILL_SLUG_MAX 65
#define TRIGGER_SKILL_D_TAG_MAX 65
#define TRIGGER_SKILL_CONTENT_MAX 4096
#define TRIGGER_FILTER_JSON_MAX 2048
@@ -16,14 +18,26 @@ 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_slug[TRIGGER_SKILL_SLUG_MAX];
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;
typedef struct trigger_manager {
@@ -37,19 +51,34 @@ 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_slug,
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_slug);
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_slug,
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);
+1 -1
View File
@@ -1 +1 @@
{"tasks":[],"next_id":9}
{"tasks":[{"id":9,"text":"Publish README.md as longform post","created_at":1773326251,"status":"done","updated_at":1773326287},{"id":10,"text":"Publish docs/CONTEXT.md as longform post","created_at":1773326251,"status":"done","updated_at":1773326310},{"id":11,"text":"Publish docs/TOOLS.md as longform post","created_at":1773326251,"status":"done","updated_at":1773326320},{"id":12,"text":"Publish docs/SUBSCRIPTIONS.md as longform post","created_at":1773326251,"status":"done","updated_at":1773327937},{"id":13,"text":"DM admin after each publish","created_at":1773326251,"status":"done","updated_at":1773327937},{"id":14,"text":"Return final summary","created_at":1773326251,"status":"done","updated_at":1773327937}],"next_id":15}
Executable
+104
View File
@@ -0,0 +1,104 @@
#!/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)"
elif [[ "$HTTP_CODE" == "000" || -z "$HTTP_CODE" ]]; then
pass "Webhook endpoint probe skipped (no local API listener detected)"
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
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
set -euo pipefail
LOG_FILE="${1:-}"
if [ -z "$LOG_FILE" ]; then
for candidate in "./didactyl.log" "./debug.log" "./context.log.md"; do
if [ -f "$candidate" ]; then
LOG_FILE="$candidate"
break
fi
done
fi
if [ -z "$LOG_FILE" ] || [ ! -f "$LOG_FILE" ]; then
echo "Log file not found." >&2
echo "Tried defaults: ./didactyl.log ./debug.log ./context.log.md" >&2
echo "Usage: $0 [path/to/logfile]" >&2
exit 1
fi
echo "Following numbered LLM API calls from: $LOG_FILE"
echo "(Press Ctrl+C to stop)"
# Count only outbound provider calls from src/llm.c:
# [didactyl] llm request: method=... url=...
START_API=$(tail -n 20000 "$LOG_FILE" | grep -Ec "\\[didactyl\\] llm request:" || true)
[ -z "$START_API" ] && START_API=0
echo "Starting api_call count from: $START_API"
tail -n 0 -F "$LOG_FILE" \
| awk -v api_start="$START_API" '
BEGIN { api = api_start }
{
line = $0
if (line !~ /\[didactyl\] llm request:/) next
api++
method = "?"
url = "?"
if (match(line, /method=[A-Z]+/)) {
method = substr(line, RSTART + 7, RLENGTH - 7)
}
if (match(line, /url=[^ ]+/)) {
url = substr(line, RSTART + 4, RLENGTH - 4)
}
printf "%d) %s %s\n", api, method, url
fflush()
}
'