From 1ab2034b2d9065b61c63f35d62190f2c26990a38 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 22 Mar 2026 10:19:11 -0400 Subject: [PATCH] v0.2.0 - Release v0.2.0: trigger-scoped skill architecture breaking changes --- README.md | 203 ++++--- docs/CONTEXT.md | 177 ++++-- docs/SKILLS.md | 621 +++++++++++++++------ docs/TOOLS.md | 96 +++- plans/trigger_scoped_skill_architecture.md | 206 +++++++ src/agent.c | 395 ++++++++++++- src/main.h | 6 +- src/prompt_template.c | 12 +- src/tools/tools.h | 2 + src/tools/tools_dispatch.c | 2 + src/trigger_manager.c | 210 +++---- src/trigger_manager.h | 10 +- 12 files changed, 1406 insertions(+), 534 deletions(-) create mode 100644 plans/trigger_scoped_skill_architecture.md diff --git a/README.md b/README.md index 1f33979..b19b918 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Didactyl - ### A decentralized, censorship-resistant agentic network. Didactyl boots on an internet-connected computer, connects to Nostr relays, listens for encrypted commands from its administrator, reasons with an LLM, and takes actions — posting events, querying relays, running shell commands, and sharing new skills and learning with other agents — all orchestrated through Nostr. @@ -55,31 +54,31 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e 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.1.21 +## Current Status — v0.2.0 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.1.21 — Fix segfault in nostr_http_request by copying CURLINFO_CONTENT_TYPE before curl cleanup and update debug deploy script to deploy *_debug binary for symbolized coredumps +> Last release update: v0.2.0 — Release v0.2.0: trigger-scoped skill architecture breaking changes - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected -- Loads base system context from default skill content (first-run from `genesis.jsonc`, subsequent runs from adopted skills on Nostr) +- Loads base system context from default skill content (first-run from`genesis.jsonc`, subsequent runs from adopted skills on Nostr) - Verifies Nostr event signatures before processing inbound messages - Applies privilege tiers: ADMIN (tools), WoT (chat-only), STRANGER (configurable canned reply or ignore) - Subscribes to admin context kinds (`0`,`3`,`10002`,`1`) for WoT + contextual awareness - Builds LLM context from default/adopted skill templates (`---template---`) with named sections, variable resolution, and per-provider content overrides; falls back to hardcoded assembly if no template present -- Adopted skills injected into context automatically from the agent's `10123` adoption list +- Adopted skills injected into context automatically from the agent's`10123` adoption list - Supports tool-calling loop with configurable max turns and local safety limits -- Triggered skills — Nostr event filters that fire skill execution automatically with `template` (deterministic) or `llm` (context-aware) actions; see [`docs/SKILLS.md`](docs/SKILLS.md) +- Triggered skills — Nostr event filters that fire skill execution automatically with`template` (deterministic) or`llm` (context-aware) actions; see[`docs/SKILLS.md`](docs/SKILLS.md) - Deduplicates inbound messages via event-ID cache and FNV-1a fingerprint debounce window -- Appends every outbound LLM context payload to [`context.log`](context.log) -- Localhost HTTP admin API on port `8484` — inspect context, run prompts, compare variants, change model at runtime +- Appends every outbound LLM context payload to[`context.log`](context.log) +- Localhost HTTP admin API on port`8484` — inspect context, run prompts, compare variants, change model at runtime ## Quick Start ### Download binary (recommended) -1. Download the latest release binary from Gitea: [https://git.laantungir.net/laantungir/didactyl/releases](https://git.laantungir.net/laantungir/didactyl/releases) +1. Download the latest release binary from Gitea:[https://git.laantungir.net/laantungir/didactyl/releases](https://git.laantungir.net/laantungir/didactyl/releases) 2. Make it executable and run it: ```bash @@ -209,8 +208,8 @@ Options: Interactive setup notes: -- First menu asks whether you are starting a **new agent** or an **existing agent**. -- Menus use first-letter hotkeys (case-insensitive), with `q`/`x` as quit/back shortcuts. +- First menu asks whether you are starting a**new agent** or an**existing agent**. +- Menus use first-letter hotkeys (case-insensitive), with`q`/`x` as quit/back shortcuts. - Existing-agent mode attempts to recover relay/admin/LLM config from Nostr before asking for missing fields. CLI debugger notes: @@ -247,10 +246,10 @@ node ./didactyl-chat-cli.js Optional environment variables: -- `DIDACTYL_API_BASE_URL` (default: `https://127.0.0.1:8484`) +- `DIDACTYL_API_BASE_URL` (default:`https://127.0.0.1:8484`) - `DIDACTYL_MODEL` (optional model override) -- `DIDACTYL_MAX_TURNS` (default: `4`) -- `DIDACTYL_INSECURE_TLS` (default: `1`, set `0` to enforce certificate verification) +- `DIDACTYL_MAX_TURNS` (default:`4`) +- `DIDACTYL_INSECURE_TLS` (default:`1`, set`0` to enforce certificate verification) Example: @@ -291,13 +290,13 @@ The CLI prints each message block with a speaker label (`You` / `Didactyl`) and Didactyl uses a two-layer skill model: authors publish skill definitions, and adopters publish which skills they use. -- `31123` — **Public Skill Definition** (replaceable by `d` tag) - - `content` is JSON with instruction fields like `description` and `template` - - `d=` (example: `d=long_form_note`) -- `31124` — **Private Skill Definition** (same schema as `31123`, private scope) - - `d=` (example: `d=admin_ops`) -- `10123` — **Skill Adoption List** - - tags contain one or more `a` references to selected skills +- `31123` —**Public Skill Definition** (replaceable by`d` tag) + - `content` is JSON with instruction fields like`description` and`template` + - `d=` (example:`d=long_form_note`) +- `31124` —**Private Skill Definition** (same schema as`31123`, private scope) + - `d=` (example:`d=admin_ops`) +- `10123` —**Skill Adoption List** + - tags contain one or more`a` references to selected skills Skills are composed by adoption list order and per-skill template resolution (no context modes). @@ -309,16 +308,16 @@ Skills are shared across Nostr without any centralized registry or approval proc ### How it works -1. **Publish**: An author publishes a skill as a kind `31123` event. The `content` field contains the skill body (markdown or structured JSON). The `d` tag is the skill's slug (e.g. `long_form_note`). -2. **Adopt**: An agent that wants to use a skill adds an `a`-tag reference to its kind `10123` adoption list. This is a public, replaceable event — anyone can see which skills an agent uses. -3. **Discover**: A new user queries `{"kinds": [10123], "authors": []}` to see which skills their web of trust has adopted. The most-referenced `31123` addresses are the most popular skills — no rating system needed. -4. **Improve**: Anyone can publish their own `31123` with the same slug but a different pubkey. If their version is better, people adopt it instead. Competition happens through adoption, not through a store ranking. +1. **Publish**: An author publishes a skill as a kind`31123` event. The`content` field contains the skill body (markdown or structured JSON). The`d` tag is the skill's slug (e.g.`long_form_note`). +2. **Adopt**: An agent that wants to use a skill adds an`a`-tag reference to its kind`10123` adoption list. This is a public, replaceable event — anyone can see which skills an agent uses. +3. **Discover**: A new user queries`{"kinds": [10123], "authors": []}` to see which skills their web of trust has adopted. The most-referenced`31123` addresses are the most popular skills — no rating system needed. +4. **Improve**: Anyone can publish their own`31123` with the same slug but a different pubkey. If their version is better, people adopt it instead. Competition happens through adoption, not through a store ranking. ### Why this works - **No gatekeeper**: Skills are just Nostr events. Anyone can publish one. - **WoT as curation**: You see what people you trust actually use, not what an algorithm promotes. -- **Visible adoption**: The `10123` list is public. Popularity is a countable fact, not a manipulable score. +- **Visible adoption**: The`10123` list is public. Popularity is a countable fact, not a manipulable score. - **Censorship resistant**: Skills live on relays. No single entity can remove a skill from the network. ## Startup @@ -327,10 +326,10 @@ Didactyl startup behavior is configured in [`genesis.jsonc`](genesis.jsonc) unde Startup model: -- First run is detected by checking for an existing kind `10002` relay-list event from the agent pubkey. -- On first run, events in `startup_events` are published to connected relays. +- First run is detected by checking for an existing kind`10002` relay-list event from the agent pubkey. +- On first run, events in`startup_events` are published to connected relays. - On subsequent runs, startup publish is skipped and relay/config state is loaded from Nostr. -- Identity can be supplied at runtime via `--nsec` or `DIDACTYL_NSEC`. +- Identity can be supplied at runtime via`--nsec` or`DIDACTYL_NSEC`. See [`docs/GENESIS.md`](docs/GENESIS.md) for full boot semantics. @@ -339,8 +338,8 @@ See [`docs/GENESIS.md`](docs/GENESIS.md) for full boot semantics. Didactyl builds tier-aware, template-driven context: - **ADMIN** request context is assembled from adopted skill templates. -- Template variables like `{{nostr_admin_profile}}` are resolved by executing tools at render time. -- Triggered skill invocations can override runtime execution parameters via trigger tags (`llm`, `max_tokens`, `temperature`, `seed`, `tools`). +- Template variables like`{{nostr_admin_profile}}` are resolved by executing tools at render time. +- Triggered skill invocations can override runtime execution parameters via trigger tags (`llm`,`max_tokens`,`temperature`,`seed`,`tools`). - **WoT** request context remains chat-only. - **STRANGER** behavior follows configured security policy. @@ -360,17 +359,17 @@ See [`docs/TOOLS.md`](docs/TOOLS.md) for the canonical tool catalog and interfac A localhost-only HTTP API on port `8484` (configurable) for agent inspection and prompt crafting. Enable with `"api": {"enabled": true}` in config. -| Endpoint | Purpose | -|---|---| -| `GET /api/status` | Agent name, version, pubkey, relay count, trigger count | -| `GET /api/context/current` | Full LLM context messages array | -| `GET /api/context/parts` | Context broken into named parts with token estimates | -| `POST /api/prompt/run-simple` | Run a simple system+user prompt, no tools | -| `POST /api/prompt/run` | Run a full messages array with tools enabled | -| `POST /api/prompt/compare` | A/B compare two prompt variants | -| `GET /api/model` | Current LLM model config | -| `PUT /api/model` | Change model at runtime (persisted in encrypted config events) | -| `GET /api/models` | List available models from provider | +| Endpoint | Purpose | +| ------------------------------- | -------------------------------------------------------------- | +| `GET /api/status` | Agent name, version, pubkey, relay count, trigger count | +| `GET /api/context/current` | Full LLM context messages array | +| `GET /api/context/parts` | Context broken into named parts with token estimates | +| `POST /api/prompt/run-simple` | Run a simple system+user prompt, no tools | +| `POST /api/prompt/run` | Run a full messages array with tools enabled | +| `POST /api/prompt/compare` | A/B compare two prompt variants | +| `GET /api/model` | Current LLM model config | +| `PUT /api/model` | Change model at runtime (persisted in encrypted config events) | +| `GET /api/models` | List available models from provider | Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_frontend.md`](plans/admin_web_frontend.md). @@ -408,75 +407,99 @@ Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_ All dependencies are statically linked into the binary at build time. No system libraries are required at runtime. -| Dependency | Purpose | Source | -|---|---|---| -| nostr_core_lib | Nostr protocol: keys, events, NIPs, relay pool | Workspace (sibling directory) | -| cJSON | JSON parsing | Bundled in nostr_core_lib | -| libcurl | HTTPS for LLM API calls | Statically linked (Alpine/MUSL) | -| libssl / libcrypto | TLS for WebSocket relay connections | Statically linked (Alpine/MUSL) | -| libsecp256k1 | Schnorr signatures, ECDH | Statically linked (Alpine/MUSL) | +| Dependency | Purpose | Source | +| ------------------ | ---------------------------------------------- | ------------------------------- | +| nostr_core_lib | Nostr protocol: keys, events, NIPs, relay pool | Workspace (sibling directory) | +| cJSON | JSON parsing | Bundled in nostr_core_lib | +| libcurl | HTTPS for LLM API calls | Statically linked (Alpine/MUSL) | +| 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: +Didactyl's long-term architecture goal is **zero filesystem dependency after first boot**. A geneisis.jsonc file can assist the first boot, but first boot can also occur throught the command line or TUI wizard. The plan: -1. **First boot** — Read `genesis.jsonc`, publish startup identity/skill/adoption events to relays. -2. **Subsequent boots** — Start with only `nsec` (CLI/env), detect initialized state from kind `10002`, and load durable state from Nostr. +1. **First boot** — Read`genesis.jsonc`, publish startup identity/skill/adoption events to relays. +2. **Subsequent boots** — Start with only`nsec` (CLI/env), detect initialized state from kind`10002`, and load durable state from Nostr. 3. **True portability** — Start your agent from any computer; keys are sufficient and 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. +### Browser-Based Didactyl + +Because all agent state lives on Nostr, Didactyl can run in a browser — no server required. A browser-based Didactyl would load the agent's identity from an nsec, fetch its skills and adoption list from relays, and execute skills using browser-available tools. + +This increases decentralization: if the server running your agent goes down, you can boot the same agent in a browser window. The agent continues living, just in a different environment with a different set of available tools — like an artist working from a vacation home instead of their full studio. + +Not all tools would be available in a browser runtime. For example, `shell_exec` and `local_file_read` require a host OS. But Nostr operations, HTTP fetches, and LLM calls work fine in a browser. Skills declare their requirements via `requires_tool` tags, so the browser runtime knows which skills it can execute and which it cannot. + +``` +Didactyl C binary (full studio) Didactyl Browser (vacation home) +════════════════════════════ ════════════════════════════════ +Available tools: Available tools: + ✓ http_fetch ✓ http_fetch + ✓ shell_exec ✗ shell_exec + ✓ nostr_query, nostr_dm, nostr_post ✓ nostr_query, nostr_dm, nostr_post + ✓ memory_read, memory_write ✓ memory_read, memory_write + ✓ local_file_read, local_file_write ✗ local_file_read, local_file_write + ✓ blossom_upload, blossom_download ✓ blossom_upload, blossom_download + +Skills that work: ALL Skills that work: those whose + requires_tool tags are satisfied +``` + ### 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 | -| Base/default behavior skill | Kind 31124 | Implemented | -| Trigger definitions | Tags on skill events | Implemented | +| 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 | +| Base/default behavior skill | Kind 31124 | Implemented | +| Trigger definitions | Tags on skill events | Implemented | ### What still needs migration -| Data | Current Location | Target | -|---|---|---| -| Admin pubkey | `genesis.jsonc` fallback | Dedicated agent config event / contact-graph derivation | -| LLM provider/key | `genesis.jsonc` fallback | Encrypted kind 30078 app-specific event | -| Security tiers | `genesis.jsonc` fallback | Agent config event on Nostr | -| API settings | local runtime flags | Local-only (not published) | +| Data | Current Location | Target | +| ---------------- | -------------------------- | ------------------------------------------------------- | +| Admin pubkey | `genesis.jsonc` fallback | Dedicated agent config event / contact-graph derivation | +| LLM provider/key | `genesis.jsonc` fallback | Encrypted kind 30078 app-specific event | +| Security tiers | `genesis.jsonc` fallback | Agent config event on Nostr | +| API settings | local runtime flags | Local-only (not published) | ## Roadmap -- [x] MVP chat agent — DM in, LLM response out -- [x] Relay pool with auto-reconnect and status logging -- [x] Per-relay startup publish on relay-connected transitions -- [x] Runtime diagnostics — relay health, message flow, event kind publish logs -- [x] Tool-calling loop (nostr_post, nostr_query, local_shell_exec, local_file_read, local_file_write) -- [x] Context assembly with startup events + recent DM history -- [x] Context payload logging to [`context.log`](context.log) -- [x] Skill kind definitions (`31123` Public Skill, `31124` Private Skill) -- [x] Skill adoption list (`10123`) for WoT-driven discovery -- [x] Signature verification on all inbound events -- [x] Privilege tiers — ADMIN (tools), WoT (chat-only), STRANGER (canned reply/ignore) -- [x] Admin context subscription (kind 0, 3, 10002, 1) with WoT contact extraction -- [x] Message deduplication (event-ID cache + FNV-1a fingerprint debounce) -- [x] Adopted skills injected into LLM context automatically -- [x] Triggered skills — Nostr event filters that fire skill execution automatically -- [x] Localhost HTTP admin API — context inspection, prompt crafting, A/B comparison -- [x] Runtime model switching via `model_set` tool (persisted in encrypted config events) -- [x] Skill-embedded prompt templates (`---template---`) — configurable context order, variable resolution, provider overrides -- [ ] Runtime skill loading from adopted `31123` events on relays +- [X] MVP chat agent — DM in, LLM response out +- [X] Relay pool with auto-reconnect and status logging +- [X] Per-relay startup publish on relay-connected transitions +- [X] Runtime diagnostics — relay health, message flow, event kind publish logs +- [X] Tool-calling loop (nostr_post, nostr_query, local_shell_exec, local_file_read, local_file_write) +- [X] Context assembly with startup events + recent DM history +- [X] Context payload logging to[`context.log`](context.log) +- [X] Skill kind definitions (`31123` Public Skill,`31124` Private Skill) +- [X] Skill adoption list (`10123`) for WoT-driven discovery +- [X] Signature verification on all inbound events +- [X] Privilege tiers — ADMIN (tools), WoT (chat-only), STRANGER (canned reply/ignore) +- [X] Admin context subscription (kind 0, 3, 10002, 1) with WoT contact extraction +- [X] Message deduplication (event-ID cache + FNV-1a fingerprint debounce) +- [X] Adopted skills injected into LLM context automatically +- [X] Triggered skills — Nostr event filters that fire skill execution automatically +- [X] Localhost HTTP admin API — context inspection, prompt crafting, A/B comparison +- [X] Runtime model switching via`model_set` tool (persisted in encrypted config events) +- [X] Skill-embedded prompt templates (`---template---`) — configurable context order, variable resolution, provider overrides +- [ ] Runtime skill loading from adopted`31123` events on relays - [ ] Skill discovery CLI/tool (query WoT adoption lists) - [ ] Upgrade to NIP-17 gift-wrapped DMs -- [x] NIP-44 encrypted private skills (`31124`) -- [x] Nostr-native data storage (kind 30078 app-specific events) +- [X] NIP-44 encrypted private skills (`31124`) +- [X] Nostr-native data storage (kind 30078 app-specific events) - [ ] Blossom blob storage integration - [ ] Agent-to-agent communication +- [ ] Browser-based Didactyl runtime (WASM/JS — same agent, browser-available tools only) +- [ ] Tool schema standardization — canonical tool definitions as Nostr events for cross-app skill portability ## License TBD - diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index d7a23f7..95d7bb1 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -12,6 +12,8 @@ Context is not just a prompt string; it is the full request payload: 2. **Tool schemas** — JSON descriptions of callable tools 3. **Model parameters** — model, temperature, max tokens, seed, etc. +The context window is composed of **skills** — blocks of markdown instructions stacked together. See [SKILLS.md](SKILLS.md) for the canonical skill specification. + --- ## OpenAI-Compatible Chat Format @@ -44,45 +46,96 @@ Didactyl uses OpenAI-compatible chat completions. | Role | Purpose | |------|---------| -| `system` | Instructions and injected context | +| `system` | Instructions and injected context (from skills) | | `user` | Input message or trigger payload | | `assistant` | Model responses / tool call envelopes | | `tool` | Tool execution results fed back to model | --- -## Context Assembly Model +## Context Assembly — Two-Layer Model -Didactyl uses **skill composition by adoption order**. +Context is assembled using a two-layer model driven by triggers and the adoption list. -There are no context modes. +### Layer 1: Triggered Skills + +When a trigger event occurs (DM, cron, subscription, webhook, chain), Didactyl walks the `10123` adoption list and finds all skills whose trigger matches the current event. These skills form layer 1 of the context window, in adoption-list order. + +Only triggered skills can be in layer 1 — the trigger system is what puts them there. + +### Layer 2: Referenced Skills + +Within each layer 1 skill, `{{skill_d_tag}}` template variables resolve to adopted skills' content. These are embedded inline — the same way tool-based template variables are resolved. + +Non-triggered skills (skills with no trigger tags) can only enter the context window via layer 2 references. ### Assembly Steps -1. Load adopted skills from kind `10123`. -2. Resolve adopted skills in list order. -3. Expand each skill template variables via tools. -4. Append resolved skill output to messages in that same order. -5. Append live input (DM text or triggering event payload). -6. Attach tool schemas. -7. Apply execution parameters from trigger tags (if invoked via trigger). +``` +Trigger event occurs (DM, cron, subscription, webhook, chain) + │ + ├─ Walk adoption list (10123) + │ │ + │ ├─ Skill has trigger matching this event? + │ │ ├─ YES → add to context (layer 1) + │ │ │ └─ Resolve {{...}} references (layer 2) + │ │ │ ├─ Known tool? → execute tool, insert result + │ │ │ ├─ Adopted skill d-tag? → insert skill content + │ │ │ └─ Unknown? → resolve to empty + │ │ │ + │ │ └─ NO → skip (not in this context) + │ │ + │ └─ Continue to next skill in list + │ + ├─ Append triggering event payload + │ └─ For DM triggers: always append raw message content + │ + ├─ Attach tool schemas (filtered by skill requires_tool tags) + │ + ├─ Apply execution parameters (llm, temperature, max_tokens) + │ └─ Walk LLM fallback chain until usable model found + │ + └─ Send to LLM +``` -```mermaid -flowchart TD - INPUT[Input: DM or trigger event] --> ADOPT[Load adopted skills from kind 10123] - ADOPT --> ORDER[Resolve skills in listed order] - ORDER --> EXPAND[Expand template variables via tools] - EXPAND --> MESSAGES[Append resolved skill messages] - MESSAGES --> LIVE[Append live input message/event] - LIVE --> TOOLS[Attach tool schemas] - TOOLS --> PARAMS[Apply runtime params from trigger tags] - PARAMS --> LLM[Send to LLM] +### Visualization + +``` +╔══════════════════════════════════════════╗ +║ CONTEXT WINDOW ║ +║ ║ +║ ┌────────────────────────────────────┐ ║ +║ │ Layer 1: personality (dm trigger) │ ║ +║ │ │ ║ +║ │ ┌──────────────────────────────┐ │ ║ +║ │ │ Layer 2: {{identity}} │ │ ║ +║ │ │ You are Didactyl. npub1... │ │ ║ +║ │ └──────────────────────────────┘ │ ║ +║ │ │ ║ +║ │ You speak concisely and directly. │ ║ +║ └────────────────────────────────────┘ ║ +║ ┌────────────────────────────────────┐ ║ +║ │ Layer 1: chat (dm trigger) │ ║ +║ │ │ ║ +║ │ Respond helpfully to the admin. │ ║ +║ │ Use tools as needed. │ ║ +║ │ │ ║ +║ │ tools: [nostr_query, nostr_dm] │ ║ +║ └────────────────────────────────────┘ ║ +║ ┌────────────────────────────────────┐ ║ +║ │ DM content (always last) │ ║ +║ │ │ ║ +║ │ "Who mentioned me today?" │ ║ +║ └────────────────────────────────────┘ ║ +║ ║ +╚══════════════════════════════════════════╝ ``` ### Why Order Matters -- Earlier adopted skills usually establish broad behavior. -- Later adopted skills can refine or narrow behavior. +- Earlier skills in the adoption list appear first in the context window. +- Earlier instructions generally set broader tone/policy. +- Later instructions can narrow/specialize behavior. - If instructions conflict, prompt-order effects apply. --- @@ -91,53 +144,64 @@ flowchart TD | Part | Source | Description | |------|--------|-------------| -| Skill templates | Adopted skill events | Core instructions assembled in order | -| Resolved variables | Tool outputs | Runtime data inserted into templates | -| Conversation history | DM history/events | Recent dialogue context | -| Live input | DM or trigger event | Current request payload | -| Tool schemas | Tool registry | Capability declaration for tool calling | -| Runtime params | Trigger tags | LLM/tool limits for this execution | +| Layer 1 skills | Triggered skills from adoption list | Skills whose trigger matches the current event, in adoption-list order | +| Layer 2 skills | `{{skill_d_tag}}` references | Adopted skills embedded inside layer 1 skills | +| Resolved variables | Tool outputs | Runtime data inserted into templates via `{{...}}` | +| Triggering event | DM content / event payload | Current request — always appended after skills | +| Tool schemas | Tool registry, filtered by skill `requires_tool` tags | Capability declaration for tool calling | +| Runtime params | Skill event tags + LLM fallback chain | Model, temperature, max_tokens, etc. | --- -## Template Variables Are Tool Calls +## Template Variable Resolution -Template variables resolve through tool execution. +When the engine encounters `{{variable_name}}` in a skill template: -Example: +1. **Check known tools** — if it matches a tool name, execute the tool and insert the result +2. **Check adopted skills** — if it matches an adopted skill's d-tag, insert that skill's content (layer 2) +3. **Neither** — resolve to empty (for portability) -- `{{admin_profile}}` resolves by running `nostr_admin_profile` -- `{{admin_notes}}` resolves by running `nostr_admin_notes` +This means `{{admin_profile}}` calls the `nostr_admin_profile` tool, while `{{identity}}` inserts the adopted "identity" skill's content. The skill author doesn't need to know which is which — the resolution is transparent. -Unknown variables should resolve to empty values for portability. +See [SKILLS.md — Template Variables](SKILLS.md#template-variables) for the full variable table. --- -## Trigger Runtime Parameters +## Execution Parameters -Execution controls are attached to trigger tags, not skill content: +Execution parameters control the LLM call: which model, what temperature, how many tokens, which tools. -- `llm` -- `max_tokens` -- `temperature` -- `seed` -- `tools` - -Resolution order for a triggered run: +### Resolution Order 1. Start with agent defaults -2. Apply trigger tag overrides -3. Execute -4. Restore defaults +2. Apply top-level execution tags from the skill event +3. Walk the `llm` fallback chain until a usable model is found +4. Execute +5. Restore defaults after the run + +### LLM Fallback Chain + +The `llm` tag uses a CSS font-stack style fallback: `provider/model, provider/model, ..., capability_keyword` + +``` +["llm", "anthropic/claude-sonnet-4-20250514, openai/gpt-4o-mini, cheap"] +``` + +See [SKILLS.md — LLM Fallback Chain](SKILLS.md#llm-fallback-chain) for the full format and capability keywords. --- -## Triggered vs Adopted Use +## Context Compaction -- **Adopted skill (`10123`)**: contributes context/instructions -- **Triggered skill**: contributes context and may supply execution overrides via tags +During long-running tool loops, the context window can grow as tool call/result pairs accumulate. When context approaches the model's token limit, compaction prevents overflow: -This separation keeps composition simple while allowing per-trigger runtime control. +1. Track approximate token usage of the messages array +2. When approaching ~70% of the model's context window, inject a summarization request +3. The LLM summarizes progress so far into a condensed form +4. Replace detailed tool history with the summary +5. Continue execution with the compacted context + +This allows skills to run complex multi-step tasks without hitting context limits. --- @@ -145,12 +209,13 @@ This separation keeps composition simple while allowing per-trigger runtime cont Context cost is controlled by: -- Adoption-list ordering and skill count -- Conversation-history limits -- Skill/template truncation limits -- Per-trigger model/runtime parameter choices +- Number of triggered skills matching the event (layer 1 count) +- Size of referenced skills (layer 2 content) +- Tool call/result accumulation during execution +- Context compaction threshold (~70% of model window) +- Per-skill model/runtime parameter choices -Use runtime context inspection endpoints to see the exact payload before LLM calls. +Use runtime context inspection endpoints (`GET /api/context/current`, `GET /api/context/parts`) to see the exact payload before LLM calls. --- diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 8fb184f..47c3b18 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -1,211 +1,470 @@ -# Didactyl — Skills +# Skills See also: [CONTEXT.md](CONTEXT.md) · [TOOLS.md](TOOLS.md) -## Overview +## The Context Window Is Made of Skills -A skill is a **set of instructions for the LLM** stored as a Nostr event. +Every time an LLM runs, it receives a context window — the complete set of instructions and information it needs to reason and respond. In this system, **the context window is broken up into units called skills.** -Skills teach the agent how to accomplish tasks — the LLM reads the instructions, reasons about them, and uses tools to take action. +``` +╔══════════════════════════════════════════╗ +║ CONTEXT WINDOW ║ +║ ║ +║ ┌────────────────────────────────────┐ ║ +║ │ Skill 1: personality │ ║ +║ │ │ ║ +║ │ You speak concisely and directly. │ ║ +║ │ You favor technical precision. │ ║ +║ │ │ ║ +║ │ tools: [my_name, my_npub] │ ║ +║ └────────────────────────────────────┘ ║ +║ ┌────────────────────────────────────┐ ║ +║ │ Skill 2: chat │ ║ +║ │ │ ║ +║ │ Respond helpfully to the admin. │ ║ +║ │ Use tools as needed. │ ║ +║ │ │ ║ +║ │ tools: [nostr_query, nostr_dm] │ ║ +║ └────────────────────────────────────┘ ║ +║ ║ +╚══════════════════════════════════════════╝ +``` + +Each skill is a block of instructions. The context window is a stack of these blocks. Different events produce different stacks — a DM conversation has one set of skills, a scheduled cron job has a completely different set. + +A skill is a **set of instructions for an LLM** stored as a Nostr event. Skills teach an LLM how to accomplish tasks — the LLM reads the instructions, reasons about them, and uses tools to take action. Think of it like a woodshop: a **skill** is knowing how to carve — technique, judgment, decision-making. A **tool** is the chisel. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. -Skills are portable, shareable, and discoverable as Nostr events. +Skills are portable, shareable, and discoverable as Nostr events. They are not specific to any single application — any app that can read Nostr events and call an LLM can use skills. + +--- + +## What Is a Skill? + +A skill has two orthogonal properties: + +- **Triggers** — A skill may have trigger tags, or not. If it has triggers, a runtime can fire it automatically when matching events occur. Triggered skills appear in the context window when their trigger matches (layer 1). +- **References** — A skill may be referenced by other skills via`{{skill_d_tag}}` template variables, or not. If referenced, its content is included inside the referencing skill (layer 2). + +These properties are independent. A skill can have triggers and be referenced. A skill can have triggers and never be referenced. A skill can have no triggers and only exist to be referenced. A skill can have neither (though that would be inert). --- ## Skill Events -| Kind | Purpose | Replaceable? | -|---|---|---| -| `31123` | Public skill definition | Yes, by d-tag | -| `31124` | Private skill definition | Yes, by d-tag | -| `10123` | Skill adoption list | Yes, single per pubkey | +| Kind | Purpose | Replaceable? | +| --------- | ------------------------ | ---------------------- | +| `31123` | Public skill definition | Yes, by d-tag | +| `31124` | Private skill definition | Yes, by d-tag | +| `10123` | Skill adoption list | Yes, single per pubkey | --- ## Skill Content -Skill `content` is JSON and should focus on **instructions**, not transport/runtime controls. +The `content` field of a skill event IS the template — markdown instructions that go directly into the context window. No JSON wrapper. The description lives in a tag, not in content. ```json { "kind": 31123, - "content": { - "description": "Check spelling and grammar", - "template": "system:\nYou are a spelling and grammar checker.\n\nRules:\n- Fix spelling errors\n- Fix grammar errors\n- Preserve original formatting\n- Return ONLY the corrected text, no explanations\n\nuser:\n{{message}}" - }, + "content": "system:\n# Spelling and Grammar Checker\n\nYou are a spelling and grammar checker.\n\n## Rules\n\n- Fix spelling errors\n- Fix grammar errors\n- Preserve original formatting\n- Return **ONLY** the corrected text, no explanations\n\nuser:\n{{message}}", "tags": [ ["d", "spellcheck"], - ["scope", "public"], - ["description", "Spelling and grammar checker"] + ["description", "Check spelling and grammar"], + ["trigger", "dm"], + ["filter", "{\"from\":\"admin\"}"], + ["llm", "openai/gpt-4o-mini, cheap"], + ["temperature", "0"] ] } ``` -### Content Fields - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `description` | string | — | Human-readable description | -| `template` | string | — | Skill instructions/template text (recommended) | -| `base` | bool | `false` | Optional hint that this skill is intended as base/default behavior | - -> Execution parameters (`llm`, `max_tokens`, `temperature`, `seed`, `tools`) are defined on **trigger tags**, not in content. +- **`content`** — the template in markdown. May include `{{...}}` template variables and `system:` / `user:` role markers. This is what goes into the context window. +- **`["description", "..."]`** — human-readable description for discovery and UI display. +- Each `["tag", "value"]` is a separate tag on the Nostr event. +- The `llm` tag uses a CSS font-stack style fallback chain. See [LLM Fallback Chain](#llm-fallback-chain). --- -## Composition Model (No Context Modes) +## Two-Layer Context Model -Skills do **not** use `context_mode`. +When a skill executes, the context window is built in two layers: -Context is assembled from kind `10123` adoption list order: +- **Layer 1:** Triggered skills whose trigger matches the current event, ordered by their position in the adoption list (`10123`). Only triggered skills can be in layer 1 — the trigger system is what puts them there. +- **Layer 2:** Skills embedded inside layer 1 skills via`{{skill_d_tag}}` template references. These are resolved inline, the same way tool-based template variables are resolved. -1. Resolve adopted skills in list order. -2. Expand each skill template/tool variables. -3. Append each resolved skill as context messages in that same order. -4. Append live user/trigger input. +``` +CONTEXT WINDOW — Admin DM arrives +═══════════════════════════════════════════════════ -The adoption list itself is the context definition. +Layer 1: Triggered skills matching "dm/admin" +(ordered by adoption list) -- One adopted skill = single-skill behavior. -- Multiple adopted skills = layered behavior in explicit order. -- Reordering `10123` changes precedence naturally. +┌─────────────────────────────────────────────────┐ +│ TRIGGERED SKILL: personality │ +│ trigger: dm, filter: {"from":"admin"} │ +│ │ +│ ┌───────────────────────────────────────┐ │ +│ │ {{identity}} (adopted, no trigger) │ │ +│ │ You are Didactyl. npub1abc...xyz │ │ +│ └───────────────────────────────────────┘ │ +│ │ +│ You speak concisely and directly. │ +│ You favor technical precision. │ +│ You use dry humor sparingly. │ +│ │ +├─────────────────────────────────────────────────┤ +│ TRIGGERED SKILL: chat │ +│ trigger: dm, filter: {"from":"admin"} │ +│ │ +│ Respond helpfully. Use tools as needed. │ +│ │ +│ tools: [nostr_query, nostr_dm, nostr_post, │ +│ memory_read, memory_write] │ +│ │ +├─────────────────────────────────────────────────┤ +│ DM CONTENT (always last for dm triggers) │ +│ │ +│ "Hey, can you check who mentioned me today?" │ +│ │ +└─────────────────────────────────────────────────┘ +``` -### Ordering Convention +``` +CONTEXT WINDOW — Cron fires at noon +═══════════════════════════════════════════════════ -- Earlier adopted skills generally set broader tone/policy. -- Later adopted skills can narrow/specialize behavior. -- If multiple skills strongly conflict, normal prompt-order effects apply. +Layer 1: Triggered skills matching "cron/0 12 * * *" +(ordered by adoption list) -### Template Variables Are Tool Calls +┌─────────────────────────────────────────────────┐ +│ TRIGGERED SKILL: readme-monitor │ +│ trigger: cron, filter: 0 12 * * * │ +│ │ +│ ┌───────────────────────────────────────┐ │ +│ │ {{identity}} (adopted, no trigger) │ │ +│ │ You are Didactyl. npub1abc...xyz │ │ +│ └───────────────────────────────────────┘ │ +│ │ +│ Check the readme at the configured URL. │ +│ Compare with last known version in memory. │ +│ If changed: post it and DM admin a summary. │ +│ │ +│ tools: [http_fetch, memory_read, │ +│ memory_write, nostr_post, nostr_dm] │ +│ │ +├─────────────────────────────────────────────────┤ +│ TRIGGERING EVENT │ +│ │ +│ {"type":"cron","filter":"0 12 * * *", │ +│ "created_at":1742641200} │ +│ │ +└─────────────────────────────────────────────────┘ -Template variables are tool calls. + personality is NOT here — it has a dm trigger, + not a cron trigger, so it doesn't match layer 1. -When the engine encounters `{{admin_profile}}`, it runs the corresponding tool and inserts the result into context. - -| Variable | Tool Called | Description | -|----------|-----------|-------------| -| `{{agent_identity}}` | `agent_identity` | Agent identity block | -| `{{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` | Triggering event JSON | - -Unknown variables should resolve to empty values for portability. + identity IS here — but only as layer 2 inside + readme-monitor, because readme-monitor includes + {{identity}} in its template. +``` --- -## Triggered Skills +## Adoption List (`10123`) -A triggered skill has a trigger source attached. +The adoption list serves two purposes: -Didactyl trigger types: +1. **Registry** — makes skills available for`{{skill_d_tag}}` resolution (layer 2 inclusion) +2. **Ordering** — determines the order of layer 1 triggered skills in the context window -- `nostr-subscription` -- `webhook` -- `cron` -- `chain` -- `dm` +```json +{ + "kind": 10123, + "tags": [ + ["a", "31124::identity"], + ["a", "31124::personality"], + ["a", "31123::chat"], + ["a", "31123::readme-monitor"] + ] +} +``` + +- `identity` — no trigger, adopted so triggered skills can include it via`{{identity}}` (layer 2) +- `personality` — has`["trigger", "dm"]`, appears in layer 1 for DM events. Also referenceable via`{{personality}}` by other skills (layer 2). +- `chat` — has`["trigger", "dm"]`, appears in layer 1 for DM events after personality (adoption list order) +- `readme-monitor` — has`["trigger", "cron"]`, appears in layer 1 for cron events. Its template includes`{{identity}}` (layer 2). + +Skills NOT in this list but with trigger tags are still armed — they fire when their trigger matches, but they execute in isolation (no layer 2 skill references available, only built-in variables). + +--- + +## Template Variables + +Template variables resolve through tool execution or skill lookup. + +When the engine encounters `{{variable_name}}`: + +1. Check if it matches a known tool — if so, execute the tool and insert the result +2. Check if it matches an adopted skill's d-tag — if so, insert that skill's content (layer 2) +3. If neither matches, resolve to empty (for portability) + +### Built-in Variables + +| Variable | Resolution | Description | +| ------------------------ | ---------------------------- | ---------------------- | +| `{{agent_identity}}` | `agent_identity` tool | Agent identity block | +| `{{admin_profile}}` | `nostr_admin_profile` tool | Admin kind 0 profile | +| `{{admin_notes}}` | `nostr_admin_notes` tool | Admin recent notes | +| `{{admin_relays}}` | `nostr_admin_relays` tool | Admin relay list | +| `{{dm_history}}` | *(expand directive)* | Recent DM conversation | +| `{{message}}` | *(built-in)* | Current user message | +| `{{triggering_event}}` | `trigger_event` tool | Triggering event JSON | + +### Skill Reference Variables + +| Variable | Resolution | Description | +| ------------------- | ------------------------------ | ----------------------- | +| `{{skill_d_tag}}` | Look up adopted skill by d-tag | Layer 2 skill inclusion | + +Unknown variables resolve to empty values for portability. + +--- + +## Triggers + +A skill with trigger tags can be fired automatically by a runtime when matching events occur. + +### Trigger Types + +- `dm` — Direct message received +- `cron` — Scheduled time expression +- `nostr-subscription` — Nostr event matches a filter +- `webhook` — HTTP request received +- `chain` — Another skill completed execution ### Trigger Tags -| Tag | Required | Description | -|---|---|---| -| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, `chain`, `dm` | -| `filter` | Yes | Type-specific filter | -| `enabled` | No | Whether active (default: `true`) | -| `llm` | No | Model spec fallback chain (e.g., `openai/gpt-4o-mini, cheap`) | -| `max_tokens` | No | Max output tokens for this trigger execution | -| `temperature` | No | Sampling temperature for this trigger execution | -| `seed` | No | Optional deterministic seed where supported | -| `tools` | No | `true` for all tools, `false` for none, or CSV list of allowed tool names | +| Tag | Required | Description | +| ----------- | -------- | --------------------------------- | +| `trigger` | Yes | Trigger type | +| `filter` | Yes | Type-specific filter | + +If a skill is in the adoption list, its triggers are active. There is no separate `enabled` flag — adoption IS enablement. + +### Execution Parameter Tags + +These tags can appear at the top level of a skill event (defaults for any app) or on trigger-specific contexts (runtime overrides). + +| Tag | Description | +| --------------- | -------------------------------------------------------------------------------------------------- | +| `llm` | Model spec with fallback chain (see below) | +| `max_tokens` | Max output tokens | +| `temperature` | Sampling temperature | +| `seed` | Optional deterministic seed | + +### LLM Fallback Chain + +The `llm` tag uses a CSS font-stack style fallback chain. The runtime tries each entry in order, falling back to the next if the previous is unavailable. + +Format: `provider/model, provider/model, ..., capability_keyword` + +``` +["llm", "anthropic/claude-sonnet-4-20250514, openai/gpt-4o-mini, cheap"] +``` + +This means: + +1. Try `anthropic/claude-sonnet-4-20250514` first +2. If unavailable, try `openai/gpt-4o-mini` +3. If unavailable, use whatever the runtime considers `cheap` + +Each entry can be: + +- **`provider/model`** — specific provider and model (e.g., `anthropic/claude-sonnet-4-20250514`) +- **`model`** — model name only, use the default provider (e.g., `gpt-4o-mini`) +- **Capability keyword** — abstract tier the runtime resolves to its best available option + +Capability keywords: + +| Keyword | Meaning | +|---------|---------| +| `cheap` | Lowest cost model available | +| `fast` | Lowest latency model available | +| `best` | Highest capability model available | +| `default` | Use the agent/app default model | + +Examples: + +``` +["llm", "openai/gpt-4o-mini"] -- specific model, no fallback +["llm", "openai/gpt-4o-mini, cheap"] -- try gpt-4o-mini, fall back to cheapest +["llm", "anthropic/claude-opus-4-20250514, openai/gpt-4o, best"] -- try opus, then gpt-4o, then best available +["llm", "fast"] -- just use the fastest available +["llm", "default"] -- use agent/app default +``` + +This is important for portability: a skill published with `["llm", "anthropic/claude-sonnet-4-20250514, cheap"]` works on any runtime — if the runtime doesn't have Anthropic access, it falls back to its cheapest available model. ### Execution Parameter Resolution When a trigger fires: -1. Start with agent defaults. -2. Apply execution tags from that trigger (`llm`, `max_tokens`, `temperature`, `seed`, `tools`). -3. Execute skill with those effective runtime settings. -4. Restore defaults after the run. - -### Adopted vs Triggered Behavior - -- **Adopted skill (`10123`)**: contributes instructions/template to context. -- **Triggered skill**: contributes instructions **and** may define execution parameters via trigger tags. +1. Start with agent/app defaults. +2. Apply top-level execution tags from the skill event. +3. Walk the `llm` fallback chain until a usable model is found. +4. Apply trigger-specific overrides if present. +5. Execute skill with those effective runtime settings. +6. Restore defaults after the run. --- -## Trigger Types +## Trigger Type Details -### `nostr-subscription` +Each example below shows a complete skill event. Every `["tag", "value"]` pair is a separate tag on the Nostr event. -`filter` is a JSON-encoded Nostr subscription filter. +### `dm` + +Fires when a direct message is received. `filter` is JSON with sender scope: `{"from":"admin"}`, `{"from":"wot"}`, or `{"from":"any"}`. + +For DM triggers, the raw message content is always appended to the end of the context window. ```json -["trigger", "nostr-subscription"], -["filter", "{\"#p\":[\"\"],\"kinds\":[1]}"], -["llm", "openai/gpt-4o-mini, cheap"], -["temperature", "0"], -["tools", "nostr_query,nostr_dm"], -["enabled", "true"] -``` - -### `webhook` - -`filter` is required and can be `{}`; webhook firing happens via HTTP. - -```json -["trigger", "webhook"], -["filter", "{}"], -["llm", "default"], -["tools", "true"], -["enabled", "true"] +{ + "kind": 31123, + "content": "{{identity}}\n\nRespond helpfully to the admin.", + "tags": [ + ["d", "chat"], + ["description", "Chat with admin"], + ["trigger", "dm"], + ["filter", "{\"from\":\"admin\"}"], + ["llm", "default"], + ["requires_skill", "identity"] + ] +} ``` ### `cron` -`filter` is a standard 5-field cron expression: `minute hour day-of-month month day-of-week`. +Fires on a schedule. `filter` is a standard 5-field cron expression: `minute hour day-of-month month day-of-week`. ```json -["trigger", "cron"], -["filter", "*/5 * * * *"], -["llm", "openai/gpt-4o-mini"], -["max_tokens", "300"], -["enabled", "true"] +{ + "kind": 31123, + "content": "{{identity}}\n\nCheck the readme at the configured URL. If changed, post it and DM admin.", + "tags": [ + ["d", "readme-monitor"], + ["description", "Check readme for changes at noon"], + ["trigger", "cron"], + ["filter", "0 12 * * *"], + ["llm", "openai/gpt-4o-mini, cheap"], + ["max_tokens", "300"], + ["requires_tool", "http_fetch"], + ["requires_tool", "memory_read"], + ["requires_tool", "memory_write"], + ["requires_skill", "identity"] + ] +} +``` + +### `nostr-subscription` + +Fires when a Nostr event matches a subscription filter. `filter` is a JSON-encoded Nostr subscription filter. + +```json +{ + "kind": 31123, + "content": "{{identity}}\n\nWhen the triggering event mentions Bitcoin or Lightning, summarize and DM admin.", + "tags": [ + ["d", "mention-monitor"], + ["description", "Monitor mentions and summarize"], + ["trigger", "nostr-subscription"], + ["filter", "{\"#p\":[\"\"],\"kinds\":[1]}"], + ["llm", "openai/gpt-4o-mini, cheap"], + ["temperature", "0"], + ["requires_tool", "nostr_query"], + ["requires_tool", "nostr_dm"], + ["requires_skill", "identity"] + ] +} +``` + +### `webhook` + +Fires when an HTTP request is received. `filter` can be `{}` (match all). + +```json +{ + "kind": 31123, + "content": "{{identity}}\n\nProcess the webhook payload and take appropriate action.", + "tags": [ + ["d", "webhook-handler"], + ["description", "Process incoming webhook"], + ["trigger", "webhook"], + ["filter", "{}"] + ] +} ``` ### `chain` -`filter` is the source skill `d` tag to chain from. +Fires when another skill completes execution. `filter` is the source skill's `d` tag. ```json -["trigger", "chain"], -["filter", "source-skill-d-tag"], -["llm", "default"], -["enabled", "true"] +{ + "kind": 31123, + "content": "{{identity}}\n\nReview the output from the previous skill and DM admin a summary.", + "tags": [ + ["d", "readme-reviewer"], + ["description", "Review results from readme monitor"], + ["trigger", "chain"], + ["filter", "readme-monitor"], + ["llm", "openai/gpt-4o-mini, cheap"], + ["requires_skill", "identity"] + ] +} ``` -### `dm` +--- -`filter` is JSON with sender scope: +## Requirements Tags -- `{"from":"admin"}` -- `{"from":"wot"}` -- `{"from":"any"}` +Skills declare what they need to run. Apps use these tags to determine which skills are compatible with their available capabilities. + +| Tag | Description | +| ------------------ | ---------------------------------------------------------------- | +| `requires_tool` | A tool that must be available for this skill to function | +| `requires_skill` | An adopted skill that must be present for `{{...}}` resolution | +| `optional_tool` | A tool that enhances the skill but is not required | ```json -["trigger", "dm"], -["filter", "{\"from\":\"admin\"}"], -["llm", "default"], -["tools", "true"], -["enabled", "true"] +["requires_tool", "http_fetch"], +["requires_tool", "memory_read"], +["requires_tool", "memory_write"], +["requires_tool", "nostr_post"], +["requires_skill", "identity"], +["optional_tool", "nostr_dm"] ``` +### How Apps Use Requirements + +``` +App starts up + │ + ├─ Knows its available tools/capabilities + │ + ├─ Fetches user's adopted skills from 10123 + │ + ├─ For each skill, checks requires_tool tags + │ ├─ All required tools available? → skill is usable + │ └─ Missing required tools? → skill is disabled + │ + └─ Presents only usable skills to the user +``` + +Tool names in requirements tags are **capability names**, not implementation names. `http_fetch` is a capability — a C binary implements it with libcurl, a browser implements it with fetch(), a mobile app implements it with its HTTP library. The capability is the same; the implementation varies. + --- ## Private Skill Encoding (`31124`) @@ -214,10 +473,10 @@ Private skills use NIP-44 encryption on event `content`. Rules for kind `31124`: -- Keep `d` tag exposed so the event stays addressable/replaceable. +- Keep`d` tag exposed so the event stays addressable/replaceable. - Move non-`d` metadata into plaintext payload before encryption. -- Encrypt full payload with NIP-44 and store ciphertext in event `content`. -- On receive: resolve by `d`, decrypt `content`, then read content + private tags. +- Encrypt full payload with NIP-44 and store ciphertext in event`content`. +- On receive: resolve by`d`, decrypt`content`, then read content + private tags. ### Private Skill Event (on relay) @@ -235,19 +494,17 @@ Rules for kind `31124`: ```json { - "content": { - "description": "Monitor mentions and DM summaries", - "template": "When {{triggering_event}} includes Bitcoin or Lightning, summarize and DM admin." - }, + "content": "{{identity}}\n\nWhen {{triggering_event}} includes Bitcoin or Lightning, summarize and DM admin.", "private_tags": [ + ["description", "Monitor mentions and DM summaries"], ["scope", "private"], ["trigger", "nostr-subscription"], ["filter", "{\"#p\":[\"\"],\"kinds\":[1]}"], - ["llm", "openai/gpt-4o-mini, fast"], + ["llm", "openai/gpt-4o-mini, cheap"], ["temperature", "0"], - ["seed", "42"], - ["tools", "nostr_query,nostr_dm"], - ["enabled", "true"] + ["requires_tool", "nostr_query"], + ["requires_tool", "nostr_dm"], + ["requires_skill", "identity"] ] } ``` @@ -256,62 +513,78 @@ Rules for kind `31124`: ## Execution Flow -```mermaid -sequenceDiagram - participant Input as Message/Trigger - participant Dispatch as Dispatcher - participant Adopt as Adoption Resolver (10123) - participant Ctx as Context Assembler - participant Trig as Trigger Runtime Params - participant LLM as LLM API - - Input->>Dispatch: message or trigger event - Dispatch->>Adopt: load adopted skills in list order - Adopt-->>Ctx: ordered skill templates - Ctx->>Ctx: resolve template variables via tools - Dispatch->>Trig: resolve trigger execution tags - Trig-->>LLM: model + max_tokens + temperature + seed + tool policy - Ctx->>LLM: composed messages - LLM-->>Input: response +``` +Trigger event occurs (DM, cron, subscription, webhook, chain) + │ + ├─ Walk adoption list (10123) + │ │ + │ ├─ For each skill whose trigger matches this event: + │ │ ├─ Resolve template variables (tools + skill references) + │ │ └─ Add to context (layer 1) + │ │ + │ └─ Skills whose trigger does NOT match: skip + │ + ├─ Append triggering event payload + │ └─ For DM triggers: always append raw message content + │ + ├─ Apply execution parameters (llm, temperature, max_tokens, tools) + │ + └─ Send to LLM → multi-turn tool loop → response ``` --- ## Limits and Safety -| Limit | Default | Description | -|---|---|---| -| 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 | +| Limit | Default | Description | +| ----------------------- | ------------- | ----------------------------- | +| 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 | --- ## Storage on Nostr -| Data | Storage | -|---|---| -| Skills | Kind 31123/31124 events | -| Adopted skills | Kind 10123 event | -| Trigger definitions + execution params | Tags on skill events | +| Data | Storage | +| -------------------------------------- | ----------------------- | +| Skills | Kind 31123/31124 events | +| Adopted skills | Kind 10123 event | +| Trigger definitions + execution params | Tags on skill events | +| Requirements declarations | Tags on skill events | --- -## Portability Guidelines +## Portability -To keep skills reusable across agents/clients: +Skills are Nostr events. Any application that can read Nostr events and call a skill which will call an llm. Skills are not specific to Didactyl or any single runtime. -- Prefer generic instructions over implementation-specific assumptions. -- Treat tool names as capabilities, not platform internals. -- Resolve unknown variables safely (empty result, no hard failure). -- Keep app-specific tags optional (`["app","didactyl"]`). +### Use Cases Beyond Didactyl -A skill should still be useful even when some variables/tools are unavailable. +- **Word processor** — "Check spelling and grammar" button triggers a spellcheck skill +- **Browser extension** — Highlight text, run a summarization skill +- **Mobile app** — Voice input triggers a transcription skill +- **Browser-based agent** — Same agent, different runtime, different available tools + +### Portability Guidelines + +| Guideline | Rationale | +| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| Use `{{message}}` for user input | Universal — every app has user input | +| Declare requirements via `requires_tool` / `requires_skill` tags | Lets apps filter to compatible skills | +| Put default execution params as top-level tags | Any app can read `llm`, `temperature`, etc. | +| Keep trigger tags as optional runtime hints | Apps without trigger systems ignore them | +| Resolve unknown variables to empty | Ensures graceful degradation | +| Prefer self-contained skills for maximum portability | Skills with `{{skill_d_tag}}` references need the adoption ecosystem | +| Treat tool names as capabilities, not implementations | `http_fetch` works in C, browser, mobile — same capability, different implementation | + +A skill should still be useful even when some variables, tools, or referenced skills are unavailable. --- ## Related Documentation -- Tool architecture and complete tool catalog: [TOOLS.md](TOOLS.md) -- Context assembly model: [CONTEXT.md](CONTEXT.md) -- Project overview/runtime behavior: [README.md](../README.md) +- Tool architecture and complete tool catalog:[TOOLS.md](TOOLS.md) +- Context assembly model:[CONTEXT.md](CONTEXT.md) +- Project overview/runtime behavior:[README.md](../README.md) + diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 7825023..c910700 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,10 +1,10 @@ # Didactyl — Tools -See also: [SKILLS.md](SKILLS.md) +See also: [SKILLS.md](SKILLS.md) · [CONTEXT.md](CONTEXT.md) ## Overview -Didactyl is a **Nostr-first sovereign AI agent** that receives commands via encrypted DMs, reasons with an LLM, and takes actions through **tools**. +Didactyl is a **Nostr-first sovereign AI agent** that reasons with an LLM and takes actions through **tools**. This document describes the tools architecture: what tools are, how they are exposed to the model, how execution loops work, what tool categories exist, and how access is gated. @@ -18,32 +18,35 @@ A **skill** teaches the agent *how* to carve — the technique, the judgment, th ## How Tools Work -1. Admin sends a DM to didactyl -2. The agent builds an LLM request with the message, context, and a JSON schema of all available tools -3. The LLM decides whether to call a tool or respond directly -4. If a tool is called, didactyl executes it and feeds the result back to the LLM -5. The loop repeats until the LLM produces a final text response -6. The response is sent back as a DM +1. A trigger fires (DM, cron, subscription, webhook, or chain) — see [SKILLS.md](SKILLS.md) +2. The agent builds the context window from triggered skills and their `{{...}}` references +3. The agent builds an LLM request with the context, triggering event, and a JSON schema of available tools +4. The LLM decides whether to call a tool or respond directly +5. If a tool is called, didactyl executes it and feeds the result back to the LLM +6. The loop repeats until the LLM produces a final text response +7. For DM triggers, the response is sent back as a DM -```mermaid -sequenceDiagram - participant Admin - participant Agent as Didactyl Agent Loop - participant LLM as LLM API - participant Tools as Tool Registry - - Admin->>Agent: Encrypted DM - Agent->>LLM: messages + tool schemas - - loop Until final answer - LLM->>Agent: tool_call request - Agent->>Tools: dispatch tool - Tools->>Agent: result JSON - Agent->>LLM: tool result + continue - end - - LLM->>Agent: final text response - Agent->>Admin: Encrypted DM reply +``` +Trigger fires (DM, cron, subscription, webhook, chain) + │ + ├─ Build context from triggered skills + ├─ Build tool schemas (filtered by skill's tools tag) + │ + ├─ Send to LLM: context + tool schemas + │ + │ ┌─────────────────────────────────────┐ + │ │ LLM reasons about the request │ + │ │ │ + │ │ Option A: call a tool │ + │ │ → agent executes tool │ + │ │ → feeds result back to LLM │ + │ │ → loop continues │ + │ │ │ + │ │ Option B: produce text response │ + │ │ → loop ends │ + │ └─────────────────────────────────────┘ + │ + └─ Deliver response (DM reply, chain forward, etc.) ``` --- @@ -724,7 +727,9 @@ These examples show the JSON structure for tool calls. ## Security Model -Tool access is gated by sender tier: +Tool access is gated at two levels: + +### Sender Tier (DM triggers) | Tier | Identity | Tools | Response | |------|----------|-------|----------| @@ -732,9 +737,42 @@ Tool access is gated by sender tier: | **WOT** | In admin's kind 3 contact list | None | Chat-only LLM | | **STRANGER** | Anyone else | None | Configurable static response | +### Skill Requirements (all triggers) + +Skills declare which tools they need via `requires_tool` tags (see [SKILLS.md — Requirements Tags](SKILLS.md#requirements-tags)). During execution, only the required and optional tools declared by the skill are exposed to the LLM. If a skill has no `requires_tool` tags, all available tools are exposed. + +--- + +## Tool Portability + +Tool names serve as the **capability vocabulary** for cross-app skill portability. When a skill declares `["requires_tool", "http_fetch"]`, any app that provides an `http_fetch` capability can run that skill — regardless of how it implements the fetch internally. + +### Runtime Availability + +Not all tools are available in every runtime. Tools that require specific host capabilities: + +| Tool | Requires | +|------|----------| +| `local_shell_exec` | Host OS shell access | +| `local_file_read` | Host filesystem | +| `local_file_write` | Host filesystem | +| `blossom_upload` | Filesystem + HTTP | +| `blossom_download` | Filesystem + HTTP | + +Tools available in any runtime (including browser): + +| Tool | Capability | +|------|-----------| +| `nostr_*` | Nostr relay WebSocket connections | +| `local_http_fetch` | HTTP client | +| `memory_save` / `memory_recall` | Nostr event storage | +| `cashu_wallet_*` | HTTP client + Nostr storage | + +Skills should declare `requires_tool` tags so apps can determine compatibility. See [SKILLS.md — Requirements Tags](SKILLS.md#requirements-tags). + --- ## Related Documentation - Skill definitions, adoption, triggers, and autonomous activation: [SKILLS.md](SKILLS.md) -- Combined index page: [TOOLS_AND_SKILLS.md](TOOLS_AND_SKILLS.md) +- Context assembly model: [CONTEXT.md](CONTEXT.md) diff --git a/plans/trigger_scoped_skill_architecture.md b/plans/trigger_scoped_skill_architecture.md new file mode 100644 index 0000000..7ba6a8b --- /dev/null +++ b/plans/trigger_scoped_skill_architecture.md @@ -0,0 +1,206 @@ +# Implementation Plan: Trigger-Scoped Skill Architecture + +Target spec: [docs/SKILLS.md](../docs/SKILLS.md) · [docs/CONTEXT.md](../docs/CONTEXT.md) · [docs/TOOLS.md](../docs/TOOLS.md) + +## Current State + +- `g_system_context` is a monolithic global string (from the default skill) prepended to every LLM call +- `agent_on_trigger()` prepends `g_system_context` to every triggered skill execution +- `agent_on_message()` falls through to `g_system_context` chat if no DM trigger fires +- `trigger_manager_fire_dm()` fires all matching DM triggers independently (separate LLM calls each) +- `trigger_manager_load_from_skills()` loads triggers from adopted skills only +- Template variables resolve to tools only — no skill-to-skill `{{d_tag}}` resolution +- `apply_trigger_runtime_to_llm_config()` parses `provider/model` but discards the provider +- Skill `content` is JSON with `description` and `template` fields (spec says content IS the template) +- `["tools", "true/false/csv"]` tag controls tool access (spec says use `requires_tool` instead) +- `["enabled", "true/false"]` tag on triggers (spec says adoption IS enablement) + +## Phase 0: Provider Override Fix + +**File:** `src/trigger_manager.c` +**Function:** `apply_trigger_runtime_to_llm_config()` (line 592) + +Currently at line 602-611, when a slash is found in the llm_spec, only the model (after slash) is extracted. The provider (before slash) is discarded. + +**Change:** When slash is found, also copy the provider prefix into `cfg->provider`. + +```c +// Current: only extracts model +if (slash) { + const char* model = slash + 1; + // ... sets cfg->model only +} + +// New: extract both provider and model +if (slash) { + size_t provider_len = (size_t)(slash - spec); + if (provider_len > 0 && provider_len < sizeof(cfg->provider)) { + snprintf(cfg->provider, sizeof(cfg->provider), "%.*s", (int)provider_len, spec); + } + const char* model = slash + 1; + // ... sets cfg->model as before +} +``` + +## Phase 1: Two-Layer Context Assembly + +### 1a. Skill-to-skill template resolution + +**File:** `src/prompt_template.c` +**Function:** `map_variable_tool_name()` (line 100) + +Currently returns a tool name for known variables, or NULL for unknown ones (which resolve to empty). + +**Change:** Add a fallback path. If the variable name doesn't match a known tool, look it up as a skill d-tag in the adoption list cache. If found, return the skill's content. + +This requires access to the skill cache from the template resolver. Options: +- Pass a skill lookup callback into the template builder +- Add a `skill_content_lookup` function pointer to `tools_context_t` + +**File:** `src/prompt_template.c` +**Function:** `prompt_template_build_messages()` (line 37) + +When resolving a `{{variable}}` that doesn't match a tool, call the skill lookup function to check adopted skills by d-tag. + +### 1b. Trigger-matched context assembly + +**File:** `src/agent.c` +**New function:** `build_context_from_triggers()` + +```c +char* build_context_from_triggers( + trigger_type_t trigger_type, + const char* trigger_filter, + cJSON* trigger_event, + const char* relay_url); +``` + +Implementation: +1. Load the adoption list (kind `10123`) — already cached at startup +2. For each adopted skill, check if it has a trigger matching `trigger_type` and `trigger_filter` +3. If match: resolve the skill's template (expanding `{{...}}` references via Phase 1a) +4. Concatenate all matched skill templates in adoption-list order +5. Append the triggering event payload +6. For DM triggers: always append raw message content at the end +7. Return the assembled system prompt + +### 1c. Replace g_system_context in trigger execution + +**File:** `src/agent.c` +**Function:** `agent_on_trigger()` (line 1817) + +**Current (line 1852-1858):** +```c +snprintf(system_prompt, system_len, "%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s", + g_system_context, + trigger_prefix, skill_d_tag, relay, skill_content); +``` + +**Replace with:** Call `build_context_from_triggers()` instead of prepending `g_system_context`. + +### 1d. Skill content format change + +**File:** `src/nostr_handler.c` and `src/config.c` + +Currently skill content is parsed as JSON to extract `template` field. The spec says `content` IS the template (plain string). + +**Change:** When loading a skill's content, check if it's a JSON object with a `template` field (backward compat) or a plain string (new format). Use the template/string directly. + +## Phase 2: DM Composition with Default Handler + +**File:** `src/agent.c` +**Function:** `agent_on_message()` (line 2085) + +Currently at line 2120-2128, calls `trigger_manager_fire_dm()` which fires each matching DM trigger independently. If none fire, falls through to `g_system_context` chat. + +**Change:** +1. Use `build_context_from_triggers()` with `TRIGGER_TYPE_DM` and the sender tier +2. This automatically finds all DM-triggered skills in adoption-list order and composes them +3. Always append the raw admin message at the end (default DM handler) +4. If no DM-triggered skill exists, use a minimal built-in default: "You are an AI agent. Respond to the message." +5. Make one LLM call with the composed context + +**File:** `src/trigger_manager.c` +**Function:** `trigger_manager_fire_dm()` (line 1545) + +Currently fires all matching DM triggers independently (each gets its own LLM call). + +**Change:** Instead of firing independently, return the list of matching DM skill d-tags (in adoption-list order). Let the caller (`agent_on_message`) compose them into a single context via `build_context_from_triggers()`. + +New function signature: +```c +int trigger_manager_get_dm_skills(trigger_manager_t* mgr, + const char* sender_pubkey_hex, + didactyl_sender_tier_t tier, + char** out_d_tags, + int max_d_tags); +``` + +## Phase 3: Trigger Discovery Independent of Adoption + +**File:** `src/trigger_manager.c` +**Function:** `trigger_manager_load_from_skills()` (line ~varies) + +Currently loads triggers only from adopted skills in the `10123` list. + +**Change:** Scan all skill events published by the agent (query own pubkey for kinds 31123/31124), not just adopted ones. Arm any skill with trigger tags. The adoption list controls `{{...}}` resolution and ordering, not trigger arming. + +Non-adopted skills with triggers fire in isolation (no layer 2 skill references available). + +## Phase 4: Context Compaction + +**File:** `src/agent.c` +**Functions:** Tool loops in `agent_on_trigger()` (line 1905) and `agent_on_message()` (line 2212) + +**New function:** +```c +static int estimate_context_tokens(cJSON* messages); +``` + +Approximate: sum character lengths of all message content fields, divide by 4. + +**Change in tool loops:** Before each LLM call, check if `estimate_context_tokens(messages)` exceeds 70% of the model's context window. If so: + +1. Build a summarization request: "Summarize your progress so far, including key findings and remaining work." +2. Send to LLM with `tool_choice: "none"` (text only) +3. Replace all tool call/result messages with a single system message containing the summary +4. Continue the tool loop with the compacted context + +## Phase 5: LLM Fallback Chain + +**File:** `src/trigger_manager.c` +**Function:** `apply_trigger_runtime_to_llm_config()` (line 592) + +Currently parses the comma in `llm_spec` but only uses the first entry. + +**Change:** Walk the comma-separated entries. For each: +1. Parse `provider/model` or bare `model` or capability keyword +2. Check if the model is available (query provider, or check a local model list) +3. If available, use it and stop +4. If not, try the next entry +5. Capability keywords (`cheap`, `fast`, `best`, `default`) resolve to runtime-configured models + +## Phase 6: Remove Obsolete Code + +**File:** `src/agent.c` +- Remove `g_system_context` global variable +- Remove `g_system_context` from `agent_init()` parameter +- Remove `g_system_context` prepend from all code paths + +**File:** `src/nostr_handler.c` +- Remove `g_system_context` global variable (line 28) +- Remove `g_system_context` initialization in `nostr_handler_reconcile_startup_events()` (line 3124-3157) +- Remove `nostr_handler_get_system_context()` function + +**File:** `src/agent.h` +- Update `agent_init()` signature to remove `system_context` parameter + +## Migration Path + +1. **Phase 0** — safe, independent fix. No behavioral change. +2. **Phase 1** — core change. Backward compatible if existing default skill has a DM trigger tag. The `g_system_context` is still used as fallback until Phase 6. +3. **Phase 2** — changes DM behavior. Existing agents work if default skill has `["trigger", "dm"]`. +4. **Phase 3** — additive. Skills without triggers are unaffected. +5. **Phase 4** — additive. New capability, no existing behavior changes. +6. **Phase 5** — additive. Currently only first entry is used; this adds fallback. +7. **Phase 6** — cleanup. Only after Phases 1-2 are stable. diff --git a/src/agent.c b/src/agent.c index 7672500..8e726a9 100644 --- a/src/agent.c +++ b/src/agent.c @@ -51,6 +51,9 @@ typedef struct { char author_pubkey_hex[65]; char d_tag[65]; char* content; + int has_trigger; + trigger_type_t trigger_type; + char filter_json[TRIGGER_FILTER_JSON_MAX]; } agent_adopted_skill_t; static agent_seen_msg_t g_seen_msgs[AGENT_DEBOUNCE_CACHE_SIZE]; @@ -63,6 +66,8 @@ static int g_adopted_skills_count = 0; static time_t g_adopted_skills_last_refresh_at = 0; static pthread_mutex_t g_adopted_skills_mutex = PTHREAD_MUTEX_INITIALIZER; +static int refresh_adopted_skills_cache_if_needed(void); + static uint64_t fnv1a64(const char* s) { uint64_t h = 1469598103934665603ULL; if (!s) return h; @@ -1392,6 +1397,291 @@ static int parse_skill_address_tag_local(const char* addr, int* out_kind, char o return 0; } +static int dm_filter_matches_tier_local(const char* filter_json, didactyl_sender_tier_t tier) { + if (!filter_json || filter_json[0] == '\0') { + return 0; + } + + cJSON* root = cJSON_Parse(filter_json); + if (!root || !cJSON_IsObject(root)) { + cJSON_Delete(root); + return 0; + } + + cJSON* from = cJSON_GetObjectItemCaseSensitive(root, "from"); + const char* from_s = (from && cJSON_IsString(from) && from->valuestring) ? from->valuestring : "admin"; + + int match = 0; + if (strcmp(from_s, "any") == 0) { + match = 1; + } else if (strcmp(from_s, "admin") == 0) { + match = (tier == DIDACTYL_SENDER_ADMIN); + } else if (strcmp(from_s, "wot") == 0) { + match = (tier == DIDACTYL_SENDER_WOT); + } + + cJSON_Delete(root); + return match; +} + +static const char* adopted_skill_content_lookup_by_d_tag_locked(const char* d_tag) { + if (!d_tag || d_tag[0] == '\0') { + return NULL; + } + + for (int i = 0; i < g_adopted_skills_count; i++) { + if (strcmp(g_adopted_skills[i].d_tag, d_tag) == 0) { + return g_adopted_skills[i].content; + } + } + + return NULL; +} + +static const char* template_skill_lookup_callback(void* user_data, const char* d_tag) { + (void)user_data; + if (!d_tag || d_tag[0] == '\0') { + return NULL; + } + + (void)refresh_adopted_skills_cache_if_needed(); + + const char* out = NULL; + pthread_mutex_lock(&g_adopted_skills_mutex); + out = adopted_skill_content_lookup_by_d_tag_locked(d_tag); + pthread_mutex_unlock(&g_adopted_skills_mutex); + return out; +} + +static char* resolve_skill_references_local(const char* input) { + if (!input) { + return strdup(""); + } + + size_t cap = strlen(input) + 128U; + size_t used = 0U; + char* out = (char*)malloc(cap); + if (!out) return NULL; + out[0] = '\0'; + + const char* p = input; + while (*p) { + const char* open = strstr(p, "{{"); + if (!open) { + size_t tail = strlen(p); + if (used + tail + 1U > cap) { + size_t next = cap; + while (used + tail + 1U > next) next *= 2U; + char* grown = (char*)realloc(out, next); + if (!grown) { + free(out); + return NULL; + } + out = grown; + cap = next; + } + memcpy(out + used, p, tail); + used += tail; + out[used] = '\0'; + break; + } + + size_t prefix_len = (size_t)(open - p); + if (used + prefix_len + 1U > cap) { + size_t next = cap; + while (used + prefix_len + 1U > next) next *= 2U; + char* grown = (char*)realloc(out, next); + if (!grown) { + free(out); + return NULL; + } + out = grown; + cap = next; + } + memcpy(out + used, p, prefix_len); + used += prefix_len; + out[used] = '\0'; + + const char* close = strstr(open + 2, "}}"); + if (!close) { + size_t rem = strlen(open); + if (used + rem + 1U > cap) { + size_t next = cap; + while (used + rem + 1U > next) next *= 2U; + char* grown = (char*)realloc(out, next); + if (!grown) { + free(out); + return NULL; + } + out = grown; + cap = next; + } + memcpy(out + used, open, rem); + used += rem; + out[used] = '\0'; + break; + } + + size_t var_len = (size_t)(close - (open + 2)); + char var[128]; + if (var_len >= sizeof(var)) var_len = sizeof(var) - 1U; + memcpy(var, open + 2, var_len); + var[var_len] = '\0'; + + char* start = var; + while (*start && isspace((unsigned char)*start)) start++; + char* end = start + strlen(start); + while (end > start && isspace((unsigned char)end[-1])) { + end[-1] = '\0'; + end--; + } + + const char* repl = ""; + pthread_mutex_lock(&g_adopted_skills_mutex); + const char* found = adopted_skill_content_lookup_by_d_tag_locked(start); + repl = found ? found : ""; + pthread_mutex_unlock(&g_adopted_skills_mutex); + + size_t repl_len = strlen(repl); + if (used + repl_len + 1U > cap) { + size_t next = cap; + while (used + repl_len + 1U > next) next *= 2U; + char* grown = (char*)realloc(out, next); + if (!grown) { + free(out); + return NULL; + } + out = grown; + cap = next; + } + memcpy(out + used, repl, repl_len); + used += repl_len; + out[used] = '\0'; + + p = close + 2; + } + + return out; +} + +static char* build_context_from_triggers(trigger_type_t trigger_type, + const char* trigger_filter, + cJSON* trigger_event, + const char* relay_url) { + (void)relay_url; + (void)trigger_filter; + + if (!g_cfg) { + return strdup("You are an AI agent. Respond to the message."); + } + + (void)refresh_adopted_skills_cache_if_needed(); + + size_t cap = 4096; + size_t used = 0; + char* out = (char*)malloc(cap); + if (!out) { + return NULL; + } + out[0] = '\0'; + + int matched = 0; + + didactyl_sender_tier_t tier = DIDACTYL_SENDER_STRANGER; + const char* message = NULL; + if (trigger_event && cJSON_IsObject(trigger_event)) { + cJSON* tier_j = cJSON_GetObjectItemCaseSensitive(trigger_event, "tier"); + cJSON* msg_j = cJSON_GetObjectItemCaseSensitive(trigger_event, "message"); + const char* tier_s = (tier_j && cJSON_IsString(tier_j) && tier_j->valuestring) ? tier_j->valuestring : "stranger"; + if (strcmp(tier_s, "admin") == 0) tier = DIDACTYL_SENDER_ADMIN; + else if (strcmp(tier_s, "wot") == 0) tier = DIDACTYL_SENDER_WOT; + if (msg_j && cJSON_IsString(msg_j) && msg_j->valuestring) { + message = msg_j->valuestring; + } + } + + pthread_mutex_lock(&g_adopted_skills_mutex); + for (int i = 0; i < g_adopted_skills_count; i++) { + const agent_adopted_skill_t* s = &g_adopted_skills[i]; + if (!s->has_trigger || s->trigger_type != trigger_type) { + continue; + } + if (trigger_type == TRIGGER_TYPE_DM && !dm_filter_matches_tier_local(s->filter_json, tier)) { + continue; + } + + char* expanded = resolve_skill_references_local(s->content ? s->content : ""); + const char* skill_text = expanded ? expanded : (s->content ? s->content : ""); + + size_t need = strlen("\n\n---\n\n") + strlen(skill_text) + 1U; + if (used + need >= cap) { + size_t next = cap; + while (used + need >= next) next *= 2U; + char* grown = (char*)realloc(out, next); + if (!grown) { + free(expanded); + pthread_mutex_unlock(&g_adopted_skills_mutex); + free(out); + return NULL; + } + out = grown; + cap = next; + } + + if (matched > 0) { + memcpy(out + used, "\n\n---\n\n", 7); + used += 7; + } + size_t sl = strlen(skill_text); + memcpy(out + used, skill_text, sl); + used += sl; + out[used] = '\0'; + matched++; + + free(expanded); + } + pthread_mutex_unlock(&g_adopted_skills_mutex); + + if (matched == 0) { + const char* fallback = "You are an AI agent. Respond to the message."; + size_t need = strlen(fallback) + 1U; + if (need > cap) { + char* grown = (char*)realloc(out, need); + if (!grown) { + free(out); + return NULL; + } + out = grown; + cap = need; + } + memcpy(out, fallback, need); + used = need - 1U; + } + + if (trigger_type == TRIGGER_TYPE_DM && message && message[0] != '\0') { + const char* suffix = "\n\nIncoming DM message:\n"; + size_t need = strlen(suffix) + strlen(message) + 1U; + if (used + need >= cap) { + size_t next = cap; + while (used + need >= next) next *= 2U; + char* grown = (char*)realloc(out, next); + if (!grown) { + free(out); + return NULL; + } + out = grown; + cap = next; + } + memcpy(out + used, suffix, strlen(suffix)); + used += strlen(suffix); + memcpy(out + used, message, strlen(message)); + used += strlen(message); + out[used] = '\0'; + } + + return out; +} + static void clear_adopted_skills_cache_locked(void) { for (int i = 0; i < g_adopted_skills_count; i++) { free(g_adopted_skills[i].content); @@ -1399,6 +1689,9 @@ static void clear_adopted_skills_cache_locked(void) { g_adopted_skills[i].kind = 0; g_adopted_skills[i].author_pubkey_hex[0] = '\0'; g_adopted_skills[i].d_tag[0] = '\0'; + g_adopted_skills[i].has_trigger = 0; + g_adopted_skills[i].trigger_type = TRIGGER_TYPE_NOSTR_SUBSCRIPTION; + g_adopted_skills[i].filter_json[0] = '\0'; } g_adopted_skills_count = 0; } @@ -1538,6 +1831,17 @@ static int refresh_adopted_skills_cache_if_needed(void) { snprintf(tmp[tmp_count].author_pubkey_hex, sizeof(tmp[tmp_count].author_pubkey_hex), "%s", skill_author); snprintf(tmp[tmp_count].d_tag, sizeof(tmp[tmp_count].d_tag), "%s", skill_d_tag); tmp[tmp_count].content = strdup(content->valuestring); + tmp[tmp_count].has_trigger = 0; + tmp[tmp_count].trigger_type = TRIGGER_TYPE_NOSTR_SUBSCRIPTION; + tmp[tmp_count].filter_json[0] = '\0'; + cJSON* trigger_tag = find_tag_value_string_local(skill_tags, "trigger"); + cJSON* filter_tag = find_tag_value_string_local(skill_tags, "filter"); + if (trigger_tag && cJSON_IsString(trigger_tag) && trigger_tag->valuestring && trigger_tag->valuestring[0] != '\0' && + filter_tag && cJSON_IsString(filter_tag) && filter_tag->valuestring && filter_tag->valuestring[0] != '\0') { + tmp[tmp_count].has_trigger = 1; + tmp[tmp_count].trigger_type = trigger_type_from_string(trigger_tag->valuestring); + snprintf(tmp[tmp_count].filter_json, sizeof(tmp[tmp_count].filter_json), "%s", filter_tag->valuestring); + } if (tmp[tmp_count].content) { tmp_count++; } @@ -1578,6 +1882,21 @@ static int refresh_adopted_skills_cache_if_needed(void) { : "unknown"); snprintf(tmp[tmp_count].d_tag, sizeof(tmp[tmp_count].d_tag), "%s", d_tag_val); tmp[tmp_count].content = strdup(se->content); + tmp[tmp_count].has_trigger = 0; + tmp[tmp_count].trigger_type = TRIGGER_TYPE_NOSTR_SUBSCRIPTION; + tmp[tmp_count].filter_json[0] = '\0'; + if (se->tags_json && se->tags_json[0] != '\0') { + cJSON* tags = cJSON_Parse(se->tags_json); + cJSON* trigger_tag = tags ? find_tag_value_string_local(tags, "trigger") : NULL; + cJSON* filter_tag = tags ? find_tag_value_string_local(tags, "filter") : NULL; + if (trigger_tag && cJSON_IsString(trigger_tag) && trigger_tag->valuestring && trigger_tag->valuestring[0] != '\0' && + filter_tag && cJSON_IsString(filter_tag) && filter_tag->valuestring && filter_tag->valuestring[0] != '\0') { + tmp[tmp_count].has_trigger = 1; + tmp[tmp_count].trigger_type = trigger_type_from_string(trigger_tag->valuestring); + snprintf(tmp[tmp_count].filter_json, sizeof(tmp[tmp_count].filter_json), "%s", filter_tag->valuestring); + } + cJSON_Delete(tags); + } if (tmp[tmp_count].content) { tmp_count++; } @@ -1802,6 +2121,9 @@ int agent_init(didactyl_config_t* config, const char* system_context) { g_adopted_skills_last_refresh_at = 0; pthread_mutex_unlock(&g_adopted_skills_mutex); + g_tools_ctx.template_skill_lookup = template_skill_lookup_callback; + g_tools_ctx.template_skill_lookup_user_data = NULL; + prompt_template_free(&g_prompt_template); g_has_prompt_template = (prompt_template_parse(g_system_context, &g_prompt_template) == 0); clear_context_part_names(); @@ -1818,7 +2140,7 @@ 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_d_tag || !skill_content || !triggering_event) { + if (!g_cfg || !skill_d_tag || !skill_content || !triggering_event) { return; } @@ -1840,11 +2162,20 @@ void agent_on_trigger(const char* skill_d_tag, "- If no action is needed, explicitly say why.\n\n" "Skill d_tag: "; - size_t system_len = strlen(g_system_context) + 2 + strlen(trigger_prefix) + strlen(skill_d_tag) + + char* composed_context = build_context_from_triggers(TRIGGER_TYPE_NOSTR_SUBSCRIPTION, + NULL, + triggering_event, + relay_url); + if (!composed_context) { + composed_context = strdup("You are an AI agent. Respond to the trigger event."); + } + + size_t system_len = strlen(composed_context ? composed_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); if (!system_prompt) { + free(composed_context); free(event_json); return; } @@ -1852,11 +2183,12 @@ void agent_on_trigger(const char* skill_d_tag, snprintf(system_prompt, system_len, "%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s", - g_system_context, + composed_context ? composed_context : "", trigger_prefix, skill_d_tag, relay, skill_content); + free(composed_context); size_t user_len = strlen("Triggering event JSON:\n") + strlen(event_json) + 1U; char* user_prompt = (char*)malloc(user_len); @@ -2117,15 +2449,22 @@ void agent_on_message(const char* sender_pubkey_hex, } } - if (g_trigger_manager) { - int dm_fired = trigger_manager_fire_dm(g_trigger_manager, - sender_pubkey_hex, - message, - tier, - "dm"); - if (dm_fired > 0) { - return; - } + cJSON* dm_event = cJSON_CreateObject(); + if (!dm_event) { + return; + } + cJSON_AddStringToObject(dm_event, "type", "dm"); + cJSON_AddStringToObject(dm_event, "sender_pubkey", sender_pubkey_hex); + cJSON_AddStringToObject(dm_event, "message", message); + cJSON_AddStringToObject(dm_event, "tier", + tier == DIDACTYL_SENDER_ADMIN ? "admin" : + (tier == DIDACTYL_SENDER_WOT ? "wot" : "stranger")); + cJSON_AddNumberToObject(dm_event, "created_at", (double)time(NULL)); + + char* dm_context = build_context_from_triggers(TRIGGER_TYPE_DM, NULL, dm_event, "dm"); + cJSON_Delete(dm_event); + if (!dm_context) { + dm_context = strdup("You are an AI agent. Respond to the message."); } if (!allow_tools) { @@ -2133,13 +2472,14 @@ void agent_on_message(const char* sender_pubkey_hex, ? "You are responding to a web-of-trust contact. Keep the response helpful and concise. Tool use is disabled for this tier." : "You are responding in chat-only mode. Tool use is disabled."; - size_t ctx_len = strlen(g_system_context) + strlen("\n\n") + strlen(tier_prefix) + 1U; + size_t ctx_len = strlen(dm_context ? dm_context : "") + strlen("\n\n") + strlen(tier_prefix) + 1U; char* system_for_chat = (char*)malloc(ctx_len); if (!system_for_chat) { + free(dm_context); return; } - snprintf(system_for_chat, ctx_len, "%s\n\n%s", g_system_context, tier_prefix); + snprintf(system_for_chat, ctx_len, "%s\n\n%s", dm_context ? dm_context : "", tier_prefix); size_t context_len = strlen("system:\n\nuser:\n") + strlen(system_for_chat) + strlen(message) + 1U; char* plain_context = (char*)malloc(context_len); @@ -2163,6 +2503,7 @@ void agent_on_message(const char* sender_pubkey_hex, strlen(response) > 240 ? "..." : ""); (void)nostr_handler_send_dm_auto(sender_pubkey_hex, response); free(response); + free(dm_context); return; } @@ -2172,25 +2513,14 @@ void agent_on_message(const char* sender_pubkey_hex, return; } - char* base_messages_json = NULL; - if (agent_build_admin_messages_json(message, tier, &base_messages_json) != 0 || !base_messages_json) { - free(tools_json); - (void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Failed to initialize conversation messages."); - return; - } - - cJSON* messages = cJSON_Parse(base_messages_json); - free(base_messages_json); - if (!messages || !cJSON_IsArray(messages)) { - cJSON_Delete(messages); - free(tools_json); - (void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Failed to initialize conversation state."); - return; - } - - if (append_simple_message(messages, "user", message) != 0) { + cJSON* messages = cJSON_CreateArray(); + if (!messages || + append_simple_message(messages, "system", dm_context ? dm_context : "You are an AI agent. Respond to the message.") != 0 || + append_recent_admin_dm_history(messages, message) != 0 || + append_simple_message(messages, "user", message) != 0) { cJSON_Delete(messages); free(tools_json); + free(dm_context); (void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Failed to initialize conversation messages."); return; } @@ -2225,6 +2555,7 @@ void agent_on_message(const char* sender_pubkey_hex, (void)nostr_handler_send_dm_auto(sender_pubkey_hex, "LLM request failed."); cJSON_Delete(messages); free(tools_json); + free(dm_context); return; } @@ -2277,6 +2608,7 @@ void agent_on_message(const char* sender_pubkey_hex, llm_response_free(&resp); cJSON_Delete(messages); free(tools_json); + free(dm_context); (void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Failed to append tool result."); return; } @@ -2333,6 +2665,7 @@ void agent_on_message(const char* sender_pubkey_hex, free(final_answer_owned); cJSON_Delete(messages); free(tools_json); + free(dm_context); } void agent_cleanup(void) { diff --git a/src/main.h b/src/main.h index 4355bdd..1831f3b 100644 --- a/src/main.h +++ b/src/main.h @@ -11,9 +11,9 @@ // Version information (auto-updated by build system) // Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros #define DIDACTYL_VERSION_MAJOR 0 -#define DIDACTYL_VERSION_MINOR 1 -#define DIDACTYL_VERSION_PATCH 21 -#define DIDACTYL_VERSION "v0.1.21" +#define DIDACTYL_VERSION_MINOR 2 +#define DIDACTYL_VERSION_PATCH 0 +#define DIDACTYL_VERSION "v0.2.0" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/prompt_template.c b/src/prompt_template.c index 74ef1c4..61955e4 100644 --- a/src/prompt_template.c +++ b/src/prompt_template.c @@ -105,7 +105,7 @@ static const char* map_variable_tool_name(const char* var_name) { if (strcmp(var_name, "admin_relays") == 0) return "nostr_admin_relays"; if (strcmp(var_name, "adopted_skills") == 0) return "adopted_skills"; if (strcmp(var_name, "triggering_event") == 0) return "trigger_event"; - return var_name; + return NULL; } static char* resolve_inline_variables_local(const char* tpl, tools_context_t* tools_ctx) { @@ -189,6 +189,16 @@ static char* resolve_inline_variables_local(const char* tpl, tools_context_t* to free(tool_result); } } + + if (!replacement_owned && tools_ctx->template_skill_lookup && var_trim[0] != '\0') { + const char* skill_content = tools_ctx->template_skill_lookup( + tools_ctx->template_skill_lookup_user_data, + var_trim); + if (skill_content && skill_content[0] != '\0') { + replacement_owned = strdup(skill_content); + } + } + replacement = replacement_owned ? replacement_owned : ""; } diff --git a/src/tools/tools.h b/src/tools/tools.h index cd7f51a..c4be8ca 100644 --- a/src/tools/tools.h +++ b/src/tools/tools.h @@ -11,6 +11,8 @@ typedef struct { int template_sender_tier; const char* template_current_user_message; const char* template_trigger_event_json; + const char* (*template_skill_lookup)(void* user_data, const char* d_tag); + void* template_skill_lookup_user_data; } tools_context_t; int tools_init(tools_context_t* ctx, didactyl_config_t* cfg); diff --git a/src/tools/tools_dispatch.c b/src/tools/tools_dispatch.c index b4bfa51..379bade 100644 --- a/src/tools/tools_dispatch.c +++ b/src/tools/tools_dispatch.c @@ -25,6 +25,8 @@ int tools_init_legacy(tools_context_t* ctx, didactyl_config_t* cfg) { ctx->template_sender_tier = 0; ctx->template_current_user_message = NULL; ctx->template_trigger_event_json = NULL; + ctx->template_skill_lookup = NULL; + ctx->template_skill_lookup_user_data = NULL; return 0; } diff --git a/src/trigger_manager.c b/src/trigger_manager.c index f12b8fb..c63a235 100644 --- a/src/trigger_manager.c +++ b/src/trigger_manager.c @@ -594,15 +594,28 @@ static void apply_trigger_runtime_to_llm_config(const active_trigger_t* t, llm_c if (t->llm_spec[0] != '\0') { const char* spec = t->llm_spec; + while (*spec && isspace((unsigned char)*spec)) spec++; + const char* comma = strchr(spec, ','); size_t len = comma ? (size_t)(comma - spec) : strlen(spec); while (len > 0 && isspace((unsigned char)spec[len - 1])) len--; - while (*spec && isspace((unsigned char)*spec)) spec++; const char* slash = memchr(spec, '/', len); if (slash) { + size_t provider_len = (size_t)(slash - spec); const char* model = slash + 1; size_t model_len = len - (size_t)(model - spec); + + while (provider_len > 0 && isspace((unsigned char)spec[provider_len - 1])) provider_len--; + while (model_len > 0 && isspace((unsigned char)*model)) { + model++; + model_len--; + } + while (model_len > 0 && isspace((unsigned char)model[model_len - 1])) model_len--; + + if (provider_len > 0 && provider_len < sizeof(cfg->provider)) { + snprintf(cfg->provider, sizeof(cfg->provider), "%.*s", (int)provider_len, spec); + } if (model_len > 0 && model_len < sizeof(cfg->model)) { snprintf(cfg->model, sizeof(cfg->model), "%.*s", (int)model_len, model); } @@ -807,137 +820,61 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) { return -1; } - char* adoption_json = nostr_handler_get_self_events_by_kind_json(10123); - if (!adoption_json) { - return 0; - } - - cJSON* adoption_events = cJSON_Parse(adoption_json); - free(adoption_json); - if (!adoption_events || !cJSON_IsArray(adoption_events) || cJSON_GetArraySize(adoption_events) <= 0) { - cJSON_Delete(adoption_events); - return 0; - } - - cJSON* list_event = cJSON_GetArrayItem(adoption_events, 0); - cJSON* list_tags = list_event ? cJSON_GetObjectItemCaseSensitive(list_event, "tags") : NULL; - if (!list_tags || !cJSON_IsArray(list_tags)) { - cJSON_Delete(adoption_events); - return 0; - } - int loaded = 0; - int tn = cJSON_GetArraySize(list_tags); - for (int i = 0; i < tn; i++) { - cJSON* tag = cJSON_GetArrayItem(list_tags, i); - if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue; + int considered = 0; + const int kinds[2] = {31123, 31124}; - cJSON* key = cJSON_GetArrayItem(tag, 0); - cJSON* val = cJSON_GetArrayItem(tag, 1); - if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || - !key->valuestring || !val->valuestring || strcmp(key->valuestring, "a") != 0) { + for (int k = 0; k < 2; k++) { + char* skill_json = nostr_handler_get_self_events_by_kind_json(kinds[k]); + if (!skill_json) { continue; } - int kind = 0; - char pubkey[65] = {0}; - char d_tag[65] = {0}; - if (parse_address_tag(val->valuestring, &kind, pubkey, d_tag) != 0) { + cJSON* skill_events = cJSON_Parse(skill_json); + free(skill_json); + if (!skill_events || !cJSON_IsArray(skill_events)) { + cJSON_Delete(skill_events); continue; } - cJSON* skill_events = NULL; - if (strcmp(pubkey, mgr->cfg->keys.public_key_hex) == 0) { - char* skill_json = nostr_handler_get_self_events_by_kind_json(kind); - if (skill_json) { - cJSON* all_events = cJSON_Parse(skill_json); - free(skill_json); - if (all_events && cJSON_IsArray(all_events)) { - skill_events = cJSON_CreateArray(); - if (skill_events) { - int all_n = cJSON_GetArraySize(all_events); - for (int ai = 0; ai < all_n; ai++) { - cJSON* ev = cJSON_GetArrayItem(all_events, ai); - cJSON* ev_pubkey = ev ? cJSON_GetObjectItemCaseSensitive(ev, "pubkey") : NULL; - cJSON* ev_tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL; - cJSON* ev_d = find_tag_value_string(ev_tags, "d"); - 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, d_tag) != 0) { - continue; - } - cJSON* dup = cJSON_Duplicate(ev, 1); - if (dup) { - cJSON_AddItemToArray(skill_events, dup); - } - } - } - } - cJSON_Delete(all_events); - } - } else { - cJSON* skill_filter = cJSON_CreateObject(); - cJSON* sk_kinds = cJSON_CreateArray(); - cJSON* sk_authors = cJSON_CreateArray(); - cJSON* d_values = cJSON_CreateArray(); - if (!skill_filter || !sk_kinds || !sk_authors || !d_values) { - cJSON_Delete(skill_filter); - cJSON_Delete(sk_kinds); - cJSON_Delete(sk_authors); - cJSON_Delete(d_values); + int sn = cJSON_GetArraySize(skill_events); + for (int i = 0; i < sn; i++) { + cJSON* skill_event = cJSON_GetArrayItem(skill_events, i); + cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL; + cJSON* tags = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "tags") : NULL; + if (!content || !cJSON_IsString(content) || !content->valuestring || !tags || !cJSON_IsArray(tags)) { continue; } - cJSON_AddItemToArray(sk_kinds, cJSON_CreateNumber(kind)); - cJSON_AddItemToObject(skill_filter, "kinds", sk_kinds); - cJSON_AddItemToArray(sk_authors, cJSON_CreateString(pubkey)); - cJSON_AddItemToObject(skill_filter, "authors", sk_authors); - cJSON_AddItemToArray(d_values, cJSON_CreateString(d_tag)); - cJSON_AddItemToObject(skill_filter, "#d", d_values); - cJSON_AddNumberToObject(skill_filter, "limit", 1); + 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"); - char* skill_json = nostr_handler_query_json(skill_filter, 2000); - cJSON_Delete(skill_filter); - if (skill_json) { - skill_events = cJSON_Parse(skill_json); - free(skill_json); + 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"; + + considered++; + if (!d_tag || d_tag[0] == '\0') { + continue; } - } - if (!skill_events || !cJSON_IsArray(skill_events) || cJSON_GetArraySize(skill_events) <= 0) { - cJSON_Delete(skill_events); - 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 || + strcmp(trigger_s, "dm") == 0); + if (!trigger_supported || !filter_s || filter_s[0] == '\0') { + continue; + } - cJSON* skill_event = cJSON_GetArrayItem(skill_events, 0); - cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL; - cJSON* tags = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "tags") : NULL; - if (!content || !cJSON_IsString(content) || !content->valuestring || !tags || !cJSON_IsArray(tags)) { - cJSON_Delete(skill_events); - continue; - } - - cJSON* trigger = find_tag_value_string(tags, "trigger"); - cJSON* filter = find_tag_value_string(tags, "filter"); - cJSON* action = find_tag_value_string(tags, "action"); - cJSON* enabled = find_tag_value_string(tags, "enabled"); - - const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL; - const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL; - const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm"; - const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true"; - - 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 || - strcmp(trigger_s, "dm") == 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); } + const char* llm_s = NULL; const char* tools_s = NULL; int has_max_tokens = 0; @@ -956,14 +893,13 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) { &has_seed, &seed); - int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1; if (trigger_manager_add(mgr, d_tag, content->valuestring, filter_s, TRIGGER_ACTION_LLM, trigger_s, - is_enabled, + 1, llm_s, tools_s, has_max_tokens, @@ -979,8 +915,7 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) { cJSON_Delete(skill_events); } - cJSON_Delete(adoption_events); - DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from skills", loaded); + DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from self skills (considered=%d)", loaded, considered); return 0; } @@ -1542,33 +1477,22 @@ static int dm_filter_matches_tier(const char* filter_json, didactyl_sender_tier_ return match; } -int trigger_manager_fire_dm(trigger_manager_t* mgr, - const char* sender_pubkey_hex, - const char* message, - didactyl_sender_tier_t tier, - const char* source_label) { - if (!mgr || !sender_pubkey_hex || !message) { +int trigger_manager_get_dm_skills(trigger_manager_t* mgr, + const char* sender_pubkey_hex, + didactyl_sender_tier_t tier, + char** out_d_tags, + int max_d_tags) { + (void)sender_pubkey_hex; + + if (!mgr || !out_d_tags || max_d_tags <= 0) { return -1; } - cJSON* event = cJSON_CreateObject(); - if (!event) { - return -1; - } - - cJSON_AddStringToObject(event, "type", "dm"); - cJSON_AddStringToObject(event, "sender_pubkey", sender_pubkey_hex); - cJSON_AddStringToObject(event, "message", message); - cJSON_AddStringToObject(event, "tier", - tier == DIDACTYL_SENDER_ADMIN ? "admin" : - (tier == DIDACTYL_SENDER_WOT ? "wot" : "stranger")); - cJSON_AddNumberToObject(event, "created_at", (double)time(NULL)); - - int fired_total = 0; + int count = 0; pthread_mutex_lock(&mgr->mutex); int count_snapshot = mgr->count; - for (int i = 0; i < count_snapshot; i++) { + for (int i = 0; i < count_snapshot && count < max_d_tags; i++) { active_trigger_t* t = &mgr->triggers[i]; if (!t->enabled || t->trigger_type != TRIGGER_TYPE_DM) { continue; @@ -1577,15 +1501,11 @@ int trigger_manager_fire_dm(trigger_manager_t* mgr, continue; } - int fired = maybe_fire_trigger_locked(mgr, i, event, source_label ? source_label : "dm"); - if (fired > 0) { - fired_total += fired; - } + out_d_tags[count++] = t->skill_d_tag; } pthread_mutex_unlock(&mgr->mutex); - cJSON_Delete(event); - return fired_total; + return count; } char* trigger_manager_status_json(trigger_manager_t* mgr) { diff --git a/src/trigger_manager.h b/src/trigger_manager.h index a1f7f98..93c9b7e 100644 --- a/src/trigger_manager.h +++ b/src/trigger_manager.h @@ -105,11 +105,11 @@ 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_fire_dm(trigger_manager_t* mgr, - const char* sender_pubkey_hex, - const char* message, - didactyl_sender_tier_t tier, - const char* source_label); +int trigger_manager_get_dm_skills(trigger_manager_t* mgr, + const char* sender_pubkey_hex, + didactyl_sender_tier_t tier, + char** out_d_tags, + int max_d_tags); 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);